diff --git a/browser/VencordNativeStub.ts b/browser/VencordNativeStub.ts index 70fc1cf9d..77c72369c 100644 --- a/browser/VencordNativeStub.ts +++ b/browser/VencordNativeStub.ts @@ -26,6 +26,7 @@ import { debounce } from "../src/utils"; import { EXTENSION_BASE_URL } from "../src/utils/web-metadata"; import { getTheme, Theme } from "../src/utils/discord"; import { getThemeInfo } from "../src/main/themes"; +import { Settings } from "../src/Vencord"; // Discord deletes this so need to store in variable const { localStorage } = window; @@ -96,8 +97,15 @@ window.VencordNative = { }, settings: { - get: () => localStorage.getItem("VencordSettings") || "{}", - set: async (s: string) => localStorage.setItem("VencordSettings", s), + get: () => { + try { + return JSON.parse(localStorage.getItem("VencordSettings") || "{}"); + } catch (e) { + console.error("Failed to parse settings from localStorage: ", e); + return {}; + } + }, + set: async (s: Settings) => localStorage.setItem("VencordSettings", JSON.stringify(s)), getSettingsDir: async () => "LocalStorage" }, diff --git a/docs/1_INSTALLING.md b/docs/1_INSTALLING.md index d57e64e58..edeed4eb5 100644 --- a/docs/1_INSTALLING.md +++ b/docs/1_INSTALLING.md @@ -1,6 +1,6 @@ -> [!WARNING] -> These instructions are only for advanced users. If you're not a Developer, you should use our [graphical installer](https://github.com/Vendicated/VencordInstaller#usage) instead. -> No support will be provided for installing in this fashion. If you cannot figure it out, you should just stick to a regular install. +> [!WARNING] +> These instructions are only for advanced users. If you're not a Developer, you should use our [graphical installer](https://github.com/Vendicated/VencordInstaller#usage) instead. +> No support will be provided for installing in this fashion. If you cannot figure it out, you should just stick to a regular install. # Installation Guide @@ -95,5 +95,3 @@ Simply run: ```shell pnpm uninject ``` - -If you need more help, ask in the support channel in our [Discord Server](https://discord.gg/D9uwnFnqmd). diff --git a/package.json b/package.json index dde55d311..78370f097 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "vencord", "private": "true", - "version": "1.7.0", + "version": "1.7.4", "description": "The cutest Discord client mod", "homepage": "https://github.com/Vendicated/Vencord#readme", "bugs": { diff --git a/scripts/generateReport.ts b/scripts/generateReport.ts index 33b099ef8..41e384295 100644 --- a/scripts/generateReport.ts +++ b/scripts/generateReport.ts @@ -67,7 +67,8 @@ const IGNORED_DISCORD_ERRORS = [ "Unable to process domain list delta: Client revision number is null", "Downloading the full bad domains file", /\[GatewaySocket\].{0,110}Cannot access '/, - "search for 'name' in undefined" + "search for 'name' in undefined", + "Attempting to set fast connect zstd when unsupported" ] as Array; function toCodeBlock(s: string) { diff --git a/src/VencordNative.ts b/src/VencordNative.ts index 0faa5569b..42e697452 100644 --- a/src/VencordNative.ts +++ b/src/VencordNative.ts @@ -4,11 +4,12 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -import { IpcEvents } from "@utils/IpcEvents"; +import { PluginIpcMappings } from "@main/ipcPlugins"; +import type { UserThemeHeader } from "@main/themes"; +import { IpcEvents } from "@shared/IpcEvents"; import { IpcRes } from "@utils/types"; +import type { Settings } from "api/Settings"; import { ipcRenderer } from "electron"; -import { PluginIpcMappings } from "main/ipcPlugins"; -import type { UserThemeHeader } from "main/themes"; function invoke(event: IpcEvents, ...args: any[]) { return ipcRenderer.invoke(event, ...args) as Promise; @@ -46,8 +47,8 @@ export default { }, settings: { - get: () => sendSync(IpcEvents.GET_SETTINGS), - set: (settings: string) => invoke(IpcEvents.SET_SETTINGS, settings), + get: () => sendSync(IpcEvents.GET_SETTINGS), + set: (settings: Settings, pathToNotify?: string) => invoke(IpcEvents.SET_SETTINGS, settings, pathToNotify), getSettingsDir: () => invoke(IpcEvents.GET_SETTINGS_DIR), }, diff --git a/src/api/ContextMenu.ts b/src/api/ContextMenu.ts index d66d98c4f..fdd4facf4 100644 --- a/src/api/ContextMenu.ts +++ b/src/api/ContextMenu.ts @@ -17,22 +17,20 @@ */ import { Logger } from "@utils/Logger"; +import { Menu, React } from "@webpack/common"; import type { ReactElement } from "react"; -type ContextMenuPatchCallbackReturn = (() => void) | void; /** * @param children The rendered context menu elements * @param args Any arguments passed into making the context menu, like the guild, channel, user or message for example - * @returns A callback which is only ran once used to modify the context menu elements (Use to avoid duplicates) */ -export type NavContextMenuPatchCallback = (children: Array, ...args: Array) => ContextMenuPatchCallbackReturn; +export type NavContextMenuPatchCallback = (children: Array, ...args: Array) => void; /** * @param navId The navId of the context menu being patched * @param children The rendered context menu elements * @param args Any arguments passed into making the context menu, like the guild, channel, user or message for example - * @returns A callback which is only ran once used to modify the context menu elements (Use to avoid duplicates) */ -export type GlobalContextMenuPatchCallback = (navId: string, children: Array, ...args: Array) => ContextMenuPatchCallbackReturn; +export type GlobalContextMenuPatchCallback = (navId: string, children: Array, ...args: Array) => void; const ContextMenuLogger = new Logger("ContextMenu"); @@ -93,14 +91,19 @@ export function removeGlobalContextMenuPatch(patch: GlobalContextMenuPatchCallba * @param id The id of the child. If an array is specified, all ids will be tried * @param children The context menu children */ -export function findGroupChildrenByChildId(id: string | string[], children: Array, _itemsArray?: Array): Array | null { +export function findGroupChildrenByChildId(id: string | string[], children: Array): Array | null { for (const child of children) { if (child == null) continue; + if (Array.isArray(child)) { + const found = findGroupChildrenByChildId(id, child); + if (found !== null) return found; + } + if ( (Array.isArray(id) && id.some(id => child.props?.id === id)) || child.props?.id === id - ) return _itemsArray ?? null; + ) return children; let nextChildren = child.props?.children; if (nextChildren) { @@ -109,7 +112,7 @@ export function findGroupChildrenByChildId(id: string | string[], children: Arra child.props.children = nextChildren; } - const found = findGroupChildrenByChildId(id, nextChildren, nextChildren); + const found = findGroupChildrenByChildId(id, nextChildren); if (found !== null) return found; } } @@ -126,9 +129,12 @@ interface ContextMenuProps { onClose: (callback: (...args: Array) => any) => void; } -const patchedMenus = new WeakSet(); +export function _usePatchContextMenu(props: ContextMenuProps) { + props = { + ...props, + children: cloneMenuChildren(props.children), + }; -export function _patchContextMenu(props: ContextMenuProps) { props.contextMenuApiArguments ??= []; const contextMenuPatches = navPatches.get(props.navId); @@ -137,8 +143,7 @@ export function _patchContextMenu(props: ContextMenuProps) { if (contextMenuPatches) { for (const patch of contextMenuPatches) { try { - const callback = patch(props.children, ...props.contextMenuApiArguments); - if (!patchedMenus.has(props)) callback?.(); + patch(props.children, ...props.contextMenuApiArguments); } catch (err) { ContextMenuLogger.error(`Patch for ${props.navId} errored,`, err); } @@ -147,12 +152,30 @@ export function _patchContextMenu(props: ContextMenuProps) { for (const patch of globalPatches) { try { - const callback = patch(props.navId, props.children, ...props.contextMenuApiArguments); - if (!patchedMenus.has(props)) callback?.(); + patch(props.navId, props.children, ...props.contextMenuApiArguments); } catch (err) { ContextMenuLogger.error("Global patch errored,", err); } } - patchedMenus.add(props); + return props; +} + +function cloneMenuChildren(obj: ReactElement | Array | null) { + if (Array.isArray(obj)) { + return obj.map(cloneMenuChildren); + } + + if (React.isValidElement(obj)) { + obj = React.cloneElement(obj); + + if ( + obj?.props?.children && + (obj.type !== Menu.MenuControlItem || obj.type === Menu.MenuControlItem && obj.props.control != null) + ) { + obj.props.children = cloneMenuChildren(obj.props.children); + } + } + + return obj; } diff --git a/src/api/Settings.ts b/src/api/Settings.ts index 004a8988b..0b7975300 100644 --- a/src/api/Settings.ts +++ b/src/api/Settings.ts @@ -16,7 +16,8 @@ * along with this program. If not, see . */ -import { debounce } from "@utils/debounce"; +import { debounce } from "@shared/debounce"; +import { SettingsStore as SettingsStoreClass } from "@shared/SettingsStore"; import { localStorage } from "@utils/localStorage"; import { Logger } from "@utils/Logger"; import { mergeDefaults } from "@utils/misc"; @@ -52,7 +53,6 @@ export interface Settings { | "under-page" | "window" | undefined; - macosTranslucency: boolean | undefined; disableMinSize: boolean; winNativeTitleBar: boolean; plugins: { @@ -88,8 +88,6 @@ const DefaultSettings: Settings = { frameless: false, transparent: false, winCtrlQ: false, - // Replaced by macosVibrancyStyle - macosTranslucency: undefined, macosVibrancyStyle: undefined, disableMinSize: false, winNativeTitleBar: false, @@ -110,13 +108,8 @@ const DefaultSettings: Settings = { } }; -try { - var settings = JSON.parse(VencordNative.settings.get()) as Settings; - mergeDefaults(settings, DefaultSettings); -} catch (err) { - var settings = mergeDefaults({} as Settings, DefaultSettings); - logger.error("An error occurred while loading the settings. Corrupt settings file?\n", err); -} +const settings = VencordNative.settings.get(); +mergeDefaults(settings, DefaultSettings); const saveSettingsOnFrequentAction = debounce(async () => { if (Settings.cloud.settingsSync && Settings.cloud.authenticated) { @@ -125,76 +118,52 @@ const saveSettingsOnFrequentAction = debounce(async () => { } }, 60_000); -type SubscriptionCallback = ((newValue: any, path: string) => void) & { _paths?: Array; }; -const subscriptions = new Set(); -const proxyCache = {} as Record; +export const SettingsStore = new SettingsStoreClass(settings, { + readOnly: true, + getDefaultValue({ + target, + key, + path + }) { + const v = target[key]; + if (!plugins) return v; // plugins not initialised yet. this means this path was reached by being called on the top level -// Wraps the passed settings object in a Proxy to nicely handle change listeners and default values -function makeProxy(settings: any, root = settings, path = ""): Settings { - return proxyCache[path] ??= new Proxy(settings, { - get(target, p: string) { - const v = target[p]; + if (path === "plugins" && key in plugins) + return target[key] = { + enabled: plugins[key].required ?? plugins[key].enabledByDefault ?? false + }; - // using "in" is important in the following cases to properly handle falsy or nullish values - if (!(p in target)) { - // Return empty for plugins with no settings - if (path === "plugins" && p in plugins) - return target[p] = makeProxy({ - enabled: plugins[p].required ?? plugins[p].enabledByDefault ?? false - }, root, `plugins.${p}`); + // Since the property is not set, check if this is a plugin's setting and if so, try to resolve + // the default value. + if (path.startsWith("plugins.")) { + const plugin = path.slice("plugins.".length); + if (plugin in plugins) { + const setting = plugins[plugin].options?.[key]; + if (!setting) return v; - // Since the property is not set, check if this is a plugin's setting and if so, try to resolve - // the default value. - if (path.startsWith("plugins.")) { - const plugin = path.slice("plugins.".length); - if (plugin in plugins) { - const setting = plugins[plugin].options?.[p]; - if (!setting) return v; - if ("default" in setting) - // normal setting with a default value - return (target[p] = setting.default); - if (setting.type === OptionType.SELECT) { - const def = setting.options.find(o => o.default); - if (def) - target[p] = def.value; - return def?.value; - } - } - } - return v; - } + if ("default" in setting) + // normal setting with a default value + return (target[key] = setting.default); - // Recursively proxy Objects with the updated property path - if (typeof v === "object" && !Array.isArray(v) && v !== null) - return makeProxy(v, root, `${path}${path && "."}${p}`); - - // primitive or similar, no need to proxy further - return v; - }, - - set(target, p: string, v) { - // avoid unnecessary updates to React Components and other listeners - if (target[p] === v) return true; - - target[p] = v; - // Call any listeners that are listening to a setting of this path - const setPath = `${path}${path && "."}${p}`; - delete proxyCache[setPath]; - for (const subscription of subscriptions) { - if (!subscription._paths || subscription._paths.includes(setPath)) { - subscription(v, setPath); + if (setting.type === OptionType.SELECT) { + const def = setting.options.find(o => o.default); + if (def) + target[key] = def.value; + return def?.value; } } - // And don't forget to persist the settings! - PlainSettings.cloud.settingsSyncVersion = Date.now(); - localStorage.Vencord_settingsDirty = true; - saveSettingsOnFrequentAction(); - VencordNative.settings.set(JSON.stringify(root, null, 4)); - return true; } - }); -} + return v; + } +}); + +SettingsStore.addGlobalChangeListener((_, path) => { + SettingsStore.plain.cloud.settingsSyncVersion = Date.now(); + localStorage.Vencord_settingsDirty = true; + saveSettingsOnFrequentAction(); + VencordNative.settings.set(SettingsStore.plain, path); +}); /** * Same as {@link Settings} but unproxied. You should treat this as readonly, @@ -210,7 +179,7 @@ export const PlainSettings = settings; * the updated settings to disk. * This recursively proxies objects. If you need the object non proxied, use {@link PlainSettings} */ -export const Settings = makeProxy(settings); +export const Settings = SettingsStore.store; /** * Settings hook for React components. Returns a smart settings @@ -223,43 +192,21 @@ export const Settings = makeProxy(settings); export function useSettings(paths?: UseSettings[]) { const [, forceUpdate] = React.useReducer(() => ({}), {}); - const onUpdate: SubscriptionCallback = paths - ? (value, path) => paths.includes(path as UseSettings) && forceUpdate() - : forceUpdate; - React.useEffect(() => { - subscriptions.add(onUpdate); - return () => void subscriptions.delete(onUpdate); + if (paths) { + paths.forEach(p => SettingsStore.addChangeListener(p, forceUpdate)); + return () => paths.forEach(p => SettingsStore.removeChangeListener(p, forceUpdate)); + } else { + SettingsStore.addGlobalChangeListener(forceUpdate); + return () => SettingsStore.removeGlobalChangeListener(forceUpdate); + } }, []); - return Settings; -} - -// Resolves a possibly nested prop in the form of "some.nested.prop" to type of T.some.nested.prop -type ResolvePropDeep = P extends "" ? T : - P extends `${infer Pre}.${infer Suf}` ? - Pre extends keyof T ? ResolvePropDeep : never : P extends keyof T ? T[P] : never; - -/** - * Add a settings listener that will be invoked whenever the desired setting is updated - * @param path Path to the setting that you want to watch, for example "plugins.Unindent.enabled" will fire your callback - * whenever Unindent is toggled. Pass an empty string to get notified for all changes - * @param onUpdate Callback function whenever a setting matching path is updated. It gets passed the new value and the path - * to the updated setting. This path will be the same as your path argument, unless it was an empty string. - * - * @example addSettingsListener("", (newValue, path) => console.log(`${path} is now ${newValue}`)) - * addSettingsListener("plugins.Unindent.enabled", v => console.log("Unindent is now", v ? "enabled" : "disabled")) - */ -export function addSettingsListener(path: Path, onUpdate: (newValue: Settings[Path], path: Path) => void): void; -export function addSettingsListener(path: Path, onUpdate: (newValue: Path extends "" ? any : ResolvePropDeep, path: Path extends "" ? string : Path) => void): void; -export function addSettingsListener(path: string, onUpdate: (newValue: any, path: string) => void) { - if (path) - ((onUpdate as SubscriptionCallback)._paths ??= []).push(path); - subscriptions.add(onUpdate); + return SettingsStore.store; } export function migratePluginSettings(name: string, ...oldNames: string[]) { - const { plugins } = settings; + const { plugins } = SettingsStore.plain; if (name in plugins) return; for (const oldName of oldNames) { @@ -267,7 +214,7 @@ export function migratePluginSettings(name: string, ...oldNames: string[]) { logger.info(`Migrating settings from old name ${oldName} to ${name}`); plugins[name] = plugins[oldName]; delete plugins[oldName]; - VencordNative.settings.set(JSON.stringify(settings, null, 4)); + SettingsStore.markAsChanged(); break; } } diff --git a/src/components/VencordSettings/PatchHelperTab.tsx b/src/components/VencordSettings/PatchHelperTab.tsx index 35f46ef50..064c872ab 100644 --- a/src/components/VencordSettings/PatchHelperTab.tsx +++ b/src/components/VencordSettings/PatchHelperTab.tsx @@ -18,13 +18,13 @@ import { CheckedTextInput } from "@components/CheckedTextInput"; import { CodeBlock } from "@components/CodeBlock"; -import { debounce } from "@utils/debounce"; +import { debounce } from "@shared/debounce"; import { Margins } from "@utils/margins"; import { canonicalizeMatch, canonicalizeReplace } from "@utils/patches"; import { makeCodeblock } from "@utils/text"; -import { ReplaceFn } from "@utils/types"; +import { Patch, ReplaceFn } from "@utils/types"; import { search } from "@webpack"; -import { Button, Clipboard, Forms, Parser, React, Switch, TextInput } from "@webpack/common"; +import { Button, Clipboard, Forms, Parser, React, Switch, TextArea, TextInput } from "@webpack/common"; import { SettingsTab, wrapTab } from "./shared"; @@ -218,6 +218,60 @@ function ReplacementInput({ replacement, setReplacement, replacementError }) { ); } +interface FullPatchInputProps { + setFind(v: string): void; + setMatch(v: string): void; + setReplacement(v: string | ReplaceFn): void; +} + +function FullPatchInput({ setFind, setMatch, setReplacement }: FullPatchInputProps) { + const [fullPatch, setFullPatch] = React.useState(""); + const [fullPatchError, setFullPatchError] = React.useState(""); + + function update() { + if (fullPatch === "") { + setFullPatchError(""); + + setFind(""); + setMatch(""); + setReplacement(""); + return; + } + + try { + const parsed = (0, eval)(`(${fullPatch})`) as Patch; + + if (!parsed.find) throw new Error("No 'find' field"); + if (!parsed.replacement) throw new Error("No 'replacement' field"); + + if (parsed.replacement instanceof Array) { + if (parsed.replacement.length === 0) throw new Error("Invalid replacement"); + + parsed.replacement = { + match: parsed.replacement[0].match, + replace: parsed.replacement[0].replace + }; + } + + if (!parsed.replacement.match) throw new Error("No 'replacement.match' field"); + if (!parsed.replacement.replace) throw new Error("No 'replacement.replace' field"); + + setFind(parsed.find); + setMatch(parsed.replacement.match instanceof RegExp ? parsed.replacement.match.source : parsed.replacement.match); + setReplacement(parsed.replacement.replace); + setFullPatchError(""); + } catch (e) { + setFullPatchError((e as Error).message); + } + } + + return <> + Paste your full JSON patch here to fill out the fields +