From 73d8490b019e97773b561ea59bc85cae8397be68 Mon Sep 17 00:00:00 2001 From: Vendicated Date: Sun, 26 Jul 2026 16:16:18 +0200 Subject: [PATCH 01/11] fix ViewIcons --- src/plugins/viewIcons/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plugins/viewIcons/index.tsx b/src/plugins/viewIcons/index.tsx index 42c9aaeff..37f90b74e 100644 --- a/src/plugins/viewIcons/index.tsx +++ b/src/plugins/viewIcons/index.tsx @@ -215,8 +215,8 @@ export default definePlugin({ { find: "return{avatarProps:{", replacement: { - match: /(?<=avatarProps:(\i),eventHandlers:\i.{0,100}?)return null==(?<=onOpenAvatar:(\i).+?)/, - replace: "$2&&=$self.openAvatar.bind(undefined,$1.src);$&", + match: /(?<=onClick:.{0,30}?)null!=(\i)(?=.{0,200}children:.{0,50}...(\i),imageClassName:)/, + replace: "null!=($1&&=$self.openAvatar.bind(undefined,$2.src))", } }, // Banners From 2f1c6a37fc4580d43ee616719f4ec4f7f68923ca Mon Sep 17 00:00:00 2001 From: V Date: Sun, 26 Jul 2026 17:07:47 +0200 Subject: [PATCH 02/11] fix many bugs found by Claude (#4436) Fixes: - Webpack Patcher: incorrect patch error handling could lead to some functional patches not being applied - CommandsAPI: subcommands were impossible to unregister - useAwaiter: on error, the value was set to null even if a fallbackValue was specified, violating the type contract - Patcher: build check boolean logic was grouped incorrectly due to missing parantheses - CustomCommands: commands weren't unregistered on stop, leading to error when enabling, disabling & re-renabling the plugin - Notification Log: delete was by timestamp instead of ID and non-atomic - FixSpotifyEmbeds: settings listener was registered inside browser-window-created event handler, leading to duplicate listeners - CustomRPC: changing activity type or timestamp didn't update the RPC - CopyStickerLinks: message patch had non-sense code, just removed it - XSOverlay: UDP socket wasn't closed on stop - VoiceMessages: Web recorder never closed the mic media stream, which continues showing the OS mic indicator - PictureInPicture: Video clone wasn't properly cleaned up, which leaked memory - Themes (Extension version): theme object urls were never cleaned up, which leaked memory on theme change - Themes (Extension version): Upload Theme button hitbox was only the text, not the whole button. Also added Drag & Drop from file manager support - Dearrow: toggling for one embed flipped the global dearrow by default setting - Extension Installer: the zip extract function was vulnerable to path traversal, although we only ever used this for React DevTools obtained via https so it has low impact - FileWatchers: ensure always properly cleaned up - components/Link: mutated props which is a very bad practice All fixes are human written, only the audit was done by Claude Fable (thanks for ur tokens, Zere). --- src/api/Badges.ts | 2 +- src/api/Commands/index.ts | 40 ++++++--- src/api/Commands/types.ts | 1 + src/api/Notifications/notificationLog.tsx | 25 ++++-- src/api/Themes.ts | 18 +++- src/components/Link.tsx | 2 + src/components/settings/QuickAction.tsx | 5 +- .../settings/tabs/plugins/index.tsx | 6 +- .../settings/tabs/themes/LocalThemesTab.tsx | 55 ++++++++++-- src/main/index.ts | 4 +- src/main/ipcMain.ts | 20 +++-- src/main/utils/ensureSafePath.ts | 16 ++++ src/main/utils/extensions.ts | 28 +++++-- src/plugins/copyStickerLinks/index.tsx | 6 +- src/plugins/customCommands/index.ts | 15 ++-- src/plugins/customCommands/settings.ts | 2 +- src/plugins/customRPC/RpcSettings.tsx | 5 +- src/plugins/dearrow/index.tsx | 1 - .../fixSpotifyEmbeds.desktop/native.ts | 20 ++--- src/plugins/friendInvites/index.ts | 4 +- src/plugins/pictureInPicture/index.tsx | 42 ++++++++-- src/plugins/previewMessage/index.tsx | 38 ++++++--- .../reviewDB/components/ReviewComponent.tsx | 2 +- src/plugins/unsuppressEmbeds/index.tsx | 2 +- src/plugins/voiceMessages/WebRecorder.tsx | 1 + src/plugins/xsOverlay/index.tsx | 1 + src/plugins/xsOverlay/native.ts | 7 +- src/utils/react.tsx | 2 +- src/webpack/patchWebpack.ts | 83 ++++++++++--------- 29 files changed, 310 insertions(+), 143 deletions(-) create mode 100644 src/main/utils/ensureSafePath.ts diff --git a/src/api/Badges.ts b/src/api/Badges.ts index 1a6d085b6..6f0260c0b 100644 --- a/src/api/Badges.ts +++ b/src/api/Badges.ts @@ -40,7 +40,7 @@ export interface ProfileBadge { /** Action to perform when you click the badge */ onClick?(event: React.MouseEvent, props: ProfileBadge & BadgeUserArgs): void; /** Action to perform when you right click the badge */ - onContextMenu?(event: React.MouseEvent, props: BadgeUserArgs & BadgeUserArgs): void; + onContextMenu?(event: React.MouseEvent, props: ProfileBadge & BadgeUserArgs): void; /** Should the user display this badge? */ shouldShow?(userInfo: BadgeUserArgs): boolean; /** Optional props (e.g. style) for the badge, ignored for component badges */ diff --git a/src/api/Commands/index.ts b/src/api/Commands/index.ts index 231b3daf1..6b5a5ac06 100644 --- a/src/api/Commands/index.ts +++ b/src/api/Commands/index.ts @@ -46,16 +46,14 @@ export let OptionalMessageOption: CommandOption = OptPlaceholder; */ export let RequiredMessageOption: CommandOption = ReqPlaceholder; -// Discord's command list has random gaps for some reason, which can cause issues while rendering the commands -// Add this offset to every added command to keep them unique -let commandIdOffset: number; +let idCounter = 99; export const _init = function (cmds: VencordCommand[]) { try { BUILT_IN = cmds; OptionalMessageOption = cmds.find(c => (c.untranslatedName || c.displayName) === "shrug")!.options![0]; RequiredMessageOption = cmds.find(c => (c.untranslatedName || c.displayName) === "me")!.options![0]; - commandIdOffset = Math.abs(BUILT_IN.map(x => Number(x.id)).sort((x, y) => x - y)[0]) - BUILT_IN.length; + idCounter = Math.abs(BUILT_IN.map(x => Number(x.id)).sort((x, y) => x - y)[0]) + 1; } catch (e) { new Logger("CommandsAPI").error("Failed to load CommandsApi", e, " - cmds is", cmds); } @@ -107,21 +105,23 @@ export function prepareOption(opt: O): return opt; } +const isSubCommandParent = (cmd: VencordCommand) => cmd.options?.[0]?.type === ApplicationCommandOptionType.SUB_COMMAND; +const getSubCommandName = (cmd: VencordCommand, option: CommandOption) => `${cmd.name} ${option.name}`; + // Yes, Discord registers individual commands for each subcommand -// TODO: This probably doesn't support nested subcommands. If that is ever needed, -// investigate function registerSubCommands(cmd: VencordCommand, plugin: string) { cmd.options?.forEach(o => { if (o.type !== ApplicationCommandOptionType.SUB_COMMAND) throw new Error("When specifying sub-command options, all options must be sub-commands."); + const subCmd = { ...cmd, ...o, options: o.options !== undefined ? o.options : undefined, type: ApplicationCommandType.CHAT_INPUT, - name: `${cmd.name} ${o.name}`, id: `${o.name}-${cmd.id}`, - displayName: `${cmd.name} ${o.name}`, + name: getSubCommandName(cmd, o), + displayName: getSubCommandName(cmd, o), subCommandPath: [{ name: o.name, type: o.type, @@ -129,10 +129,18 @@ function registerSubCommands(cmd: VencordCommand, plugin: string) { }], rootCommand: cmd }; - registerCommand(subCmd as any, plugin); + registerCommand(subCmd, plugin); }); } +function unregisterSubCommands(cmd: VencordCommand): boolean { + const results = BUILT_IN + .filter(c => c.rootCommand === cmd) + .map(c => unregisterCommand(c.name)); + + return results.length > 0 && results.every(x => x); +} + export function registerCommand(command: C, plugin: string) { if (!BUILT_IN) { console.warn( @@ -149,24 +157,30 @@ export function registerCommand(command: C, plugin: st command.isVencordCommand = true; command.untranslatedName ??= command.name; command.untranslatedDescription ??= command.description; - command.id ??= `-${BUILT_IN.length + commandIdOffset + 1}`; + command.id ??= `-${idCounter++}`; command.applicationId ??= "-1"; // BUILT_IN; command.type ??= ApplicationCommandType.CHAT_INPUT; command.inputType ??= ApplicationCommandInputType.BUILT_IN_TEXT; command.plugin ||= plugin; prepareOption(command); + commands[command.name] = command; - if (command.options?.[0]?.type === ApplicationCommandOptionType.SUB_COMMAND) { + if (isSubCommandParent(command)) { registerSubCommands(command, plugin); return; } - commands[command.name] = command; BUILT_IN.push(command); } -export function unregisterCommand(name: string) { +export function unregisterCommand(name: string, isSubCommands = false) { + const cmd = commands[name]; + if (cmd && isSubCommandParent(cmd)) { + delete commands[name]; + return unregisterSubCommands(cmd); + } + const idx = BUILT_IN.findIndex(c => c.name === name); if (idx === -1) return false; diff --git a/src/api/Commands/types.ts b/src/api/Commands/types.ts index c5ecb35ec..dd238a296 100644 --- a/src/api/Commands/types.ts +++ b/src/api/Commands/types.ts @@ -9,4 +9,5 @@ export { ApplicationCommandInputType, ApplicationCommandOptionType, ApplicationC export interface VencordCommand extends Command { isVencordCommand?: boolean; + rootCommand?: VencordCommand; } diff --git a/src/api/Notifications/notificationLog.tsx b/src/api/Notifications/notificationLog.tsx index 7cfd55a80..ee36ecf1e 100644 --- a/src/api/Notifications/notificationLog.tsx +++ b/src/api/Notifications/notificationLog.tsx @@ -22,7 +22,7 @@ import { openNotificationSettingsModal } from "@components/settings/tabs/vencord import { classNameFactory } from "@utils/css"; import { useAwaiter } from "@utils/react"; import { RenderModalProps } from "@vencord/discord-types"; -import { ConfirmModal, Forms, ListScrollerThin, Modal,openModal, React, Timestamp, useEffect, useReducer, useState } from "@webpack/common"; +import { ConfirmModal, Forms, ListScrollerThin, Modal, openModal, React, Timestamp, useEffect, useReducer, useState } from "@webpack/common"; import { nanoid } from "nanoid"; import type { DispatchWithoutAction } from "react"; @@ -74,14 +74,21 @@ export async function persistNotification(notification: NotificationData) { signals.forEach(x => x()); } -export async function deleteNotification(timestamp: number) { - const log = await getLog(); - const index = log.findIndex(x => x.timestamp === timestamp); - if (index === -1) return; +export async function deleteNotification(id: string) { + let found = false; - log.splice(index, 1); - await DataStore.set(KEY, log); - signals.forEach(x => x()); + await DataStore.update(KEY, (old: PersistentNotificationData[] | undefined) => { + const log = old ?? []; + const index = log.findIndex(x => x.id === id); + if (index === -1) return log; + + log.splice(index, 1); + found = true; + return log; + }); + + if (found) + signals.forEach(x => x()); } export function useLogs() { @@ -113,7 +120,7 @@ function NotificationEntry({ data }: { data: PersistentNotificationData; }) { if (removing) return; setRemoving(true); - setTimeout(() => deleteNotification(data.timestamp), 200); + setTimeout(() => deleteNotification(data.id), 200); }} richBody={
diff --git a/src/api/Themes.ts b/src/api/Themes.ts index 0a761e567..2aadd78e8 100644 --- a/src/api/Themes.ts +++ b/src/api/Themes.ts @@ -18,6 +18,7 @@ import { Settings, SettingsStore } from "@api/Settings"; import { createAndAppendStyle } from "@utils/css"; +import { isNonNullish } from "@utils/guards"; import { ThemeStore } from "@vencord/discord-types"; import { PopoutWindowStore } from "@webpack/common"; @@ -42,6 +43,9 @@ async function toggle(isEnabled: boolean) { style.disabled = !isEnabled; } +// for cleanup +let previousThemeBlobObjectURLs = [] as string[]; + async function initThemes() { themesStyle ??= createAndAppendStyle("vencord-themes", userStyleRootNode); @@ -66,12 +70,18 @@ async function initThemes() { .filter(link => link !== null); if (IS_WEB) { - for (const theme of enabledThemes) { + previousThemeBlobObjectURLs.forEach(url => URL.revokeObjectURL(url)); + + const objectUrls = await Promise.all(enabledThemes.map(async theme => { const themeData = await VencordNative.themes.getThemeData(theme); - if (!themeData) continue; + if (!themeData) return null; + const blob = new Blob([themeData], { type: "text/css" }); - links.push(URL.createObjectURL(blob)); - } + return URL.createObjectURL(blob); + })); + + previousThemeBlobObjectURLs = objectUrls.filter(isNonNullish); + links.push(...previousThemeBlobObjectURLs); } else { const localThemes = enabledThemes.map(theme => `vencord:///themes/${theme}?v=${Date.now()}`); links.push(...localThemes); diff --git a/src/components/Link.tsx b/src/components/Link.tsx index 2eb7ab00f..418f48ddd 100644 --- a/src/components/Link.tsx +++ b/src/components/Link.tsx @@ -23,6 +23,8 @@ interface Props extends React.DetailedHTMLProps) { + props = { ...props }; + if (props.disabled) { props.style ??= {}; props.style.pointerEvents = "none"; diff --git a/src/components/settings/QuickAction.tsx b/src/components/settings/QuickAction.tsx index 49b893a5a..ad2612d10 100644 --- a/src/components/settings/QuickAction.tsx +++ b/src/components/settings/QuickAction.tsx @@ -17,13 +17,14 @@ export interface QuickActionProps { text: ReactNode; action?: () => void; disabled?: boolean; + style?: React.CSSProperties; } export function QuickAction(props: QuickActionProps) { - const { Icon, action, text, disabled } = props; + const { Icon, action, text, disabled, style } = props; return ( - diff --git a/src/components/settings/tabs/plugins/index.tsx b/src/components/settings/tabs/plugins/index.tsx index 99a327bd2..69d9a7c08 100644 --- a/src/components/settings/tabs/plugins/index.tsx +++ b/src/components/settings/tabs/plugins/index.tsx @@ -35,7 +35,7 @@ import { Margins } from "@utils/margins"; import { classes } from "@utils/misc"; import { useAwaiter, useCleanupEffect } from "@utils/react"; import { PluginTag, PluginTags } from "@utils/types"; -import { Button, ConfirmModal,lodash, openModal, Parser, React, SearchableSelect, Select, TextInput, Tooltip, useMemo, useRef, useState } from "@webpack/common"; +import { Button, ConfirmModal, lodash, openModal, Parser, React, SearchableSelect, Select, TextInput, Tooltip, useMemo, useRef, useState } from "@webpack/common"; import { JSX } from "react"; import Plugins, { ExcludedPlugins, PluginMeta } from "~plugins"; @@ -133,10 +133,10 @@ function PluginSettings() { <>

The following plugins require a restart:

{changes.map((s, i) => ( - <> + {i > 0 && ", "} {Parser.parse("`" + s.split(".")[0] + "`")} - + ))}
diff --git a/src/components/settings/tabs/themes/LocalThemesTab.tsx b/src/components/settings/tabs/themes/LocalThemesTab.tsx index c2601e3fb..0ed8c1f78 100644 --- a/src/components/settings/tabs/themes/LocalThemesTab.tsx +++ b/src/components/settings/tabs/themes/LocalThemesTab.tsx @@ -43,13 +43,7 @@ function onLocalThemeChange(fileName: string, value: boolean) { } } -async function onFileUpload(e: SyntheticEvent) { - e.stopPropagation(); - e.preventDefault(); - - if (!e.currentTarget?.files?.length) return; - const { files } = e.currentTarget; - +async function doUploadThemes(files: ArrayLike) { const uploads = Array.from(files, file => { const { name } = file; if (!name.endsWith(".css")) return; @@ -68,6 +62,47 @@ async function onFileUpload(e: SyntheticEvent) { await Promise.all(uploads); } +async function onFileUpload(e: SyntheticEvent) { + e.stopPropagation(); + e.preventDefault(); + + if (!e.currentTarget?.files?.length) return; + await doUploadThemes(e.currentTarget.files); +} + +function useDropFile(refreshThemes: Function) { + useEffect(() => { + const onDragOver = (e: DragEvent) => { + if (!e.dataTransfer?.items.length) return; + if (!Array.from(e.dataTransfer.items).some(item => item.kind === "file" && item.getAsFile()?.name.endsWith(".css"))) + return; + + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + }; + + const onDrop = async (e: DragEvent) => { + e.preventDefault(); + + if (!e.dataTransfer?.files.length) return; + + await doUploadThemes( + Array.from(e.dataTransfer.files).filter(file => file.name.endsWith(".css")) + ); + + refreshThemes(); + }; + + window.addEventListener("dragover", onDragOver); + window.addEventListener("drop", onDrop); + + return () => { + window.removeEventListener("dragover", onDragOver); + window.removeEventListener("drop", onDrop); + }; + }, []); +} + export function LocalThemesTab() { const settings = useSettings(["enabledThemes"]); @@ -79,6 +114,9 @@ export function LocalThemesTab() { refreshLocalThemes(); }, []); + // This condition is compile time so conditional hook is okay + if (IS_WEB) useDropFile(refreshLocalThemes); + async function refreshLocalThemes() { const themes = await VencordNative.themes.getThemesList(); setUserThemes(themes); @@ -109,7 +147,7 @@ export function LocalThemesTab() { ( + Upload Theme } Icon={PlusIcon} + style={{ position: "relative" }} /> ) : ( . */ +import "./ipcMain"; + import { app, net, protocol } from "electron"; import { join } from "path"; import { pathToFileURL } from "url"; import { initCsp } from "./csp"; -import { ensureSafePath } from "./ipcMain"; import { RendererSettings } from "./settings"; import { IS_VANILLA, THEMES_DIR } from "./utils/constants"; +import { ensureSafePath } from "./utils/ensureSafePath"; import { installExt } from "./utils/extensions"; if (IS_VESKTOP || !IS_VANILLA) { diff --git a/src/main/ipcMain.ts b/src/main/ipcMain.ts index 9a1f6e530..652fb09a5 100644 --- a/src/main/ipcMain.ts +++ b/src/main/ipcMain.ts @@ -27,11 +27,12 @@ import monacoHtml from "file://monacoWin.html?minify&base64"; import { FSWatcher, mkdirSync, readFileSync, watch, writeFileSync } from "fs"; import { open, readdir, readFile } from "fs/promises"; import { release } from "os"; -import { join, normalize } from "path"; +import { join } from "path"; import { registerCspIpcHandlers } from "./csp/manager"; import { getThemeInfo, stripBOM, UserThemeHeader } from "./themes"; import { ALLOWED_PROTOCOLS, QUICK_CSS_PATH, SETTINGS_DIR, THEMES_DIR } from "./utils/constants"; +import { ensureSafePath } from "./utils/ensureSafePath"; import { makeLinksOpenExternally } from "./utils/externalLinks"; const RENDERER_CSS_PATH = join(__dirname, IS_VESKTOP ? "vencordDesktopRenderer.css" : "renderer.css"); @@ -40,13 +41,6 @@ mkdirSync(THEMES_DIR, { recursive: true }); registerCspIpcHandlers(); -export function ensureSafePath(basePath: string, path: string) { - const normalizedBasePath = normalize(basePath + "/"); - const newPath = join(basePath, path); - const normalizedPath = normalize(newPath); - return normalizedPath.startsWith(normalizedBasePath) ? normalizedPath : null; -} - function readCss() { return readFile(QUICK_CSS_PATH, "utf-8").catch(() => ""); } @@ -86,7 +80,8 @@ ipcMain.handle(IpcEvents.OPEN_EXTERNAL, (_, url) => { if (!ALLOWED_PROTOCOLS.includes(protocol)) throw "Disallowed protocol."; - shell.openExternal(url); + shell.openExternal(url) + .catch(err => console.error("[Vencord] Failed to open external link", url, err)); }); @@ -112,7 +107,11 @@ ipcMain.handle(IpcEvents.GET_THEME_SYSTEM_VALUES, () => { ipcMain.handle(IpcEvents.OPEN_THEMES_FOLDER, () => shell.openPath(THEMES_DIR)); ipcMain.handle(IpcEvents.OPEN_SETTINGS_FOLDER, () => shell.openPath(SETTINGS_DIR)); +let fsWatchers = [] as FSWatcher[]; + ipcMain.handle(IpcEvents.INIT_FILE_WATCHERS, ({ sender }) => { + fsWatchers.forEach(w => w.close()); + let quickCssWatcher: FSWatcher | undefined; let rendererCssWatcher: FSWatcher | undefined; @@ -133,10 +132,13 @@ ipcMain.handle(IpcEvents.INIT_FILE_WATCHERS, ({ sender }) => { }); } + fsWatchers = [quickCssWatcher, themesWatcher, rendererCssWatcher].filter(Boolean) as FSWatcher[]; + sender.once("destroyed", () => { quickCssWatcher?.close(); themesWatcher.close(); rendererCssWatcher?.close(); + fsWatchers = []; }); }); diff --git a/src/main/utils/ensureSafePath.ts b/src/main/utils/ensureSafePath.ts new file mode 100644 index 000000000..e6db7e6d7 --- /dev/null +++ b/src/main/utils/ensureSafePath.ts @@ -0,0 +1,16 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { join, normalize } from "path"; + +export function ensureSafePath(basePath: string, path: string) { + const normalizedBasePath = normalize(basePath + "/"); + const newPath = join(basePath, path); + const normalizedPath = normalize(newPath); + return normalizedPath === normalize(basePath) || normalizedPath.startsWith(normalizedBasePath) + ? normalizedPath + : null; +} diff --git a/src/main/utils/extensions.ts b/src/main/utils/extensions.ts index 1d7559fb9..5f4ccb653 100644 --- a/src/main/utils/extensions.ts +++ b/src/main/utils/extensions.ts @@ -24,6 +24,7 @@ import { join } from "path"; import { DATA_DIR } from "./constants"; import { crxToZip } from "./crxToZip"; +import { ensureSafePath } from "./ensureSafePath"; import { fetchBuffer } from "./http"; const extensionCacheDir = join(DATA_DIR, "ExtensionCache"); @@ -33,24 +34,35 @@ async function extract(data: Buffer, outDir: string) { return new Promise((resolve, reject) => { unzip(data, (err, files) => { if (err) return void reject(err); + Promise.all(Object.keys(files).map(async f => { // Signature stuff // 'Cannot load extension with file or directory name // _metadata. Filenames starting with "_" are reserved for use by the system.'; if (f.startsWith("_metadata/")) return; - if (f.endsWith("/")) return void mkdir(join(outDir, f), { recursive: true }); + if (f.includes("\0")) throw new Error(`Invalid filename: "${f}"`); + + if (f.endsWith("/")) { + const dir = ensureSafePath(outDir, f); + if (!dir) throw new Error(`Path traversal detected: "${f}"`); + return void await mkdir(dir, { recursive: true }); + } const pathElements = f.split("/"); - const name = pathElements.pop()!; - const directories = pathElements.join("/"); - const dir = join(outDir, directories); + const directories = pathElements.slice(0, -1).join("/"); + + const dir = ensureSafePath(outDir, directories); + if (!dir) throw new Error(`Path traversal detected: "${f}"`); + + const filePath = ensureSafePath(outDir, f); + if (!filePath) throw new Error(`Path traversal detected: "${f}"`); if (directories) { await mkdir(dir, { recursive: true }); } - await writeFile(join(dir, name), files[f]); + await writeFile(filePath, files[f]); })) .then(() => resolve()) .catch(err => { @@ -79,5 +91,9 @@ export async function installExt(id: string) { .catch(err => console.error(`Failed to extract extension ${id}`, err)); } - session.defaultSession.loadExtension(extDir); + if (session.defaultSession.extensions) { + session.defaultSession.extensions.loadExtension(id); + } else { + session.defaultSession.loadExtension(extDir); + } } diff --git a/src/plugins/copyStickerLinks/index.tsx b/src/plugins/copyStickerLinks/index.tsx index bc8fe8e90..530f19509 100644 --- a/src/plugins/copyStickerLinks/index.tsx +++ b/src/plugins/copyStickerLinks/index.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu"; +import { NavContextMenuPatchCallback } from "@api/ContextMenu"; import { isPluginEnabled } from "@api/PluginManager"; import ExpressionClonerPlugin from "@plugins/expressionCloner"; import { Devs } from "@utils/constants"; @@ -68,9 +68,7 @@ const messageContextMenuPatch: NavContextMenuPatchCallback = ( const sticker = message.stickerItems.find(s => s.id === favoriteableId); if (!sticker?.format_type) return; - const idx = children.findIndex(c => Array.isArray(c) && findGroupChildrenByChildId("vc-copy-sticker-url", c) != null); - - children.splice(idx, 0, buildMenuItem(sticker, idx !== -1)); + children.splice(-1, 0, buildMenuItem(sticker, false)); }; const expressionPickerPatch: NavContextMenuPatchCallback = (children, props: { target: HTMLElement; }) => { diff --git a/src/plugins/customCommands/index.ts b/src/plugins/customCommands/index.ts index fe22de6e4..e5f4503f8 100644 --- a/src/plugins/customCommands/index.ts +++ b/src/plugins/customCommands/index.ts @@ -18,7 +18,7 @@ import "./styles.css"; -import { ApplicationCommandInputType, ApplicationCommandOptionType, findOption, registerCommand, sendBotMessage } from "@api/Commands"; +import { ApplicationCommandInputType, ApplicationCommandOptionType, findOption, registerCommand, sendBotMessage, unregisterCommand } from "@api/Commands"; import { migratePluginSettings } from "@api/Settings"; import { Devs } from "@utils/constants"; import { sendMessage } from "@utils/discord"; @@ -96,11 +96,12 @@ export default definePlugin({ tags: ["Commands", "Customisation", "Utility"], settings, - async start() { - const tags = getTags(); - for (const tagName in tags) { - registerTagCommand(tags[tagName]); - } + start() { + getTags().forEach(registerTagCommand); + }, + + stop() { + getTags().forEach(tag => unregisterCommand(tag.name)); }, commands: [ @@ -160,7 +161,7 @@ export default definePlugin({ } case "list": { - const content = Object.values(getTags()) + const content = getTags() .map(tag => `\`${tag.name}\`: ${tag.message.slice(0, 72).replaceAll("\\n", " ")}${tag.message.length > 72 ? "..." : ""}`) .join("\n"); diff --git a/src/plugins/customCommands/settings.ts b/src/plugins/customCommands/settings.ts index 418f06fa2..ad3ecbb0d 100644 --- a/src/plugins/customCommands/settings.ts +++ b/src/plugins/customCommands/settings.ts @@ -28,7 +28,7 @@ export interface Tag { } export function getTags() { - return settings.store.tagsList; + return Object.values(settings.store.tagsList); } export function getTag(name: string) { diff --git a/src/plugins/customRPC/RpcSettings.tsx b/src/plugins/customRPC/RpcSettings.tsx index bdca847d2..f642e09be 100644 --- a/src/plugins/customRPC/RpcSettings.tsx +++ b/src/plugins/customRPC/RpcSettings.tsx @@ -139,7 +139,10 @@ function SelectSetting({ settingsKey, label, options, disabled }: SelectOptio options={options} maxVisibleItems={5} closeOnSelect={true} - select={v => settings.store[settingsKey] = v} + select={v => { + settings.store[settingsKey] = v; + updateRPC(); + }} isSelected={v => v === settings.store[settingsKey]} serialize={v => String(v)} isDisabled={disabled} diff --git a/src/plugins/dearrow/index.tsx b/src/plugins/dearrow/index.tsx index f91c91d45..164f91cbd 100644 --- a/src/plugins/dearrow/index.tsx +++ b/src/plugins/dearrow/index.tsx @@ -100,7 +100,6 @@ function DearrowButton({ component }: { component: Component; }) { className={"vc-dearrow-toggle-" + (embed.dearrow.enabled ? "on" : "off")} onClick={() => { const { enabled, oldThumb, oldTitle } = embed.dearrow; - settings.store.dearrowByDefault = !enabled; embed.dearrow.enabled = !enabled; if (oldTitle) { embed.dearrow.oldTitle = embed.rawTitle; diff --git a/src/plugins/fixSpotifyEmbeds.desktop/native.ts b/src/plugins/fixSpotifyEmbeds.desktop/native.ts index b730883c1..203fb18d3 100644 --- a/src/plugins/fixSpotifyEmbeds.desktop/native.ts +++ b/src/plugins/fixSpotifyEmbeds.desktop/native.ts @@ -50,14 +50,14 @@ app.on("browser-window-created", (_, win) => { } }); }); - - RendererSettings.addChangeListener("plugins.FixSpotifyEmbeds.volume", newVolume => { - try { - cleanUpAndGetSpotifyFrames().forEach(frame => - frame.executeJavaScript(`globalThis._vcVolume = ${newVolume / 100}`) - ); - } catch (e) { - console.error("FixSpotifyEmbeds: Failed to update volume", e); - } - }); +}); + +RendererSettings.addChangeListener("plugins.FixSpotifyEmbeds.volume", newVolume => { + try { + cleanUpAndGetSpotifyFrames().forEach(frame => + frame.executeJavaScript(`globalThis._vcVolume = ${newVolume / 100}`) + ); + } catch (e) { + console.error("FixSpotifyEmbeds: Failed to update volume", e); + } }); diff --git a/src/plugins/friendInvites/index.ts b/src/plugins/friendInvites/index.ts index ec677e297..eee2d4127 100644 --- a/src/plugins/friendInvites/index.ts +++ b/src/plugins/friendInvites/index.ts @@ -40,7 +40,7 @@ export default definePlugin({ sendBotMessage(ctx.channel.id, { content: ` discord.gg/${invite.code} · - Expires: · + Expires: · Max uses: \`${invite.max_uses}\` `.trim().replace(/\s+/g, " ") }); @@ -55,7 +55,7 @@ export default definePlugin({ const friendInviteList = invites.map(i => ` _discord.gg/${i.code}_ · - Expires: · + Expires: · Times used: \`${i.uses}/${i.max_uses}\` `.trim().replace(/\s+/g, " ") ); diff --git a/src/plugins/pictureInPicture/index.tsx b/src/plugins/pictureInPicture/index.tsx index cba14d74f..ae097a090 100644 --- a/src/plugins/pictureInPicture/index.tsx +++ b/src/plugins/pictureInPicture/index.tsx @@ -57,16 +57,44 @@ export default definePlugin({ videoClone.loop = settings.store.loop; videoClone.style.display = "none"; - videoClone.onleavepictureinpicture = () => videoClone.remove(); - function launchPiP() { - videoClone.currentTime = video.currentTime; - videoClone.requestPictureInPicture(); - video.pause(); - videoClone.play(); + let cleaned = false; + function cleanup() { + if (cleaned) return; + cleaned = true; + + const { currentTime } = videoClone; + + videoClone.onloadedmetadata = null; + videoClone.onleavepictureinpicture = null; + + videoClone.pause(); + videoClone.removeAttribute("src"); + videoClone.load(); + videoClone.remove(); + + // resume original if still in the document + if (video.isConnected) { + video.currentTime = currentTime; + video.play().catch(() => 0); + } } - if (videoClone.readyState === 4 /* HAVE_ENOUGH_DATA */) + videoClone.onleavepictureinpicture = cleanup; + + async function launchPiP() { + try { + videoClone.currentTime = video.currentTime; + video.pause(); + await videoClone.play(); + await videoClone.requestPictureInPicture(); + } catch (err) { + console.error("Failed to enter Picture-in-Picture", err); + cleanup(); + } + } + + if (videoClone.readyState >= 1) launchPiP(); else videoClone.onloadedmetadata = launchPiP; diff --git a/src/plugins/previewMessage/index.tsx b/src/plugins/previewMessage/index.tsx index 58f43af6e..4d2aa89c3 100644 --- a/src/plugins/previewMessage/index.tsx +++ b/src/plugins/previewMessage/index.tsx @@ -23,8 +23,9 @@ import definePlugin, { IconComponent, StartAt } from "@utils/types"; import { CloudUpload, MessageAttachment } from "@vencord/discord-types"; import { DraftStore, DraftType, UploadAttachmentStore, UserStore, useStateFromStores } from "@webpack/common"; -const getDraft = (channelId: string) => DraftStore.getDraft(channelId, DraftType.ChannelMessage); +const objectURLMap = new Map(); +const getDraft = (channelId: string) => DraftStore.getDraft(channelId, DraftType.ChannelMessage); const getImageBox = (url: string): Promise<{ width: number, height: number; } | null> => new Promise(res => { @@ -44,6 +45,8 @@ const getAttachments = async (channelId: string) => UploadAttachmentStore.getUploads(channelId, DraftType.ChannelMessage) .map(async (upload: CloudUpload) => { const { isImage, filename, spoiler, item: { file } } = upload; + + // FIXME: revoke object url to fix memory leak const url = URL.createObjectURL(file); const attachment: MessageAttachment = { id: generateId(), @@ -59,13 +62,13 @@ const getAttachments = async (channelId: string) => if (isImage) { const box = await getImageBox(url); - if (!box) return attachment; - - attachment.width = box.width; - attachment.height = box.height; + if (box) { + attachment.width = box.width; + attachment.height = box.height; + } } - return attachment; + return { attachment, objectURL: url }; }) ); @@ -99,15 +102,20 @@ const PreviewButton: ChatBarButtonFactory = ({ isAnyChat, isEmpty, type: { attac return ( - sendBotMessage( + onClick={async () => { + const attachments = hasAttachments ? await getAttachments(channelId) : undefined; + const message = sendBotMessage( channelId, { content: getDraft(channelId), author: UserStore.getCurrentUser(), - attachments: hasAttachments ? await getAttachments(channelId) : undefined, + attachments: attachments?.map(a => a.attachment), } - )} + ); + + if (attachments) + objectURLMap.set(message.id, attachments.map(a => a.objectURL)); + }} buttonProps={{ style: { translate: "0 2px" @@ -132,5 +140,15 @@ export default definePlugin({ chatBarButton: { icon: PreviewIcon, render: PreviewButton + }, + + flux: { + MESSAGE_DELETE({ id: messageId }) { + const objectURLs = objectURLMap.get(messageId); + if (objectURLs) { + objectURLs.forEach(url => URL.revokeObjectURL(url)); + objectURLMap.delete(messageId); + } + } } }); diff --git a/src/plugins/reviewDB/components/ReviewComponent.tsx b/src/plugins/reviewDB/components/ReviewComponent.tsx index 295ce1a52..d2eec254e 100644 --- a/src/plugins/reviewDB/components/ReviewComponent.tsx +++ b/src/plugins/reviewDB/components/ReviewComponent.tsx @@ -232,7 +232,7 @@ export default function ReviewComponent({ review, refetch, profileId }: { review <> {Parser.parseGuildEventDescription(review.comment.substring(0, 200))}...
- setShowAll(true)}>Read more] + setShowAll(true)}>Read more ) : Parser.parseGuildEventDescription(review.comment)} diff --git a/src/plugins/unsuppressEmbeds/index.tsx b/src/plugins/unsuppressEmbeds/index.tsx index 4c25ffea0..79ddaac70 100644 --- a/src/plugins/unsuppressEmbeds/index.tsx +++ b/src/plugins/unsuppressEmbeds/index.tsx @@ -40,7 +40,7 @@ const messageContextMenuPatch: NavContextMenuPatchCallback = ( const menuGroup = findGroupChildrenByChildId("delete", children); const deleteIndex = menuGroup?.findIndex(i => i?.props?.id === "delete"); - if (!deleteIndex || !menuGroup) return; + if (deleteIndex == null || !menuGroup) return; menuGroup.splice(deleteIndex - 1, 0, ( track.stop()); } } } diff --git a/src/plugins/xsOverlay/index.tsx b/src/plugins/xsOverlay/index.tsx index 7146a80ce..51550c9c3 100644 --- a/src/plugins/xsOverlay/index.tsx +++ b/src/plugins/xsOverlay/index.tsx @@ -305,6 +305,7 @@ export default definePlugin({ stop() { socket.close(); + Native.closeSocket(); }, settingsAboutComponent: () => ( diff --git a/src/plugins/xsOverlay/native.ts b/src/plugins/xsOverlay/native.ts index 75ddfeea5..afb82f468 100644 --- a/src/plugins/xsOverlay/native.ts +++ b/src/plugins/xsOverlay/native.ts @@ -6,7 +6,7 @@ import { createSocket, Socket } from "dgram"; -let xsoSocket: Socket; +let xsoSocket: Socket | null = null; export function sendToOverlay(_, data: any) { data.messageType = data.type; @@ -14,3 +14,8 @@ export function sendToOverlay(_, data: any) { xsoSocket ??= createSocket("udp4"); xsoSocket.send(json, 42069, "127.0.0.1"); } + +export function closeSocket() { + xsoSocket?.close(); + xsoSocket = null; +} diff --git a/src/utils/react.tsx b/src/utils/react.tsx index f283c2a8d..53eec023f 100644 --- a/src/utils/react.tsx +++ b/src/utils/react.tsx @@ -113,7 +113,7 @@ export function useAwaiter(factory: () => Promise, providedOpts?: AwaiterO }) .catch(error => { if (!isAlive) return; - setState({ value: null, error, pending: false }); + setState({ value: opts.fallbackValue, error, pending: false }); opts.onError?.(error); }); diff --git a/src/webpack/patchWebpack.ts b/src/webpack/patchWebpack.ts index a25199e1b..b6a2c4c48 100644 --- a/src/webpack/patchWebpack.ts +++ b/src/webpack/patchWebpack.ts @@ -506,8 +506,8 @@ function patchFactory(moduleId: PropertyKey, originalFactory: AnyModuleFactory): const isArrowFunction = originalFactoryCode.startsWith("("); // 0, prefix to turn it into an expression: 0,function(){} would be invalid syntax without the 0, - let code = "0," + (!isArrowFunction ? "function" : "") + originalFactoryCode.slice(originalFactoryCode.indexOf("(")); - let patchedSource = code; + let patchedCode = "0," + (!isArrowFunction ? "function" : "") + originalFactoryCode.slice(originalFactoryCode.indexOf("(")); + let patchedSource = patchedCode; let patchedFactory = originalFactory; const patchedBy = new Set(); @@ -520,70 +520,65 @@ function patchFactory(moduleId: PropertyKey, originalFactory: AnyModuleFactory): if ( shouldCheckBuildNumber && - (patch.fromBuild != null && buildNumber < patch.fromBuild) || - (patch.toBuild != null && buildNumber > patch.toBuild) + ((patch.fromBuild != null && buildNumber < patch.fromBuild) || (patch.toBuild != null && buildNumber > patch.toBuild)) ) { patches.splice(i--, 1); continue; } const moduleMatches = typeof patch.find === "string" - ? code.includes(patch.find) - : (patch.find.global && (patch.find.lastIndex = 0), patch.find.test(code)); + ? originalFactoryCode.includes(patch.find) + : (patch.find.global && (patch.find.lastIndex = 0), patch.find.test(originalFactoryCode)); if (!moduleMatches) { continue; } + // Save the result from the previous patch so we can restore it in case a patch group fails. + const previousPatchedCode = patchedCode; + const previousPatchedSource = patchedSource; + const previousPatchedFactory = patchedFactory; + + let shouldRestorePrevious = false; + let markedAsPatched = false; + const executePatch = traceFunctionWithResults(`patch by ${patch.plugin}`, (match: string | RegExp, replace: string) => { if (typeof match !== "string" && match.global) { match.lastIndex = 0; } - return code.replace(match, replace); + return patchedCode.replace(match, replace); }); - const previousCode = code; - const previousFactory = originalFactory; - let markedAsPatched = false; - // We change all patch.replacement to array in PluginManager for (const replacement of patch.replacement as PatchReplacement[]) { if ( shouldCheckBuildNumber && - (replacement.fromBuild != null && buildNumber < replacement.fromBuild) || - (replacement.toBuild != null && buildNumber > replacement.toBuild) + ((replacement.fromBuild != null && buildNumber < replacement.fromBuild) || (replacement.toBuild != null && buildNumber > replacement.toBuild)) ) { continue; } - const lastCode = code; - const lastFactory = originalFactory; - + let newPatchedCode: string = ""; try { - const [newCode, totalTime] = executePatch(replacement.match, replacement.replace as string); + const [patchResult, totalTime] = executePatch(replacement.match, replacement.replace as string); + newPatchedCode = patchResult; if (IS_REPORTER) { patchTimings.push([patch.plugin, moduleId, replacement.match, totalTime]); } - if (newCode === code) { + if (newPatchedCode === patchedCode) { if (!(patch.noWarn || replacement.noWarn)) { logger.warn(`Patch by ${patch.plugin} had no effect (Module id is ${String(moduleId)}): ${replacement.match}`); if (IS_DEV) { - logger.debug("Function Source:\n", code); + logger.debug("Function Source:\n", patchedCode); } } if (patch.group) { logger.warn(`Undoing patch group ${patch.find} by ${patch.plugin} because replacement ${replacement.match} had no effect`); - code = previousCode; - patchedFactory = previousFactory; - - if (markedAsPatched) { - patchedBy.delete(patch.plugin); - } - + shouldRestorePrevious = true; break; } @@ -595,14 +590,17 @@ function patchFactory(moduleId: PropertyKey, originalFactory: AnyModuleFactory): pluginsList.push(patch.plugin); } - code = newCode; - patchedSource = `// Webpack Module ${String(moduleId)} - Patched by ${pluginsList.join(", ")}\n${code}\n//# sourceURL=file:///WebpackModule${String(moduleId)}`; - patchedFactory = (0, eval)(patchedSource); + const newPatchedSource = `// Webpack Module ${String(moduleId)} - Patched by ${pluginsList.join(", ")}\n${newPatchedCode}\n//# sourceURL=file:///WebpackModule${String(moduleId)}`; + const newPatchedFactory = (0, eval)(newPatchedSource); if (!patchedBy.has(patch.plugin)) { patchedBy.add(patch.plugin); markedAsPatched = true; } + + patchedCode = newPatchedCode; + patchedSource = newPatchedSource; + patchedFactory = newPatchedFactory; } catch (err) { // FIXME: Maybe fix this properly const shouldSuppressError = patch.plugin === "ContextMenuAPI" && err instanceof SyntaxError && err.message.includes("arguments"); @@ -610,23 +608,25 @@ function patchFactory(moduleId: PropertyKey, originalFactory: AnyModuleFactory): logger.error(`Patch by ${patch.plugin} errored (Module id is ${String(moduleId)}): ${replacement.match}\n`, err); if (IS_DEV) { - diffErroredPatch(code, lastCode, lastCode.match(replacement.match)!); + diffErroredPatch(newPatchedCode, patchedCode, patchedCode.match(replacement.match)!); } } - if (markedAsPatched) { - patchedBy.delete(patch.plugin); - } - if (patch.group) { logger.warn(`Undoing patch group ${patch.find} by ${patch.plugin} because replacement ${replacement.match} errored`); - code = previousCode; - patchedFactory = previousFactory; + shouldRestorePrevious = true; break; } + } + } - code = lastCode; - patchedFactory = lastFactory; + if (shouldRestorePrevious) { + patchedCode = previousPatchedCode; + patchedSource = previousPatchedSource; + patchedFactory = previousPatchedFactory; + + if (markedAsPatched) { + patchedBy.delete(patch.plugin); } } @@ -648,9 +648,12 @@ function patchFactory(moduleId: PropertyKey, originalFactory: AnyModuleFactory): function diffErroredPatch(code: string, lastCode: string, match: RegExpMatchArray) { const changeSize = code.length - lastCode.length; + const matchIndex = match?.index ?? 0; + const matchLength = match?.[0]?.length ?? 0; + // Use 200 surrounding characters of context - const start = Math.max(0, match.index! - 200); - const end = Math.min(lastCode.length, match.index! + match[0].length + 200); + const start = Math.max(0, matchIndex - 200); + const end = Math.min(lastCode.length, matchIndex + matchLength + 200); // (changeSize may be negative) const endPatched = end + changeSize; From a9eeac9912ec2420dfc18d729b16e52d7f8fa8e2 Mon Sep 17 00:00:00 2001 From: V Date: Sun, 26 Jul 2026 17:31:31 +0200 Subject: [PATCH 03/11] GameActivity: add spotify activity toggle context menu (#4442) --- src/plugins/gameActivityToggle/index.tsx | 79 ++++++++++++++++++++---- 1 file changed, 66 insertions(+), 13 deletions(-) diff --git a/src/plugins/gameActivityToggle/index.tsx b/src/plugins/gameActivityToggle/index.tsx index 2fde126d5..3fff13f02 100644 --- a/src/plugins/gameActivityToggle/index.tsx +++ b/src/plugins/gameActivityToggle/index.tsx @@ -23,12 +23,26 @@ import ErrorBoundary from "@components/ErrorBoundary"; import VencordToolboxPlugin from "@plugins/vencordToolbox"; import { Devs } from "@utils/constants"; import definePlugin, { OptionType } from "@utils/types"; -import { findComponentByCodeLazy } from "@webpack"; -import { Menu } from "@webpack/common"; +import { FluxStore } from "@vencord/discord-types"; +import { findByPropsLazy, findComponentByCodeLazy, findStoreLazy } from "@webpack"; +import { Menu, Popout, useRef, useState, useStateFromStores } from "@webpack/common"; import managedStyle from "./style.css?managed"; +interface ConnectedAccount { + id: string; + type: string; + revoked: boolean; + showActivity: boolean; +} + +interface ConnectedAccountsStore extends FluxStore { + getAccounts(): ConnectedAccount[]; +} + const Button = findComponentByCodeLazy(".GREEN,positionKeyStemOverride:"); +const ConnectedAccountsStore = findStoreLazy("ConnectedAccountsStore") as ConnectedAccountsStore; +const ConnectedAccountActions = findByPropsLazy("setShowActivity"); const ShowCurrentGame = getUserSettingLazy("status", "showCurrentGame")!; @@ -55,7 +69,6 @@ function Icon() { const { oldIcon } = settings.use(["oldIcon"]); const showCurrentGame = ShowCurrentGame.useSetting(); - const redLinePath = !oldIcon ? "M22.7 2.7a1 1 0 0 0-1.4-1.4l-20 20a1 1 0 1 0 1.4 1.4Z" : "M23 2.27 21.73 1 1 21.73 2.27 23 23 2.27Z"; @@ -86,24 +99,64 @@ function GameActivityToggleButton(props: { nameplate?: any; }) { const { location } = settings.use(["location"]); const showCurrentGame = ShowCurrentGame.useSetting(); + const connectedAccounts = useStateFromStores([ConnectedAccountsStore], () => ConnectedAccountsStore.getAccounts()); + const spotifyAccounts = connectedAccounts.filter(account => account.type === "spotify" && !account.revoked); + // The update is an API request which takes a bit to update the store, so we have to use our own state to reflect the change immediately + const [shareSpotifyActivity, setShareSpotifyActivity] = useState(spotifyAccounts[0]?.showActivity ?? false); + + const buttonRef = useRef(null); + if (location !== "PANEL" && isPluginEnabled(VencordToolboxPlugin.name)) return null; + const buttonProps = { + tooltipText: showCurrentGame ? "Disable Game Activity" : "Enable Game Activity", + icon: Icon, + role: "switch", + ariaChecked: !showCurrentGame, + redGlow: !showCurrentGame, + plated: props?.nameplate != null, + onClick: () => ShowCurrentGame.updateSetting(old => !old) + }; + + // Only show switch if there's exactly one Spotify account connected. Otherwise it may lead to confusion + if (spotifyAccounts.length !== 1) + return
)} + {props => (
@@ -94,6 +123,7 @@ export function MemberCount({ isTooltip, tooltipGuildId }: { isTooltip?: true; t
)}
+ {includeVoice && voiceActivityCount > 0 && {props => ( diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 9156a454e..2a7b6af50 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -650,6 +650,10 @@ export const Devs = /* #__PURE__*/ Object.freeze({ name: "Lunascape", id: 383365021415243776n }, + jax: { + name: "jax", + id: 1493703027801194598n + }, yuna0x0: { name: "yuna0x0", id: 213656926414831616n From a040c8dbe630b6e798d80fe6058d6f296896d90f Mon Sep 17 00:00:00 2001 From: Vendicated Date: Sun, 26 Jul 2026 18:06:28 +0200 Subject: [PATCH 05/11] SpotifyControls: improve time formatting for tracks over an hour --- src/plugins/callTimer/index.tsx | 28 ++--------------- .../components/HiddenChannelLockScreen.tsx | 8 ++--- .../spotifyControls/PlayerComponent.tsx | 14 +++------ src/utils/text.ts | 31 ++++++++++++++++++- 4 files changed, 41 insertions(+), 40 deletions(-) diff --git a/src/plugins/callTimer/index.tsx b/src/plugins/callTimer/index.tsx index bf0bb1caa..1930e6334 100644 --- a/src/plugins/callTimer/index.tsx +++ b/src/plugins/callTimer/index.tsx @@ -20,6 +20,7 @@ import { definePluginSettings } from "@api/Settings"; import ErrorBoundary from "@components/ErrorBoundary"; import { Devs } from "@utils/constants"; import { useTimer } from "@utils/react"; +import { formatDuration } from "@utils/text"; import definePlugin, { OptionType } from "@utils/types"; import { React } from "@webpack/common"; @@ -39,33 +40,10 @@ const settings = definePluginSettings({ label: "30d 23h 00m 42s", value: "human" } - ] + ] as const } }); -function formatDuration(ms: number) { - // here be dragons (moment fucking sucks) - const human = settings.store.format === "human"; - - const format = (n: number) => human ? n : n.toString().padStart(2, "0"); - const unit = (s: string) => human ? s : ""; - const delim = human ? " " : ":"; - - // thx copilot - const d = Math.floor(ms / 86400000); - const h = Math.floor((ms % 86400000) / 3600000); - const m = Math.floor(((ms % 86400000) % 3600000) / 60000); - const s = Math.floor((((ms % 86400000) % 3600000) % 60000) / 1000); - - let res = ""; - if (d) res += `${d}d `; - if (h || res) res += `${format(h)}${unit("h")}${delim}`; - if (m || res || !human) res += `${format(m)}${unit("m")}${delim}`; - res += `${format(s)}${unit("s")}`; - - return res; -} - export default definePlugin({ @@ -94,7 +72,7 @@ export default definePlugin({ return (

- {formatDuration(time)} + {formatDuration(time, settings.store.format === "human")}

); }, { noop: true }), diff --git a/src/plugins/showHiddenChannels/components/HiddenChannelLockScreen.tsx b/src/plugins/showHiddenChannels/components/HiddenChannelLockScreen.tsx index e091bda80..8038bbade 100644 --- a/src/plugins/showHiddenChannels/components/HiddenChannelLockScreen.tsx +++ b/src/plugins/showHiddenChannels/components/HiddenChannelLockScreen.tsx @@ -22,7 +22,7 @@ import PermissionsViewerPlugin from "@plugins/permissionsViewer"; import openRolesAndUsersPermissionsModal from "@plugins/permissionsViewer/components/RolesAndUsersPermissions"; import { sortPermissionOverwrites } from "@plugins/permissionsViewer/utils"; import { classes } from "@utils/misc"; -import { formatDuration } from "@utils/text"; +import { formatDurationVerbose } from "@utils/text"; import type { Channel, RoleOrUserPermission } from "@vencord/discord-types"; import { findByPropsLazy, findComponentByCodeLazy, findCssClassesLazy } from "@webpack"; import { EmojiStore, FluxDispatcher, GuildMemberStore, GuildStore, Parser, PermissionsBits, PermissionStore, SnowflakeUtils, Text, Timestamp, Tooltip, useEffect, useState } from "@webpack/common"; @@ -204,11 +204,11 @@ function HiddenChannelLockScreen({ channel }: { channel: Channel; }) { Last message pin: } {(rateLimitPerUser ?? 0) > 0 && - Slowmode: {formatDuration(rateLimitPerUser!, "seconds")} + Slowmode: {formatDurationVerbose(rateLimitPerUser!, "seconds")} } {(defaultThreadRateLimitPerUser ?? 0) > 0 && - Default thread slowmode: {formatDuration(defaultThreadRateLimitPerUser!, "seconds")} + Default thread slowmode: {formatDurationVerbose(defaultThreadRateLimitPerUser!, "seconds")} } {((channel.isGuildVoice() || channel.isGuildStageVoice()) && bitrate != null) && @@ -223,7 +223,7 @@ function HiddenChannelLockScreen({ channel }: { channel: Channel; }) { {(defaultAutoArchiveDuration ?? 0) > 0 && Default inactivity duration before archiving {channel.isForumChannel() ? "posts" : "threads"}: - {" " + formatDuration(defaultAutoArchiveDuration!, "minutes")} + {" " + formatDurationVerbose(defaultAutoArchiveDuration!, "minutes")} } {defaultForumLayout != null && diff --git a/src/plugins/spotifyControls/PlayerComponent.tsx b/src/plugins/spotifyControls/PlayerComponent.tsx index 3074c5648..f52ff43ea 100644 --- a/src/plugins/spotifyControls/PlayerComponent.tsx +++ b/src/plugins/spotifyControls/PlayerComponent.tsx @@ -26,6 +26,7 @@ import { debounce } from "@shared/debounce"; import { classNameFactory } from "@utils/css"; import { copyWithToast, openImageModal } from "@utils/discord"; import { classes } from "@utils/misc"; +import { formatDuration } from "@utils/text"; import { ContextMenuApi, FluxDispatcher, Menu, React, useEffect, useState, useStateFromStores } from "@webpack/common"; import { settings } from "."; @@ -34,13 +35,6 @@ import { SpotifyStore, Track } from "./SpotifyStore"; const cl = classNameFactory("vc-spotify-"); -function msToHuman(ms: number) { - const minutes = ms / 1000 / 60; - const m = Math.floor(minutes); - const s = Math.floor((minutes - m) * 60); - return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`; -} - function Svg(path: string, label: string) { return () => ( - {msToHuman(position)} + {formatDuration(position)} - {msToHuman(duration)} + {formatDuration(duration)} ); diff --git a/src/utils/text.ts b/src/utils/text.ts index 385bfa0b7..538ecff6a 100644 --- a/src/utils/text.ts +++ b/src/utils/text.ts @@ -16,6 +16,8 @@ * along with this program. If not, see . */ +import { isTruthy } from "./guards"; + // Utils for readable text transformations eg: `toTitle(fromKebab())` // Case style to words @@ -51,7 +53,7 @@ function getUnitStr(unit: Units, isOne: boolean, short: boolean) { * @param unit The unit the time is on * @param short Whether to use short units like "d" instead of "days" */ -export function formatDuration(time: number, unit: Units, short: boolean = false) { +export function formatDurationVerbose(time: number, unit: Units, short: boolean = false) { const { moment } = require("@webpack/common") as typeof import("@webpack/common"); const dur = moment.duration(time, unit); @@ -93,6 +95,33 @@ export function formatDuration(time: number, unit: Units, short: boolean = false return res.length ? res : `0 ${getUnitStr(unit, false, short)}`; } +export function formatDuration(ms: number, human = false) { + const DAY = 86_400_000; + const HOUR = 3_600_000; + const MINUTE = 60_000; + + const days = Math.floor(ms / DAY); + const hours = Math.floor((ms % DAY) / HOUR); + const minutes = Math.floor((ms % HOUR) / MINUTE); + const seconds = Math.floor((ms % MINUTE) / 1000); + + const pad = (n: number) => human ? n : n.toString().padStart(2, "0"); + const unit = (s: string) => human ? s : ""; + + const showDays = days !== 0; + const showHours = showDays || hours !== 0; + const showMinutes = showHours || minutes !== 0 || !human; + + const duration = [ + showHours && `${pad(hours)}${unit("h")}`, + showMinutes && `${pad(minutes)}${unit("m")}`, + `${pad(seconds)}${unit("s")}` + ].filter(isTruthy).join(human ? " " : ":"); + + const prefix = showDays ? `${days}d ` : ""; + return prefix + duration; +} + /** * Join an array of strings in a human readable way (1, 2 and 3) * @param elements Elements From 42b3ae8026730b80ebd7f192cc7f46fa685745ca Mon Sep 17 00:00:00 2001 From: sadan <117494111+sadan4@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:11:18 -0400 Subject: [PATCH 06/11] ViewIcons: fix conflicts with profile/group modal (#3720) --- src/plugins/viewIcons/index.tsx | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/plugins/viewIcons/index.tsx b/src/plugins/viewIcons/index.tsx index 37f90b74e..0e0a87195 100644 --- a/src/plugins/viewIcons/index.tsx +++ b/src/plugins/viewIcons/index.tsx @@ -24,6 +24,7 @@ import { openImageModal } from "@utils/discord"; import definePlugin, { OptionType } from "@utils/types"; import type { Channel, Guild, User } from "@vencord/discord-types"; import { GuildMemberStore, IconUtils, Menu } from "@webpack/common"; +import type { MouseEvent } from "react"; interface UserContextProps { @@ -68,10 +69,18 @@ const settings = definePluginSettings({ } }); -const openAvatar = (url: string) => openImage(url, 512, 512); -const openBanner = (url: string) => openImage(url, 1024); +const openAvatar = (url: string, event?: MouseEvent) => openImage({ url, width: 512, height: 512, event }); +const openBanner = (url: string, event?: MouseEvent) => openImage({ url, width: 1024, event }); -function openImage(url: string, width: number, height?: number) { +interface OpenImageProps { + url: string; + width: number; + height?: number; + event?: MouseEvent; +} + +function openImage({ url, width, height, event }: OpenImageProps) { + event?.stopPropagation(); const u = new URL(url, window.location.href); const format = url.startsWith("/") @@ -224,7 +233,7 @@ export default definePlugin({ find: 'backgroundColor:"COMPLETE"', replacement: { match: /(overflow:"visible",.{0,125}?!1\),)style:{(?=.+?backgroundImage:null!=(\i)\?`url\(\$\{\2\}\))/, - replace: (_, rest, bannerSrc) => `${rest}onClick:()=>${bannerSrc}!=null&&$self.openBanner(${bannerSrc}),style:{cursor:${bannerSrc}!=null?"pointer":void 0,` + replace: (_, rest, bannerSrc) => `${rest}onClick:vcEvent=>${bannerSrc}!=null&&$self.openBanner(${bannerSrc}, vcEvent),style:{cursor:${bannerSrc}!=null?"pointer":void 0,` } }, // Group DMs top small & large icon @@ -233,7 +242,7 @@ export default definePlugin({ replacement: { match: /null==\i\.icon\?.+?src:(\(0,\i\.\i\).+?\))(?=[,}])/, // We have to check that icon is not an unread GDM in the server bar - replace: (m, iconUrl) => `${m},onClick:()=>arguments[0]?.size!=="SIZE_48"&&$self.openAvatar(${iconUrl})` + replace: (m, iconUrl) => `${m},onClick:vcEvent=>arguments[0]?.size!=="SIZE_48"&&$self.openAvatar(${iconUrl},vcEvent)` } }, // User DMs top small icon @@ -241,7 +250,7 @@ export default definePlugin({ find: ".channel.getRecipientId(),", replacement: { match: /(?=,src:(\i.getAvatarURL\(.+?[)]))/, - replace: (_, avatarUrl) => `,onClick:()=>$self.openAvatar(${avatarUrl})` + replace: (_, avatarUrl) => `,onClick:vcEvent=>$self.openAvatar(${avatarUrl},vcEvent)` } }, // User Dms top large icon @@ -249,7 +258,7 @@ export default definePlugin({ find: ".EMPTY_GROUP_DM)", replacement: { match: /(?<=SIZE_80,)(?=src:(.+?\))[,}])/, - replace: (_, avatarUrl) => `onClick:()=>$self.openAvatar(${avatarUrl}),` + replace: (_, avatarUrl) => `onClick:vcEvent=>$self.openAvatar(${avatarUrl},vcEvent),` } } ] From 66f18ac0a339ca4d32ba2f641701ff408ac8cab3 Mon Sep 17 00:00:00 2001 From: Elliott Tallis Date: Sun, 26 Jul 2026 18:24:28 +0100 Subject: [PATCH 07/11] Unhardcode IS_STANDALONE in web build (#4238) --- scripts/build/buildWeb.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/build/buildWeb.mjs b/scripts/build/buildWeb.mjs index 6cbb16446..8fa0c59fe 100644 --- a/scripts/build/buildWeb.mjs +++ b/scripts/build/buildWeb.mjs @@ -24,7 +24,7 @@ import { appendFile, mkdir, readdir, readFile, rm, writeFile } from "fs/promises import { join } from "path"; import Zip from "zip-local"; -import { BUILD_TIMESTAMP, commonOpts, globPlugins, IS_DEV, IS_REPORTER, IS_ANTI_CRASH_TEST, VERSION, commonRendererPlugins, buildOrWatchAll, stringifyValues } from "./common.mjs"; +import { BUILD_TIMESTAMP, commonOpts, globPlugins, IS_DEV, IS_REPORTER, IS_ANTI_CRASH_TEST, IS_STANDALONE, VERSION, commonRendererPlugins, buildOrWatchAll, stringifyValues } from "./common.mjs"; /** * @type {import("esbuild").BuildOptions} @@ -44,7 +44,7 @@ const commonOptions = { IS_WEB: true, IS_EXTENSION: false, IS_USERSCRIPT: false, - IS_STANDALONE: true, + IS_STANDALONE, IS_DEV, IS_REPORTER, IS_ANTI_CRASH_TEST, From a98bce31e7be6672413878916834e4042495f044 Mon Sep 17 00:00:00 2001 From: Artavazd <90024173+Artavazdpet@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:03:49 +0400 Subject: [PATCH 08/11] FakeNitro: improve settings UI for sizes (#3915) --- src/plugins/fakeNitro/index.tsx | 35 ++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/src/plugins/fakeNitro/index.tsx b/src/plugins/fakeNitro/index.tsx index 0c47236ea..754375be3 100644 --- a/src/plugins/fakeNitro/index.tsx +++ b/src/plugins/fakeNitro/index.tsx @@ -72,6 +72,10 @@ const fakeNitroEmojiRegex = /\/emojis\/(\d+?)\.(png|webp|gif)/; const fakeNitroStickerRegex = /\/stickers\/(\d+?)\./; const fakeNitroGifStickerRegex = /\/attachments\/\d+?\/\d+?\/(\d+?)\.gif/; const hyperLinkRegex = /\[.+?\]\((https?:\/\/.+?)\)/; +const mediaSizes = [16, 32, 48, 56, 64, 96, 128, 160, 256, 512, 1024]; + +const DEFAULT_EMOJI_SIZE = 48; +const DEFAULT_STICKER_SIZE = 160; const settings = definePluginSettings({ enableEmojiBypass: { @@ -82,9 +86,12 @@ const settings = definePluginSettings({ }, emojiSize: { description: "Size of the emojis when sending", - type: OptionType.SLIDER, - default: 48, - markers: [32, 48, 56, 64, 96, 128, 160, 256, 512] + type: OptionType.SELECT, + default: DEFAULT_EMOJI_SIZE, + options: mediaSizes.map(size => ({ + label: `${size}px`, + value: size + })) }, transformEmojis: { description: "Whether to transform fake emojis into real ones", @@ -100,9 +107,12 @@ const settings = definePluginSettings({ }, stickerSize: { description: "Size of the stickers when sending", - type: OptionType.SLIDER, - default: 160, - markers: [32, 64, 128, 160, 256, 512] + type: OptionType.SELECT, + default: DEFAULT_STICKER_SIZE, + options: mediaSizes.map(size => ({ + label: `${size}px`, + value: size + })) }, transformStickers: { description: "Whether to transform fake stickers into real ones", @@ -747,7 +757,7 @@ export default definePlugin({ .then(parseAPNG); const gif = GIFEncoder(); - const resolution = settings.store.stickerSize; + const resolution = settings.store.stickerSize ?? DEFAULT_STICKER_SIZE; const canvas = document.createElement("canvas"); canvas.width = resolution; @@ -887,10 +897,11 @@ export default definePlugin({ hasBypass = true; + const emojiSize = s.emojiSize ?? DEFAULT_EMOJI_SIZE; const emojiString = `<${emoji.animated ? "a" : ""}:${emoji.originalName || emoji.name}:${emoji.id}>`; - const url = new URL(IconUtils.getEmojiURL({ id: emoji.id, animated: emoji.animated, size: s.emojiSize })); - url.searchParams.set("size", s.emojiSize.toString()); + const url = new URL(IconUtils.getEmojiURL({ id: emoji.id, animated: emoji.animated, size: emojiSize })); + url.searchParams.set("size", emojiSize.toString()); url.searchParams.set("name", emoji.name); url.searchParams.set("lossless", "true"); @@ -923,8 +934,10 @@ export default definePlugin({ hasBypass = true; - const url = new URL(IconUtils.getEmojiURL({ id: emoji.id, animated: emoji.animated, size: s.emojiSize })); - url.searchParams.set("size", s.emojiSize.toString()); + const emojiSize = s.emojiSize ?? DEFAULT_EMOJI_SIZE; + + const url = new URL(IconUtils.getEmojiURL({ id: emoji.id, animated: emoji.animated, size: emojiSize })); + url.searchParams.set("size", emojiSize.toString()); url.searchParams.set("name", emoji.name); url.searchParams.set("lossless", "true"); From 5d4d2447ff69d286bb57ed8db54bf7b3e3081c10 Mon Sep 17 00:00:00 2001 From: Vendicated Date: Sun, 26 Jul 2026 20:37:18 +0200 Subject: [PATCH 09/11] AutoDNDWhilePlaying: respect invisible & manual user change --- src/main/utils/extensions.ts | 4 +- .../index.ts | 43 ++++++++++++++++--- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/src/main/utils/extensions.ts b/src/main/utils/extensions.ts index 5f4ccb653..14bb21204 100644 --- a/src/main/utils/extensions.ts +++ b/src/main/utils/extensions.ts @@ -74,7 +74,7 @@ async function extract(data: Buffer, outDir: string) { } export async function installExt(id: string) { - const extDir = join(extensionCacheDir, `${id}`); + const extDir = join(extensionCacheDir, id); try { await access(extDir, fsConstants.F_OK); @@ -92,7 +92,7 @@ export async function installExt(id: string) { } if (session.defaultSession.extensions) { - session.defaultSession.extensions.loadExtension(id); + session.defaultSession.extensions.loadExtension(extDir); } else { session.defaultSession.loadExtension(extDir); } diff --git a/src/plugins/autoDndWhilePlaying.discordDesktop/index.ts b/src/plugins/autoDndWhilePlaying.discordDesktop/index.ts index 5a2026689..eb68c861a 100644 --- a/src/plugins/autoDndWhilePlaying.discordDesktop/index.ts +++ b/src/plugins/autoDndWhilePlaying.discordDesktop/index.ts @@ -8,6 +8,7 @@ import { definePluginSettings } from "@api/Settings"; import { getUserSettingLazy } from "@api/UserSettings"; import { Devs } from "@utils/constants"; import definePlugin, { OptionType } from "@utils/types"; +import { UserSettingsProtoStore } from "@webpack/common"; let savedStatus: string | null; @@ -39,24 +40,56 @@ const settings = definePluginSettings({ } }); +let lastStatus: string | null = null; + +function handleUserSettingsChange() { + const status = StatusSettings.getSetting(); + if (status !== lastStatus) { + lastStatus = status; + + savedStatus = null; + } +} + +async function setStatus(status: string) { + lastStatus = status; + await StatusSettings.updateSetting(status); +} + + export default definePlugin({ name: "AutoDNDWhilePlaying", description: "Automatically updates your online status (online, idle, dnd) when launching games", tags: ["Activity", "Utility"], authors: [Devs.thororen], settings, + flux: { - RUNNING_GAMES_CHANGE({ games }) { + async RUNNING_GAMES_CHANGE({ games }) { const status = StatusSettings.getSetting(); if (games.length > 0) { - if (status !== settings.store.statusToSet) { + if (status !== settings.store.statusToSet && status !== "invisible") { savedStatus = status; - StatusSettings.updateSetting(settings.store.statusToSet); + await setStatus(settings.store.statusToSet); + } + } else if (savedStatus) { + const toRestore = savedStatus; + savedStatus = null; + + if (status !== toRestore) { + await setStatus(toRestore); } - } else if (savedStatus && savedStatus !== settings.store.statusToSet) { - StatusSettings.updateSetting(savedStatus); } } + }, + + start() { + lastStatus = StatusSettings.getSetting(); + UserSettingsProtoStore.addChangeListener(handleUserSettingsChange); + }, + + stop() { + UserSettingsProtoStore.removeChangeListener(handleUserSettingsChange); } }); From 83b74e2305cb4718b3d55af5fbd93ade50d2bb50 Mon Sep 17 00:00:00 2001 From: Vendicated Date: Sun, 26 Jul 2026 21:26:57 +0200 Subject: [PATCH 10/11] v1.15.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2eff65bd1..559e98cf7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "vencord", "private": "true", - "version": "1.14.16", + "version": "1.15.0", "description": "The cutest Discord client mod", "homepage": "https://github.com/Vendicated/Vencord#readme", "bugs": { From 67ed651e288d5d6f3b01b5871159e05bc97da054 Mon Sep 17 00:00:00 2001 From: Vendicated Date: Mon, 27 Jul 2026 20:57:31 +0200 Subject: [PATCH 11/11] add plugin favourites --- src/api/Settings.ts | 1 + src/components/Icons.tsx | 28 ++++++++++ .../tabs/plugins/ContributorModal.tsx | 4 +- .../settings/tabs/plugins/LinkIconButton.css | 13 ----- .../settings/tabs/plugins/LinkIconButton.tsx | 41 -------------- .../settings/tabs/plugins/PluginModal.tsx | 8 ++- .../tabs/plugins/PluginModalButtons.css | 37 +++++++++++++ .../tabs/plugins/PluginModalButtons.tsx | 53 +++++++++++++++++++ .../settings/tabs/plugins/index.tsx | 8 ++- 9 files changed, 134 insertions(+), 59 deletions(-) delete mode 100644 src/components/settings/tabs/plugins/LinkIconButton.css delete mode 100644 src/components/settings/tabs/plugins/LinkIconButton.tsx create mode 100644 src/components/settings/tabs/plugins/PluginModalButtons.css create mode 100644 src/components/settings/tabs/plugins/PluginModalButtons.tsx diff --git a/src/api/Settings.ts b/src/api/Settings.ts index 73bc9ee6b..2bcc785f9 100644 --- a/src/api/Settings.ts +++ b/src/api/Settings.ts @@ -68,6 +68,7 @@ export interface Settings { plugins: { [plugin: string]: { enabled: boolean; + isFavorite?: boolean; [setting: string]: any; }; }; diff --git a/src/components/Icons.tsx b/src/components/Icons.tsx index 47a52a82c..ff89b3f48 100644 --- a/src/components/Icons.tsx +++ b/src/components/Icons.tsx @@ -639,3 +639,31 @@ export function RightArrow(props: IconProps) { ); } + +export function StarFilled(props: IconProps) { + return ( + + + + ); +} + +export function StarOutlined(props: IconProps) { + return ( + + + + ); +} diff --git a/src/components/settings/tabs/plugins/ContributorModal.tsx b/src/components/settings/tabs/plugins/ContributorModal.tsx index 9139fe8f3..9ba303864 100644 --- a/src/components/settings/tabs/plugins/ContributorModal.tsx +++ b/src/components/settings/tabs/plugins/ContributorModal.tsx @@ -13,12 +13,12 @@ import { classNameFactory } from "@utils/css"; import { fetchUserProfile } from "@utils/discord"; import { classes, pluralise } from "@utils/misc"; import { RenderModalProps, User } from "@vencord/discord-types"; -import { Forms, Modal,openModal, showToast, useEffect, useMemo, UserProfileStore, useStateFromStores } from "@webpack/common"; +import { Forms, Modal, openModal, showToast, useEffect, useMemo, UserProfileStore, useStateFromStores } from "@webpack/common"; import Plugins from "~plugins"; -import { GithubButton, WebsiteButton } from "./LinkIconButton"; import { PluginCard } from "./PluginCard"; +import { GithubButton, WebsiteButton } from "./PluginModalButtons"; const cl = classNameFactory("vc-author-modal-"); diff --git a/src/components/settings/tabs/plugins/LinkIconButton.css b/src/components/settings/tabs/plugins/LinkIconButton.css deleted file mode 100644 index 835206381..000000000 --- a/src/components/settings/tabs/plugins/LinkIconButton.css +++ /dev/null @@ -1,13 +0,0 @@ -.vc-settings-modal-link-icon { - height: 32px; - width: 32px; - border-radius: 50%; - border: 4px solid var(--background-base-lowest); - box-sizing: border-box -} - -.vc-settings-modal-links { - display: flex; - gap: 0.2em; - margin-left: auto; -} \ No newline at end of file diff --git a/src/components/settings/tabs/plugins/LinkIconButton.tsx b/src/components/settings/tabs/plugins/LinkIconButton.tsx deleted file mode 100644 index 9f9ecfebb..000000000 --- a/src/components/settings/tabs/plugins/LinkIconButton.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2024 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -import "./LinkIconButton.css"; - -import { GithubIcon, WebsiteIcon } from "@components/Icons"; -import { getTheme, Theme } from "@utils/discord"; -import { MaskedLink, Tooltip } from "@webpack/common"; - -export function GithubLinkIcon() { - const theme = getTheme() === Theme.Light ? "#000000" : "#FFFFFF"; - return ; -} - -export function WebsiteLinkIcon() { - const theme = getTheme() === Theme.Light ? "#000000" : "#FFFFFF"; - return ; -} - -interface Props { - text: string; - href: string; -} - -function LinkIcon({ text, href, Icon }: Props & { Icon: React.ComponentType; }) { - return ( - - {props => ( - - - - )} - - ); -} - -export const WebsiteButton = (props: Props) => ; -export const GithubButton = (props: Props) => ; diff --git a/src/components/settings/tabs/plugins/PluginModal.tsx b/src/components/settings/tabs/plugins/PluginModal.tsx index 0e0422c9a..942c552f3 100644 --- a/src/components/settings/tabs/plugins/PluginModal.tsx +++ b/src/components/settings/tabs/plugins/PluginModal.tsx @@ -32,14 +32,14 @@ import { classes } from "@utils/misc"; import { OptionType, Plugin, PluginTag } from "@utils/types"; import { RenderModalProps, User } from "@vencord/discord-types"; import { findCssClassesLazy } from "@webpack"; -import { Clickable, FluxDispatcher, Forms, Modal,openModal, React, Text, Tooltip, useEffect, useMemo, UserStore, UserSummaryItem, UserUtils, useState } from "@webpack/common"; +import { Clickable, FluxDispatcher, Forms, Modal, openModal, React, Text, Tooltip, useEffect, useMemo, UserStore, UserSummaryItem, UserUtils, useState } from "@webpack/common"; import { Constructor } from "type-fest"; import { PluginMeta } from "~plugins"; import { OptionComponentMap } from "./components"; import { openContributorModal } from "./ContributorModal"; -import { GithubButton, WebsiteButton } from "./LinkIconButton"; +import { FavoriteButton, GithubButton, WebsiteButton } from "./PluginModalButtons"; const cl = classNameFactory("vc-plugin-modal-"); @@ -176,6 +176,10 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti {plugin.name} {!pluginMeta.userPlugin && (
+ pluginSettings.isFavorite = !pluginSettings.isFavorite} + /> getTheme() !== Theme.Light ? "dark" : "light"; + +interface Props { + text: string; + href: string; +} + +function LinkIcon({ text, href, Icon }: Props & { Icon: React.ComponentType; }) { + return ( + + {props => + + + + } + + ); +} + +const GithubLinkIcon = () => ; +const WebsiteLinkIcon = () => ; + +export const GithubButton = (props: Props) => ; +export const WebsiteButton = (props: Props) => ; + +export function FavoriteButton({ isFavorite, onClick }: { isFavorite: boolean; onClick: () => void; }) { + const Icon = isFavorite ? StarFilled : StarOutlined; + + return ( + + {props => + + + + } + + ); +} diff --git a/src/components/settings/tabs/plugins/index.tsx b/src/components/settings/tabs/plugins/index.tsx index 69d9a7c08..90ce4311a 100644 --- a/src/components/settings/tabs/plugins/index.tsx +++ b/src/components/settings/tabs/plugins/index.tsx @@ -74,6 +74,7 @@ function ReloadRequiredCard({ required }: { required: boolean; }) { const enum SearchStatus { ALL, + FAVORITES, ENABLED, DISABLED, NEW, @@ -160,7 +161,8 @@ function PluginSettings() { const sortedPlugins = useMemo(() => Object.values(Plugins).sort((a, b) => a.name.localeCompare(b.name)), [] - ); + ) + .toSorted((a, b) => Number(settings.plugins[b.name]?.isFavorite ?? false) - Number(settings.plugins[a.name]?.isFavorite ?? false)); const hasUserPlugins = useMemo(() => !IS_STANDALONE && Object.values(PluginMeta).some(m => m.userPlugin), []); @@ -173,6 +175,9 @@ function PluginSettings() { const { status, tags } = searchValue; switch (status) { + case SearchStatus.FAVORITES: + if (!settings.plugins[plugin.name]?.isFavorite) return false; + break; case SearchStatus.DISABLED: if (isPluginEnabled(plugin.name)) return false; break; @@ -288,6 +293,7 @@ function PluginSettings() {