void>>();
+ private globalListeners = new Set<(newData: T, path: string) => void>();
+
+ /**
+ * The store object. Making changes to this object will trigger the applicable change listeners
+ */
+ public declare store: T;
+ /**
+ * The plain data. Changes to this object will not trigger any change listeners
+ */
+ public declare plain: T;
+
+ public constructor(plain: T, options: SettingsStoreOptions = {}) {
+ this.plain = plain;
+ this.store = this.makeProxy(plain);
+ Object.assign(this, options);
+ }
+
+ private makeProxy(object: any, root: T = object, path: string = "") {
+ const self = this;
+
+ return new Proxy(object, {
+ get(target, key: string) {
+ let v = target[key];
+
+ if (!(key in target) && self.getDefaultValue) {
+ v = self.getDefaultValue({
+ target,
+ key,
+ root,
+ path
+ });
+ }
+
+ if (typeof v === "object" && v !== null && !Array.isArray(v))
+ return self.makeProxy(v, root, `${path}${path && "."}${key}`);
+
+ return v;
+ },
+ set(target, key: string, value) {
+ if (target[key] === value) return true;
+
+ Reflect.set(target, key, value);
+ const setPath = `${path}${path && "."}${key}`;
+
+ self.globalListeners.forEach(cb => cb(value, setPath));
+ self.pathListeners.get(setPath)?.forEach(cb => cb(value));
+
+ return true;
+ }
+ });
+ }
+
+ /**
+ * Set the data of the store.
+ * This will update this.store and this.plain (and old references to them will be stale! Avoid storing them in variables)
+ *
+ * Additionally, all global listeners (and those for pathToNotify, if specified) will be called with the new data
+ * @param value New data
+ * @param pathToNotify Optional path to notify instead of globally. Used to transfer path via ipc
+ */
+ public setData(value: T, pathToNotify?: string) {
+ if (this.readOnly) throw new Error("SettingsStore is read-only");
+
+ this.plain = value;
+ this.store = this.makeProxy(value);
+
+ if (pathToNotify) {
+ let v = value;
+
+ const path = pathToNotify.split(".");
+ for (const p of path) {
+ if (!v) {
+ console.warn(
+ `Settings#setData: Path ${pathToNotify} does not exist in new data. Not dispatching update`
+ );
+ return;
+ }
+ v = v[p];
+ }
+
+ this.pathListeners.get(pathToNotify)?.forEach(cb => cb(v));
+ }
+
+ this.markAsChanged();
+ }
+
+ /**
+ * Add a global change listener, that will fire whenever any setting is changed
+ *
+ * @param data The new data. This is either the new value set on the path, or the new root object if it was changed
+ * @param path The path of the setting that was changed. Empty string if the root object was changed
+ */
+ public addGlobalChangeListener(cb: (data: any, path: string) => void) {
+ this.globalListeners.add(cb);
+ }
+
+ /**
+ * Add a scoped change listener that will fire whenever a setting matching the specified path is changed.
+ *
+ * For example if path is `"foo.bar"`, the listener will fire on
+ * ```js
+ * Setting.store.foo.bar = "hi"
+ * ```
+ * but not on
+ * ```js
+ * Setting.store.foo.baz = "hi"
+ * ```
+ * @param path
+ * @param cb
+ */
+ public addChangeListener>(
+ path: P,
+ cb: (data: ResolvePropDeep) => void
+ ) {
+ const listeners = this.pathListeners.get(path as string) ?? new Set();
+ listeners.add(cb);
+ this.pathListeners.set(path as string, listeners);
+ }
+
+ /**
+ * Remove a global listener
+ * @see {@link addGlobalChangeListener}
+ */
+ public removeGlobalChangeListener(cb: (data: any, path: string) => void) {
+ this.globalListeners.delete(cb);
+ }
+
+ /**
+ * Remove a scoped listener
+ * @see {@link addChangeListener}
+ */
+ public removeChangeListener(path: LiteralUnion, cb: (data: any) => void) {
+ const listeners = this.pathListeners.get(path as string);
+ if (!listeners) return;
+
+ listeners.delete(cb);
+ if (!listeners.size) this.pathListeners.delete(path as string);
+ }
+
+ /**
+ * Call all global change listeners
+ */
+ public markAsChanged() {
+ this.globalListeners.forEach(cb => cb(this.plain, ""));
+ }
+}
diff --git a/src/utils/debounce.ts b/src/shared/debounce.ts
similarity index 100%
rename from src/utils/debounce.ts
rename to src/shared/debounce.ts
diff --git a/src/utils/onceDefined.ts b/src/shared/onceDefined.ts
similarity index 100%
rename from src/utils/onceDefined.ts
rename to src/shared/onceDefined.ts
diff --git a/src/shared/vencordUserAgent.ts b/src/shared/vencordUserAgent.ts
new file mode 100644
index 000000000..0cb1882bf
--- /dev/null
+++ b/src/shared/vencordUserAgent.ts
@@ -0,0 +1,12 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2024 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import gitHash from "~git-hash";
+import gitRemote from "~git-remote";
+
+export { gitHash, gitRemote };
+
+export const VENCORD_USER_AGENT = `Vencord/${gitHash}${gitRemote ? ` (https://github.com/${gitRemote})` : ""}`;
diff --git a/src/utils/cloud.tsx b/src/utils/cloud.tsx
index f56c78dc5..508b1c7ef 100644
--- a/src/utils/cloud.tsx
+++ b/src/utils/cloud.tsx
@@ -106,7 +106,7 @@ export async function authorizeCloud() {
try {
const res = await fetch(location, {
- headers: new Headers({ Accept: "application/json" })
+ headers: { Accept: "application/json" }
});
const { secret } = await res.json();
if (secret) {
diff --git a/src/utils/constants.ts b/src/utils/constants.ts
index d5274203f..ca570361a 100644
--- a/src/utils/constants.ts
+++ b/src/utils/constants.ts
@@ -16,17 +16,8 @@
* along with this program. If not, see .
*/
-import gitHash from "~git-hash";
-import gitRemote from "~git-remote";
-
-export {
- gitHash,
- gitRemote
-};
-
export const WEBPACK_CHUNK = "webpackChunkdiscord_app";
export const REACT_GLOBAL = "Vencord.Webpack.Common.React";
-export const VENCORD_USER_AGENT = `Vencord/${gitHash}${gitRemote ? ` (https://github.com/${gitRemote})` : ""}`;
export const SUPPORT_CHANNEL_ID = "1026515880080842772";
export interface Dev {
@@ -42,6 +33,10 @@ export interface Dev {
* If you are fine with attribution but don't want the badge, add badge: false
*/
export const Devs = /* #__PURE__*/ Object.freeze({
+ Nobody: {
+ name: "Nobody",
+ id: 0n,
+ },
Ven: {
name: "Vendicated",
id: 343383572805058560n
@@ -54,6 +49,10 @@ export const Devs = /* #__PURE__*/ Object.freeze({
name: "Cynosphere",
id: 150745989836308480n
},
+ Trwy: {
+ name: "trey",
+ id: 354427199023218689n
+ },
Megu: {
name: "Megumin",
id: 545581357812678656n
@@ -62,8 +61,8 @@ export const Devs = /* #__PURE__*/ Object.freeze({
name: "botato",
id: 440990343899643943n
},
- obscurity: {
- name: "obscurity",
+ fawn: {
+ name: "fawn",
id: 336678828233588736n,
},
rushii: {
@@ -156,7 +155,7 @@ export const Devs = /* #__PURE__*/ Object.freeze({
},
kemo: {
name: "kemo",
- id: 299693897859465228n
+ id: 715746190813298788n
},
dzshn: {
name: "dzshn",
@@ -267,6 +266,10 @@ export const Devs = /* #__PURE__*/ Object.freeze({
name: "Dziurwa",
id: 1001086404203389018n
},
+ arHSM: {
+ name: "arHSM",
+ id: 841509053422632990n
+ },
F53: {
name: "F53",
id: 280411966126948353n
@@ -287,10 +290,6 @@ export const Devs = /* #__PURE__*/ Object.freeze({
name: "RyanCaoDev",
id: 952235800110694471n,
},
- Strencher: {
- name: "Strencher",
- id: 415849376598982656n
- },
FieryFlames: {
name: "Fiery",
id: 890228870559698955n
@@ -359,10 +358,6 @@ export const Devs = /* #__PURE__*/ Object.freeze({
name: "bb010g",
id: 72791153467990016n,
},
- Lumap: {
- name: "lumap",
- id: 635383782576357407n
- },
Dolfies: {
name: "Dolfies",
id: 852892297661906993n,
@@ -383,6 +378,10 @@ export const Devs = /* #__PURE__*/ Object.freeze({
name: "ProffDea",
id: 609329952180928513n
},
+ UlyssesZhan: {
+ name: "UlyssesZhan",
+ id: 586808226058862623n
+ },
ant0n: {
name: "ant0n",
id: 145224646868860928n
@@ -391,6 +390,10 @@ export const Devs = /* #__PURE__*/ Object.freeze({
name: "MrDiamond",
id: 523338295644782592n
},
+ Board: {
+ name: "BoardTM",
+ id: 285475344817848320n,
+ },
philipbry: {
name: "philipbry",
id: 554994003318276106n
@@ -403,6 +406,118 @@ export const Devs = /* #__PURE__*/ Object.freeze({
name: "maisy",
id: 257109471589957632n,
},
+ Mopi: {
+ name: "Mopi",
+ id: 1022189106614243350n
+ },
+ Grzesiek11: {
+ name: "Grzesiek11",
+ id: 368475654662127616n,
+ },
+ Samwich: {
+ name: "Samwich",
+ id: 976176454511509554n,
+ },
+ coolelectronics: {
+ name: "coolelectronics",
+ id: 696392247205298207n,
+ },
+ Av32000: {
+ name: "Av32000",
+ id: 593436735380127770n,
+ },
+ Noxillio: {
+ name: "Noxillio",
+ id: 138616536502894592n,
+ },
+ Kyuuhachi: {
+ name: "Kyuuhachi",
+ id: 236588665420251137n,
+ },
+ nin0dev: {
+ name: "nin0dev",
+ id: 886685857560539176n
+ },
+ Elvyra: {
+ name: "Elvyra",
+ id: 708275751816003615n,
+ },
+ Inbestigator: {
+ name: "Inbestigator",
+ id: 761777382041714690n
+ },
+ newwares: {
+ name: "newwares",
+ id: 421405303951851520n
+ },
+ JohnyTheCarrot: {
+ name: "JohnyTheCarrot",
+ id: 132819036282159104n
+ },
+ puv: {
+ name: "puv",
+ id: 469441552251355137n
+ },
+ Kodarru: {
+ name: "Kodarru",
+ id: 785227396218748949n
+ },
+ nakoyasha: {
+ name: "nakoyasha",
+ id: 222069018507345921n
+ },
+ Sqaaakoi: {
+ name: "Sqaaakoi",
+ id: 259558259491340288n
+ },
+ Byron: {
+ name: "byeoon",
+ id: 1167275288036655133n
+ },
+ Kaitlyn: {
+ name: "kaitlyn",
+ id: 306158896630988801n
+ },
+ PolisanTheEasyNick: {
+ name: "Oleh Polisan",
+ id: 242305263313485825n
+ },
+ HAHALOSAH: {
+ name: "HAHALOSAH",
+ id: 903418691268513883n
+ },
+ GabiRP: {
+ name: "GabiRP",
+ id: 507955112027750401n
+ },
+ ImBanana: {
+ name: "Im_Banana",
+ id: 635250116688871425n
+ },
+ xocherry: {
+ name: "xocherry",
+ id: 221288171013406720n
+ },
+ ScattrdBlade: {
+ name: "ScattrdBlade",
+ id: 678007540608532491n
+ },
+ goodbee: {
+ name: "goodbee",
+ id: 658968552606400512n
+ },
+ Moxxie: {
+ name: "Moxxie",
+ id: 712653921692155965n,
+ },
+ Ethan: {
+ name: "Ethan",
+ id: 721717126523781240n,
+ },
+ nyx: {
+ name: "verticalsync",
+ id: 328165170536775680n
+ },
} satisfies Record);
// iife so #__PURE__ works correctly
diff --git a/src/utils/discord.tsx b/src/utils/discord.tsx
index 74e1aefe8..57202ba3c 100644
--- a/src/utils/discord.tsx
+++ b/src/utils/discord.tsx
@@ -17,7 +17,7 @@
*/
import { MessageObject } from "@api/MessageEvents";
-import { ChannelStore, ComponentDispatch, FluxDispatcher, GuildStore, InviteActions, MaskedLink, MessageActions, ModalImageClasses, PrivateChannelsStore, RestAPI, SelectedChannelStore, SelectedGuildStore, UserProfileActions, UserProfileStore, UserSettingsActionCreators, UserUtils } from "@webpack/common";
+import { ChannelStore, ComponentDispatch, Constants, FluxDispatcher, GuildStore, InviteActions, MaskedLink, MessageActions, ModalImageClasses, PrivateChannelsStore, RestAPI, SelectedChannelStore, SelectedGuildStore, UserProfileActions, UserProfileStore, UserSettingsActionCreators, UserUtils } from "@webpack/common";
import { Guild, Message, User } from "discord-types/general";
import { ImageModal, ModalRoot, ModalSize, openModal } from "./modal";
@@ -162,7 +162,7 @@ export async function fetchUserProfile(id: string, options?: FetchUserProfileOpt
FluxDispatcher.dispatch({ type: "USER_PROFILE_FETCH_START", userId: id });
const { body } = await RestAPI.get({
- url: `/users/${id}/profile`,
+ url: Constants.Endpoints.USER_PROFILE(id),
query: {
with_mutual_guilds: false,
with_mutual_friends_count: false,
diff --git a/src/utils/index.ts b/src/utils/index.ts
index 90bf86082..62f3f6e96 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -16,18 +16,20 @@
* along with this program. If not, see .
*/
+export * from "../shared/debounce";
+export * from "../shared/onceDefined";
export * from "./ChangeList";
export * from "./constants";
-export * from "./debounce";
export * from "./discord";
export * from "./guards";
export * from "./lazy";
+export * from "./lazyReact";
export * from "./localStorage";
export * from "./Logger";
export * from "./margins";
+export * from "./mergeDefaults";
export * from "./misc";
export * from "./modal";
-export * from "./onceDefined";
export * from "./onlyOnce";
export * from "./patches";
export * from "./Queue";
diff --git a/src/utils/lazy.ts b/src/utils/lazy.ts
index 32336fb40..a61785df9 100644
--- a/src/utils/lazy.ts
+++ b/src/utils/lazy.ts
@@ -116,8 +116,11 @@ export function proxyLazy(factory: () => T, attempts = 5, isChild = false): T
attempts,
true
);
-
- return Reflect.get(target[kGET](), p, receiver);
+ const lazyTarget = target[kGET]();
+ if (typeof lazyTarget === "object" || typeof lazyTarget === "function") {
+ return Reflect.get(lazyTarget, p, receiver);
+ }
+ throw new Error("proxyLazy called on a primitive value");
}
}) as any;
}
diff --git a/src/utils/mergeDefaults.ts b/src/utils/mergeDefaults.ts
new file mode 100644
index 000000000..58ba136dd
--- /dev/null
+++ b/src/utils/mergeDefaults.ts
@@ -0,0 +1,24 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2024 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+/**
+ * Recursively merges defaults into an object and returns the same object
+ * @param obj Object
+ * @param defaults Defaults
+ * @returns obj
+ */
+export function mergeDefaults(obj: T, defaults: T): T {
+ for (const key in defaults) {
+ const v = defaults[key];
+ if (typeof v === "object" && !Array.isArray(v)) {
+ obj[key] ??= {} as any;
+ mergeDefaults(obj[key], v);
+ } else {
+ obj[key] ??= v;
+ }
+ }
+ return obj;
+}
diff --git a/src/utils/misc.tsx b/src/utils/misc.tsx
index 2b8ccf8a7..fb08c93f6 100644
--- a/src/utils/misc.tsx
+++ b/src/utils/misc.tsx
@@ -20,25 +20,6 @@ import { Clipboard, Toasts } from "@webpack/common";
import { DevsById } from "./constants";
-/**
- * Recursively merges defaults into an object and returns the same object
- * @param obj Object
- * @param defaults Defaults
- * @returns obj
- */
-export function mergeDefaults(obj: T, defaults: T): T {
- for (const key in defaults) {
- const v = defaults[key];
- if (typeof v === "object" && !Array.isArray(v)) {
- obj[key] ??= {} as any;
- mergeDefaults(obj[key], v);
- } else {
- obj[key] ??= v;
- }
- }
- return obj;
-}
-
/**
* Calls .join(" ") on the arguments
* classes("one", "two") => "one two"
@@ -114,3 +95,7 @@ export function identity(value: T): T {
export const isMobile = navigator.userAgent.includes("Mobi");
export const isPluginDev = (id: string) => Object.hasOwn(DevsById, id);
+
+export function pluralise(amount: number, singular: string, plural = singular + "s") {
+ return amount === 1 ? `${amount} ${singular}` : `${amount} ${plural}`;
+}
diff --git a/src/utils/patches.ts b/src/utils/patches.ts
index c30f7b17c..87f3ce78c 100644
--- a/src/utils/patches.ts
+++ b/src/utils/patches.ts
@@ -16,22 +16,22 @@
* along with this program. If not, see .
*/
-import { PatchReplacement, ReplaceFn } from "./types";
+import { Patch, PatchReplacement, ReplaceFn } from "./types";
-export function canonicalizeMatch(match: RegExp | string) {
+export function canonicalizeMatch(match: T): T {
if (typeof match === "string") return match;
const canonSource = match.source
.replaceAll("\\i", "[A-Za-z_$][\\w$]*");
- return new RegExp(canonSource, match.flags);
+ return new RegExp(canonSource, match.flags) as T;
}
-export function canonicalizeReplace(replace: string | ReplaceFn, pluginName: string): string | ReplaceFn {
+export function canonicalizeReplace(replace: T, pluginName: string): T {
const self = `Vencord.Plugins.plugins[${JSON.stringify(pluginName)}]`;
if (typeof replace !== "function")
- return replace.replaceAll("$self", self);
+ return replace.replaceAll("$self", self) as T;
- return (...args) => replace(...args).replaceAll("$self", self);
+ return ((...args) => replace(...args).replaceAll("$self", self)) as T;
}
export function canonicalizeDescriptor(descriptor: TypedPropertyDescriptor, canonicalize: (value: T) => T) {
@@ -55,3 +55,9 @@ export function canonicalizeReplacement(replacement: Pick.
*/
-import { addSettingsListener, Settings } from "@api/Settings";
+import { Settings, SettingsStore } from "@api/Settings";
let style: HTMLStyleElement;
@@ -81,10 +81,10 @@ document.addEventListener("DOMContentLoaded", () => {
initThemes();
toggle(Settings.useQuickCss);
- addSettingsListener("useQuickCss", toggle);
+ SettingsStore.addChangeListener("useQuickCss", toggle);
- addSettingsListener("themeLinks", initThemes);
- addSettingsListener("enabledThemes", initThemes);
+ SettingsStore.addChangeListener("themeLinks", initThemes);
+ SettingsStore.addChangeListener("enabledThemes", initThemes);
if (!IS_WEB)
VencordNative.quickCss.addThemeChangeListener(initThemes);
diff --git a/src/utils/settingsSync.ts b/src/utils/settingsSync.ts
index ec32e2b1e..f19928ac4 100644
--- a/src/utils/settingsSync.ts
+++ b/src/utils/settingsSync.ts
@@ -18,7 +18,7 @@
import { showNotification } from "@api/Notifications";
import { PlainSettings, Settings } from "@api/Settings";
-import { Toasts } from "@webpack/common";
+import { moment, Toasts } from "@webpack/common";
import { deflateSync, inflateSync } from "fflate";
import { getCloudAuth, getCloudUrl } from "./cloud";
@@ -36,20 +36,20 @@ export async function importSettings(data: string) {
if ("settings" in parsed && "quickCss" in parsed) {
Object.assign(PlainSettings, parsed.settings);
- await VencordNative.settings.set(JSON.stringify(parsed.settings, null, 4));
+ await VencordNative.settings.set(parsed.settings);
await VencordNative.quickCss.set(parsed.quickCss);
} else
throw new Error("Invalid Settings. Is this even a Vencord Settings file?");
}
export async function exportSettings({ minify }: { minify?: boolean; } = {}) {
- const settings = JSON.parse(VencordNative.settings.get());
+ const settings = VencordNative.settings.get();
const quickCss = await VencordNative.quickCss.get();
return JSON.stringify({ settings, quickCss }, null, minify ? undefined : 4);
}
export async function downloadSettingsBackup() {
- const filename = "vencord-settings-backup.json";
+ const filename = `vencord-settings-backup-${moment().format("YYYY-MM-DD")}.json`;
const backup = await exportSettings();
const data = new TextEncoder().encode(backup);
@@ -118,10 +118,10 @@ export async function putCloudSettings(manual?: boolean) {
try {
const res = await fetch(new URL("/v1/settings", getCloudUrl()), {
method: "PUT",
- headers: new Headers({
+ headers: {
Authorization: await getCloudAuth(),
"Content-Type": "application/octet-stream"
- }),
+ },
body: deflateSync(new TextEncoder().encode(settings))
});
@@ -137,7 +137,7 @@ export async function putCloudSettings(manual?: boolean) {
const { written } = await res.json();
PlainSettings.cloud.settingsSyncVersion = written;
- VencordNative.settings.set(JSON.stringify(PlainSettings, null, 4));
+ VencordNative.settings.set(PlainSettings);
cloudSettingsLogger.info("Settings uploaded to cloud successfully");
@@ -162,11 +162,11 @@ export async function getCloudSettings(shouldNotify = true, force = false) {
try {
const res = await fetch(new URL("/v1/settings", getCloudUrl()), {
method: "GET",
- headers: new Headers({
+ headers: {
Authorization: await getCloudAuth(),
Accept: "application/octet-stream",
"If-None-Match": Settings.cloud.settingsSyncVersion.toString()
- }),
+ },
});
if (res.status === 404) {
@@ -222,7 +222,7 @@ export async function getCloudSettings(shouldNotify = true, force = false) {
// sync with server timestamp instead of local one
PlainSettings.cloud.settingsSyncVersion = written;
- VencordNative.settings.set(JSON.stringify(PlainSettings, null, 4));
+ VencordNative.settings.set(PlainSettings);
cloudSettingsLogger.info("Settings loaded from cloud successfully");
if (shouldNotify)
@@ -251,9 +251,7 @@ export async function deleteCloudSettings() {
try {
const res = await fetch(new URL("/v1/settings", getCloudUrl()), {
method: "DELETE",
- headers: new Headers({
- Authorization: await getCloudAuth()
- }),
+ headers: { Authorization: await getCloudAuth() },
});
if (!res.ok) {
diff --git a/src/utils/types.ts b/src/utils/types.ts
index 16867a43c..6e1524196 100644
--- a/src/utils/types.ts
+++ b/src/utils/types.ts
@@ -17,6 +17,7 @@
*/
import { Command } from "@api/Commands";
+import { NavContextMenuPatchCallback } from "@api/ContextMenu";
import { FluxEvents } from "@webpack/types";
import { Promisable } from "type-fest";
@@ -28,14 +29,19 @@ export default function definePlugin(p: P & Record string;
export interface PatchReplacement {
+ /** The match for the patch replacement. If you use a string it will be implicitly converted to a RegExp */
match: string | RegExp;
+ /** The replacement string or function which returns the string for the patch replacement */
replace: string | ReplaceFn;
+ /** A function which returns whether this patch replacement should be applied */
predicate?(): boolean;
}
export interface Patch {
plugin: string;
- find: string;
+ /** A string or RegExp which is only include/matched in the module code you wish to patch. Prefer only using a RegExp if a simple string test is not enough */
+ find: string | RegExp;
+ /** The replacement(s) for the module being patched */
replacement: PatchReplacement | PatchReplacement[];
/** Whether this patch should apply to multiple modules */
all?: boolean;
@@ -43,6 +49,7 @@ export interface Patch {
noWarn?: boolean;
/** Only apply this set of replacements if all of them succeed. Use this if your replacements depend on each other */
group?: boolean;
+ /** A function which returns whether this patch should be applied */
predicate?(): boolean;
}
@@ -115,6 +122,10 @@ export interface PluginDef {
flux?: {
[E in FluxEvents]?: (event: any) => void;
};
+ /**
+ * Allows you to manipulate context menus
+ */
+ contextMenus?: Record;
/**
* Allows you to add custom actions to the Vencord Toolbox.
* The key will be used as text for the button
@@ -233,7 +244,7 @@ export interface PluginSettingSliderDef {
stickToMarkers?: boolean;
}
-interface IPluginOptionComponentProps {
+export interface IPluginOptionComponentProps {
/**
* Run this when the value changes.
*
diff --git a/src/webpack/common/components.ts b/src/webpack/common/components.ts
index d7bb5d759..46f843ce6 100644
--- a/src/webpack/common/components.ts
+++ b/src/webpack/common/components.ts
@@ -36,6 +36,7 @@ export let Tooltip: t.Tooltip;
export let TextInput: t.TextInput;
export let TextArea: t.TextArea;
export let Text: t.Text;
+export let Heading: t.Heading;
export let Select: t.Select;
export let SearchableSelect: t.SearchableSelect;
export let Slider: t.Slider;
@@ -47,17 +48,40 @@ export let Paginator: t.Paginator;
export let ScrollerThin: t.ScrollerThin;
export let Clickable: t.Clickable;
export let Avatar: t.Avatar;
+export let FocusLock: t.FocusLock;
// token lagger real
/** css colour resolver stuff, no clue what exactly this does, just copied usage from Discord */
export let useToken: t.useToken;
-export const MaskedLink = waitForComponent("MaskedLink", m => m?.type?.toString().includes("MASKED_LINK)"));
+export const MaskedLink = waitForComponent("MaskedLink", filters.componentByCode("MASKED_LINK)"));
export const Timestamp = waitForComponent("Timestamp", filters.byCode(".Messages.MESSAGE_EDITED_TIMESTAMP_A11Y_LABEL.format"));
export const Flex = waitForComponent("Flex", ["Justify", "Align", "Wrap"]);
export const { OAuth2AuthorizeModal } = findByPropsLazy("OAuth2AuthorizeModal");
waitFor(["FormItem", "Button"], m => {
- ({ useToken, Card, Button, FormSwitch: Switch, Tooltip, TextInput, TextArea, Text, Select, SearchableSelect, Slider, ButtonLooks, TabBar, Popout, Dialog, Paginator, ScrollerThin, Clickable, Avatar } = m);
+ ({
+ useToken,
+ Card,
+ Button,
+ FormSwitch: Switch,
+ Tooltip,
+ TextInput,
+ TextArea,
+ Text,
+ Select,
+ SearchableSelect,
+ Slider,
+ ButtonLooks,
+ TabBar,
+ Popout,
+ Dialog,
+ Paginator,
+ ScrollerThin,
+ Clickable,
+ Avatar,
+ FocusLock,
+ Heading
+ } = m);
Forms = m;
});
diff --git a/src/webpack/common/settingsStores.ts b/src/webpack/common/settingsStores.ts
index 6db21949a..4a48efda6 100644
--- a/src/webpack/common/settingsStores.ts
+++ b/src/webpack/common/settingsStores.ts
@@ -6,7 +6,10 @@
import { findByPropsLazy } from "@webpack";
-export const TextAndImagesSettingsStores = findByPropsLazy("MessageDisplayCompact");
-export const StatusSettingsStores = findByPropsLazy("ShowCurrentGame");
+import * as t from "./types/settingsStores";
+
+
+export const TextAndImagesSettingsStores = findByPropsLazy("MessageDisplayCompact") as Record;
+export const StatusSettingsStores = findByPropsLazy("ShowCurrentGame") as Record;
export const UserSettingsActionCreators = findByPropsLazy("PreloadedUserSettingsActionCreators");
diff --git a/src/webpack/common/stores.ts b/src/webpack/common/stores.ts
index 0c470d6a6..123c62b05 100644
--- a/src/webpack/common/stores.ts
+++ b/src/webpack/common/stores.ts
@@ -27,12 +27,7 @@ export const Flux: t.Flux = findByPropsLazy("connectStores");
export type GenericStore = t.FluxStore & Record;
-export enum DraftType {
- ChannelMessage = 0,
- ThreadSettings = 1,
- FirstThreadMessage = 2,
- ApplicationLauncherCommand = 3
-}
+export const { DraftType }: { DraftType: typeof t.DraftType; } = findByPropsLazy("DraftType");
export let MessageStore: Omit & {
getMessages(chanId: string): any;
@@ -44,9 +39,8 @@ export let PermissionStore: GenericStore;
export let GuildChannelStore: GenericStore;
export let ReadStateStore: GenericStore;
export let PresenceStore: GenericStore;
-export let PoggerModeSettingsStore: GenericStore;
-export let GuildStore: Stores.GuildStore & t.FluxStore;
+export let GuildStore: t.GuildStore;
export let UserStore: Stores.UserStore & t.FluxStore;
export let UserProfileStore: GenericStore;
export let SelectedChannelStore: Stores.SelectedChannelStore & t.FluxStore;
@@ -65,23 +59,15 @@ export let DraftStore: t.DraftStore;
/**
* React hook that returns stateful data for one or more stores
* You might need a custom comparator (4th argument) if your store data is an object
- *
* @param stores The stores to listen to
* @param mapper A function that returns the data you need
- * @param idk some thing, idk just pass null
+ * @param dependencies An array of reactive values which the hook depends on. Use this if your mapper or equality function depends on the value of another hook
* @param isEqual A custom comparator for the data returned by mapper
*
* @example const user = useStateFromStores([UserStore], () => UserStore.getCurrentUser(), null, (old, current) => old.id === current.id);
*/
-export const { useStateFromStores }: {
- useStateFromStores: (
- stores: t.FluxStore[],
- mapper: () => T,
- idk?: any,
- isEqual?: (old: T, newer: T) => boolean
- ) => T;
-}
- = findByPropsLazy("useStateFromStores");
+// eslint-disable-next-line prefer-destructuring
+export const useStateFromStores: t.useStateFromStores = findByPropsLazy("useStateFromStores").useStateFromStores;
waitForStore("DraftStore", s => DraftStore = s);
waitForStore("UserStore", s => UserStore = s);
diff --git a/src/webpack/common/types/components.d.ts b/src/webpack/common/types/components.d.ts
index b9bc434c6..c51264370 100644
--- a/src/webpack/common/types/components.d.ts
+++ b/src/webpack/common/types/components.d.ts
@@ -16,28 +16,28 @@
* along with this program. If not, see .
*/
-import type { Moment } from "moment";
import type { ComponentType, CSSProperties, FunctionComponent, HtmlHTMLAttributes, HTMLProps, KeyboardEvent, MouseEvent, PropsWithChildren, PropsWithRef, ReactNode, Ref } from "react";
export type TextVariant = "heading-sm/normal" | "heading-sm/medium" | "heading-sm/semibold" | "heading-sm/bold" | "heading-md/normal" | "heading-md/medium" | "heading-md/semibold" | "heading-md/bold" | "heading-lg/normal" | "heading-lg/medium" | "heading-lg/semibold" | "heading-lg/bold" | "heading-xl/normal" | "heading-xl/medium" | "heading-xl/bold" | "heading-xxl/normal" | "heading-xxl/medium" | "heading-xxl/bold" | "eyebrow" | "heading-deprecated-14/normal" | "heading-deprecated-14/medium" | "heading-deprecated-14/bold" | "text-xxs/normal" | "text-xxs/medium" | "text-xxs/semibold" | "text-xxs/bold" | "text-xs/normal" | "text-xs/medium" | "text-xs/semibold" | "text-xs/bold" | "text-sm/normal" | "text-sm/medium" | "text-sm/semibold" | "text-sm/bold" | "text-md/normal" | "text-md/medium" | "text-md/semibold" | "text-md/bold" | "text-lg/normal" | "text-lg/medium" | "text-lg/semibold" | "text-lg/bold" | "display-sm" | "display-md" | "display-lg" | "code";
export type FormTextTypes = Record<"DEFAULT" | "INPUT_PLACEHOLDER" | "DESCRIPTION" | "LABEL_BOLD" | "LABEL_SELECTED" | "LABEL_DESCRIPTOR" | "ERROR" | "SUCCESS", string>;
-export type Heading = `h${1 | 2 | 3 | 4 | 5 | 6}`;
+export type HeadingTag = `h${1 | 2 | 3 | 4 | 5 | 6}`;
export type Margins = Record<"marginTop16" | "marginTop8" | "marginBottom8" | "marginTop20" | "marginBottom20", string>;
export type ButtonLooks = Record<"FILLED" | "INVERTED" | "OUTLINED" | "LINK" | "BLANK", string>;
export type TextProps = PropsWithChildren & {
variant?: TextVariant;
- tag?: "div" | "span" | "p" | "strong" | Heading;
+ tag?: "div" | "span" | "p" | "strong" | HeadingTag;
selectable?: boolean;
lineClamp?: number;
}>;
export type Text = ComponentType;
+export type Heading = ComponentType;
export type FormTitle = ComponentType & PropsWithChildren<{
/** default is h5 */
- tag?: Heading;
+ tag?: HeadingTag;
faded?: boolean;
disabled?: boolean;
required?: boolean;
@@ -46,7 +46,7 @@ export type FormTitle = ComponentType & PropsWithChi
export type FormSection = ComponentType>;
export type Timestamp = ComponentType>;
+
+type FocusLock = ComponentType;
+}>>;
diff --git a/src/webpack/common/types/index.d.ts b/src/webpack/common/types/index.d.ts
index af4b5e1fb..01c968553 100644
--- a/src/webpack/common/types/index.d.ts
+++ b/src/webpack/common/types/index.d.ts
@@ -16,9 +16,11 @@
* along with this program. If not, see .
*/
+export * from "./classes";
export * from "./components";
export * from "./fluxEvents";
+export * from "./i18nMessages";
export * from "./menu";
+export * from "./settingsStores";
export * from "./stores";
export * from "./utils";
-
diff --git a/src/webpack/common/types/settingsStores.ts b/src/webpack/common/types/settingsStores.ts
new file mode 100644
index 000000000..5453ca352
--- /dev/null
+++ b/src/webpack/common/types/settingsStores.ts
@@ -0,0 +1,11 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2024 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+export interface SettingsStore {
+ getSetting(): T;
+ updateSetting(value: T): void;
+ useSetting(): T;
+}
diff --git a/src/webpack/common/types/stores.d.ts b/src/webpack/common/types/stores.d.ts
index ecc87d74c..059924f5a 100644
--- a/src/webpack/common/types/stores.d.ts
+++ b/src/webpack/common/types/stores.d.ts
@@ -17,7 +17,7 @@
*/
import { DraftType } from "@webpack/common";
-import { Channel } from "discord-types/general";
+import { Channel, Guild, Role } from "discord-types/general";
import { FluxDispatcher, FluxEvents } from "./utils";
@@ -63,7 +63,7 @@ export interface CustomEmoji {
originalName?: string;
require_colons: boolean;
roles: string[];
- url: string;
+ type: "GUILD_EMOJI";
}
export interface UnicodeEmoji {
@@ -75,6 +75,7 @@ export interface UnicodeEmoji {
};
index: number;
surrogates: string;
+ type: "UNICODE";
uniqueName: string;
useSpriteSheet: boolean;
get allNamesString(): string;
@@ -172,3 +173,29 @@ export class DraftStore extends FluxStore {
getThreadDraftWithParentMessageId?(arg: any): any;
getThreadSettings(channelId: string): any | null;
}
+
+export enum DraftType {
+ ChannelMessage,
+ ThreadSettings,
+ FirstThreadMessage,
+ ApplicationLauncherCommand,
+ Poll,
+ SlashCommand,
+}
+
+export class GuildStore extends FluxStore {
+ getGuild(guildId: string): Guild;
+ getGuildCount(): number;
+ getGuilds(): Record;
+ getGuildIds(): string[];
+ getRole(guildId: string, roleId: string): Role;
+ getRoles(guildId: string): Record;
+ getAllGuildRoles(): Record>;
+}
+
+export type useStateFromStores = (
+ stores: t.FluxStore[],
+ mapper: () => T,
+ dependencies?: any,
+ isEqual?: (old: T, newer: T) => boolean
+) => T;
diff --git a/src/webpack/common/types/utils.d.ts b/src/webpack/common/types/utils.d.ts
index 246659146..39af843c5 100644
--- a/src/webpack/common/types/utils.d.ts
+++ b/src/webpack/common/types/utils.d.ts
@@ -16,6 +16,7 @@
* along with this program. If not, see .
*/
+import { Guild, GuildMember } from "discord-types/general";
import type { ReactNode } from "react";
import type { FluxEvents } from "./fluxEvents";
@@ -58,6 +59,7 @@ export interface Alerts {
onCancel?(): void;
onConfirm?(): void;
onConfirmSecondary?(): void;
+ onCloseCallback?(): void;
}): void;
/** This is a noop, it does nothing. */
close(): void;
@@ -79,11 +81,7 @@ interface RestRequestData {
retries?: number;
}
-export type RestAPI = Record<"delete" | "get" | "patch" | "post" | "put", (data: RestRequestData) => Promise> & {
- V6OrEarlierAPIError: Error;
- V8APIError: Error;
- getAPIBaseURL(withVersion?: boolean): string;
-};
+export type RestAPI = Record<"delete" | "get" | "patch" | "post" | "put", (data: RestRequestData) => Promise>;
export type Permissions = "CREATE_INSTANT_INVITE"
| "KICK_MEMBERS"
@@ -182,3 +180,47 @@ export interface NavigationRouter {
getLastRouteChangeSource(): any;
getLastRouteChangeSourceLocationStack(): any;
}
+
+export interface IconUtils {
+ getUserAvatarURL(user: User, canAnimate?: boolean, size?: number, format?: string): string;
+ getDefaultAvatarURL(id: string, discriminator?: string): string;
+ getUserBannerURL(data: { id: string, banner: string, canAnimate?: boolean, size: number; }): string | undefined;
+ getAvatarDecorationURL(dara: { avatarDecoration: string, size: number; canCanimate?: boolean; }): string | undefined;
+
+ getGuildMemberAvatarURL(member: GuildMember, canAnimate?: string): string | null;
+ getGuildMemberAvatarURLSimple(data: { guildId: string, userId: string, avatar: string, canAnimate?: boolean; size?: number; }): string;
+ getGuildMemberBannerURL(data: { id: string, guildId: string, banner: string, canAnimate?: boolean, size: number; }): string | undefined;
+
+ getGuildIconURL(data: { id: string, icon?: string, size?: number, canAnimate?: boolean; }): string | undefined;
+ getGuildBannerURL(guild: Guild, canAnimate?: boolean): string | null;
+
+ getChannelIconURL(data: { id: string; icon?: string; applicationId?: string; size?: number; }): string | undefined;
+ getEmojiURL(data: { id: string, animated: boolean, size: number, forcePNG?: boolean; }): string;
+
+ hasAnimatedGuildIcon(guild: Guild): boolean;
+ isAnimatedIconHash(hash: string): boolean;
+
+ getGuildSplashURL: any;
+ getGuildDiscoverySplashURL: any;
+ getGuildHomeHeaderURL: any;
+ getResourceChannelIconURL: any;
+ getNewMemberActionIconURL: any;
+ getGuildTemplateIconURL: any;
+ getApplicationIconURL: any;
+ getGameAssetURL: any;
+ getVideoFilterAssetURL: any;
+
+ getGuildMemberAvatarSource: any;
+ getUserAvatarSource: any;
+ getGuildSplashSource: any;
+ getGuildDiscoverySplashSource: any;
+ makeSource: any;
+ getGameAssetSource: any;
+ getGuildIconSource: any;
+ getGuildTemplateIconSource: any;
+ getGuildBannerSource: any;
+ getGuildHomeHeaderSource: any;
+ getChannelIconSource: any;
+ getApplicationIconSource: any;
+ getAnimatableSourceWithFallback: any;
+}
diff --git a/src/webpack/common/utils.ts b/src/webpack/common/utils.ts
index c62f745a9..72a71f31c 100644
--- a/src/webpack/common/utils.ts
+++ b/src/webpack/common/utils.ts
@@ -19,13 +19,15 @@
import type { Channel, User } from "discord-types/general";
// eslint-disable-next-line path-alias/no-relative
-import { _resolveReady, filters, findByCodeLazy, findByPropsLazy, findLazy, waitFor } from "../webpack";
+import { _resolveReady, filters, findByCodeLazy, findByProps, findByPropsLazy, findLazy, proxyLazyWebpack, waitFor } from "../webpack";
import type * as t from "./types/utils";
export let FluxDispatcher: t.FluxDispatcher;
-
waitFor(["dispatch", "subscribe"], m => {
FluxDispatcher = m;
+ // Non import call to avoid circular dependency
+ Vencord.Plugins.subscribeAllPluginsFluxEvents(m);
+
const cb = () => {
m.unsubscribe("CONNECTION_OPEN", cb);
_resolveReady();
@@ -37,7 +39,12 @@ export let ComponentDispatch;
waitFor(["ComponentDispatch", "ComponentDispatcher"], m => ComponentDispatch = m.ComponentDispatch);
-export const RestAPI: t.RestAPI = findByPropsLazy("getAPIBaseURL", "get");
+export const Constants = findByPropsLazy("Endpoints");
+
+export const RestAPI: t.RestAPI = proxyLazyWebpack(() => {
+ const mod = findByProps("getAPIBaseURL");
+ return mod.HTTP ?? mod;
+});
export const moment: typeof import("moment") = findByPropsLazy("parseTwoDigitYear");
export const hljs: typeof import("highlight.js") = findByPropsLazy("highlight", "registerLanguage");
@@ -112,6 +119,8 @@ export function showToast(message: string, type = ToastType.MESSAGE) {
}
export const UserUtils = findByPropsLazy("getUser", "fetchCurrentUser") as { getUser: (id: string) => Promise; };
+
+export const UploadManager = findByPropsLazy("clearAll", "addFile");
export const UploadHandler = findByPropsLazy("showUploadFileSizeExceededError", "promptToUpload") as {
promptToUpload: (files: File[], channel: Channel, draftType: Number) => void;
};
@@ -129,11 +138,13 @@ waitFor(["open", "saveAccountChanges"], m => SettingsRouter = m);
export const { Permissions: PermissionsBits } = findLazy(m => typeof m.Permissions?.ADMINISTRATOR === "bigint") as { Permissions: t.PermissionsBits; };
-export const zustandCreate: typeof import("zustand").default = findByCodeLazy("will be removed in v4");
+export const zustandCreate = findByCodeLazy("will be removed in v4");
const persistFilter = filters.byCode("[zustand persist middleware]");
-export const { persist: zustandPersist }: typeof import("zustand/middleware") = findLazy(m => m.persist && persistFilter(m.persist));
+export const { persist: zustandPersist } = findLazy(m => m.persist && persistFilter(m.persist));
export const MessageActions = findByPropsLazy("editMessage", "sendMessage");
export const UserProfileActions = findByPropsLazy("openUserProfileModal", "closeUserProfileModal");
export const InviteActions = findByPropsLazy("resolveInvite");
+
+export const IconUtils: t.IconUtils = findByPropsLazy("getGuildBannerURL", "getUserAvatarURL");
diff --git a/src/webpack/patchWebpack.ts b/src/webpack/patchWebpack.ts
index db47c875a..311e6f2bc 100644
--- a/src/webpack/patchWebpack.ts
+++ b/src/webpack/patchWebpack.ts
@@ -18,66 +18,131 @@
import { WEBPACK_CHUNK } from "@utils/constants";
import { Logger } from "@utils/Logger";
-import { canonicalizeReplacement } from "@utils/patches";
+import { canonicalizeMatch, canonicalizeReplacement } from "@utils/patches";
import { PatchReplacement } from "@utils/types";
+import { WebpackInstance } from "discord-types/other";
import { traceFunction } from "../debug/Tracer";
-import { _initWebpack } from ".";
+import { patches } from "../plugins";
+import { _initWebpack, beforeInitListeners, factoryListeners, moduleListeners, subscriptions, wreq } from ".";
+
+const logger = new Logger("WebpackInterceptor", "#8caaee");
+const initCallbackRegex = canonicalizeMatch(/{return \i\(".+?"\)}/);
let webpackChunk: any[];
-const logger = new Logger("WebpackInterceptor", "#8caaee");
+// Patch the window webpack chunk setter to monkey patch the push method before any chunks are pushed
+// This way we can patch the factory of everything being pushed to the modules array
+Object.defineProperty(window, WEBPACK_CHUNK, {
+ configurable: true,
-if (window[WEBPACK_CHUNK]) {
- logger.info(`Patching ${WEBPACK_CHUNK}.push (was already existent, likely from cache!)`);
- _initWebpack(window[WEBPACK_CHUNK]);
- patchPush(window[WEBPACK_CHUNK]);
-} else {
- Object.defineProperty(window, WEBPACK_CHUNK, {
- get: () => webpackChunk,
- set: v => {
- if (v?.push) {
- if (!v.push.$$vencordOriginal) {
- logger.info(`Patching ${WEBPACK_CHUNK}.push`);
- patchPush(v);
+ get: () => webpackChunk,
+ set: v => {
+ if (v?.push) {
+ if (!v.push.$$vencordOriginal) {
+ logger.info(`Patching ${WEBPACK_CHUNK}.push`);
+ patchPush(v);
+
+ // @ts-ignore
+ delete window[WEBPACK_CHUNK];
+ window[WEBPACK_CHUNK] = v;
+ }
+ }
+
+ webpackChunk = v;
+ }
+});
+
+// wreq.O is the webpack onChunksLoaded function
+// Discord uses it to await for all the chunks to be loaded before initializing the app
+// We monkey patch it to also monkey patch the initialize app callback to get immediate access to the webpack require and run our listeners before doing it
+Object.defineProperty(Function.prototype, "O", {
+ configurable: true,
+
+ set(onChunksLoaded: any) {
+ // When using react devtools or other extensions, or even when discord loads the sentry, we may also catch their webpack here.
+ // This ensures we actually got the right one
+ // this.e (wreq.e) is the method for loading a chunk, and only the main webpack has it
+ const { stack } = new Error();
+ if ((stack?.includes("discord.com") || stack?.includes("discordapp.com")) && String(this.e).includes("Promise.all")) {
+ logger.info("Found main WebpackRequire.onChunksLoaded");
+
+ delete (Function.prototype as any).O;
+
+ const originalOnChunksLoaded = onChunksLoaded;
+ onChunksLoaded = function (this: unknown, result: any, chunkIds: string[], callback: () => any, priority: number) {
+ if (callback != null && initCallbackRegex.test(callback.toString())) {
+ Object.defineProperty(this, "O", {
+ value: originalOnChunksLoaded,
+ configurable: true
+ });
+
+ const wreq = this as WebpackInstance;
+
+ const originalCallback = callback;
+ callback = function (this: unknown) {
+ logger.info("Patched initialize app callback invoked, initializing our internal references to WebpackRequire and running beforeInitListeners");
+ _initWebpack(wreq);
+
+ for (const beforeInitListener of beforeInitListeners) {
+ beforeInitListener(wreq);
+ }
+
+ originalCallback.apply(this, arguments as any);
+ };
+
+ callback.toString = originalCallback.toString.bind(originalCallback);
+ arguments[2] = callback;
}
- if (_initWebpack(v)) {
- logger.info("Successfully initialised Vencord webpack");
- // @ts-ignore
- delete window[WEBPACK_CHUNK];
- window[WEBPACK_CHUNK] = v;
- }
- }
- webpackChunk = v;
- },
- configurable: true
- });
+ originalOnChunksLoaded.apply(this, arguments as any);
+ };
- // wreq.m is the webpack module factory.
- // normally, this is populated via webpackGlobal.push, which we patch below.
- // However, Discord has their .m prepopulated.
- // Thus, we use this hack to immediately access their wreq.m and patch all already existing factories
- //
- // Update: Discord now has TWO webpack instances. Their normal one and sentry
- // Sentry does not push chunks to the global at all, so this same patch now also handles their sentry modules
- Object.defineProperty(Function.prototype, "m", {
- set(v: any) {
- // When using react devtools or other extensions, we may also catch their webpack here.
- // This ensures we actually got the right one
- if (new Error().stack?.includes("discord.com")) {
- logger.info("Found webpack module factory");
- patchFactories(v);
- }
+ onChunksLoaded.toString = originalOnChunksLoaded.toString.bind(originalOnChunksLoaded);
- Object.defineProperty(this, "m", {
- value: v,
- configurable: true,
+ // Returns whether a chunk has been loaded
+ Object.defineProperty(onChunksLoaded, "j", {
+ set(v) {
+ delete onChunksLoaded.j;
+ onChunksLoaded.j = v;
+ originalOnChunksLoaded.j = v;
+ },
+ configurable: true
});
- },
- configurable: true
- });
-}
+ }
+
+ Object.defineProperty(this, "O", {
+ value: onChunksLoaded,
+ configurable: true
+ });
+ }
+});
+
+// wreq.m is the webpack module factory.
+// normally, this is populated via webpackGlobal.push, which we patch below.
+// However, Discord has their .m prepopulated.
+// Thus, we use this hack to immediately access their wreq.m and patch all already existing factories
+//
+// Update: Discord now has TWO webpack instances. Their normal one and sentry
+// Sentry does not push chunks to the global at all, so this same patch now also handles their sentry modules
+Object.defineProperty(Function.prototype, "m", {
+ configurable: true,
+
+ set(v: any) {
+ // When using react devtools or other extensions, we may also catch their webpack here.
+ // This ensures we actually got the right one
+ const { stack } = new Error();
+ if ((stack?.includes("discord.com") || stack?.includes("discordapp.com")) && !Array.isArray(v)) {
+ logger.info("Found Webpack module factory", stack.match(/\/assets\/(.+?\.js)/)?.[1] ?? "");
+ patchFactories(v);
+ }
+
+ Object.defineProperty(this, "m", {
+ value: v,
+ configurable: true
+ });
+ }
+});
function patchPush(webpackGlobal: any) {
function handlePush(chunk: any) {
@@ -91,6 +156,7 @@ function patchPush(webpackGlobal: any) {
}
handlePush.$$vencordOriginal = webpackGlobal.push;
+ handlePush.toString = handlePush.$$vencordOriginal.toString.bind(handlePush.$$vencordOriginal);
// Webpack overwrites .push with its own push like so: `d.push = n.bind(null, d.push.bind(d));`
// it wraps the old push (`d.push.bind(d)`). this old push is in this case our handlePush.
// If we then repatched the new push, we would end up with recursive patching, which leads to our patches
@@ -99,41 +165,41 @@ function patchPush(webpackGlobal: any) {
handlePush.bind = (...args: unknown[]) => handlePush.$$vencordOriginal.bind(...args);
Object.defineProperty(webpackGlobal, "push", {
+ configurable: true,
+
get: () => handlePush,
set(v) {
handlePush.$$vencordOriginal = v;
- },
- configurable: true
+ }
});
}
-function patchFactories(factories: Record void>) {
- const { subscriptions, listeners } = Vencord.Webpack;
- const { patches } = Vencord.Plugins;
+let webpackNotInitializedLogged = false;
+function patchFactories(factories: Record void>) {
for (const id in factories) {
let mod = factories[id];
- // Discords Webpack chunks for some ungodly reason contain random
- // newlines. Cyn recommended this workaround and it seems to work fine,
- // however this could potentially break code, so if anything goes weird,
- // this is probably why.
- // Additionally, `[actual newline]` is one less char than "\n", so if Discord
- // ever targets newer browsers, the minifier could potentially use this trick and
- // cause issues.
- //
- // 0, prefix is to turn it into an expression: 0,function(){} would be invalid syntax without the 0,
- let code: string = "0," + mod.toString().replaceAll("\n", "");
+
const originalMod = mod;
const patchedBy = new Set();
- const factory = factories[id] = function (module, exports, require) {
+ const factory = factories[id] = function (module: any, exports: any, require: WebpackInstance) {
+ if (wreq == null && IS_DEV) {
+ if (!webpackNotInitializedLogged) {
+ webpackNotInitializedLogged = true;
+ logger.error("WebpackRequire was not initialized, running modules without patches instead.");
+ }
+
+ return void originalMod(module, exports, require);
+ }
+
try {
mod(module, exports, require);
} catch (err) {
// Just rethrow discord errors
if (mod === originalMod) throw err;
- logger.error("Error in patched chunk", err);
+ logger.error("Error in patched module", err);
return void originalMod(module, exports, require);
}
@@ -153,11 +219,11 @@ function patchFactories(factories: Record string, original: any, (...args: any[]): void; };
- // for some reason throws some error on which calling .toString() leads to infinite recursion
- // when you force load all chunks???
- factory.toString = () => mod.toString();
+ factory.toString = originalMod.toString.bind(originalMod);
factory.original = originalMod;
+ for (const factoryListener of factoryListeners) {
+ try {
+ factoryListener(originalMod);
+ } catch (err) {
+ logger.error("Error in Webpack factory listener:\n", err, factoryListener);
+ }
+ }
+
+ // Discords Webpack chunks for some ungodly reason contain random
+ // newlines. Cyn recommended this workaround and it seems to work fine,
+ // however this could potentially break code, so if anything goes weird,
+ // this is probably why.
+ // Additionally, `[actual newline]` is one less char than "\n", so if Discord
+ // ever targets newer browsers, the minifier could potentially use this trick and
+ // cause issues.
+ //
+ // 0, prefix is to turn it into an expression: 0,function(){} would be invalid syntax without the 0,
+ let code: string = "0," + mod.toString().replaceAll("\n", "");
+
for (let i = 0; i < patches.length; i++) {
const patch = patches[i];
- const executePatch = traceFunction(`patch by ${patch.plugin}`, (match: string | RegExp, replace: string) => code.replace(match, replace));
if (patch.predicate && !patch.predicate()) continue;
- if (code.includes(patch.find)) {
- patchedBy.add(patch.plugin);
+ const moduleMatches = typeof patch.find === "string"
+ ? code.includes(patch.find)
+ : patch.find.test(code);
- const previousMod = mod;
- const previousCode = code;
+ if (!moduleMatches) continue;
- // we change all patch.replacement to array in plugins/index
- for (const replacement of patch.replacement as PatchReplacement[]) {
- if (replacement.predicate && !replacement.predicate()) continue;
- const lastMod = mod;
- const lastCode = code;
+ patchedBy.add(patch.plugin);
- canonicalizeReplacement(replacement, patch.plugin);
+ const executePatch = traceFunction(`patch by ${patch.plugin}`, (match: string | RegExp, replace: string) => code.replace(match, replace));
+ const previousMod = mod;
+ const previousCode = code;
- try {
- const newCode = executePatch(replacement.match, replacement.replace as string);
- if (newCode === code) {
- if (!patch.noWarn) {
- logger.warn(`Patch by ${patch.plugin} had no effect (Module id is ${id}): ${replacement.match}`);
- if (IS_DEV) {
- logger.debug("Function Source:\n", code);
- }
+ // We change all patch.replacement to array in plugins/index
+ for (const replacement of patch.replacement as PatchReplacement[]) {
+ if (replacement.predicate && !replacement.predicate()) continue;
+
+ const lastMod = mod;
+ const lastCode = code;
+
+ canonicalizeReplacement(replacement, patch.plugin);
+
+ try {
+ const newCode = executePatch(replacement.match, replacement.replace as string);
+ if (newCode === code) {
+ if (!patch.noWarn) {
+ logger.warn(`Patch by ${patch.plugin} had no effect (Module id is ${id}): ${replacement.match}`);
+ if (IS_DEV) {
+ logger.debug("Function Source:\n", code);
}
-
- if (patch.group) {
- logger.warn(`Undoing patch group ${patch.find} by ${patch.plugin} because replacement ${replacement.match} had no effect`);
- code = previousCode;
- mod = previousMod;
- patchedBy.delete(patch.plugin);
- break;
- }
- } else {
- code = newCode;
- mod = (0, eval)(`// Webpack Module ${id} - Patched by ${[...patchedBy].join(", ")}\n${newCode}\n//# sourceURL=WebpackModule${id}`);
- }
- } catch (err) {
- logger.error(`Patch by ${patch.plugin} errored (Module id is ${id}): ${replacement.match}\n`, err);
-
- if (IS_DEV) {
- const changeSize = code.length - lastCode.length;
- const match = lastCode.match(replacement.match)!;
-
- // Use 200 surrounding characters of context
- const start = Math.max(0, match.index! - 200);
- const end = Math.min(lastCode.length, match.index! + match[0].length + 200);
- // (changeSize may be negative)
- const endPatched = end + changeSize;
-
- const context = lastCode.slice(start, end);
- const patchedContext = code.slice(start, endPatched);
-
- // inline require to avoid including it in !IS_DEV builds
- const diff = (require("diff") as typeof import("diff")).diffWordsWithSpace(context, patchedContext);
- let fmt = "%c %s ";
- const elements = [] as string[];
- for (const d of diff) {
- const color = d.removed
- ? "red"
- : d.added
- ? "lime"
- : "grey";
- fmt += "%c%s";
- elements.push("color:" + color, d.value);
- }
-
- logger.errorCustomFmt(...Logger.makeTitle("white", "Before"), context);
- logger.errorCustomFmt(...Logger.makeTitle("white", "After"), patchedContext);
- const [titleFmt, ...titleElements] = Logger.makeTitle("white", "Diff");
- logger.errorCustomFmt(titleFmt + fmt, ...titleElements, ...elements);
}
- patchedBy.delete(patch.plugin);
if (patch.group) {
- logger.warn(`Undoing patch group ${patch.find} by ${patch.plugin} because replacement ${replacement.match} errored`);
- code = previousCode;
+ logger.warn(`Undoing patch group ${patch.find} by ${patch.plugin} because replacement ${replacement.match} had no effect`);
mod = previousMod;
+ code = previousCode;
+ patchedBy.delete(patch.plugin);
break;
}
- code = lastCode;
- mod = lastMod;
+ continue;
}
- }
- if (!patch.all) patches.splice(i--, 1);
+ code = newCode;
+ mod = (0, eval)(`// Webpack Module ${id} - Patched by ${[...patchedBy].join(", ")}\n${newCode}\n//# sourceURL=WebpackModule${id}`);
+ } catch (err) {
+ logger.error(`Patch by ${patch.plugin} errored (Module id is ${id}): ${replacement.match}\n`, err);
+
+ if (IS_DEV) {
+ const changeSize = code.length - lastCode.length;
+ const match = lastCode.match(replacement.match)!;
+
+ // Use 200 surrounding characters of context
+ const start = Math.max(0, match.index! - 200);
+ const end = Math.min(lastCode.length, match.index! + match[0].length + 200);
+ // (changeSize may be negative)
+ const endPatched = end + changeSize;
+
+ const context = lastCode.slice(start, end);
+ const patchedContext = code.slice(start, endPatched);
+
+ // inline require to avoid including it in !IS_DEV builds
+ const diff = (require("diff") as typeof import("diff")).diffWordsWithSpace(context, patchedContext);
+ let fmt = "%c %s ";
+ const elements = [] as string[];
+ for (const d of diff) {
+ const color = d.removed
+ ? "red"
+ : d.added
+ ? "lime"
+ : "grey";
+ fmt += "%c%s";
+ elements.push("color:" + color, d.value);
+ }
+
+ logger.errorCustomFmt(...Logger.makeTitle("white", "Before"), context);
+ logger.errorCustomFmt(...Logger.makeTitle("white", "After"), patchedContext);
+ const [titleFmt, ...titleElements] = Logger.makeTitle("white", "Diff");
+ logger.errorCustomFmt(titleFmt + fmt, ...titleElements, ...elements);
+ }
+
+ patchedBy.delete(patch.plugin);
+
+ if (patch.group) {
+ logger.warn(`Undoing patch group ${patch.find} by ${patch.plugin} because replacement ${replacement.match} errored`);
+ mod = previousMod;
+ code = previousCode;
+ break;
+ }
+
+ mod = lastMod;
+ code = lastCode;
+ }
}
+
+ if (!patch.all) patches.splice(i--, 1);
}
}
}
diff --git a/src/webpack/webpack.ts b/src/webpack/webpack.ts
index d65f57fcb..854820851 100644
--- a/src/webpack/webpack.ts
+++ b/src/webpack/webpack.ts
@@ -16,7 +16,7 @@
* along with this program. If not, see .
*/
-import { proxyLazy } from "@utils/lazy";
+import { makeLazy, proxyLazy } from "@utils/lazy";
import { LazyComponent } from "@utils/lazyReact";
import { Logger } from "@utils/Logger";
import { canonicalizeMatch } from "@utils/patches";
@@ -60,6 +60,7 @@ export const filters = {
return m => {
if (filter(m)) return true;
if (!m.$$typeof) return false;
+ if (m.type && m.type.render) return filter(m.type.render); // memo + forwardRef
if (m.type) return filter(m.type); // memos
if (m.render) return filter(m.render); // forwardRefs
return false;
@@ -67,31 +68,27 @@ export const filters = {
}
};
-export const subscriptions = new Map();
-export const listeners = new Set();
-
export type CallbackFn = (mod: any, id: string) => void;
-export function _initWebpack(instance: typeof window.webpackChunkdiscord_app) {
- if (cache !== void 0) throw "no.";
+export const subscriptions = new Map();
+export const moduleListeners = new Set();
+export const factoryListeners = new Set<(factory: (module: any, exports: any, require: WebpackInstance) => void) => void>();
+export const beforeInitListeners = new Set<(wreq: WebpackInstance) => void>();
- instance.push([[Symbol("Vencord")], {}, r => wreq = r]);
- instance.pop();
- if (!wreq) return false;
-
- cache = wreq.c;
- return true;
+export function _initWebpack(webpackRequire: WebpackInstance) {
+ wreq = webpackRequire;
+ cache = webpackRequire.c;
}
+let devToolsOpen = false;
if (IS_DEV && IS_DISCORD_DESKTOP) {
- var devToolsOpen = false;
// At this point in time, DiscordNative has not been exposed yet, so setImmediate is needed
setTimeout(() => {
DiscordNative/* just to make sure */?.window.setDevtoolsCallbacks(() => devToolsOpen = true, () => devToolsOpen = false);
}, 0);
}
-function handleModuleNotFound(method: string, ...filter: unknown[]) {
+export function handleModuleNotFound(method: string, ...filter: unknown[]) {
const err = new Error(`webpack.${method} found no module`);
logger.error(err, "Filter:", filter);
@@ -109,13 +106,13 @@ export const find = traceFunction("find", function find(filter: FilterFn, { isIn
for (const key in cache) {
const mod = cache[key];
- if (!mod?.exports) continue;
+ if (!mod?.exports || mod.exports === window) continue;
if (filter(mod.exports)) {
return isWaitFor ? [mod.exports, key] : mod.exports;
}
- if (mod.exports.default && filter(mod.exports.default)) {
+ if (mod.exports.default && mod.exports.default !== window && filter(mod.exports.default)) {
const found = mod.exports.default;
return isWaitFor ? [found, key] : found;
}
@@ -405,12 +402,16 @@ export function findExportedComponentLazy(...props: stri
});
}
+export const DefaultExtractAndLoadChunksRegex = /(?:Promise\.all\(\[(\i\.\i\("[^)]+?"\)[^\]]+?)\]\)|(\i\.\i\("[^)]+?"\))|Promise\.resolve\(\))\.then\(\i\.bind\(\i,"([^)]+?)"\)\)/;
+export const ChunkIdsRegex = /\("(.+?)"\)/g;
+
/**
* Extract and load chunks using their entry point
- * @param code An array of all the code the module factory containing the entry point (as of using it to load chunks) must include
- * @param matcher A RegExp that returns the entry point id as the first capture group. Defaults to a matcher that captures the first entry point found in the module factory
+ * @param code An array of all the code the module factory containing the lazy chunk loading must include
+ * @param matcher A RegExp that returns the chunk ids array as the first capture group and the entry point id as the second. Defaults to a matcher that captures the lazy chunk loading found in the module factory
+ * @returns A promise that resolves when the chunks were loaded
*/
-export async function extractAndLoadChunks(code: string[], matcher: RegExp = /\.el\("(.+?)"\)(?<=(\i)\.el.+?)\.then\(\2\.bind\(\2,"\1"\)\)/) {
+export async function extractAndLoadChunks(code: string[], matcher: RegExp = DefaultExtractAndLoadChunksRegex) {
const module = findModuleFactory(...code);
if (!module) {
const err = new Error("extractAndLoadChunks: Couldn't find module factory");
@@ -421,7 +422,7 @@ export async function extractAndLoadChunks(code: string[], matcher: RegExp = /\.
const match = module.toString().match(canonicalizeMatch(matcher));
if (!match) {
- const err = new Error("extractAndLoadChunks: Couldn't find entry point id in module factory code");
+ const err = new Error("extractAndLoadChunks: Couldn't find chunk loading in module factory code");
logger.warn(err, "Code:", code, "Matcher:", matcher);
// Strict behaviour in DevBuilds to fail early and make sure the issue is found
@@ -431,9 +432,9 @@ export async function extractAndLoadChunks(code: string[], matcher: RegExp = /\.
return;
}
- const [, id] = match;
- if (!id || !Number(id)) {
- const err = new Error("extractAndLoadChunks: Matcher didn't return a capturing group with the entry point, or the entry point returned wasn't a number");
+ const [, rawChunkIdsArray, rawChunkIdsSingle, entryPointId] = match;
+ if (Number.isNaN(Number(entryPointId))) {
+ const err = new Error("extractAndLoadChunks: Matcher didn't return a capturing group with the chunk ids array, or the entry point id returned as the second group wasn't a number");
logger.warn(err, "Code:", code, "Matcher:", matcher);
// Strict behaviour in DevBuilds to fail early and make sure the issue is found
@@ -443,22 +444,27 @@ export async function extractAndLoadChunks(code: string[], matcher: RegExp = /\.
return;
}
- await (wreq as any).el(id);
- return wreq(id as any);
+ const rawChunkIds = rawChunkIdsArray ?? rawChunkIdsSingle;
+ if (rawChunkIds) {
+ const chunkIds = Array.from(rawChunkIds.matchAll(ChunkIdsRegex)).map((m: any) => m[1]);
+ await Promise.all(chunkIds.map(id => wreq.e(id)));
+ }
+
+ wreq(entryPointId);
}
/**
* This is just a wrapper around {@link extractAndLoadChunks} to make our reporter test for your webpack finds.
*
* Extract and load chunks using their entry point
- * @param code An array of all the code the module factory containing the entry point (as of using it to load chunks) must include
- * @param matcher A RegExp that returns the entry point id as the first capture group. Defaults to a matcher that captures the first entry point found in the module factory
- * @returns A function that loads the chunks on first call
+ * @param code An array of all the code the module factory containing the lazy chunk loading must include
+ * @param matcher A RegExp that returns the chunk ids array as the first capture group and the entry point id as the second. Defaults to a matcher that captures the lazy chunk loading found in the module factory
+ * @returns A function that returns a promise that resolves when the chunks were loaded, on first call
*/
-export function extractAndLoadChunksLazy(code: string[], matcher: RegExp = /\.el\("(.+?)"\)(?<=(\i)\.el.+?)\.then\(\2\.bind\(\2,"\1"\)\)/) {
+export function extractAndLoadChunksLazy(code: string[], matcher = DefaultExtractAndLoadChunksRegex) {
if (IS_DEV) lazyWebpackSearchHistory.push(["extractAndLoadChunks", [code, matcher]]);
- return () => extractAndLoadChunks(code, matcher);
+ return makeLazy(() => extractAndLoadChunks(code, matcher));
}
/**
@@ -475,20 +481,14 @@ export function waitFor(filter: string | string[] | FilterFn, callback: Callback
else if (typeof filter !== "function")
throw new Error("filter must be a string, string[] or function, got " + typeof filter);
- const [existing, id] = find(filter!, { isIndirect: true, isWaitFor: true });
- if (existing) return void callback(existing, id);
+ if (cache != null) {
+ const [existing, id] = find(filter, { isIndirect: true, isWaitFor: true });
+ if (existing) return void callback(existing, id);
+ }
subscriptions.set(filter, callback);
}
-export function addListener(callback: CallbackFn) {
- listeners.add(callback);
-}
-
-export function removeListener(callback: CallbackFn) {
- listeners.delete(callback);
-}
-
/**
* Search modules by keyword. This searches the factory methods,
* meaning you can search all sorts of things, displayName, methodName, strings somewhere in the code, etc
diff --git a/tsconfig.json b/tsconfig.json
index 4563f3f86..8db0ab3c1 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"skipLibCheck": true,
@@ -11,7 +12,7 @@
"esnext.asynciterable",
"esnext.symbol"
],
- "module": "commonjs",
+ "module": "esnext",
"moduleResolution": "node",
"strict": true,
"noImplicitAny": false,
@@ -20,13 +21,23 @@
"baseUrl": "./src/",
"paths": {
+ "@main/*": ["./main/*"],
"@api/*": ["./api/*"],
"@components/*": ["./components/*"],
"@utils/*": ["./utils/*"],
+ "@shared/*": ["./shared/*"],
"@webpack/types": ["./webpack/common/types"],
"@webpack/common": ["./webpack/common"],
"@webpack": ["./webpack/webpack"]
- }
+ },
+
+ "plugins": [
+ // Transform paths in output .d.ts files (Include this line if you output declarations files)
+ {
+ "transform": "typescript-transform-paths",
+ "afterDeclarations": true
+ }
+ ]
},
- "include": ["src/**/*"]
+ "include": ["src/**/*", "browser/**/*", "scripts/**/*"]
}