diff --git a/browser/GMPolyfill.js b/browser/GMPolyfill.js index f8801551e..387389ce6 100644 --- a/browser/GMPolyfill.js +++ b/browser/GMPolyfill.js @@ -62,7 +62,7 @@ function GM_fetch(url, opt) { resp.arrayBuffer = () => blobTo("arrayBuffer", blob); resp.text = () => blobTo("text", blob); resp.json = async () => JSON.parse(await blobTo("text", blob)); - resp.headers = new Headers(parseHeaders(resp.responseHeaders)); + resp.headers = parseHeaders(resp.responseHeaders); resp.ok = resp.status >= 200 && resp.status < 300; resolve(resp); }; 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/package.json b/package.json index 5ffac7ff5..9d7b6347a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "vencord", "private": "true", - "version": "1.6.9", + "version": "1.7.2", "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 0a17e8d7e..33b099ef8 100644 --- a/scripts/generateReport.ts +++ b/scripts/generateReport.ts @@ -428,10 +428,11 @@ function runTime(token: string) { if (searchType === "findComponent") method = "find"; if (searchType === "findExportedComponent") method = "findByProps"; - if (searchType === "waitFor" || searchType === "waitForComponent" || searchType === "waitForStore") { + if (searchType === "waitFor" || searchType === "waitForComponent") { if (typeof args[0] === "string") method = "findByProps"; else method = "find"; } + if (searchType === "waitForStore") method = "findStore"; try { let result: any; 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/MessageEvents.ts b/src/api/MessageEvents.ts index 341b4e678..d6eba748f 100644 --- a/src/api/MessageEvents.ts +++ b/src/api/MessageEvents.ts @@ -74,7 +74,7 @@ export interface MessageExtra { } export type SendListener = (channelId: string, messageObj: MessageObject, extra: MessageExtra) => Promisable; -export type EditListener = (channelId: string, messageId: string, messageObj: MessageObject) => Promisable; +export type EditListener = (channelId: string, messageId: string, messageObj: MessageObject) => Promisable; const sendListeners = new Set(); const editListeners = new Set(); @@ -84,7 +84,7 @@ export async function _handlePreSend(channelId: string, messageObj: MessageObjec for (const listener of sendListeners) { try { const result = await listener(channelId, messageObj, extra); - if (result && result.cancel === true) { + if (result?.cancel) { return true; } } catch (e) { @@ -97,11 +97,15 @@ export async function _handlePreSend(channelId: string, messageObj: MessageObjec export async function _handlePreEdit(channelId: string, messageId: string, messageObj: MessageObject) { for (const listener of editListeners) { try { - await listener(channelId, messageId, messageObj); + const result = await listener(channelId, messageId, messageObj); + if (result?.cancel) { + return true; + } } catch (e) { MessageEventsLogger.error("MessageEditHandler: Listener encountered an unknown error\n", e); } } + return false; } /** 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/CloudTab.tsx b/src/components/VencordSettings/CloudTab.tsx index 0392a451c..080dd8dd9 100644 --- a/src/components/VencordSettings/CloudTab.tsx +++ b/src/components/VencordSettings/CloudTab.tsx @@ -39,9 +39,7 @@ function validateUrl(url: string) { async function eraseAllData() { const res = await fetch(new URL("/v1/", getCloudUrl()), { method: "DELETE", - headers: new Headers({ - Authorization: await getCloudAuth() - }) + headers: { Authorization: await getCloudAuth() } }); if (!res.ok) { diff --git a/src/components/VencordSettings/PatchHelperTab.tsx b/src/components/VencordSettings/PatchHelperTab.tsx index 35f46ef50..7e68ec1e7 100644 --- a/src/components/VencordSettings/PatchHelperTab.tsx +++ b/src/components/VencordSettings/PatchHelperTab.tsx @@ -18,7 +18,7 @@ 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"; diff --git a/src/components/VencordSettings/ThemesTab.tsx b/src/components/VencordSettings/ThemesTab.tsx index 8abaaba4f..2eb91cb82 100644 --- a/src/components/VencordSettings/ThemesTab.tsx +++ b/src/components/VencordSettings/ThemesTab.tsx @@ -22,6 +22,7 @@ import { Flex } from "@components/Flex"; import { DeleteIcon } from "@components/Icons"; import { Link } from "@components/Link"; import PluginModal from "@components/PluginSettings/PluginModal"; +import type { UserThemeHeader } from "@main/themes"; import { openInviteModal } from "@utils/discord"; import { Margins } from "@utils/margins"; import { classes } from "@utils/misc"; @@ -30,7 +31,6 @@ import { showItemInFolder } from "@utils/native"; import { useAwaiter } from "@utils/react"; import { findByPropsLazy, findLazy } from "@webpack"; import { Button, Card, Forms, React, showToast, TabBar, TextArea, useEffect, useRef, useState } from "@webpack/common"; -import { UserThemeHeader } from "main/themes"; import type { ComponentType, Ref, SyntheticEvent } from "react"; import { AddonCard } from "./AddonCard"; diff --git a/src/components/VencordSettings/VencordTab.tsx b/src/components/VencordSettings/VencordTab.tsx index ab910ea2a..c0a66fdc7 100644 --- a/src/components/VencordSettings/VencordTab.tsx +++ b/src/components/VencordSettings/VencordTab.tsx @@ -50,14 +50,6 @@ function VencordSettings() { const isMac = navigator.platform.toLowerCase().startsWith("mac"); const needsVibrancySettings = IS_DISCORD_DESKTOP && isMac; - // One-time migration of the old setting to the new one if necessary. - React.useEffect(() => { - if (settings.macosTranslucency === true && !settings.macosVibrancyStyle) { - settings.macosVibrancyStyle = "sidebar"; - settings.macosTranslucency = undefined; - } - }, []); - const Switches: Array; title: string; @@ -164,7 +156,7 @@ function VencordSettings() { options={[ // Sorted from most opaque to most transparent { - label: "No vibrancy", default: !settings.macosTranslucency, value: undefined + label: "No vibrancy", value: undefined }, { label: "Under Page (window tinting)", @@ -191,9 +183,8 @@ function VencordSettings() { value: "header" }, { - label: "Sidebar (old value for transparent windows)", - value: "sidebar", - default: settings.macosTranslucency + label: "Sidebar", + value: "sidebar" }, { label: "Tooltip", diff --git a/src/main/index.ts b/src/main/index.ts index 481736a98..5519d47ac 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -19,7 +19,8 @@ import { app, protocol, session } from "electron"; import { join } from "path"; -import { ensureSafePath, getSettings } from "./ipcMain"; +import { ensureSafePath } from "./ipcMain"; +import { RendererSettings } from "./settings"; import { IS_VANILLA, THEMES_DIR } from "./utils/constants"; import { installExt } from "./utils/extensions"; @@ -55,7 +56,7 @@ if (IS_VESKTOP || !IS_VANILLA) { }); try { - if (getSettings().enableReactDevtools) + if (RendererSettings.store.enableReactDevtools) installExt("fmkadmapgofadopljbjfkapdkoienihi") .then(() => console.info("[Vencord] Installed React Developer Tools")) .catch(err => console.error("[Vencord] Failed to install React Developer Tools", err)); diff --git a/src/main/ipcMain.ts b/src/main/ipcMain.ts index 47d400eb6..9c9741db5 100644 --- a/src/main/ipcMain.ts +++ b/src/main/ipcMain.ts @@ -18,22 +18,21 @@ import "./updater"; import "./ipcPlugins"; +import "./settings"; -import { debounce } from "@utils/debounce"; -import { IpcEvents } from "@utils/IpcEvents"; -import { Queue } from "@utils/Queue"; +import { debounce } from "@shared/debounce"; +import { IpcEvents } from "@shared/IpcEvents"; import { BrowserWindow, ipcMain, shell, systemPreferences } from "electron"; -import { mkdirSync, readFileSync, watch } from "fs"; -import { open, readdir, readFile, writeFile } from "fs/promises"; +import { FSWatcher, mkdirSync, watch, writeFileSync } from "fs"; +import { open, readdir, readFile } from "fs/promises"; import { join, normalize } from "path"; import monacoHtml from "~fileContent/monacoWin.html;base64"; import { getThemeInfo, stripBOM, UserThemeHeader } from "./themes"; -import { ALLOWED_PROTOCOLS, QUICKCSS_PATH, SETTINGS_DIR, SETTINGS_FILE, THEMES_DIR } from "./utils/constants"; +import { ALLOWED_PROTOCOLS, QUICKCSS_PATH, THEMES_DIR } from "./utils/constants"; import { makeLinksOpenExternally } from "./utils/externalLinks"; -mkdirSync(SETTINGS_DIR, { recursive: true }); mkdirSync(THEMES_DIR, { recursive: true }); export function ensureSafePath(basePath: string, path: string) { @@ -71,22 +70,6 @@ function getThemeData(fileName: string) { return readFile(safePath, "utf-8"); } -export function readSettings() { - try { - return readFileSync(SETTINGS_FILE, "utf-8"); - } catch { - return "{}"; - } -} - -export function getSettings(): typeof import("@api/Settings").Settings { - try { - return JSON.parse(readSettings()); - } catch { - return {} as any; - } -} - ipcMain.handle(IpcEvents.OPEN_QUICKCSS, () => shell.openPath(QUICKCSS_PATH)); ipcMain.handle(IpcEvents.OPEN_EXTERNAL, (_, url) => { @@ -101,12 +84,10 @@ ipcMain.handle(IpcEvents.OPEN_EXTERNAL, (_, url) => { shell.openExternal(url); }); -const cssWriteQueue = new Queue(); -const settingsWriteQueue = new Queue(); ipcMain.handle(IpcEvents.GET_QUICK_CSS, () => readCss()); ipcMain.handle(IpcEvents.SET_QUICK_CSS, (_, css) => - cssWriteQueue.push(() => writeFile(QUICKCSS_PATH, css)) + writeFileSync(QUICKCSS_PATH, css) ); ipcMain.handle(IpcEvents.GET_THEMES_DIR, () => THEMES_DIR); @@ -117,25 +98,25 @@ ipcMain.handle(IpcEvents.GET_THEME_SYSTEM_VALUES, () => ({ "os-accent-color": `#${systemPreferences.getAccentColor?.() || ""}` })); -ipcMain.handle(IpcEvents.GET_SETTINGS_DIR, () => SETTINGS_DIR); -ipcMain.on(IpcEvents.GET_SETTINGS, e => e.returnValue = readSettings()); - -ipcMain.handle(IpcEvents.SET_SETTINGS, (_, s) => { - settingsWriteQueue.push(() => writeFile(SETTINGS_FILE, s)); -}); - export function initIpc(mainWindow: BrowserWindow) { + let quickCssWatcher: FSWatcher | undefined; + open(QUICKCSS_PATH, "a+").then(fd => { fd.close(); - watch(QUICKCSS_PATH, { persistent: false }, debounce(async () => { + quickCssWatcher = watch(QUICKCSS_PATH, { persistent: false }, debounce(async () => { mainWindow.webContents.postMessage(IpcEvents.QUICK_CSS_UPDATE, await readCss()); }, 50)); - }); + }).catch(() => { }); - watch(THEMES_DIR, { persistent: false }, debounce(() => { + const themesWatcher = watch(THEMES_DIR, { persistent: false }, debounce(() => { mainWindow.webContents.postMessage(IpcEvents.THEME_UPDATE, void 0); })); + + mainWindow.once("closed", () => { + quickCssWatcher?.close(); + themesWatcher.close(); + }); } ipcMain.handle(IpcEvents.OPEN_MONACO_EDITOR, async () => { diff --git a/src/main/ipcPlugins.ts b/src/main/ipcPlugins.ts index 5d679fc0b..5236dbec4 100644 --- a/src/main/ipcPlugins.ts +++ b/src/main/ipcPlugins.ts @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import { IpcEvents } from "@utils/IpcEvents"; +import { IpcEvents } from "@shared/IpcEvents"; import { ipcMain } from "electron"; import PluginNatives from "~pluginNatives"; diff --git a/src/main/patcher.ts b/src/main/patcher.ts index 3ee44d92c..0d79a96f6 100644 --- a/src/main/patcher.ts +++ b/src/main/patcher.ts @@ -16,11 +16,12 @@ * along with this program. If not, see . */ -import { onceDefined } from "@utils/onceDefined"; +import { onceDefined } from "@shared/onceDefined"; import electron, { app, BrowserWindowConstructorOptions, Menu } from "electron"; import { dirname, join } from "path"; -import { getSettings, initIpc } from "./ipcMain"; +import { initIpc } from "./ipcMain"; +import { RendererSettings } from "./settings"; import { IS_VANILLA } from "./utils/constants"; console.log("[Vencord] Starting up..."); @@ -41,8 +42,7 @@ require.main!.filename = join(asarPath, discordPkg.main); app.setAppPath(asarPath); if (!IS_VANILLA) { - const settings = getSettings(); - + const settings = RendererSettings.store; // Repatch after host updates on Windows if (process.platform === "win32") { require("./patchWin32Updater"); @@ -84,13 +84,11 @@ if (!IS_VANILLA) { options.backgroundColor = "#00000000"; } - const needsVibrancy = process.platform === "darwin" || (settings.macosVibrancyStyle || settings.macosTranslucency); + const needsVibrancy = process.platform === "darwin" && settings.macosVibrancyStyle; if (needsVibrancy) { options.backgroundColor = "#00000000"; - if (settings.macosTranslucency) { - options.vibrancy = "sidebar"; - } else if (settings.macosVibrancyStyle) { + if (settings.macosVibrancyStyle) { options.vibrancy = settings.macosVibrancyStyle; } } diff --git a/src/main/settings.ts b/src/main/settings.ts new file mode 100644 index 000000000..96efdd672 --- /dev/null +++ b/src/main/settings.ts @@ -0,0 +1,53 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import type { Settings } from "@api/Settings"; +import { IpcEvents } from "@shared/IpcEvents"; +import { SettingsStore } from "@shared/SettingsStore"; +import { ipcMain } from "electron"; +import { mkdirSync, readFileSync, writeFileSync } from "fs"; + +import { NATIVE_SETTINGS_FILE, SETTINGS_DIR, SETTINGS_FILE } from "./utils/constants"; + +mkdirSync(SETTINGS_DIR, { recursive: true }); + +function readSettings(name: string, file: string): Partial { + try { + return JSON.parse(readFileSync(file, "utf-8")); + } catch (err: any) { + if (err?.code !== "ENOENT") + console.error(`Failed to read ${name} settings`, err); + + return {}; + } +} + +export const RendererSettings = new SettingsStore(readSettings("renderer", SETTINGS_FILE)); + +RendererSettings.addGlobalChangeListener(() => { + try { + writeFileSync(SETTINGS_FILE, JSON.stringify(RendererSettings.plain, null, 4)); + } catch (e) { + console.error("Failed to write renderer settings", e); + } +}); + +ipcMain.handle(IpcEvents.GET_SETTINGS_DIR, () => SETTINGS_DIR); +ipcMain.on(IpcEvents.GET_SETTINGS, e => e.returnValue = RendererSettings.plain); + +ipcMain.handle(IpcEvents.SET_SETTINGS, (_, data: Settings, pathToNotify?: string) => { + RendererSettings.setData(data, pathToNotify); +}); + +export const NativeSettings = new SettingsStore(readSettings("native", NATIVE_SETTINGS_FILE)); + +NativeSettings.addGlobalChangeListener(() => { + try { + writeFileSync(NATIVE_SETTINGS_FILE, JSON.stringify(NativeSettings.plain, null, 4)); + } catch (e) { + console.error("Failed to write native settings", e); + } +}); diff --git a/src/main/updater/git.ts b/src/main/updater/git.ts index 2ff3ba512..82c38b6bc 100644 --- a/src/main/updater/git.ts +++ b/src/main/updater/git.ts @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import { IpcEvents } from "@utils/IpcEvents"; +import { IpcEvents } from "@shared/IpcEvents"; import { execFile as cpExecFile } from "child_process"; import { ipcMain } from "electron"; import { join } from "path"; @@ -49,9 +49,12 @@ async function getRepo() { async function calculateGitChanges() { await git("fetch"); - const branch = await git("branch", "--show-current"); + const branch = (await git("branch", "--show-current")).stdout.trim(); - const res = await git("log", `HEAD...origin/${branch.stdout.trim()}`, "--pretty=format:%an/%h/%s"); + const existsOnOrigin = (await git("ls-remote", "origin", branch)).stdout.length > 0; + if (!existsOnOrigin) return []; + + const res = await git("log", `HEAD...origin/${branch}`, "--pretty=format:%an/%h/%s"); const commits = res.stdout.trim(); return commits ? commits.split("\n").map(line => { diff --git a/src/main/updater/http.ts b/src/main/updater/http.ts index 5653d0143..9e5a1cef4 100644 --- a/src/main/updater/http.ts +++ b/src/main/updater/http.ts @@ -16,8 +16,8 @@ * along with this program. If not, see . */ -import { VENCORD_USER_AGENT } from "@utils/constants"; -import { IpcEvents } from "@utils/IpcEvents"; +import { IpcEvents } from "@shared/IpcEvents"; +import { VENCORD_USER_AGENT } from "@shared/vencordUserAgent"; import { ipcMain } from "electron"; import { writeFile } from "fs/promises"; import { join } from "path"; @@ -53,7 +53,7 @@ async function calculateGitChanges() { // github api only sends the long sha hash: c.sha.slice(0, 7), author: c.author.login, - message: c.commit.message + message: c.commit.message.substring(c.commit.message.indexOf("\n") + 1) })); } diff --git a/src/main/utils/constants.ts b/src/main/utils/constants.ts index cd6e509f7..6c076c328 100644 --- a/src/main/utils/constants.ts +++ b/src/main/utils/constants.ts @@ -28,6 +28,7 @@ export const SETTINGS_DIR = join(DATA_DIR, "settings"); export const THEMES_DIR = join(DATA_DIR, "themes"); export const QUICKCSS_PATH = join(SETTINGS_DIR, "quickCss.css"); export const SETTINGS_FILE = join(SETTINGS_DIR, "settings.json"); +export const NATIVE_SETTINGS_FILE = join(SETTINGS_DIR, "native-settings.json"); export const ALLOWED_PROTOCOLS = [ "https:", "http:", diff --git a/src/plugins/_api/contextMenu.ts b/src/plugins/_api/contextMenu.ts index 55fdf3eae..01619546d 100644 --- a/src/plugins/_api/contextMenu.ts +++ b/src/plugins/_api/contextMenu.ts @@ -22,15 +22,15 @@ import definePlugin from "@utils/types"; export default definePlugin({ name: "ContextMenuAPI", description: "API for adding/removing items to/from context menus.", - authors: [Devs.Nuckyz, Devs.Ven], + authors: [Devs.Nuckyz, Devs.Ven, Devs.Kyuuhachi], required: true, patches: [ { find: "♫ (つ。◕‿‿◕。)つ ♪", replacement: { - match: /let{navId:/, - replace: "Vencord.Api.ContextMenu._patchContextMenu(arguments[0]);$&" + match: /(?=let{navId:)(?<=function \i\((\i)\).+?)/, + replace: "$1=Vencord.Api.ContextMenu._usePatchContextMenu($1);" } }, { diff --git a/src/plugins/_api/messageEvents.ts b/src/plugins/_api/messageEvents.ts index bc5f5abf2..1b4a2d15a 100644 --- a/src/plugins/_api/messageEvents.ts +++ b/src/plugins/_api/messageEvents.ts @@ -25,10 +25,13 @@ export default definePlugin({ authors: [Devs.Arjix, Devs.hunt, Devs.Ven], patches: [ { - find: '"MessageActionCreators"', + find: ".Messages.EDIT_TEXTAREA_HELP", replacement: { - match: /async editMessage\(.+?\)\{/, - replace: "$&await Vencord.Api.MessageEvents._handlePreEdit(...arguments);" + match: /(?<=,channel:\i\}\)\.then\().+?(?=return \i\.content!==this\.props\.message\.content&&\i\((.+?)\))/, + replace: (match, args) => "" + + `async ${match}` + + `if(await Vencord.Api.MessageEvents._handlePreEdit(${args}))` + + "return Promise.resolve({shoudClear:true,shouldRefocus:true});" } }, { diff --git a/src/plugins/_core/settings.tsx b/src/plugins/_core/settings.tsx index 6f43b76a8..01220eb4e 100644 --- a/src/plugins/_core/settings.tsx +++ b/src/plugins/_core/settings.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import { addContextMenuPatch } from "@api/ContextMenu"; +import { findGroupChildrenByChildId } from "@api/ContextMenu"; import { Settings } from "@api/Settings"; import { Devs } from "@utils/constants"; import definePlugin, { OptionType } from "@utils/types"; @@ -30,21 +30,21 @@ export default definePlugin({ authors: [Devs.Ven, Devs.Megu], required: true, - start() { + contextMenus: { // The settings shortcuts in the user settings cog context menu // read the elements from a hardcoded map which for obvious reason // doesn't contain our sections. This patches the actions of our // sections to manually use SettingsRouter (which only works on desktop // but the context menu is usually not available on mobile anyway) - addContextMenuPatch("user-settings-cog", children => () => { - const section = children.find(c => Array.isArray(c) && c.some(it => it?.props?.id === "VencordSettings")) as any; + "user-settings-cog"(children) { + const section = findGroupChildrenByChildId("VencordSettings", children); section?.forEach(c => { const id = c?.props?.id; if (id?.startsWith("Vencord") || id?.startsWith("Vesktop")) { - c.props.action = () => SettingsRouter.open(id); + c!.props.action = () => SettingsRouter.open(id); } }); - }); + } }, patches: [{ diff --git a/src/plugins/anonymiseFileNames/index.tsx b/src/plugins/anonymiseFileNames/index.tsx index 0382b65c2..911440230 100644 --- a/src/plugins/anonymiseFileNames/index.tsx +++ b/src/plugins/anonymiseFileNames/index.tsx @@ -67,7 +67,7 @@ const settings = definePluginSettings({ export default definePlugin({ name: "AnonymiseFileNames", - authors: [Devs.obscurity], + authors: [Devs.fawn], description: "Anonymise uploaded file names", patches: [ { diff --git a/src/plugins/betterRoleContext/README.md b/src/plugins/betterRoleContext/README.md new file mode 100644 index 000000000..3f3086bdb --- /dev/null +++ b/src/plugins/betterRoleContext/README.md @@ -0,0 +1,6 @@ +# BetterRoleContext + +Adds options to copy role color and edit role when right clicking roles in the user profile + +![](https://github.com/Vendicated/Vencord/assets/45497981/d1765e9e-7db2-4a3c-b110-139c59235326) + diff --git a/src/plugins/betterRoleContext/index.tsx b/src/plugins/betterRoleContext/index.tsx new file mode 100644 index 000000000..3db3494f9 --- /dev/null +++ b/src/plugins/betterRoleContext/index.tsx @@ -0,0 +1,81 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Devs } from "@utils/constants"; +import { getCurrentGuild } from "@utils/discord"; +import definePlugin from "@utils/types"; +import { findByPropsLazy } from "@webpack"; +import { Clipboard, GuildStore, Menu, PermissionStore, TextAndImagesSettingsStores } from "@webpack/common"; + +const GuildSettingsActions = findByPropsLazy("open", "selectRole", "updateGuild"); + +function PencilIcon() { + return ( + + + + ); +} + +function AppearanceIcon() { + return ( + + + + ); +} + +export default definePlugin({ + name: "BetterRoleContext", + description: "Adds options to copy role color / edit role when right clicking roles in the user profile", + authors: [Devs.Ven], + + start() { + // DeveloperMode needs to be enabled for the context menu to be shown + TextAndImagesSettingsStores.DeveloperMode.updateSetting(true); + }, + + contextMenus: { + "dev-context"(children, { id }: { id: string; }) { + const guild = getCurrentGuild(); + if (!guild) return; + + const role = GuildStore.getRole(guild.id, id); + if (!role) return; + + if (role.colorString) { + children.push( + Clipboard.copy(role.colorString!)} + icon={AppearanceIcon} + /> + ); + } + + if (PermissionStore.getGuildPermissionProps(guild).canManageRoles) { + children.push( + { + await GuildSettingsActions.open(guild.id, "ROLES"); + GuildSettingsActions.selectRole(id); + }} + icon={PencilIcon} + /> + ); + } + } + } +}); diff --git a/src/plugins/betterUploadButton/index.ts b/src/plugins/betterUploadButton/index.ts index 3eeb1e453..511d12a4a 100644 --- a/src/plugins/betterUploadButton/index.ts +++ b/src/plugins/betterUploadButton/index.ts @@ -21,7 +21,7 @@ import definePlugin from "@utils/types"; export default definePlugin({ name: "BetterUploadButton", - authors: [Devs.obscurity, Devs.Ven], + authors: [Devs.fawn, Devs.Ven], description: "Upload with a single click, open menu with right click", patches: [ { diff --git a/src/plugins/biggerStreamPreview/index.tsx b/src/plugins/biggerStreamPreview/index.tsx index 40bbe30a8..8cca912bc 100644 --- a/src/plugins/biggerStreamPreview/index.tsx +++ b/src/plugins/biggerStreamPreview/index.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import { addContextMenuPatch, NavContextMenuPatchCallback, removeContextMenuPatch } from "@api/ContextMenu"; +import { NavContextMenuPatchCallback } from "@api/ContextMenu"; import { ScreenshareIcon } from "@components/Icons"; import { Devs } from "@utils/constants"; import { openImageModal } from "@utils/discord"; @@ -60,7 +60,7 @@ export const handleViewPreview = async ({ guildId, channelId, ownerId }: Applica openImageModal(previewUrl); }; -export const addViewStreamContext: NavContextMenuPatchCallback = (children, { userId }: { userId: string | bigint; }) => () => { +export const addViewStreamContext: NavContextMenuPatchCallback = (children, { userId }: { userId: string | bigint; }) => { const stream = ApplicationStreamingStore.getAnyStreamForUser(userId); if (!stream) return; @@ -89,12 +89,8 @@ export default definePlugin({ name: "BiggerStreamPreview", description: "This plugin allows you to enlarge stream previews", authors: [Devs.phil], - start: () => { - addContextMenuPatch("user-context", userContextPatch); - addContextMenuPatch("stream-context", streamContextPatch); - }, - stop: () => { - removeContextMenuPatch("user-context", userContextPatch); - removeContextMenuPatch("stream-context", streamContextPatch); + contextMenus: { + "user-context": userContextPatch, + "stream-context": streamContextPatch } }); diff --git a/src/plugins/clientTheme/index.tsx b/src/plugins/clientTheme/index.tsx index 5d8cd4dc0..4e07daf42 100644 --- a/src/plugins/clientTheme/index.tsx +++ b/src/plugins/clientTheme/index.tsx @@ -12,7 +12,7 @@ import { Margins } from "@utils/margins"; import { classes } from "@utils/misc"; import definePlugin, { OptionType, StartAt } from "@utils/types"; import { findByPropsLazy, findComponentByCodeLazy, findStoreLazy } from "@webpack"; -import { Button, Forms, lodash as _, useStateFromStores } from "@webpack/common"; +import { Button, Forms, useStateFromStores } from "@webpack/common"; const ColorPicker = findComponentByCodeLazy(".Messages.USER_SETTINGS_PROFILE_COLOR_SELECT_COLOR", ".BACKGROUND_PRIMARY)"); @@ -200,8 +200,8 @@ function captureOne(str, regex) { return (result === null) ? null : result[1]; } -function mapReject(arr, mapFunc, rejectFunc = _.isNull) { - return _.reject(arr.map(mapFunc), rejectFunc); +function mapReject(arr, mapFunc) { + return arr.map(mapFunc).filter(Boolean); } function updateColorVars(color: string) { diff --git a/src/plugins/copyUserURLs/index.tsx b/src/plugins/copyUserURLs/index.tsx index 9f69674cf..7af8502db 100644 --- a/src/plugins/copyUserURLs/index.tsx +++ b/src/plugins/copyUserURLs/index.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import { addContextMenuPatch, NavContextMenuPatchCallback, removeContextMenuPatch } from "@api/ContextMenu"; +import { NavContextMenuPatchCallback } from "@api/ContextMenu"; import { LinkIcon } from "@components/Icons"; import { Devs } from "@utils/constants"; import definePlugin from "@utils/types"; @@ -29,7 +29,7 @@ interface UserContextProps { user: User; } -const UserContextMenuPatch: NavContextMenuPatchCallback = (children, { user }: UserContextProps) => () => { +const UserContextMenuPatch: NavContextMenuPatchCallback = (children, { user }: UserContextProps) => { if (!user) return; children.push( @@ -46,12 +46,7 @@ export default definePlugin({ name: "CopyUserURLs", authors: [Devs.castdrian], description: "Adds a 'Copy User URL' option to the user context menu.", - - start() { - addContextMenuPatch("user-context", UserContextMenuPatch); - }, - - stop() { - removeContextMenuPatch("user-context", UserContextMenuPatch); - }, + contextMenus: { + "user-context": UserContextMenuPatch + } }); diff --git a/src/plugins/customRPC/index.tsx b/src/plugins/customRPC/index.tsx index 3653a0776..e70f8c908 100644 --- a/src/plugins/customRPC/index.tsx +++ b/src/plugins/customRPC/index.tsx @@ -175,7 +175,7 @@ const settings = definePluginSettings({ }, startTime: { type: OptionType.NUMBER, - description: "Start timestamp (only for custom timestamp mode)", + description: "Start timestamp in milisecond (only for custom timestamp mode)", onChange: onChange, disabled: isTimestampDisabled, isValid: (value: number) => { @@ -185,7 +185,7 @@ const settings = definePluginSettings({ }, endTime: { type: OptionType.NUMBER, - description: "End timestamp (only for custom timestamp mode)", + description: "End timestamp in milisecond (only for custom timestamp mode)", onChange: onChange, disabled: isTimestampDisabled, isValid: (value: number) => { @@ -313,12 +313,12 @@ async function createActivity(): Promise { switch (settings.store.timestampMode) { case TimestampMode.NOW: activity.timestamps = { - start: Math.floor(Date.now() / 1000) + start: Date.now() }; break; case TimestampMode.TIME: activity.timestamps = { - start: Math.floor(Date.now() / 1000) - (new Date().getHours() * 3600) - (new Date().getMinutes() * 60) - new Date().getSeconds() + start: Date.now() - (new Date().getHours() * 3600 + new Date().getMinutes() * 60 + new Date().getSeconds()) * 1000 }; break; case TimestampMode.CUSTOM: diff --git a/src/plugins/decor/index.tsx b/src/plugins/decor/index.tsx index ce546d309..713b17d76 100644 --- a/src/plugins/decor/index.tsx +++ b/src/plugins/decor/index.tsx @@ -72,7 +72,7 @@ export default definePlugin({ replacement: [ // Add Decor avatar decoration hook to avatar decoration hook { - match: /(?<=TryItOut:\i}\),)(?<=user:(\i).+?)/, + match: /(?<=TryItOut:\i,guildId:\i}\),)(?<=user:(\i).+?)/, replace: "vcDecorAvatarDecoration=$self.useUserDecorAvatarDecoration($1)," }, // Use added hook @@ -131,9 +131,10 @@ export default definePlugin({ getDecorAvatarDecorationURL({ avatarDecoration, canAnimate }: { avatarDecoration: AvatarDecoration | null; canAnimate?: boolean; }) { // Only Decor avatar decorations have this SKU ID if (avatarDecoration?.skuId === SKU_ID) { - const url = new URL(`${CDN_URL}/${avatarDecoration.asset}.png`); - url.searchParams.set("animate", (!!canAnimate && isAnimatedAvatarDecoration(avatarDecoration.asset)).toString()); - return url.toString(); + const parts = avatarDecoration.asset.split("_"); + // Remove a_ prefix if it's animated and animation is disabled + if (isAnimatedAvatarDecoration(avatarDecoration.asset) && !canAnimate) parts.shift(); + return `${CDN_URL}/${parts.join("_")}.png`; } else if (avatarDecoration?.skuId === RAW_SKU_ID) { return avatarDecoration.asset; } diff --git a/src/plugins/decor/lib/stores/UsersDecorationsStore.ts b/src/plugins/decor/lib/stores/UsersDecorationsStore.ts index 7295a3b17..b29945f82 100644 --- a/src/plugins/decor/lib/stores/UsersDecorationsStore.ts +++ b/src/plugins/decor/lib/stores/UsersDecorationsStore.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -import { debounce } from "@utils/debounce"; +import { debounce } from "@shared/debounce"; import { proxyLazy } from "@utils/lazy"; import { useEffect, useState, zustandCreate } from "@webpack/common"; import { User } from "discord-types/general"; diff --git a/src/plugins/emoteCloner/index.tsx b/src/plugins/emoteCloner/index.tsx index 219ce435f..b25e1be27 100644 --- a/src/plugins/emoteCloner/index.tsx +++ b/src/plugins/emoteCloner/index.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import { addContextMenuPatch, findGroupChildrenByChildId, NavContextMenuPatchCallback, removeContextMenuPatch } from "@api/ContextMenu"; +import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu"; import { CheckedTextInput } from "@components/CheckedTextInput"; import { Devs } from "@utils/constants"; import { Logger } from "@utils/Logger"; @@ -312,7 +312,7 @@ function isGifUrl(url: string) { return new URL(url).pathname.endsWith(".gif"); } -const messageContextMenuPatch: NavContextMenuPatchCallback = (children, props) => () => { +const messageContextMenuPatch: NavContextMenuPatchCallback = (children, props) => { const { favoriteableId, itemHref, itemSrc, favoriteableType } = props ?? {}; if (!favoriteableId) return; @@ -341,7 +341,7 @@ const messageContextMenuPatch: NavContextMenuPatchCallback = (children, props) = findGroupChildrenByChildId("copy-link", children)?.push(menuItem); }; -const expressionPickerPatch: NavContextMenuPatchCallback = (children, props: { target: HTMLElement; }) => () => { +const expressionPickerPatch: NavContextMenuPatchCallback = (children, props: { target: HTMLElement; }) => { const { id, name, type } = props?.target?.dataset ?? {}; if (!id) return; @@ -363,14 +363,8 @@ export default definePlugin({ description: "Allows you to clone Emotes & Stickers to your own server (right click them)", tags: ["StickerCloner"], authors: [Devs.Ven, Devs.Nuckyz], - - start() { - addContextMenuPatch("message", messageContextMenuPatch); - addContextMenuPatch("expression-picker", expressionPickerPatch); - }, - - stop() { - removeContextMenuPatch("message", messageContextMenuPatch); - removeContextMenuPatch("expression-picker", expressionPickerPatch); + contextMenus: { + "message": messageContextMenuPatch, + "expression-picker": expressionPickerPatch } }); diff --git a/src/plugins/fakeNitro/index.ts b/src/plugins/fakeNitro/index.tsx similarity index 85% rename from src/plugins/fakeNitro/index.ts rename to src/plugins/fakeNitro/index.tsx index 560cae381..30744276e 100644 --- a/src/plugins/fakeNitro/index.ts +++ b/src/plugins/fakeNitro/index.tsx @@ -17,14 +17,14 @@ */ import { addPreEditListener, addPreSendListener, removePreEditListener, removePreSendListener } from "@api/MessageEvents"; -import { definePluginSettings, Settings } from "@api/Settings"; +import { definePluginSettings } from "@api/Settings"; import { Devs } from "@utils/constants"; import { ApngBlendOp, ApngDisposeOp, importApngJs } from "@utils/dependencies"; import { getCurrentGuild } from "@utils/discord"; import { Logger } from "@utils/Logger"; import definePlugin, { OptionType } from "@utils/types"; import { findByPropsLazy, findStoreLazy, proxyLazyWebpack } from "@webpack"; -import { ChannelStore, EmojiStore, FluxDispatcher, lodash, Parser, PermissionStore, UploadHandler, UserSettingsActionCreators, UserStore } from "@webpack/common"; +import { Alerts, ChannelStore, EmojiStore, FluxDispatcher, Forms, lodash, Parser, PermissionsBits, PermissionStore, UploadHandler, UserSettingsActionCreators, UserStore } from "@webpack/common"; import type { Message } from "discord-types/general"; import { applyPalette, GIFEncoder, quantize } from "gifenc"; import type { ReactElement, ReactNode } from "react"; @@ -51,8 +51,6 @@ const PreloadedUserSettingsActionCreators = proxyLazyWebpack(() => UserSettingsA const AppearanceSettingsActionCreators = proxyLazyWebpack(() => searchProtoClassField("appearance", PreloadedUserSettingsActionCreators.ProtoClass)); const ClientThemeSettingsActionsCreators = proxyLazyWebpack(() => searchProtoClassField("clientThemeSettings", AppearanceSettingsActionCreators)); -const USE_EXTERNAL_EMOJIS = 1n << 18n; -const USE_EXTERNAL_STICKERS = 1n << 37n; const enum EmojiIntentions { REACTION = 0, @@ -162,12 +160,32 @@ const settings = definePluginSettings({ description: "Whether to use hyperlinks when sending fake emojis and stickers", type: OptionType.BOOLEAN, default: true + }, + hyperLinkText: { + description: "What text the hyperlink should use. {{NAME}} will be replaced with the emoji/sticker name.", + type: OptionType.STRING, + default: "{{NAME}}" } -}); +}).withPrivateSettings<{ + disableEmbedPermissionCheck: boolean; +}>(); + +function hasPermission(channelId: string, permission: bigint) { + const channel = ChannelStore.getChannel(channelId); + + if (!channel || channel.isPrivate()) return true; + + return PermissionStore.can(permission, channel); +} + +const hasExternalEmojiPerms = (channelId: string) => hasPermission(channelId, PermissionsBits.USE_EXTERNAL_EMOJIS); +const hasExternalStickerPerms = (channelId: string) => hasPermission(channelId, PermissionsBits.USE_EXTERNAL_STICKERS); +const hasEmbedPerms = (channelId: string) => hasPermission(channelId, PermissionsBits.EMBED_LINKS); +const hasAttachmentPerms = (channelId: string) => hasPermission(channelId, PermissionsBits.ATTACH_FILES); export default definePlugin({ name: "FakeNitro", - authors: [Devs.Arjix, Devs.D3SOX, Devs.Ven, Devs.obscurity, Devs.captain, Devs.Nuckyz, Devs.AutumnVN], + authors: [Devs.Arjix, Devs.D3SOX, Devs.Ven, Devs.fawn, Devs.captain, Devs.Nuckyz, Devs.AutumnVN], description: "Allows you to stream in nitro quality, send fake emojis/stickers and use client themes.", dependencies: ["MessageEventsAPI"], @@ -351,8 +369,8 @@ export default definePlugin({ predicate: () => settings.store.transformEmojis, replacement: { // Add the fake nitro emoji notice - match: /(?<=isDiscoverable:\i,emojiComesFromCurrentGuild:\i,.+?}=(\i).+?;)(.*?return )(.{0,1000}\.Messages\.EMOJI_POPOUT_UNJOINED_DISCOVERABLE_GUILD_DESCRIPTION.+?)(?=},)/, - replace: (_, props, rest, reactNode) => `let{fakeNitroNode}=${props};${rest}$self.addFakeNotice(${FakeNoticeType.Emoji},${reactNode},!!fakeNitroNode?.fake)` + match: /(?<=emojiDescription:)(\i)(?<=\1=\i\((\i)\).+?)/, + replace: (_, reactNode, props) => `$self.addFakeNotice(${FakeNoticeType.Emoji},${reactNode},!!${props}?.fakeNitroNode?.fake)` } }, // Allow using custom app icons @@ -456,7 +474,7 @@ export default definePlugin({ if (typeof firstContent === "string") { content[0] = firstContent.trimStart(); content[0] || content.shift(); - } else if (firstContent?.type === "span") { + } else if (typeof firstContent?.props?.children === "string") { firstContent.props.children = firstContent.props.children.trimStart(); firstContent.props.children || content.shift(); } @@ -466,7 +484,7 @@ export default definePlugin({ if (typeof lastContent === "string") { content[lastIndex] = lastContent.trimEnd(); content[lastIndex] || content.pop(); - } else if (lastContent?.type === "span") { + } else if (typeof lastContent?.props?.children === "string") { lastContent.props.children = lastContent.props.children.trimEnd(); lastContent.props.children || content.pop(); } @@ -567,13 +585,15 @@ export default definePlugin({ for (const [index, child] of children.entries()) children[index] = modifyChild(child); children = this.clearEmptyArrayItems(children); - this.trimContent(children); return children; }; try { - return modifyChildren(lodash.cloneDeep(content)); + const newContent = modifyChildren(lodash.cloneDeep(content)); + this.trimContent(newContent); + + return newContent; } catch (err) { new Logger("FakeNitro").error(err); return content; @@ -696,22 +716,6 @@ export default definePlugin({ } }, - hasPermissionToUseExternalEmojis(channelId: string): boolean { - const channel = ChannelStore.getChannel(channelId); - - if (!channel || channel.isDM() || channel.isGroupDM() || channel.isMultiUserDM()) return true; - - return PermissionStore.can(USE_EXTERNAL_EMOJIS, channel); - }, - - hasPermissionToUseExternalStickers(channelId: string) { - const channel = ChannelStore.getChannel(channelId); - - if (!channel || channel.isDM() || channel.isGroupDM() || channel.isMultiUserDM()) return true; - - return PermissionStore.can(USE_EXTERNAL_STICKERS, channel); - }, - getStickerLink(stickerId: string) { return `https://media.discordapp.net/stickers/${stickerId}.png?size=${settings.store.stickerSize}`; }, @@ -722,7 +726,7 @@ export default definePlugin({ const { frames, width, height } = await parseURL(stickerLink); const gif = GIFEncoder(); - const resolution = Settings.plugins.FakeNitro.stickerSize; + const resolution = settings.store.stickerSize; const canvas = document.createElement("canvas"); canvas.width = resolution; @@ -783,9 +787,38 @@ export default definePlugin({ return (!origStr[offset] || /\s/.test(origStr[offset])) ? "" : " "; } - this.preSend = addPreSendListener((channelId, messageObj, extra) => { + function cannotEmbedNotice() { + return new Promise(resolve => { + Alerts.show({ + title: "Hold on!", + body:
+ + You are trying to send/edit a message that contains a FakeNitro emoji or sticker, + however you do not have permissions to embed links in the current channel. + Are you sure you want to send this message? Your FakeNitro items will appear as a link only. + + + You can disable this notice in the plugin settings. + +
, + confirmText: "Send Anyway", + cancelText: "Cancel", + secondaryConfirmText: "Do not show again", + onConfirm: () => resolve(true), + onCloseCallback: () => setImmediate(() => resolve(false)), + onConfirmSecondary() { + settings.store.disableEmbedPermissionCheck = true; + resolve(true); + } + }); + }); + } + + this.preSend = addPreSendListener(async (channelId, messageObj, extra) => { const { guildId } = this; + let hasBypass = false; + stickerBypass: { if (!s.enableStickerBypass) break stickerBypass; @@ -798,7 +831,7 @@ export default definePlugin({ if ("pack_id" in sticker) break stickerBypass; - const canUseStickers = this.canUseStickers && this.hasPermissionToUseExternalStickers(channelId); + const canUseStickers = this.canUseStickers && hasExternalStickerPerms(channelId); if (sticker.available !== false && (canUseStickers || sticker.guild_id === guildId)) break stickerBypass; @@ -812,47 +845,76 @@ export default definePlugin({ } if (sticker.format_type === StickerType.APNG) { - this.sendAnimatedSticker(link, sticker.id, channelId); + if (!hasAttachmentPerms(channelId)) { + Alerts.show({ + title: "Hold on!", + body:
+ + You cannot send this message because it contains an animated FakeNitro sticker, + and you do not have permissions to attach files in the current channel. Please remove the sticker to proceed. + +
+ }); + } else { + this.sendAnimatedSticker(link, sticker.id, channelId); + } + return { cancel: true }; } else { + hasBypass = true; + const url = new URL(link); url.searchParams.set("name", sticker.name); - messageObj.content += `${getWordBoundary(messageObj.content, messageObj.content.length - 1)}${s.useHyperLinks ? `[${sticker.name}](${url})` : url}`; + const linkText = s.hyperLinkText.replaceAll("{{NAME}}", sticker.name); + + messageObj.content += `${getWordBoundary(messageObj.content, messageObj.content.length - 1)}${s.useHyperLinks ? `[${linkText}](${url})` : url}`; extra.stickers!.length = 0; } } if (s.enableEmojiBypass) { - const canUseEmotes = this.canUseEmotes && this.hasPermissionToUseExternalEmojis(channelId); + const canUseEmotes = this.canUseEmotes && hasExternalEmojiPerms(channelId); for (const emoji of messageObj.validNonShortcutEmojis) { if (!emoji.require_colons) continue; if (emoji.available !== false && canUseEmotes) continue; if (emoji.guildId === guildId && !emoji.animated) continue; + hasBypass = true; + const emojiString = `<${emoji.animated ? "a" : ""}:${emoji.originalName || emoji.name}:${emoji.id}>`; const url = new URL(emoji.url); url.searchParams.set("size", s.emojiSize.toString()); url.searchParams.set("name", emoji.name); + const linkText = s.hyperLinkText.replaceAll("{{NAME}}", emoji.name); + messageObj.content = messageObj.content.replace(emojiString, (match, offset, origStr) => { - return `${getWordBoundary(origStr, offset - 1)}${s.useHyperLinks ? `[${emoji.name}](${url})` : url}${getWordBoundary(origStr, offset + match.length)}`; + return `${getWordBoundary(origStr, offset - 1)}${s.useHyperLinks ? `[${linkText}](${url})` : url}${getWordBoundary(origStr, offset + match.length)}`; }); } } + if (hasBypass && !s.disableEmbedPermissionCheck && !hasEmbedPerms(channelId)) { + if (!await cannotEmbedNotice()) { + return { cancel: true }; + } + } + return { cancel: false }; }); - this.preEdit = addPreEditListener((channelId, __, messageObj) => { + this.preEdit = addPreEditListener(async (channelId, __, messageObj) => { if (!s.enableEmojiBypass) return; - const canUseEmotes = this.canUseEmotes && this.hasPermissionToUseExternalEmojis(channelId); - const { guildId } = this; + let hasBypass = false; + + const canUseEmotes = this.canUseEmotes && hasExternalEmojiPerms(channelId); + messageObj.content = messageObj.content.replace(/(?/ig, (emojiStr, emojiId, offset, origStr) => { const emoji = EmojiStore.getCustomEmojiById(emojiId); if (emoji == null) return emojiStr; @@ -860,12 +922,24 @@ export default definePlugin({ if (emoji.available !== false && canUseEmotes) return emojiStr; if (emoji.guildId === guildId && !emoji.animated) return emojiStr; + hasBypass = true; + const url = new URL(emoji.url); url.searchParams.set("size", s.emojiSize.toString()); url.searchParams.set("name", emoji.name); - return `${getWordBoundary(origStr, offset - 1)}${s.useHyperLinks ? `[${emoji.name}](${url})` : url}${getWordBoundary(origStr, offset + emojiStr.length)}`; + const linkText = s.hyperLinkText.replaceAll("{{NAME}}", emoji.name); + + return `${getWordBoundary(origStr, offset - 1)}${s.useHyperLinks ? `[${linkText}](${url})` : url}${getWordBoundary(origStr, offset + emojiStr.length)}`; }); + + if (hasBypass && !s.disableEmbedPermissionCheck && !hasEmbedPerms(channelId)) { + if (!await cannotEmbedNotice()) { + return { cancel: true }; + } + } + + return { cancel: false }; }); }, diff --git a/src/plugins/favGifSearch/index.tsx b/src/plugins/favGifSearch/index.tsx index 592d8f547..d71f56795 100644 --- a/src/plugins/favGifSearch/index.tsx +++ b/src/plugins/favGifSearch/index.tsx @@ -200,7 +200,14 @@ function SearchBar({ instance, SearchBarComponent }: { instance: Instance; Searc export function getTargetString(urlStr: string) { - const url = new URL(urlStr); + let url: URL; + try { + url = new URL(urlStr); + } catch (err) { + // Can't resolve URL, return as-is + return urlStr; + } + switch (settings.store.searchOption) { case "url": return url.href; diff --git a/src/plugins/fixSpotifyEmbeds.desktop/native.ts b/src/plugins/fixSpotifyEmbeds.desktop/native.ts index f779c400a..e15e4a441 100644 --- a/src/plugins/fixSpotifyEmbeds.desktop/native.ts +++ b/src/plugins/fixSpotifyEmbeds.desktop/native.ts @@ -4,14 +4,14 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ +import { RendererSettings } from "@main/settings"; import { app } from "electron"; -import { getSettings } from "main/ipcMain"; app.on("browser-window-created", (_, win) => { win.webContents.on("frame-created", (_, { frame }) => { frame.once("dom-ready", () => { if (frame.url.startsWith("https://open.spotify.com/embed/")) { - const settings = getSettings().plugins?.FixSpotifyEmbeds; + const settings = RendererSettings.store.plugins?.FixSpotifyEmbeds; if (!settings?.enabled) return; frame.executeJavaScript(` diff --git a/src/plugins/fixYoutubeEmbeds.desktop/native.ts b/src/plugins/fixYoutubeEmbeds.desktop/native.ts index d5c2df363..003cba9e3 100644 --- a/src/plugins/fixYoutubeEmbeds.desktop/native.ts +++ b/src/plugins/fixYoutubeEmbeds.desktop/native.ts @@ -4,14 +4,14 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ +import { RendererSettings } from "@main/settings"; import { app } from "electron"; -import { getSettings } from "main/ipcMain"; app.on("browser-window-created", (_, win) => { win.webContents.on("frame-created", (_, { frame }) => { frame.once("dom-ready", () => { if (frame.url.startsWith("https://www.youtube.com/")) { - const settings = getSettings().plugins?.FixYoutubeEmbeds; + const settings = RendererSettings.store.plugins?.FixYoutubeEmbeds; if (!settings?.enabled) return; frame.executeJavaScript(` diff --git a/src/plugins/friendsSince/README.md b/src/plugins/friendsSince/README.md new file mode 100644 index 000000000..ccbb09b67 --- /dev/null +++ b/src/plugins/friendsSince/README.md @@ -0,0 +1,5 @@ +# FriendsSince + +Shows when you became friends with someone in the user popout + +![](https://github.com/Vendicated/Vencord/assets/45497981/bb258188-ab48-4c4d-9858-1e90ba41e926) diff --git a/src/plugins/friendsSince/index.tsx b/src/plugins/friendsSince/index.tsx new file mode 100644 index 000000000..ab3320dfe --- /dev/null +++ b/src/plugins/friendsSince/index.tsx @@ -0,0 +1,60 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import ErrorBoundary from "@components/ErrorBoundary"; +import { Devs } from "@utils/constants"; +import definePlugin from "@utils/types"; +import { findByPropsLazy } from "@webpack"; +import { React, RelationshipStore } from "@webpack/common"; + +const { Heading, Text } = findByPropsLazy("Heading", "Text"); +const container = findByPropsLazy("memberSinceContainer"); +const { getCreatedAtDate } = findByPropsLazy("getCreatedAtDate"); +const clydeMoreInfo = findByPropsLazy("clydeMoreInfo"); +const locale = findByPropsLazy("getLocale"); +const lastSection = findByPropsLazy("lastSection"); + +export default definePlugin({ + name: "FriendsSince", + description: "Shows when you became friends with someone in the user popout", + authors: [Devs.Elvyra], + patches: [ + { + find: ".AnalyticsSections.USER_PROFILE}", + replacement: { + match: /\i.default,\{userId:(\i.id).{0,30}}\)/, + replace: "$&,$self.friendsSince({ userId: $1 })" + } + }, + { + find: ".UserPopoutUpsellSource.PROFILE_PANEL,", + replacement: { + match: /\i.default,\{userId:(\i)}\)/, + replace: "$&,$self.friendsSince({ userId: $1 })" + } + } + ], + + friendsSince: ErrorBoundary.wrap(({ userId }: { userId: string; }) => { + const friendsSince = RelationshipStore.getSince(userId); + if (!friendsSince) return null; + + return ( +
+ + Friends Since + + +
+ + {getCreatedAtDate(friendsSince, locale.getLocale())} + +
+
+ ); + }, { noop: true }) +}); + diff --git a/src/plugins/ignoreActivities/index.tsx b/src/plugins/ignoreActivities/index.tsx index 4e747f363..c04ce1c56 100644 --- a/src/plugins/ignoreActivities/index.tsx +++ b/src/plugins/ignoreActivities/index.tsx @@ -5,12 +5,14 @@ */ import * as DataStore from "@api/DataStore"; -import { definePluginSettings } from "@api/Settings"; +import { definePluginSettings, Settings } from "@api/Settings"; import ErrorBoundary from "@components/ErrorBoundary"; +import { Flex } from "@components/Flex"; import { Devs } from "@utils/constants"; -import definePlugin from "@utils/types"; +import { Margins } from "@utils/margins"; +import definePlugin, { OptionType } from "@utils/types"; import { findStoreLazy } from "@webpack"; -import { StatusSettingsStores, Tooltip } from "webpack/common"; +import { Button, Forms, showToast, StatusSettingsStores, TextInput, Toasts, Tooltip, useEffect, useState } from "webpack/common"; const enum ActivitiesTypes { Game, @@ -69,7 +71,113 @@ function handleActivityToggle(e: React.MouseEvent StatusSettingsStores.ShowCurrentGame.updateSetting(old => old); } -const settings = definePluginSettings({}).withPrivateSettings<{ +function ImportCustomRPCComponent() { + return ( + + Import the application id of the CustomRPC plugin to the allowed list +
+ +
+
+ ); +} + +let allowedIdsPushID: ((id: string) => boolean) | null = null; + +function AllowedIdsComponent(props: { setValue: (value: string) => void; }) { + const [allowedIds, setAllowedIds] = useState(settings.store.allowedIds ?? ""); + + allowedIdsPushID = (id: string) => { + const currentIds = new Set(allowedIds.split(",").map(id => id.trim()).filter(Boolean)); + + const isAlreadyAdded = currentIds.has(id) || (currentIds.add(id), false); + + const ids = Array.from(currentIds).join(", "); + setAllowedIds(ids); + props.setValue(ids); + + return isAlreadyAdded; + }; + + useEffect(() => () => { + allowedIdsPushID = null; + }, []); + + function handleChange(newValue: string) { + setAllowedIds(newValue); + props.setValue(newValue); + } + + return ( + + Allowed List + Comma separated list of activity IDs to allow (Useful for allowing RPC activities and CustomRPC) + + + ); +} + +const settings = definePluginSettings({ + importCustomRPC: { + type: OptionType.COMPONENT, + description: "", + component: () => + }, + allowedIds: { + type: OptionType.COMPONENT, + description: "", + default: "", + onChange(newValue: string) { + const ids = new Set(newValue.split(",").map(id => id.trim()).filter(Boolean)); + settings.store.allowedIds = Array.from(ids).join(", "); + }, + component: props => + }, + ignorePlaying: { + type: OptionType.BOOLEAN, + description: "Ignore all playing activities (These are usually game and RPC activities)", + default: false + }, + ignoreStreaming: { + type: OptionType.BOOLEAN, + description: "Ignore all streaming activities", + default: false + }, + ignoreListening: { + type: OptionType.BOOLEAN, + description: "Ignore all listening activities (These are usually spotify activities)", + default: false + }, + ignoreWatching: { + type: OptionType.BOOLEAN, + description: "Ignore all watching activities", + default: false + }, + ignoreCompeting: { + type: OptionType.BOOLEAN, + description: "Ignore all competing activities (These are normally special game activities)", + default: false + } +}).withPrivateSettings<{ ignoredActivities: IgnoredActivity[]; }>(); @@ -77,10 +185,26 @@ function getIgnoredActivities() { return settings.store.ignoredActivities ??= []; } +function isActivityTypeIgnored(type: number, id?: string) { + if (id && settings.store.allowedIds.includes(id)) { + return false; + } + + switch (type) { + case 0: return settings.store.ignorePlaying; + case 1: return settings.store.ignoreStreaming; + case 2: return settings.store.ignoreListening; + case 3: return settings.store.ignoreWatching; + case 5: return settings.store.ignoreCompeting; + } + + return false; +} + export default definePlugin({ name: "IgnoreActivities", authors: [Devs.Nuckyz], - description: "Ignore activities from showing up on your status ONLY. You can configure which ones are ignored from the Registered Games and Activities tabs.", + description: "Ignore activities from showing up on your status ONLY. You can configure which ones are specifically ignored from the Registered Games and Activities tabs, or use the general settings below.", settings, @@ -141,13 +265,17 @@ export default definePlugin({ }, isActivityNotIgnored(props: { type: number; application_id?: string; name?: string; }) { - if (props.type === 0 || props.type === 3) { - if (props.application_id != null) return !getIgnoredActivities().some(activity => activity.id === props.application_id); - else { - const exePath = RunningGameStore.getRunningGames().find(game => game.name === props.name)?.exePath; - if (exePath) return !getIgnoredActivities().some(activity => activity.id === exePath); + if (isActivityTypeIgnored(props.type, props.application_id)) return false; + + if (props.application_id != null) { + return !getIgnoredActivities().some(activity => activity.id === props.application_id) || settings.store.allowedIds.includes(props.application_id); + } else { + const exePath = RunningGameStore.getRunningGames().find(game => game.name === props.name)?.exePath; + if (exePath) { + return !getIgnoredActivities().some(activity => activity.id === exePath); } } + return true; }, diff --git a/src/plugins/imageZoom/index.tsx b/src/plugins/imageZoom/index.tsx index 8d8b6726d..cbaa07d2a 100644 --- a/src/plugins/imageZoom/index.tsx +++ b/src/plugins/imageZoom/index.tsx @@ -16,14 +16,14 @@ * along with this program. If not, see . */ -import { addContextMenuPatch, NavContextMenuPatchCallback, removeContextMenuPatch } from "@api/ContextMenu"; +import { NavContextMenuPatchCallback } from "@api/ContextMenu"; import { definePluginSettings } from "@api/Settings"; import { disableStyle, enableStyle } from "@api/Styles"; import { makeRange } from "@components/PluginSettings/components"; +import { debounce } from "@shared/debounce"; import { Devs } from "@utils/constants"; -import { debounce } from "@utils/debounce"; import definePlugin, { OptionType } from "@utils/types"; -import { ContextMenuApi, Menu, React, ReactDOM } from "@webpack/common"; +import { Menu, React, ReactDOM } from "@webpack/common"; import type { Root } from "react-dom/client"; import { Magnifier, MagnifierProps } from "./components/Magnifier"; @@ -80,25 +80,25 @@ export const settings = definePluginSettings({ }); -const imageContextMenuPatch: NavContextMenuPatchCallback = children => () => { +const imageContextMenuPatch: NavContextMenuPatchCallback = children => { + const { square, nearestNeighbour } = settings.use(["square", "nearestNeighbour"]); + children.push( { - settings.store.square = !settings.store.square; - ContextMenuApi.closeContextMenu(); + settings.store.square = !square; }} /> { - settings.store.nearestNeighbour = !settings.store.nearestNeighbour; - ContextMenuApi.closeContextMenu(); + settings.store.nearestNeighbour = !nearestNeighbour; }} /> | null, @@ -245,7 +248,6 @@ export default definePlugin({ start() { enableStyle(styles); - addContextMenuPatch("image-context", imageContextMenuPatch); this.element = document.createElement("div"); this.element.classList.add("MagnifierContainer"); document.body.appendChild(this.element); @@ -256,6 +258,5 @@ export default definePlugin({ // so componenetWillUnMount gets called if Magnifier component is still alive this.root && this.root.unmount(); this.element?.remove(); - removeContextMenuPatch("image-context", imageContextMenuPatch); } }); diff --git a/src/plugins/imageZoom/styles.css b/src/plugins/imageZoom/styles.css index 51e225c05..c3776d90e 100644 --- a/src/plugins/imageZoom/styles.css +++ b/src/plugins/imageZoom/styles.css @@ -9,6 +9,9 @@ box-shadow: inset 0 0 10px 2px grey; filter: drop-shadow(0 0 2px grey); pointer-events: none; + + /* negate the border offsetting the lens */ + margin: -2px; } .vc-imgzoom-square { diff --git a/src/plugins/index.ts b/src/plugins/index.ts index 234838606..7092001ee 100644 --- a/src/plugins/index.ts +++ b/src/plugins/index.ts @@ -17,6 +17,7 @@ */ import { registerCommand, unregisterCommand } from "@api/Commands"; +import { addContextMenuPatch, removeContextMenuPatch } from "@api/ContextMenu"; import { Settings } from "@api/Settings"; import { Logger } from "@utils/Logger"; import { Patch, Plugin, StartAt } from "@utils/types"; @@ -119,7 +120,7 @@ export function startDependenciesRecursive(p: Plugin) { } export const startPlugin = traceFunction("startPlugin", function startPlugin(p: Plugin) { - const { name, commands, flux } = p; + const { name, commands, flux, contextMenus } = p; if (p.start) { logger.info("Starting plugin", name); @@ -154,11 +155,17 @@ export const startPlugin = traceFunction("startPlugin", function startPlugin(p: } } + if (contextMenus) { + for (const navId in contextMenus) { + addContextMenuPatch(navId, contextMenus[navId]); + } + } + return true; }, p => `startPlugin ${p.name}`); export const stopPlugin = traceFunction("stopPlugin", function stopPlugin(p: Plugin) { - const { name, commands, flux } = p; + const { name, commands, flux, contextMenus } = p; if (p.stop) { logger.info("Stopping plugin", name); if (!p.started) { @@ -192,5 +199,11 @@ export const stopPlugin = traceFunction("stopPlugin", function stopPlugin(p: Plu } } + if (contextMenus) { + for (const navId in contextMenus) { + removeContextMenuPatch(navId, contextMenus[navId]); + } + } + return true; }, p => `stopPlugin ${p.name}`); diff --git a/src/plugins/lastfm/index.tsx b/src/plugins/lastfm/index.tsx index 179b8260c..5dfec8a32 100644 --- a/src/plugins/lastfm/index.tsx +++ b/src/plugins/lastfm/index.tsx @@ -170,6 +170,11 @@ const settings = definePluginSettings({ } ], }, + showLastFmLogo: { + description: "show the Last.fm logo by the album cover", + type: OptionType.BOOLEAN, + default: true, + } }); export default definePlugin({ @@ -276,8 +281,10 @@ export default definePlugin({ { large_image: await getApplicationAsset(largeImage), large_text: trackData.album || undefined, - small_image: await getApplicationAsset("lastfm-small"), - small_text: "Last.fm", + ...(settings.store.showLastFmLogo && { + small_image: await getApplicationAsset("lastfm-small"), + small_text: "Last.fm" + }), } : { large_image: await getApplicationAsset("lastfm-large"), large_text: trackData.album || undefined, diff --git a/src/plugins/memberCount/MemberCount.tsx b/src/plugins/memberCount/MemberCount.tsx new file mode 100644 index 000000000..50665353e --- /dev/null +++ b/src/plugins/memberCount/MemberCount.tsx @@ -0,0 +1,66 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { getCurrentChannel } from "@utils/discord"; +import { SelectedChannelStore, Tooltip, useEffect, useStateFromStores } from "@webpack/common"; + +import { ChannelMemberStore, cl, GuildMemberCountStore, numberFormat } from "."; +import { OnlineMemberCountStore } from "./OnlineMemberCountStore"; + +export function MemberCount({ isTooltip, tooltipGuildId }: { isTooltip?: true; tooltipGuildId?: string; }) { + const currentChannel = useStateFromStores([SelectedChannelStore], () => getCurrentChannel()); + + const guildId = isTooltip ? tooltipGuildId! : currentChannel.guild_id; + + const totalCount = useStateFromStores( + [GuildMemberCountStore], + () => GuildMemberCountStore.getMemberCount(guildId) + ); + + let onlineCount = useStateFromStores( + [OnlineMemberCountStore], + () => OnlineMemberCountStore.getCount(guildId) + ); + + const { groups } = useStateFromStores( + [ChannelMemberStore], + () => ChannelMemberStore.getProps(guildId, currentChannel.id) + ); + + if (!isTooltip && (groups.length >= 1 || groups[0].id !== "unknown")) { + onlineCount = groups.reduce((total, curr) => total + (curr.id === "offline" ? 0 : curr.count), 0); + } + + useEffect(() => { + OnlineMemberCountStore.ensureCount(guildId); + }, [guildId]); + + if (totalCount == null) + return null; + + const formattedOnlineCount = onlineCount != null ? numberFormat(onlineCount) : "?"; + + return ( +
+ + {props => ( +
+ + {formattedOnlineCount} +
+ )} +
+ + {props => ( +
+ + {numberFormat(totalCount)} +
+ )} +
+
+ ); +} diff --git a/src/plugins/memberCount/OnlineMemberCountStore.ts b/src/plugins/memberCount/OnlineMemberCountStore.ts new file mode 100644 index 000000000..8790f5e29 --- /dev/null +++ b/src/plugins/memberCount/OnlineMemberCountStore.ts @@ -0,0 +1,52 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { proxyLazy } from "@utils/lazy"; +import { sleep } from "@utils/misc"; +import { Queue } from "@utils/Queue"; +import { Flux, FluxDispatcher, GuildChannelStore, PrivateChannelsStore } from "@webpack/common"; + +export const OnlineMemberCountStore = proxyLazy(() => { + const preloadQueue = new Queue(); + + const onlineMemberMap = new Map(); + + class OnlineMemberCountStore extends Flux.Store { + getCount(guildId: string) { + return onlineMemberMap.get(guildId); + } + + async _ensureCount(guildId: string) { + if (onlineMemberMap.has(guildId)) return; + + await PrivateChannelsStore.preload(guildId, GuildChannelStore.getDefaultChannel(guildId).id); + } + + ensureCount(guildId: string) { + if (onlineMemberMap.has(guildId)) return; + + preloadQueue.push(() => + this._ensureCount(guildId) + .then( + () => sleep(200), + () => sleep(200) + ) + ); + } + } + + return new OnlineMemberCountStore(FluxDispatcher, { + GUILD_MEMBER_LIST_UPDATE({ guildId, groups }: { guildId: string, groups: { count: number; id: string; }[]; }) { + onlineMemberMap.set( + guildId, + groups.reduce((total, curr) => total + (curr.id === "offline" ? 0 : curr.count), 0) + ); + }, + ONLINE_GUILD_MEMBER_COUNT_UPDATE({ guildId, count }) { + onlineMemberMap.set(guildId, count); + } + }); +}); diff --git a/src/plugins/memberCount/index.tsx b/src/plugins/memberCount/index.tsx index d9cd548e9..92e9a2057 100644 --- a/src/plugins/memberCount/index.tsx +++ b/src/plugins/memberCount/index.tsx @@ -16,101 +16,66 @@ * along with this program. If not, see . */ +import "./style.css"; + +import { definePluginSettings } from "@api/Settings"; +import { classNameFactory } from "@api/Styles"; import ErrorBoundary from "@components/ErrorBoundary"; -import { Flex } from "@components/Flex"; import { Devs } from "@utils/constants"; -import { getCurrentChannel } from "@utils/discord"; -import definePlugin from "@utils/types"; +import definePlugin, { OptionType } from "@utils/types"; import { findStoreLazy } from "@webpack"; -import { SelectedChannelStore, Tooltip, useStateFromStores } from "@webpack/common"; import { FluxStore } from "@webpack/types"; -const GuildMemberCountStore = findStoreLazy("GuildMemberCountStore") as FluxStore & { getMemberCount(guildId: string): number | null; }; -const ChannelMemberStore = findStoreLazy("ChannelMemberStore") as FluxStore & { +import { MemberCount } from "./MemberCount"; + +export const GuildMemberCountStore = findStoreLazy("GuildMemberCountStore") as FluxStore & { getMemberCount(guildId: string): number | null; }; +export const ChannelMemberStore = findStoreLazy("ChannelMemberStore") as FluxStore & { getProps(guildId: string, channelId: string): { groups: { count: number; id: string; }[]; }; }; +const settings = definePluginSettings({ + toolTip: { + type: OptionType.BOOLEAN, + description: "If the member count should be displayed on the server tooltip", + default: true, + restartNeeded: true + }, + memberList: { + type: OptionType.BOOLEAN, + description: "If the member count should be displayed on the member list", + default: true, + restartNeeded: true + } +}); + const sharedIntlNumberFormat = new Intl.NumberFormat(); -const numberFormat = (value: number) => sharedIntlNumberFormat.format(value); - -function MemberCount() { - const { id: channelId, guild_id: guildId } = useStateFromStores([SelectedChannelStore], () => getCurrentChannel()); - const { groups } = useStateFromStores( - [ChannelMemberStore], - () => ChannelMemberStore.getProps(guildId, channelId) - ); - const total = useStateFromStores( - [GuildMemberCountStore], - () => GuildMemberCountStore.getMemberCount(guildId) - ); - - if (total == null) - return null; - - const online = - (groups.length === 1 && groups[0].id === "unknown") - ? 0 - : groups.reduce((count, curr) => count + (curr.id === "offline" ? 0 : curr.count), 0); - - return ( - - - {props => ( -
- - {numberFormat(online)} -
- )} -
- - {props => ( -
- - {numberFormat(total)} -
- )} -
-
- ); -} +export const numberFormat = (value: number) => sharedIntlNumberFormat.format(value); +export const cl = classNameFactory("vc-membercount-"); export default definePlugin({ name: "MemberCount", - description: "Shows the amount of online & total members in the server member list", + description: "Shows the amount of online & total members in the server member list and tooltip", authors: [Devs.Ven, Devs.Commandtechno], + settings, - patches: [{ - find: "{isSidebarVisible:", - replacement: { - match: /(?<=let\{className:(\i),.+?children):\[(\i\.useMemo[^}]+"aria-multiselectable")/, - replace: ":[$1?.startsWith('members')?$self.render():null,$2" + patches: [ + { + find: "{isSidebarVisible:", + replacement: { + match: /(?<=let\{className:(\i),.+?children):\[(\i\.useMemo[^}]+"aria-multiselectable")/, + replace: ":[$1?.startsWith('members')?$self.render():null,$2" + }, + predicate: () => settings.store.memberList + }, + { + find: ".invitesDisabledTooltip", + replacement: { + match: /(?<=\.VIEW_AS_ROLES_MENTIONS_WARNING.{0,100})]/, + replace: ",$self.renderTooltip(arguments[0].guild)]" + }, + predicate: () => settings.store.toolTip } - }], - - render: ErrorBoundary.wrap(MemberCount, { noop: true }) + ], + render: ErrorBoundary.wrap(MemberCount, { noop: true }), + renderTooltip: ErrorBoundary.wrap(guild => , { noop: true }) }); diff --git a/src/plugins/memberCount/style.css b/src/plugins/memberCount/style.css new file mode 100644 index 000000000..f43bff830 --- /dev/null +++ b/src/plugins/memberCount/style.css @@ -0,0 +1,44 @@ +.vc-membercount-widget { + display: flex; + align-content: center; + + --color-online: var(--green-360); + --color-total: var(--primary-400); +} + +.vc-membercount-tooltip { + margin-top: 0.25em; + margin-left: 2px; +} + +.vc-membercount-member-list { + justify-content: center; + margin-top: 1em; + padding-inline: 1em; +} + +.vc-membercount-online { + color: var(--color-online); +} + +.vc-membercount-total { + color: var(--color-total); +} + +.vc-membercount-online-dot { + background-color: var(--color-online); + display: inline-block; + width: 12px; + height: 12px; + border-radius: 50%; + margin-right: 0.5em; +} + +.vc-membercount-total-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + border: 3px solid var(--color-total); + margin: 0 0.5em 0 1em; +} diff --git a/src/plugins/messageLogger/index.tsx b/src/plugins/messageLogger/index.tsx index ef986bf87..8bc563b19 100644 --- a/src/plugins/messageLogger/index.tsx +++ b/src/plugins/messageLogger/index.tsx @@ -18,7 +18,7 @@ import "./messageLogger.css"; -import { addContextMenuPatch, NavContextMenuPatchCallback, removeContextMenuPatch } from "@api/ContextMenu"; +import { NavContextMenuPatchCallback } from "@api/ContextMenu"; import { Settings } from "@api/Settings"; import { disableStyle, enableStyle } from "@api/Styles"; import ErrorBoundary from "@components/ErrorBoundary"; @@ -45,7 +45,7 @@ function addDeleteStyle() { const REMOVE_HISTORY_ID = "ml-remove-history"; const TOGGLE_DELETE_STYLE_ID = "ml-toggle-style"; -const patchMessageContextMenu: NavContextMenuPatchCallback = (children, props) => () => { +const patchMessageContextMenu: NavContextMenuPatchCallback = (children, props) => { const { message } = props; const { deleted, editHistory, id, channel_id } = message; @@ -94,13 +94,12 @@ export default definePlugin({ description: "Temporarily logs deleted and edited messages.", authors: [Devs.rushii, Devs.Ven, Devs.AutumnVN], - start() { - addDeleteStyle(); - addContextMenuPatch("message", patchMessageContextMenu); + contextMenus: { + "message": patchMessageContextMenu }, - stop() { - removeContextMenuPatch("message", patchMessageContextMenu); + start() { + addDeleteStyle(); }, renderEdit(edit: { timestamp: any, content: string; }) { diff --git a/src/plugins/mutualGroupDMs/index.tsx b/src/plugins/mutualGroupDMs/index.tsx index 40d5201cb..f5e4b6149 100644 --- a/src/plugins/mutualGroupDMs/index.tsx +++ b/src/plugins/mutualGroupDMs/index.tsx @@ -47,8 +47,8 @@ export default definePlugin({ { find: ".Messages.USER_PROFILE_MODAL", // Note: the module is lazy-loaded replacement: { - match: /(?<=\.MUTUAL_GUILDS\}\),)(?=(\i\.bot).{0,20}(\(0,\i\.jsx\)\(.{0,100}id:))/, - replace: '($1||arguments[0].isCurrentUser)?null:$2"MUTUAL_GDMS",children:"Mutual Groups"}),' + match: /(?<=\.tabBarItem.{0,50}MUTUAL_GUILDS.+?}\),)(?=.+?(\(0,\i\.jsxs?\)\(.{0,100}id:))/, + replace: '(arguments[0].user.bot||arguments[0].isCurrentUser)?null:$1"MUTUAL_GDMS",children:"Mutual Groups"}),' } }, { diff --git a/src/plugins/muteNewGuild/index.tsx b/src/plugins/newGuildSettings/index.tsx similarity index 72% rename from src/plugins/muteNewGuild/index.tsx rename to src/plugins/newGuildSettings/index.tsx index 08c558a95..ff6f1c261 100644 --- a/src/plugins/muteNewGuild/index.tsx +++ b/src/plugins/newGuildSettings/index.tsx @@ -16,16 +16,18 @@ * along with this program. If not, see . */ -import { definePluginSettings } from "@api/Settings"; +import { definePluginSettings,migratePluginSettings } from "@api/Settings"; import { Devs } from "@utils/constants"; import definePlugin, { OptionType } from "@utils/types"; import { findByPropsLazy } from "@webpack"; const { updateGuildNotificationSettings } = findByPropsLazy("updateGuildNotificationSettings"); +const { toggleShowAllChannels } = findByPropsLazy("toggleShowAllChannels"); +const { isOptInEnabledForGuild } = findByPropsLazy("isOptInEnabledForGuild"); const settings = definePluginSettings({ guild: { - description: "Mute Guild", + description: "Mute Guild automatically", type: OptionType.BOOLEAN, default: true }, @@ -38,13 +40,20 @@ const settings = definePluginSettings({ description: "Suppress All Role @mentions", type: OptionType.BOOLEAN, default: true + }, + showAllChannels: { + description: "Show all channels automatically", + type: OptionType.BOOLEAN, + default: true } }); +migratePluginSettings("NewGuildSettings", "MuteNewGuild"); export default definePlugin({ - name: "MuteNewGuild", - description: "Mutes newly joined guilds", - authors: [Devs.Glitch, Devs.Nuckyz, Devs.carince], + name: "NewGuildSettings", + description: "Automatically mute new servers and change various other settings upon joining", + tags: ["MuteNewGuild", "mute", "server"], + authors: [Devs.Glitch, Devs.Nuckyz, Devs.carince, Devs.Mopi], patches: [ { find: ",acceptInvite(", @@ -70,7 +79,9 @@ export default definePlugin({ muted: settings.store.guild, suppress_everyone: settings.store.everyone, suppress_roles: settings.store.role - } - ); + }); + if (settings.store.showAllChannels && isOptInEnabledForGuild(guildId)) { + toggleShowAllChannels(guildId); + } } }); diff --git a/src/plugins/notificationVolume/index.ts b/src/plugins/notificationVolume/index.ts index 50eabee73..bc3c7539d 100644 --- a/src/plugins/notificationVolume/index.ts +++ b/src/plugins/notificationVolume/index.ts @@ -27,8 +27,8 @@ export default definePlugin({ { find: "_ensureAudio(){", replacement: { - match: /onloadeddata=\(\)=>\{.\.volume=/, - replace: "$&$self.settings.store.notificationVolume/100*" + match: /(?=Math\.min\(\i\.\i\.getOutputVolume\(\)\/100)/, + replace: "$self.settings.store.notificationVolume/100*" }, }, ], diff --git a/src/plugins/permissionsViewer/components/RolesAndUsersPermissions.tsx b/src/plugins/permissionsViewer/components/RolesAndUsersPermissions.tsx index e0d25c7ab..c2e50cedd 100644 --- a/src/plugins/permissionsViewer/components/RolesAndUsersPermissions.tsx +++ b/src/plugins/permissionsViewer/components/RolesAndUsersPermissions.tsx @@ -21,7 +21,7 @@ import { Flex } from "@components/Flex"; import { InfoIcon, OwnerCrownIcon } from "@components/Icons"; import { getUniqueUsername } from "@utils/discord"; import { ModalCloseButton, ModalContent, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal"; -import { ContextMenuApi, FluxDispatcher, GuildMemberStore, Menu, PermissionsBits, Text, Tooltip, useEffect, UserStore, useState, useStateFromStores } from "@webpack/common"; +import { ContextMenuApi, FluxDispatcher, GuildMemberStore, GuildStore, Menu, PermissionsBits, Text, Tooltip, useEffect, UserStore, useState, useStateFromStores } from "@webpack/common"; import type { Guild } from "discord-types/general"; import { settings } from ".."; @@ -78,6 +78,8 @@ function RolesAndUsersPermissionsComponent({ permissions, guild, modalProps, hea const [selectedItemIndex, selectItem] = useState(0); const selectedItem = permissions[selectedItemIndex]; + const roles = GuildStore.getRoles(guild.id); + return ( {permissions.map((permission, index) => { const user = UserStore.getUser(permission.id ?? ""); - const role = guild.roles[permission.id ?? ""]; + const role = roles[permission.id ?? ""]; return (