mirror of
https://github.com/Vendicated/Vencord.git
synced 2025-01-10 18:06:22 +00:00
Merge branch 'dev' into openMoreConnections
This commit is contained in:
commit
7d4f0e4c3e
12 changed files with 193 additions and 140 deletions
|
@ -35,7 +35,8 @@ export const ALLOWED_PROTOCOLS = [
|
|||
"steam:",
|
||||
"spotify:",
|
||||
"com.epicgames.launcher:",
|
||||
"tidal:"
|
||||
"tidal:",
|
||||
"itunes:",
|
||||
];
|
||||
|
||||
export const IS_VANILLA = /* @__PURE__ */ process.argv.includes("--vanilla");
|
||||
|
|
|
@ -27,12 +27,8 @@ export default definePlugin({
|
|||
find: "Messages.MESSAGE_UTILITIES_A11Y_LABEL",
|
||||
replacement: {
|
||||
// foo && !bar ? createElement(reactionStuffs)... createElement(blah,...makeElement(reply-other))
|
||||
match: /\i&&!\i\?\(0,\i\.jsxs?\)\(.{0,200}renderEmojiPicker:.{0,500}\?(\i)\(\{key:"reply-other"/,
|
||||
replace: (m, makeElement) => {
|
||||
const msg = m.match(/message:(.{1,3}),/)?.[1];
|
||||
if (!msg) throw new Error("Could not find message variable");
|
||||
return `...Vencord.Api.MessagePopover._buildPopoverElements(${msg},${makeElement}),${m}`;
|
||||
}
|
||||
match: /\i&&!\i\?\(0,\i\.jsxs?\)\(.{0,200}renderEmojiPicker:.{0,500}\?(\i)\(\{key:"reply-other"(?<=message:(\i).+?)/,
|
||||
replace: (m, makeElement, msg) => `...Vencord.Api.MessagePopover._buildPopoverElements(${msg},${makeElement}),${m}`
|
||||
}
|
||||
}],
|
||||
});
|
||||
|
|
|
@ -25,11 +25,9 @@ export default definePlugin({
|
|||
description: "Upload with a single click, open menu with right click",
|
||||
patches: [
|
||||
{
|
||||
find: "Messages.CHAT_ATTACH_UPLOAD_OR_INVITE",
|
||||
find: '"ChannelAttachButton"',
|
||||
replacement: {
|
||||
// Discord merges multiple props here with Object.assign()
|
||||
// This patch passes a third object to it with which we override onClick and onContextMenu
|
||||
match: /CHAT_ATTACH_UPLOAD_OR_INVITE,onDoubleClick:(.+?:void 0),\.\.\.(\i),/,
|
||||
match: /\.attachButtonInner,"aria-label":.{0,50},onDoubleClick:(.+?:void 0),\.\.\.(\i),/,
|
||||
replace: "$&onClick:$1,onContextMenu:$2.onClick,",
|
||||
},
|
||||
},
|
||||
|
|
11
src/plugins/openInApp/README.md
Normal file
11
src/plugins/openInApp/README.md
Normal file
|
@ -0,0 +1,11 @@
|
|||
# OpenInApp
|
||||
|
||||
Open links in their respective apps instead of your browser
|
||||
|
||||
## Currently supports:
|
||||
|
||||
- Spotify
|
||||
- Steam
|
||||
- EpicGames
|
||||
- Tidal
|
||||
- Apple Music (iTunes)
|
|
@ -18,46 +18,70 @@
|
|||
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { Devs } from "@utils/constants";
|
||||
import definePlugin, { OptionType, PluginNative } from "@utils/types";
|
||||
import definePlugin, { OptionType, PluginNative, SettingsDefinition } from "@utils/types";
|
||||
import { showToast, Toasts } from "@webpack/common";
|
||||
import type { MouseEvent } from "react";
|
||||
|
||||
const ShortUrlMatcher = /^https:\/\/(spotify\.link|s\.team)\/.+$/;
|
||||
const SpotifyMatcher = /^https:\/\/open\.spotify\.com\/(track|album|artist|playlist|user|episode)\/(.+)(?:\?.+?)?$/;
|
||||
const SteamMatcher = /^https:\/\/(steamcommunity\.com|(?:help|store)\.steampowered\.com)\/.+$/;
|
||||
const EpicMatcher = /^https:\/\/store\.epicgames\.com\/(.+)$/;
|
||||
const TidalMatcher = /^https:\/\/tidal\.com\/browse\/(track|album|artist|playlist|user|video|mix)\/(.+)(?:\?.+?)?$/;
|
||||
interface URLReplacementRule {
|
||||
match: RegExp;
|
||||
replace: (...matches: string[]) => string;
|
||||
description: string;
|
||||
shortlinkMatch?: RegExp;
|
||||
accountViewReplace?: (userId: string) => string;
|
||||
}
|
||||
|
||||
const settings = definePluginSettings({
|
||||
// Do not forget to add protocols to the ALLOWED_PROTOCOLS constant
|
||||
const UrlReplacementRules: Record<string, URLReplacementRule> = {
|
||||
spotify: {
|
||||
type: OptionType.BOOLEAN,
|
||||
match: /^https:\/\/open\.spotify\.com\/(track|album|artist|playlist|user|episode)\/(.+)(?:\?.+?)?$/,
|
||||
replace: (_, type, id) => `spotify://${type}/${id}`,
|
||||
description: "Open Spotify links in the Spotify app",
|
||||
default: true,
|
||||
shortlinkMatch: /^https:\/\/spotify\.link\/.+$/,
|
||||
accountViewReplace: userId => `spotify:user:${userId}`,
|
||||
},
|
||||
steam: {
|
||||
type: OptionType.BOOLEAN,
|
||||
match: /^https:\/\/(steamcommunity\.com|(?:help|store)\.steampowered\.com)\/.+$/,
|
||||
replace: match => `steam://openurl/${match}`,
|
||||
description: "Open Steam links in the Steam app",
|
||||
default: true,
|
||||
shortlinkMatch: /^https:\/\/s.team\/.+$/,
|
||||
accountViewReplace: userId => `steam://openurl/https://steamcommunity.com/profiles/${userId}`,
|
||||
},
|
||||
epic: {
|
||||
type: OptionType.BOOLEAN,
|
||||
match: /^https:\/\/store\.epicgames\.com\/(.+)$/,
|
||||
replace: (_, id) => `com.epicgames.launcher://store/${id}`,
|
||||
description: "Open Epic Games links in the Epic Games Launcher",
|
||||
default: true,
|
||||
},
|
||||
tidal: {
|
||||
type: OptionType.BOOLEAN,
|
||||
match: /^https:\/\/tidal\.com\/browse\/(track|album|artist|playlist|user|video|mix)\/(.+)(?:\?.+?)?$/,
|
||||
replace: (_, type, id) => `tidal://${type}/${id}`,
|
||||
description: "Open Tidal links in the Tidal app",
|
||||
default: true,
|
||||
}
|
||||
});
|
||||
},
|
||||
itunes: {
|
||||
match: /^https:\/\/music\.apple\.com\/([a-z]{2}\/)?(album|artist|playlist|song|curator)\/([^/?#]+)\/?([^/?#]+)?(?:\?.*)?(?:#.*)?$/,
|
||||
replace: (_, lang, type, name, id) => id ? `itunes://music.apple.com/us/${type}/${name}/${id}` : `itunes://music.apple.com/us/${type}/${name}`,
|
||||
description: "Open Apple Music links in the iTunes app"
|
||||
},
|
||||
};
|
||||
|
||||
const pluginSettings = definePluginSettings(
|
||||
Object.entries(UrlReplacementRules).reduce((acc, [key, rule]) => {
|
||||
acc[key] = {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: rule.description,
|
||||
default: true,
|
||||
};
|
||||
return acc;
|
||||
}, {} as SettingsDefinition)
|
||||
);
|
||||
|
||||
|
||||
const Native = VencordNative.pluginHelpers.OpenInApp as PluginNative<typeof import("./native")>;
|
||||
|
||||
export default definePlugin({
|
||||
name: "OpenInApp",
|
||||
description: "Open Spotify, Tidal, Steam and Epic Games URLs in their respective apps instead of your browser",
|
||||
authors: [Devs.Ven],
|
||||
settings,
|
||||
description: "Open links in their respective apps instead of your browser",
|
||||
authors: [Devs.Ven, Devs.surgedevs],
|
||||
settings: pluginSettings,
|
||||
|
||||
patches: [
|
||||
{
|
||||
|
@ -70,7 +94,7 @@ export default definePlugin({
|
|||
// Make Spotify profile activity links open in app on web
|
||||
{
|
||||
find: "WEB_OPEN(",
|
||||
predicate: () => !IS_DISCORD_DESKTOP && settings.store.spotify,
|
||||
predicate: () => !IS_DISCORD_DESKTOP && pluginSettings.store.spotify,
|
||||
replacement: {
|
||||
match: /\i\.\i\.isProtocolRegistered\(\)(.{0,100})window.open/g,
|
||||
replace: "true$1VencordNative.native.openExternal"
|
||||
|
@ -79,8 +103,8 @@ export default definePlugin({
|
|||
{
|
||||
find: ".CONNECTED_ACCOUNT_VIEWED,",
|
||||
replacement: {
|
||||
match: /(?<=href:\i,onClick:\i=>\{)(?=.{0,10}\i=(\i)\.type,.{0,100}CONNECTED_ACCOUNT_VIEWED)/,
|
||||
replace: "$self.handleAccountView(arguments[0],$1.type,$1.id);"
|
||||
match: /(?<=href:\i,onClick:(\i)=>\{)(?=.{0,10}\i=(\i)\.type,.{0,100}CONNECTED_ACCOUNT_VIEWED)/,
|
||||
replace: "if($self.handleAccountView($1,$2.type,$2.id)) return;"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
@ -89,61 +113,25 @@ export default definePlugin({
|
|||
if (!data) return false;
|
||||
|
||||
let url = data.href;
|
||||
if (!IS_WEB && ShortUrlMatcher.test(url)) {
|
||||
event?.preventDefault();
|
||||
// CORS jumpscare
|
||||
url = await Native.resolveRedirect(url);
|
||||
}
|
||||
if (!url) return false;
|
||||
|
||||
spotify: {
|
||||
if (!settings.store.spotify) break spotify;
|
||||
for (const [key, rule] of Object.entries(UrlReplacementRules)) {
|
||||
if (!pluginSettings.store[key]) continue;
|
||||
|
||||
const match = SpotifyMatcher.exec(url);
|
||||
if (!match) break spotify;
|
||||
if (rule.shortlinkMatch?.test(url)) {
|
||||
event?.preventDefault();
|
||||
url = await Native.resolveRedirect(url);
|
||||
}
|
||||
|
||||
const [, type, id] = match;
|
||||
VencordNative.native.openExternal(`spotify:${type}:${id}`);
|
||||
if (rule.match.test(url)) {
|
||||
showToast("Opened link in native app", Toasts.Type.SUCCESS);
|
||||
|
||||
event?.preventDefault();
|
||||
return true;
|
||||
}
|
||||
const newUrl = url.replace(rule.match, rule.replace);
|
||||
VencordNative.native.openExternal(newUrl);
|
||||
|
||||
steam: {
|
||||
if (!settings.store.steam) break steam;
|
||||
|
||||
if (!SteamMatcher.test(url)) break steam;
|
||||
|
||||
VencordNative.native.openExternal(`steam://openurl/${url}`);
|
||||
event?.preventDefault();
|
||||
|
||||
// Steam does not focus itself so show a toast so it's slightly less confusing
|
||||
showToast("Opened link in Steam", Toasts.Type.SUCCESS);
|
||||
return true;
|
||||
}
|
||||
|
||||
epic: {
|
||||
if (!settings.store.epic) break epic;
|
||||
|
||||
const match = EpicMatcher.exec(url);
|
||||
if (!match) break epic;
|
||||
|
||||
VencordNative.native.openExternal(`com.epicgames.launcher://store/${match[1]}`);
|
||||
event?.preventDefault();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
tidal: {
|
||||
if (!settings.store.tidal) break tidal;
|
||||
|
||||
const match = TidalMatcher.exec(url);
|
||||
if (!match) break tidal;
|
||||
|
||||
const [, type, id] = match;
|
||||
VencordNative.native.openExternal(`tidal://${type}/${id}`);
|
||||
|
||||
event?.preventDefault();
|
||||
return true;
|
||||
event?.preventDefault();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// in case short url didn't end up being something we can handle
|
||||
|
@ -155,14 +143,12 @@ export default definePlugin({
|
|||
return false;
|
||||
},
|
||||
|
||||
handleAccountView(event: { preventDefault(): void; }, platformType: string, userId: string) {
|
||||
if (platformType === "spotify" && settings.store.spotify) {
|
||||
VencordNative.native.openExternal(`spotify:user:${userId}`);
|
||||
event.preventDefault();
|
||||
} else if (platformType === "steam" && settings.store.steam) {
|
||||
VencordNative.native.openExternal(`steam://openurl/https://steamcommunity.com/profiles/${userId}`);
|
||||
showToast("Opened link in Steam", Toasts.Type.SUCCESS);
|
||||
event.preventDefault();
|
||||
handleAccountView(e: MouseEvent, platformType: string, userId: string) {
|
||||
const rule = UrlReplacementRules[platformType];
|
||||
if (rule?.accountViewReplace && pluginSettings.store[platformType]) {
|
||||
VencordNative.native.openExternal(rule.accountViewReplace(userId));
|
||||
e.preventDefault();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
@ -211,9 +211,9 @@ export default definePlugin({
|
|||
}
|
||||
},
|
||||
{
|
||||
find: /\.BITE_SIZE,onOpenProfile:\i,usernameIcon:/,
|
||||
find: '"BiteSizeProfileBody"',
|
||||
replacement: {
|
||||
match: /currentUser:\i,guild:\i,onOpenProfile:.+?}\)(?=])(?<=user:(\i),bio:null==(\i)\?.+?)/,
|
||||
match: /currentUser:\i,guild:\i}\)(?<=user:(\i),bio:null==(\i)\?.+?)/,
|
||||
replace: "$&,$self.profilePopoutComponent({ user: $1, displayProfile: $2, simplified: true })"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -66,6 +66,15 @@ export default definePlugin({
|
|||
replace: "return true",
|
||||
}
|
||||
},
|
||||
// fixes a bug where Members page must be loaded to see highest role, why is Discord depending on MemberSafetyStore.getEnhancedMember for something that can be obtained here?
|
||||
{
|
||||
find: "Messages.GUILD_MEMBER_MOD_VIEW_PERMISSION_GRANTED_BY_ARIA_LABEL,tooltipContentClassName",
|
||||
predicate: () => settings.store.showModView,
|
||||
replacement: {
|
||||
match: /(role:)\i(?=,guildId.{0,100}role:(\i\[))/,
|
||||
replace: "$1$2arguments[0].member.highestRoleId]",
|
||||
}
|
||||
},
|
||||
{
|
||||
find: "prod_discoverable_guilds",
|
||||
predicate: () => settings.store.disableDiscoveryFilters,
|
||||
|
|
|
@ -1,15 +0,0 @@
|
|||
# XSOverlay Notifier
|
||||
|
||||
Sends Discord messages to [XSOverlay](https://store.steampowered.com/app/1173510/XSOverlay/) for easier viewing while using VR.
|
||||
|
||||
## Preview
|
||||
|
||||
![](https://github.com/Vendicated/Vencord/assets/24845294/205d2055-bb4a-44e4-b7e3-265391bccd40)
|
||||
|
||||
![](https://github.com/Vendicated/Vencord/assets/24845294/f15eff61-2d52-4620-bcab-808ecb1606d2)
|
||||
|
||||
## Usage
|
||||
- Enable this plugin
|
||||
- Set plugin settings as desired
|
||||
- Open XSOverlay
|
||||
- get ping spammed
|
|
@ -1,16 +0,0 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { createSocket, Socket } from "dgram";
|
||||
|
||||
let xsoSocket: Socket;
|
||||
|
||||
export function sendToOverlay(_, data: any) {
|
||||
data.icon = Buffer.from(data.icon).toString("base64");
|
||||
const json = JSON.stringify(data);
|
||||
xsoSocket ??= createSocket("udp4");
|
||||
xsoSocket.send(json, 42069, "127.0.0.1");
|
||||
}
|
14
src/plugins/xsOverlay/README.md
Normal file
14
src/plugins/xsOverlay/README.md
Normal file
|
@ -0,0 +1,14 @@
|
|||
# XSOverlay Notifier
|
||||
|
||||
Sends Discord messages to [XSOverlay](https://store.steampowered.com/app/1173510/XSOverlay/) for easier viewing while using VR.
|
||||
|
||||
## Preview
|
||||
|
||||
![Resulting notification inside XSOverlay](https://github.com/Vendicated/Vencord/assets/24845294/205d2055-bb4a-44e4-b7e3-265391bccd40)
|
||||
|
||||
![Test notification inside XSOverlay](https://github.com/user-attachments/assets/d3b0c387-1d67-4697-a470-d4a927e228f4)
|
||||
|
||||
## Usage
|
||||
- Enable this plugin
|
||||
- Set port and plugin settings as desired (defaults should work fine)
|
||||
- Open SteamVR and XSOverlay
|
|
@ -8,9 +8,9 @@ import { definePluginSettings } from "@api/Settings";
|
|||
import { makeRange } from "@components/PluginSettings/components";
|
||||
import { Devs } from "@utils/constants";
|
||||
import { Logger } from "@utils/Logger";
|
||||
import definePlugin, { OptionType, PluginNative, ReporterTestable } from "@utils/types";
|
||||
import definePlugin, { OptionType, ReporterTestable } from "@utils/types";
|
||||
import { findByCodeLazy, findLazy } from "@webpack";
|
||||
import { ChannelStore, GuildStore, UserStore } from "@webpack/common";
|
||||
import { Button, ChannelStore, GuildStore, UserStore } from "@webpack/common";
|
||||
import type { Channel, Embed, GuildMember, MessageAttachment, User } from "discord-types/general";
|
||||
|
||||
const ChannelTypes = findLazy(m => m.ANNOUNCEMENT_THREAD === 10);
|
||||
|
@ -68,10 +68,40 @@ interface Call {
|
|||
ringing: string[];
|
||||
}
|
||||
|
||||
interface ApiObject {
|
||||
sender: string,
|
||||
target: string,
|
||||
command: string,
|
||||
jsonData: string,
|
||||
rawData: string | null,
|
||||
}
|
||||
|
||||
interface NotificationObject {
|
||||
type: number;
|
||||
timeout: number;
|
||||
height: number;
|
||||
opacity: number;
|
||||
volume: number;
|
||||
audioPath: string;
|
||||
title: string;
|
||||
content: string;
|
||||
useBase64Icon: boolean;
|
||||
icon: ArrayBuffer | string;
|
||||
sourceApp: string;
|
||||
}
|
||||
|
||||
const notificationsShouldNotify = findByCodeLazy(".SUPPRESS_NOTIFICATIONS))return!1");
|
||||
const XSLog = new Logger("XSOverlay");
|
||||
const logger = new Logger("XSOverlay");
|
||||
|
||||
const settings = definePluginSettings({
|
||||
webSocketPort: {
|
||||
type: OptionType.NUMBER,
|
||||
description: "Websocket port",
|
||||
default: 42070,
|
||||
async onChange() {
|
||||
await start();
|
||||
}
|
||||
},
|
||||
botNotifications: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Allow bot notifications",
|
||||
|
@ -136,7 +166,17 @@ const settings = definePluginSettings({
|
|||
},
|
||||
});
|
||||
|
||||
const Native = VencordNative.pluginHelpers.XSOverlay as PluginNative<typeof import("./native")>;
|
||||
let socket: WebSocket;
|
||||
|
||||
async function start() {
|
||||
if (socket) socket.close();
|
||||
socket = new WebSocket(`ws://127.0.0.1:${settings.store.webSocketPort ?? 42070}/?client=Vencord`);
|
||||
return new Promise((resolve, reject) => {
|
||||
socket.onopen = resolve;
|
||||
socket.onerror = reject;
|
||||
setTimeout(reject, 3000);
|
||||
});
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
name: "XSOverlay",
|
||||
|
@ -248,7 +288,21 @@ export default definePlugin({
|
|||
if (shouldIgnoreForChannelType(channel)) return;
|
||||
sendMsgNotif(titleString, finalMsg, message);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
start,
|
||||
|
||||
stop() {
|
||||
socket.close();
|
||||
},
|
||||
|
||||
settingsAboutComponent: () => (
|
||||
<>
|
||||
<Button onClick={() => sendOtherNotif("This is a test notification! explode", "Hello from Vendor!")}>
|
||||
Send test notification
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
});
|
||||
|
||||
function shouldIgnoreForChannelType(channel: Channel) {
|
||||
|
@ -259,9 +313,8 @@ function shouldIgnoreForChannelType(channel: Channel) {
|
|||
|
||||
function sendMsgNotif(titleString: string, content: string, message: Message) {
|
||||
fetch(`https://cdn.discordapp.com/avatars/${message.author.id}/${message.author.avatar}.png?size=128`).then(response => response.arrayBuffer()).then(result => {
|
||||
const msgData = {
|
||||
messageType: 1,
|
||||
index: 0,
|
||||
const msgData: NotificationObject = {
|
||||
type: 1,
|
||||
timeout: settings.store.lengthBasedTimeout ? calculateTimeout(content) : settings.store.timeout,
|
||||
height: calculateHeight(content),
|
||||
opacity: settings.store.opacity,
|
||||
|
@ -270,17 +323,17 @@ function sendMsgNotif(titleString: string, content: string, message: Message) {
|
|||
title: titleString,
|
||||
content: content,
|
||||
useBase64Icon: true,
|
||||
icon: result,
|
||||
icon: new TextDecoder().decode(result),
|
||||
sourceApp: "Vencord"
|
||||
};
|
||||
Native.sendToOverlay(msgData);
|
||||
|
||||
sendToOverlay(msgData);
|
||||
});
|
||||
}
|
||||
|
||||
function sendOtherNotif(content: string, titleString: string) {
|
||||
const msgData = {
|
||||
messageType: 1,
|
||||
index: 0,
|
||||
const msgData: NotificationObject = {
|
||||
type: 1,
|
||||
timeout: settings.store.lengthBasedTimeout ? calculateTimeout(content) : settings.store.timeout,
|
||||
height: calculateHeight(content),
|
||||
opacity: settings.store.opacity,
|
||||
|
@ -289,10 +342,22 @@ function sendOtherNotif(content: string, titleString: string) {
|
|||
title: titleString,
|
||||
content: content,
|
||||
useBase64Icon: false,
|
||||
icon: null,
|
||||
icon: "default",
|
||||
sourceApp: "Vencord"
|
||||
};
|
||||
Native.sendToOverlay(msgData);
|
||||
sendToOverlay(msgData);
|
||||
}
|
||||
|
||||
async function sendToOverlay(notif: NotificationObject) {
|
||||
const apiObject: ApiObject = {
|
||||
sender: "Vencord",
|
||||
target: "xsoverlay",
|
||||
command: "SendNotification",
|
||||
jsonData: JSON.stringify(notif),
|
||||
rawData: null
|
||||
};
|
||||
if (socket.readyState !== socket.OPEN) await start();
|
||||
socket.send(JSON.stringify(apiObject));
|
||||
}
|
||||
|
||||
function shouldNotify(message: Message, channel: string) {
|
|
@ -542,6 +542,10 @@ export const Devs = /* #__PURE__*/ Object.freeze({
|
|||
name: "Joona",
|
||||
id: 297410829589020673n
|
||||
},
|
||||
surgedevs: {
|
||||
name: "Chloe",
|
||||
id: 1084592643784331324n
|
||||
}
|
||||
} satisfies Record<string, Dev>);
|
||||
|
||||
// iife so #__PURE__ works correctly
|
||||
|
|
Loading…
Reference in a new issue