diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0d7d4d27b..bd826a2eb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,10 +23,10 @@ jobs: - uses: pnpm/action-setup@v3 - - name: Use Node.js 20 + - name: Use Node.js 24 uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 cache: "pnpm" - name: Install dependencies @@ -72,6 +72,7 @@ jobs: gh release upload latest --clobber dist/release/* - name: Upload Plugin JSONs to Equibored repo + if: github.repository == 'Equicord/Equicord' run: | git config --global user.name "GitHub-Actions" git config --global user.email actions@github.com diff --git a/.github/workflows/buildDev.yml b/.github/workflows/buildBranches.yml similarity index 96% rename from .github/workflows/buildDev.yml rename to .github/workflows/buildBranches.yml index 6ae23fef9..f4e98b2ac 100644 --- a/.github/workflows/buildDev.yml +++ b/.github/workflows/buildBranches.yml @@ -1,4 +1,4 @@ -name: Build +name: Build Branches on: push: @@ -28,6 +28,7 @@ jobs: Build: name: Build Equicord runs-on: ubuntu-latest + if: github.repository == 'Equicord/Equicord' steps: - uses: actions/checkout@v4 @@ -37,11 +38,11 @@ jobs: - uses: pnpm/action-setup@v3 if: github.ref_name != 'bun' - - name: Use Node.js 20 + - name: Use Node.js 24 if: github.ref_name != 'bun' uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 cache: "pnpm" - name: Install dependencies @@ -64,9 +65,8 @@ jobs: pnpm generateVencordPluginJson dist/vencordplugins.json pnpm generateDevsList dist/devs.json - - uses: actions/checkout@v4 - if: github.ref_name == 'bun' - uses: oven-sh/setup-bun@v2 + if: github.ref_name == 'bun' with: bun-version: latest - name: Install dependencies diff --git a/.github/workflows/nixosBuild.yml b/.github/workflows/nixosBuild.yml index 2ea5a91b9..bd2c85c8a 100644 --- a/.github/workflows/nixosBuild.yml +++ b/.github/workflows/nixosBuild.yml @@ -20,10 +20,10 @@ jobs: - uses: pnpm/action-setup@v3 - - name: Use Node.js 20 + - name: Use Node.js 24 uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 cache: "pnpm" - name: Install dependencies @@ -50,7 +50,7 @@ jobs: cp browser/browser.* release cp Equicord.user.{js,js.LEGAL.txt} release - # copy the plugin data jsons, the extension zips and the desktop/vesktop asars + # copy the plugin data jsons, the extension zips and the desktop/equibop asars cp *.{json,zip,asar} release # legacy un-asared files diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2452e5fc8..694368312 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -62,11 +62,11 @@ jobs: if: steps.check.outputs.release == 'true' uses: pnpm/action-setup@v3 - - name: Use Node.js 20 + - name: Use Node.js 24 if: steps.check.outputs.release == 'true' uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 cache: "pnpm" - name: Install dependencies diff --git a/.github/workflows/reportBrokenPlugins.yml b/.github/workflows/reportBrokenPlugins.yml index 117a9a70c..ef0225a1f 100644 --- a/.github/workflows/reportBrokenPlugins.yml +++ b/.github/workflows/reportBrokenPlugins.yml @@ -33,10 +33,10 @@ jobs: - uses: pnpm/action-setup@v3 # Install pnpm using packageManager key in package.json - - name: Use Node.js 20 + - name: Use Node.js 24 uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 cache: "pnpm" - name: Install dependencies diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 708974e68..21d7e8b6c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,10 +18,10 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v3 # Install pnpm using packageManager key in package.json - - name: Use Node.js 20 + - name: Use Node.js 24 uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 cache: "pnpm" - name: Install dependencies diff --git a/src/api/DataStore/index.ts b/src/api/DataStore/index.ts index 3cdb50c7e..80ebb065f 100644 --- a/src/api/DataStore/index.ts +++ b/src/api/DataStore/index.ts @@ -18,97 +18,31 @@ * limitations under the License. */ -import { Logger } from "@utils/Logger"; - -const logger = new Logger("DataStore"); -let loggedFailure = false; -const memoryDatabases = new Map>>(); - -interface MemoryStore { - __memoryStore: true; - get(key: IDBValidKey): T | undefined; - put(value: unknown, key: IDBValidKey): void; - delete(key: IDBValidKey): void; - clear(): void; - getAllKeys(): KeyType[]; - getAll(): T[]; - entries(): [KeyType, ValueType][]; -} - -type StoreLike = IDBObjectStore | MemoryStore; - -function handleFailure(error: unknown) { - if (loggedFailure) return; - loggedFailure = true; - logger.error("IndexedDB unavailable, using empty storage.", error); -} - -function withFailureFallback(promise: Promise, fallback: T): Promise { - return promise.catch(error => { - handleFailure(error); - return fallback; - }); -} - -function getMemoryStore(dbName: string, storeName: string): MemoryStore { - let database = memoryDatabases.get(dbName); - if (!database) { - database = new Map(); - memoryDatabases.set(dbName, database); - } - - let data = database.get(storeName); - if (!data) { - data = new Map(); - database.set(storeName, data); - } - - return { - __memoryStore: true, - get: (key: IDBValidKey) => data.get(key) as T | undefined, - put: (value, key) => { data.set(key, value); }, - delete: key => { data.delete(key); }, - clear: () => { data.clear(); }, - getAllKeys: () => Array.from(data.keys()) as KeyType[], - getAll: () => Array.from(data.values()) as T[], - entries: () => Array.from(data.entries()) as [KeyType, ValueType][] - }; -} - -function isMemoryStore(store: StoreLike): store is MemoryStore { - return "__memoryStore" in store; -} - export function promisifyRequest( request: IDBRequest | IDBTransaction, ): Promise { return new Promise((resolve, reject) => { - const target = request as IDBRequest & IDBTransaction; - target.oncomplete = target.onsuccess = () => resolve(target.result); - target.onabort = target.onerror = () => reject(target.error); + // @ts-expect-error - file size hacks + request.oncomplete = request.onsuccess = () => resolve(request.result); + // @ts-expect-error - file size hacks + request.onabort = request.onerror = () => reject(request.error); }); } export function createStore(dbName: string, storeName: string): UseStore { const request = indexedDB.open(dbName); request.onupgradeneeded = () => request.result.createObjectStore(storeName); - const dbp = promisifyRequest(request).catch(error => { - handleFailure(error); - return null; - }); - const memoryStore = getMemoryStore(dbName, storeName); + const dbp = promisifyRequest(request); - return async (txMode, callback) => { - const db = await dbp; - if (!db) return callback(memoryStore); - - return callback(db.transaction(storeName, txMode).objectStore(storeName)); - }; + return (txMode, callback) => + dbp.then(db => + callback(db.transaction(storeName, txMode).objectStore(storeName)), + ); } export type UseStore = ( txMode: IDBTransactionMode, - callback: (store: StoreLike) => T | PromiseLike, + callback: (store: IDBObjectStore) => T | PromiseLike, ) => Promise; let defaultGetStoreFunc: UseStore | undefined; @@ -120,68 +54,88 @@ function defaultGetStore() { return defaultGetStoreFunc; } +/** + * Get a value by its key. + * + * @param key + * @param customStore Method to get a custom store. Use with caution (see the docs). + */ export function get( key: IDBValidKey, customStore = defaultGetStore(), ): Promise { - return withFailureFallback(customStore("readonly", store => { - if (isMemoryStore(store)) return store.get(key) as T | undefined; - return promisifyRequest(store.get(key)); - }), undefined); + return customStore("readonly", store => promisifyRequest(store.get(key))); } +/** + * Set a value with a key. + * + * @param key + * @param value + * @param customStore Method to get a custom store. Use with caution (see the docs). + */ export function set( key: IDBValidKey, value: any, customStore = defaultGetStore(), ): Promise { - return withFailureFallback(customStore("readwrite", store => { + return customStore("readwrite", store => { store.put(value, key); - if (isMemoryStore(store)) return; - return promisifyRequest(store.transaction); - }), undefined); + }); } +/** + * Set multiple values at once. This is faster than calling set() multiple times. + * It's also atomic – if one of the pairs can't be added, none will be added. + * + * @param entries Array of entries, where each entry is an array of `[key, value]`. + * @param customStore Method to get a custom store. Use with caution (see the docs). + */ export function setMany( entries: [IDBValidKey, any][], customStore = defaultGetStore(), ): Promise { - return withFailureFallback(customStore("readwrite", store => { + return customStore("readwrite", store => { entries.forEach(entry => store.put(entry[1], entry[0])); - if (isMemoryStore(store)) return; - return promisifyRequest(store.transaction); - }), undefined); + }); } +/** + * Get multiple values by their keys + * + * @param keys + * @param customStore Method to get a custom store. Use with caution (see the docs). + */ export function getMany( keys: IDBValidKey[], customStore = defaultGetStore(), ): Promise { - return withFailureFallback(customStore("readonly", store => { - if (isMemoryStore(store)) { - return keys.map(key => store.get(key) as T | undefined); - } - - return Promise.all(keys.map(key => promisifyRequest(store.get(key)))); - }), []); + return customStore("readonly", store => + Promise.all(keys.map(key => promisifyRequest(store.get(key)))), + ); } +/** + * Update a value. This lets you see the old value and update it as an atomic operation. + * + * @param key + * @param updater A callback that takes the old value and returns a new value. + * @param customStore Method to get a custom store. Use with caution (see the docs). + */ export function update( key: IDBValidKey, updater: (oldValue: T | undefined) => T, customStore = defaultGetStore(), ): Promise { - return withFailureFallback(customStore( + return customStore( "readwrite", - store => { - if (isMemoryStore(store)) { - store.put(updater(store.get(key) as T | undefined), key); - return; - } - - return new Promise((resolve, reject) => { + store => + // Need to create the promise manually. + // If I try to chain promises, the transaction closes in browsers + // that use a promise polyfill (IE10/11). + new Promise((resolve, reject) => { store.get(key).onsuccess = function () { try { store.put(updater(this.result), key); @@ -190,42 +144,52 @@ export function update( reject(err); } }; - }); - }, - ), undefined); + }), + ); } +/** + * Delete a particular key from the store. + * + * @param key + * @param customStore Method to get a custom store. Use with caution (see the docs). + */ export function del( key: IDBValidKey, customStore = defaultGetStore(), ): Promise { - return withFailureFallback(customStore("readwrite", store => { + return customStore("readwrite", store => { store.delete(key); - if (isMemoryStore(store)) return; - return promisifyRequest(store.transaction); - }), undefined); + }); } +/** + * Delete multiple keys at once. + * + * @param keys List of keys to delete. + * @param customStore Method to get a custom store. Use with caution (see the docs). + */ export function delMany( keys: IDBValidKey[], customStore = defaultGetStore(), ): Promise { - return withFailureFallback(customStore("readwrite", store => { - keys.forEach(key => store.delete(key)); - if (isMemoryStore(store)) return; - + return customStore("readwrite", (store: IDBObjectStore) => { + keys.forEach((key: IDBValidKey) => store.delete(key)); return promisifyRequest(store.transaction); - }), undefined); + }); } +/** + * Clear all values in the store. + * + * @param customStore Method to get a custom store. Use with caution (see the docs). + */ export function clear(customStore = defaultGetStore()): Promise { - return withFailureFallback(customStore("readwrite", store => { + return customStore("readwrite", store => { store.clear(); - if (isMemoryStore(store)) return; - return promisifyRequest(store.transaction); - }), undefined); + }); } function eachCursor( @@ -240,12 +204,16 @@ function eachCursor( return promisifyRequest(store.transaction); } +/** + * Get all keys in the store. + * + * @param customStore Method to get a custom store. Use with caution (see the docs). + */ export function keys( customStore = defaultGetStore(), ): Promise { - return withFailureFallback(customStore("readonly", store => { - if (isMemoryStore(store)) return store.getAllKeys(); - + return customStore("readonly", store => { + // Fast path for modern browsers if (store.getAllKeys) { return promisifyRequest( store.getAllKeys() as unknown as IDBRequest, @@ -253,45 +221,59 @@ export function keys( } const items: KeyType[] = []; + return eachCursor(store, cursor => items.push(cursor.key as KeyType), ).then(() => items); - }), [] as KeyType[]); + }); } +/** + * Get all values in the store. + * + * @param customStore Method to get a custom store. Use with caution (see the docs). + */ export function values(customStore = defaultGetStore()): Promise { - return withFailureFallback(customStore("readonly", store => { - if (isMemoryStore(store)) return store.getAll(); - + return customStore("readonly", store => { + // Fast path for modern browsers if (store.getAll) { return promisifyRequest(store.getAll() as IDBRequest); } const items: T[] = []; + return eachCursor(store, cursor => items.push(cursor.value as T)).then( () => items, ); - }), [] as T[]); + }); } +/** + * Get all entries in the store. Each entry is an array of `[key, value]`. + * + * @param customStore Method to get a custom store. Use with caution (see the docs). + */ export function entries( customStore = defaultGetStore(), ): Promise<[KeyType, ValueType][]> { - return withFailureFallback(customStore("readonly", store => { - if (isMemoryStore(store)) return store.entries(); - + return customStore("readonly", store => { + // Fast path for modern browsers + // (although, hopefully we'll get a simpler path some day) if (store.getAll && store.getAllKeys) { return Promise.all([ promisifyRequest( store.getAllKeys() as unknown as IDBRequest, ), promisifyRequest(store.getAll() as IDBRequest), - ]).then(([keys, values]) => keys.map((key, i) => [key, values[i]] as [KeyType, ValueType])); + ]).then(([keys, values]) => keys.map((key, i) => [key, values[i]])); } const items: [KeyType, ValueType][] = []; - return eachCursor(store, cursor => - items.push([cursor.key as KeyType, cursor.value]), - ).then(() => items); - }), [] as [KeyType, ValueType][]); + + return customStore("readonly", store => + eachCursor(store, cursor => + items.push([cursor.key as KeyType, cursor.value]), + ).then(() => items), + ); + }); } diff --git a/src/equicordplugins/_api/userArea.ts b/src/equicordplugins/_api/userArea.ts index 112ac9bce..6fbefa328 100644 --- a/src/equicordplugins/_api/userArea.ts +++ b/src/equicordplugins/_api/userArea.ts @@ -22,8 +22,8 @@ export default definePlugin({ find: ".DISPLAY_NAME_STYLES_COACHMARK)", replacement: [ { - match: /(?<=className:(\i)\.\i,style:\i,)children:\[/, - replace: "children:[...$self.renderButtons(arguments[0],$1)," + match: /children:\[(?=.{0,50}accountContainerRef:\i)/, + replace: "children:[...$self.renderButtons(arguments[0])," }, // fix discord weird shrink with extra buttons { diff --git a/src/equicordplugins/betterActivities/index.tsx b/src/equicordplugins/betterActivities/index.tsx index bea8c1136..edb29a7c8 100644 --- a/src/equicordplugins/betterActivities/index.tsx +++ b/src/equicordplugins/betterActivities/index.tsx @@ -45,7 +45,7 @@ export default definePlugin({ // Show all activities in the user popout/sidebar find: /onOpenUserProfileModal:\i,onClose:\i\}\),null/, replacement: { - match: /((\i)=.{0,10}(\i)\.id\).*?,onInteraction:\i\}\),).{0,250}onClose:\i\}\)/, + match: /((\i)=.{0,10}(\i)\.id\).*?,onOpenUserProfileModal:\i\}\),).{0,250}onClose:\i\}\)/, replace: "$1$self.showAllActivitiesComponent({ activity: $2, user: $3 })" }, predicate: () => settings.store.userPopout diff --git a/src/equicordplugins/collapsibleUi/index.tsx b/src/equicordplugins/collapsibleUi/index.tsx new file mode 100644 index 000000000..d72033893 --- /dev/null +++ b/src/equicordplugins/collapsibleUi/index.tsx @@ -0,0 +1,148 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { ChannelToolbarButton } from "@api/HeaderBar"; +import ErrorBoundary from "@components/ErrorBoundary"; +import { EquicordDevs } from "@utils/constants"; +import { classNameFactory } from "@utils/css"; +import { classes } from "@utils/misc"; +import definePlugin from "@utils/types"; +import { ContextMenuApi, Menu } from "@webpack/common"; +import type { ReactNode, SVGProps } from "react"; + +import { PanelId, panelRegistry, settings, toolbarPanelOrder } from "./settings"; +import managedStyle from "./style.css?managed"; + +const cl = classNameFactory("vc-collapsible-ui-"); + +type StateStore = ReturnType; + +function PanelsIcon(props: SVGProps) { + return ( + + + + ); +} + +function getStateClasses(store: StateStore) { + const classNames = [cl("state"), cl(`transition-${store.transitionMs}`)]; + + for (const panelId of toolbarPanelOrder) { + const panel = panelRegistry[panelId]; + if (store[panel.collapsedKey]) classNames.push(cl(`${panel.classId}-collapsed`)); + } + + return classes(...classNames); +} + +function togglePanel(panelId: PanelId) { + const key = panelRegistry[panelId].collapsedKey; + settings.store[key] = !settings.store[key]; +} + +const ToolbarMenu = ErrorBoundary.wrap(({ onClose }: { onClose(): void; }) => { + const store = settings.use(); + + return ( + + {toolbarPanelOrder.map(panelId => { + const panel = panelRegistry[panelId]; + const collapsed = store[panel.collapsedKey]; + + return ( + togglePanel(panelId)} + /> + ); + })} + + ); +}, { noop: true }); + +const ToolbarButtons = ErrorBoundary.wrap(() => { + const store = settings.use(); + const anyCollapsed = toolbarPanelOrder.some(panelId => store[panelRegistry[panelId].collapsedKey]); + + return ( + <> + + ContextMenuApi.openContextMenu(event, () => )} + onContextMenu={event => ContextMenuApi.openContextMenu(event, () => )} + /> + + ); +}, { noop: true }); + +const ChatButtonsRow = ErrorBoundary.wrap(({ buttons }: { buttons: ReactNode[]; }) => { + const { chatButtonsCollapsed } = settings.use(); + + if (buttons.length === 0) return <>{buttons}; + + return ( +
+
+ {buttons} +
+
+ ); +}, { noop: true }); + +export default definePlugin({ + name: "CollapsibleUI", + description: "Native collapsible channel, member, chat button, and user area surfaces.", + authors: [EquicordDevs.benjii], + tags: ["ui", "sidebar", "layout"], + managedStyle, + settings, + + headerBarButton: { + icon: PanelsIcon, + location: "channeltoolbar", + priority: 25, + render: () => , + }, + + patches: [ + { + find: ".DISPLAY_NAME_STYLES_COACHMARK)", + replacement: { + match: /className:(\i\.\i),(?=style:(\i),children:\[)/, + replace: "className:$self.userAreaControlsClass($1)," + } + }, + { + find: '"sticker")', + replacement: { + match: /(?<="div",\{.{0,15}children:)(.+?)\}/, + replace: "$self.wrapChatButtons($1)}" + } + } + ], + + wrapChatButtons(buttons: ReactNode[]) { + if (!Array.isArray(buttons) || buttons.length === 0) return buttons; + return ; + }, + + userAreaControlsClass(className: string) { + return classes(className, cl("user-area-controls")); + }, +}); diff --git a/src/equicordplugins/collapsibleUi/settings.ts b/src/equicordplugins/collapsibleUi/settings.ts new file mode 100644 index 000000000..dce2485b2 --- /dev/null +++ b/src/equicordplugins/collapsibleUi/settings.ts @@ -0,0 +1,70 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { definePluginSettings } from "@api/Settings"; +import { OptionType } from "@utils/types"; + +export const transitionMarkers = [0, 80, 120, 160, 200, 260, 320]; + +export const panelRegistry = { + channelList: { + id: "channelList", + classId: "channel-list", + label: "Channel List", + collapsedKey: "channelListCollapsed", + }, + membersList: { + id: "membersList", + classId: "members-list", + label: "Members List", + collapsedKey: "membersListCollapsed", + }, + chatButtons: { + id: "chatButtons", + classId: "chat-buttons", + label: "Message Buttons", + collapsedKey: "chatButtonsCollapsed", + }, + userArea: { + id: "userArea", + classId: "user-area", + label: "User Area", + collapsedKey: "userAreaCollapsed", + }, +} as const; + +export type PanelId = keyof typeof panelRegistry; + +export const toolbarPanelOrder = ["channelList", "membersList", "chatButtons", "userArea"] as const satisfies readonly PanelId[]; + +export const settings = definePluginSettings({ + transitionMs: { + type: OptionType.SLIDER, + description: "Panel transition speed in milliseconds.", + default: 160, + markers: transitionMarkers, + }, + channelListCollapsed: { + type: OptionType.BOOLEAN, + description: "Persist the channel list as collapsed.", + default: false, + }, + membersListCollapsed: { + type: OptionType.BOOLEAN, + description: "Persist the members list as collapsed.", + default: false, + }, + chatButtonsCollapsed: { + type: OptionType.BOOLEAN, + description: "Persist the message button row as collapsed.", + default: false, + }, + userAreaCollapsed: { + type: OptionType.BOOLEAN, + description: "Persist the user area as collapsed.", + default: false, + }, +}); diff --git a/src/equicordplugins/collapsibleUi/style.css b/src/equicordplugins/collapsibleUi/style.css new file mode 100644 index 000000000..0ff9d8cc3 --- /dev/null +++ b/src/equicordplugins/collapsibleUi/style.css @@ -0,0 +1,213 @@ +body:has(.vc-collapsible-ui-state) { + --vc-cui-chat-buttons-width: min(320px, 36vw); + --vc-cui-collapsed-size: 12px; + --vc-cui-hover-peek: 18px; + --vc-cui-hover-delay: 120ms; + --vc-cui-user-area-collapsed-width: 52px; + --vc-cui-transition: 160ms; + --vc-cui-ease: cubic-bezier(.2, 0, 0, 1); +} + +body:has(.vc-collapsible-ui-transition-0) { + --vc-cui-transition: 0ms; +} + +body:has(.vc-collapsible-ui-transition-80) { + --vc-cui-transition: 80ms; +} + +body:has(.vc-collapsible-ui-transition-120) { + --vc-cui-transition: 120ms; +} + +body:has(.vc-collapsible-ui-transition-160) { + --vc-cui-transition: 160ms; +} + +body:has(.vc-collapsible-ui-transition-200) { + --vc-cui-transition: 200ms; +} + +body:has(.vc-collapsible-ui-transition-260) { + --vc-cui-transition: 260ms; +} + +body:has(.vc-collapsible-ui-transition-320) { + --vc-cui-transition: 320ms; +} + +body:has(.vc-collapsible-ui-channel-list-collapsed):has([class*="sidebarList_"]:hover) [class*="sidebarList_"] { + transition-delay: var(--vc-cui-hover-delay); +} + +body:has(.vc-collapsible-ui-channel-list-collapsed):has([class*="sidebarList_"]:hover) [class*="sidebarList_"] > * { + opacity: 1; + pointer-events: auto; + transition: opacity var(--vc-cui-transition) var(--vc-cui-ease); + transition-delay: var(--vc-cui-hover-delay); +} + +body:has(.vc-collapsible-ui-channel-list-collapsed):not(:has([class*="sidebarList_"]:hover), :has([class*="sidebar_"] > [class*="panels_"]:hover)) [class*="sidebarList_"] { + width: var(--vc-cui-collapsed-size) !important; + inline-size: var(--vc-cui-collapsed-size) !important; + min-width: var(--vc-cui-collapsed-size) !important; + min-inline-size: var(--vc-cui-collapsed-size) !important; + max-width: var(--vc-cui-collapsed-size) !important; + max-inline-size: var(--vc-cui-collapsed-size) !important; + flex: 0 0 var(--vc-cui-collapsed-size) !important; + overflow: hidden !important; + transition: + width var(--vc-cui-transition) var(--vc-cui-ease), + inline-size var(--vc-cui-transition) var(--vc-cui-ease), + min-width var(--vc-cui-transition) var(--vc-cui-ease), + min-inline-size var(--vc-cui-transition) var(--vc-cui-ease), + max-width var(--vc-cui-transition) var(--vc-cui-ease), + max-inline-size var(--vc-cui-transition) var(--vc-cui-ease), + flex-basis var(--vc-cui-transition) var(--vc-cui-ease) !important; +} + +body:has(.vc-collapsible-ui-channel-list-collapsed):not(:has([class*="sidebarList_"]:hover), :has([class*="sidebar_"] > [class*="panels_"]:hover)) [class*="sidebarList_"] > * { + opacity: 0; + pointer-events: none; + transition: opacity var(--vc-cui-transition) var(--vc-cui-ease); +} + +body:has(.vc-collapsible-ui-channel-list-collapsed):not(:has([class*="sidebar_"] > [class*="panels_"]:hover)) [class*="sidebar_"] > [class*="panels_"] { + width: var(--vc-cui-user-area-collapsed-width) !important; + inline-size: var(--vc-cui-user-area-collapsed-width) !important; + min-width: var(--vc-cui-user-area-collapsed-width) !important; + min-inline-size: var(--vc-cui-user-area-collapsed-width) !important; + max-width: var(--vc-cui-user-area-collapsed-width) !important; + max-inline-size: var(--vc-cui-user-area-collapsed-width) !important; + overflow: hidden !important; + transition: + width var(--vc-cui-transition) var(--vc-cui-ease), + inline-size var(--vc-cui-transition) var(--vc-cui-ease), + min-width var(--vc-cui-transition) var(--vc-cui-ease), + min-inline-size var(--vc-cui-transition) var(--vc-cui-ease), + max-width var(--vc-cui-transition) var(--vc-cui-ease), + max-inline-size var(--vc-cui-transition) var(--vc-cui-ease) !important; +} + +body:has(.vc-collapsible-ui-channel-list-collapsed):not(:has([class*="sidebar_"] > [class*="panels_"]:hover)) .vc-collapsible-ui-user-area-controls { + max-width: 0 !important; + min-width: 0 !important; + overflow: hidden !important; + opacity: 0; + pointer-events: none; + transition: + max-width var(--vc-cui-transition) var(--vc-cui-ease), + opacity var(--vc-cui-transition) var(--vc-cui-ease) !important; +} + +body:has(.vc-collapsible-ui-channel-list-collapsed):has([class*="sidebar_"] > [class*="panels_"]:hover) .vc-collapsible-ui-user-area-controls { + opacity: 1; + pointer-events: auto; +} + +body:has(.vc-collapsible-ui-members-list-collapsed) [class*="membersWrap"] { + overflow: hidden !important; + transition: + width var(--vc-cui-transition) var(--vc-cui-ease), + min-width var(--vc-cui-transition) var(--vc-cui-ease), + max-width var(--vc-cui-transition) var(--vc-cui-ease) !important; +} + +body:has(.vc-collapsible-ui-members-list-collapsed):has([class*="membersWrap"]:hover) [class*="membersWrap"], +body:has(.vc-collapsible-ui-members-list-collapsed):has([class*="membersWrap"]:focus-within) [class*="membersWrap"] { + transition-delay: var(--vc-cui-hover-delay); +} + +body:has(.vc-collapsible-ui-members-list-collapsed):has([class*="membersWrap"]:hover) [class*="membersWrap"] > :where(*), +body:has(.vc-collapsible-ui-members-list-collapsed):has([class*="membersWrap"]:focus-within) [class*="membersWrap"] > :where(*) { + opacity: 1; + pointer-events: auto; + transition: opacity var(--vc-cui-transition) var(--vc-cui-ease); + transition-delay: var(--vc-cui-hover-delay); +} + +body:has(.vc-collapsible-ui-members-list-collapsed):not(:has([class*="membersWrap"]:hover), :has([class*="membersWrap"]:focus-within)) [class*="membersWrap"] { + width: var(--vc-cui-collapsed-size) !important; + min-width: var(--vc-cui-collapsed-size) !important; + max-width: var(--vc-cui-collapsed-size) !important; +} + +body:has(.vc-collapsible-ui-members-list-collapsed):not(:has([class*="membersWrap"]:hover), :has([class*="membersWrap"]:focus-within)) [class*="membersWrap"] > :where(*) { + opacity: 0; + pointer-events: none; + transition: opacity var(--vc-cui-transition) var(--vc-cui-ease); +} + +body:has(.vc-collapsible-ui-user-area-collapsed):has([class*="sidebar_"] > [class*="panels_"]:hover) [class*="sidebar_"] > [class*="panels_"] > :where(*), +body:has(.vc-collapsible-ui-user-area-collapsed):has([class*="sidebar_"] > [class*="panels_"]:focus-within) [class*="sidebar_"] > [class*="panels_"] > :where(*) { + opacity: 1; + pointer-events: auto; + transition: opacity var(--vc-cui-transition) var(--vc-cui-ease); +} + +body:has(.vc-collapsible-ui-user-area-collapsed):not(:has([class*="sidebar_"] > [class*="panels_"]:hover), :has([class*="sidebar_"] > [class*="panels_"]:focus-within)) [class*="sidebar_"] > [class*="panels_"] { + height: var(--vc-cui-hover-peek) !important; + block-size: var(--vc-cui-hover-peek) !important; + min-height: var(--vc-cui-hover-peek) !important; + min-block-size: var(--vc-cui-hover-peek) !important; + max-height: var(--vc-cui-hover-peek) !important; + max-block-size: var(--vc-cui-hover-peek) !important; + overflow: hidden !important; + border-top-width: 0 !important; + padding-block: 0 !important; + transition: + height var(--vc-cui-transition) var(--vc-cui-ease), + block-size var(--vc-cui-transition) var(--vc-cui-ease), + min-height var(--vc-cui-transition) var(--vc-cui-ease), + min-block-size var(--vc-cui-transition) var(--vc-cui-ease), + max-height var(--vc-cui-transition) var(--vc-cui-ease), + max-block-size var(--vc-cui-transition) var(--vc-cui-ease), + padding var(--vc-cui-transition) var(--vc-cui-ease) !important; +} + +body:has(.vc-collapsible-ui-user-area-collapsed):not(:has([class*="sidebar_"] > [class*="panels_"]:hover), :has([class*="sidebar_"] > [class*="panels_"]:focus-within)) [class*="sidebar_"] > [class*="panels_"] > :where(*) { + opacity: 0; + pointer-events: none; + transition: opacity var(--vc-cui-transition) var(--vc-cui-ease); +} + +.vc-collapsible-ui-chat-buttons { + display: flex; + align-items: center; + min-inline-size: 0; +} + +.vc-collapsible-ui-chat-buttons-collapsed { + min-inline-size: var(--vc-cui-hover-peek); +} + +.vc-collapsible-ui-chat-buttons-items { + display: flex; + align-items: center; + min-inline-size: 0; + max-inline-size: var(--vc-cui-chat-buttons-width); + overflow: hidden; + transition: + max-inline-size var(--vc-cui-transition) var(--vc-cui-ease), + opacity var(--vc-cui-transition) var(--vc-cui-ease), + margin var(--vc-cui-transition) var(--vc-cui-ease); +} + +.vc-collapsible-ui-chat-buttons-collapsed .vc-collapsible-ui-chat-buttons-items { + max-inline-size: 0; + opacity: 0; + pointer-events: none; +} + +.vc-collapsible-ui-chat-buttons-collapsed:hover .vc-collapsible-ui-chat-buttons-items { + max-inline-size: var(--vc-cui-chat-buttons-width); + opacity: 1; + pointer-events: auto; + transition-delay: var(--vc-cui-hover-delay); +} + +@media (prefers-reduced-motion: reduce) { + body:has(.vc-collapsible-ui-state) { + --vc-cui-transition: 0ms; + } +} diff --git a/src/equicordplugins/customStatusTimeouts/index.tsx b/src/equicordplugins/customStatusTimeouts/index.tsx new file mode 100644 index 000000000..90b8eed4b --- /dev/null +++ b/src/equicordplugins/customStatusTimeouts/index.tsx @@ -0,0 +1,115 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { definePluginSettings } from "@api/Settings"; +import { EquicordDevs } from "@utils/constants"; +import definePlugin, { OptionType } from "@utils/types"; + +const Millis = { + HALF_SECOND: 500, + SECOND: 1e3, + MINUTE: 6e4, + HOUR: 36e5, + DAY: 864e5, + WEEK: 6048e5, + DAYS_30: 2592e6 +}; + +const settings = definePluginSettings({ + showForeverOnTop: { + type: OptionType.BOOLEAN, + description: "Show the Forever option at the top of the list instead of the bottom.", + restartNeeded: true, + default: true + }, + extraSeconds: { + type: OptionType.STRING, + description: "Extra seconds to add, separated by a comma (e.g. 5, 10, 30)", + restartNeeded: true, + default: "15, 30, 45" + }, + extraMinutes: { + type: OptionType.STRING, + description: "Extra minutes to add, separated by a comma (e.g. 5, 10, 30)", + restartNeeded: true, + default: "5, 10, 30" + }, + extraHours: { + type: OptionType.STRING, + description: "Extra hours to add, separated by a comma (e.g. 2, 4, 6, 12)", + restartNeeded: true, + default: "2, 4, 6, 12" + }, + extraDays: { + type: OptionType.STRING, + description: "Extra days to add, separated by a comma (e.g. 1, 2)", + restartNeeded: true, + default: "1, 2" + }, +}); + +export default definePlugin({ + name: "CustomStatusTimeouts", + description: "Adds configurable timeout presets to the status (presence) menu.", + authors: [EquicordDevs.Kiri, EquicordDevs.thororen], + settings, + patches: [ + { + find: "#{intl::DURATION_FOREVER}", + replacement: { + match: /\[\{duration.*?#{intl::DURATION_FOREVER}\)\}\]/, + replace: "$self.buildTimeouts($&)" + } + } + ], + buildTimeouts(existing) { + const parse = (str: string) => str.split(",").map(s => Number(s.trim())).filter(Boolean); + + const seconds = parse(settings.store.extraSeconds); + const minutes = parse(settings.store.extraMinutes); + const hours = parse(settings.store.extraHours); + const days = parse(settings.store.extraDays); + // I was testing weeks and months and for some reason random ones didnt apply look below for the working ones + // tested weeks between 1-12 working weeks were 1, 2, 3, 8, 9, 10 the rest didnt work + // tested months between 1-12 working months were 2, 4, 10, 12 the rest didnt work + // for months I only tested evens except for 1 so I hardcoded them to avoid user complaints + const weeks = [1, 2, 3]; + const months = [2, 4]; + + const extra = [ + ...seconds.map(s => ({ + duration: s * Millis.SECOND, + label: () => `For ${s} ${s === 1 ? "Second" : "Seconds"}` + })), + ...minutes.map(m => ({ + duration: m * Millis.MINUTE, + label: () => `For ${m} ${m === 1 ? "Minute" : "Minutes"}` + })), + ...hours.map(h => ({ + duration: h * Millis.HOUR, + label: () => `For ${h} ${h === 1 ? "Hour" : "Hours"}` + })), + ...days.map(d => ({ + duration: d * Millis.DAY, + label: () => `For ${d} ${d === 1 ? "Day" : "Days"}` + })), + ...weeks.map(w => ({ + duration: w * Millis.WEEK, + label: () => `For ${w} ${w === 1 ? "Week" : "Weeks"}` + })), + ...months.map(m => ({ + duration: m * Millis.DAYS_30, + label: () => `For ${m} ${m === 1 ? "Month" : "Months"}` + })), + ]; + + return [...existing, ...extra].sort((a, b) => { + if (a.duration === undefined) return settings.store.showForeverOnTop ? -1 : 1; + if (b.duration === undefined) return settings.store.showForeverOnTop ? 1 : -1; + return a.duration - b.duration; + }); + } +}); diff --git a/src/equicordplugins/dragify/index.tsx b/src/equicordplugins/dragify/index.tsx index ea819273e..d076ea7a7 100644 --- a/src/equicordplugins/dragify/index.tsx +++ b/src/equicordplugins/dragify/index.tsx @@ -155,7 +155,7 @@ export default definePlugin({ patches: [ // Voice user rows (voice channel sidebar list) { - find: '"system:click_outside","user:escape"', + find: ".VOICE_USER],shouldShow:", replacement: { match: /(?<=GuildChannelUserContextMenu.{0,150})"data-dnd-name":\i/, replace: "\"data-dragify-user\":!0,\"data-user-id\":arguments[0].user?.id,draggable:!0,onDragStart:e=>$self.onUserDragStart(e,arguments[0].user),$&" diff --git a/src/equicordplugins/fileUpload/settings.tsx b/src/equicordplugins/fileUpload/settings.tsx index b1aceb041..ab697c3fa 100644 --- a/src/equicordplugins/fileUpload/settings.tsx +++ b/src/equicordplugins/fileUpload/settings.tsx @@ -209,6 +209,12 @@ export const settings = definePluginSettings({ default: false, hidden: true }, + preserveOriginalFilename: { + type: OptionType.BOOLEAN, + description: "Preserve the original filename when uploading.", + default: true, + hidden: true + }, autoCopy: { type: OptionType.BOOLEAN, description: "Auto copy upload URL", @@ -508,6 +514,13 @@ export function SettingsComponent() { /> + + (store as { preserveOriginalFilename?: boolean; }).preserveOriginalFilename = v} + /> + + 0) { - const invite = invites[0]; - const inviteCode = invite.code; - const inviteLink = `https://discord.gg/${inviteCode}`; - - if (this.usersCollection[guildId]) { - this.usersCollection[guildId].inviteLink = inviteLink; - } - } - } catch (error) { - console.warn("Failed to collect invite link for guild:", guildId, error); - } - } - storageAutoSaveProtocol() { this._storageAutoSaveProtocol_interval = setInterval( this.updateStorage.bind(this), diff --git a/src/equicordplugins/messageLoggerEnhanced/LoggedMessageManager.ts b/src/equicordplugins/messageLoggerEnhanced/LoggedMessageManager.ts index 7f51572af..5408349e9 100644 --- a/src/equicordplugins/messageLoggerEnhanced/LoggedMessageManager.ts +++ b/src/equicordplugins/messageLoggerEnhanced/LoggedMessageManager.ts @@ -17,7 +17,7 @@ */ import { Flogger, settings } from "."; -import { addMessageIDB, countMessagesIDB, DBMessageStatus, deleteMessagesBulkIDB, getOlderThanTimestampForGuildsIDB, getOldestMessagesIDB } from "./db"; +import { addMessageIDB, db, DBMessageStatus, deleteMessagesBulkIDB, getOlderThanTimestampForGuildsIDB, getOldestMessagesIDB } from "./db"; import { LoggedMessage, LoggedMessageJSON } from "./types"; import { cleanupMessage } from "./utils"; import { cacheMessageImages } from "./utils/saveImage"; @@ -47,7 +47,7 @@ export const addMessage = async (message: LoggedMessage | LoggedMessageJSON, sta } if (settings.store.messageLimit > 0) { - const currentMessageCount = await countMessagesIDB(); + const currentMessageCount = await db.count("messages"); if (currentMessageCount > settings.store.messageLimit) { const messagesToDelete = currentMessageCount - settings.store.messageLimit; if (messagesToDelete <= 0 || messagesToDelete >= settings.store.messageLimit) return; diff --git a/src/equicordplugins/messageLoggerEnhanced/components/LogsModal.tsx b/src/equicordplugins/messageLoggerEnhanced/components/LogsModal.tsx index 06727dba8..1d28a6348 100644 --- a/src/equicordplugins/messageLoggerEnhanced/components/LogsModal.tsx +++ b/src/equicordplugins/messageLoggerEnhanced/components/LogsModal.tsx @@ -16,8 +16,8 @@ import { type User } from "@vencord/discord-types"; import { find, findByCode, findByCodeLazy } from "@webpack"; import { Alerts, ChannelStore, ContextMenuApi, FluxDispatcher, GuildStore, Menu, NavigationRouter, React, TabBar, TextInput, Tooltip, useMemo, useRef, useState } from "@webpack/common"; -import { clearMessagesIDB, DBMessageRecord, deleteMessageIDB, deleteMessagesBulkIDB } from "../db"; -import { cl, settings } from "../index"; +import { DBMessageRecord, deleteMessageIDB, deleteMessagesBulkIDB } from "../db"; +import { clearLogs, cl, settings } from "../index"; import { LoggedMessage, LoggedMessageJSON } from "../types"; import { messageJsonToMessageClass } from "../utils"; import { importLogs } from "../utils/settingsUtils"; @@ -147,7 +147,7 @@ export function LogsModal({ modalProps, initalQuery }: Props) { confirmVariant: "critical-primary", cancelText: "Cancel", onConfirm: async () => { - await clearMessagesIDB(); + await clearLogs(); reset(); } diff --git a/src/equicordplugins/messageLoggerEnhanced/db.ts b/src/equicordplugins/messageLoggerEnhanced/db.ts index 31c34ef45..a9824e3dc 100644 --- a/src/equicordplugins/messageLoggerEnhanced/db.ts +++ b/src/equicordplugins/messageLoggerEnhanced/db.ts @@ -4,7 +4,6 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -import { Logger } from "@utils/Logger"; import { ChannelStore, Toasts } from "@webpack/common"; import { DBSchema, IDBPDatabase, openDB } from "idb"; @@ -38,63 +37,17 @@ export interface MLIDB extends DBSchema { by_timestamp_and_message_id: [string, string]; }; }; + } -const logger = new Logger("MessageLoggerEnhanced"); -let db: IDBPDatabase | null = null; -let dbPromise: Promise | null> | null = null; -let loggedFailure = false; - +export let db: IDBPDatabase; export const cachedMessages = new Map(); -function handleDbFailure(error: unknown) { - if (loggedFailure) return; - loggedFailure = true; - logger.error("Message storage unavailable, using memory only.", error); -} - -async function ensureDb() { - if (db) return db; - if (!dbPromise) { - dbPromise = openDB(DB_NAME, DB_VERSION, { - upgrade(database) { - const messageStore = database.createObjectStore("messages", { keyPath: "message_id" }); - messageStore.createIndex("by_channel_id", "channel_id"); - messageStore.createIndex("by_status", "status"); - messageStore.createIndex("by_timestamp", "message.timestamp"); - messageStore.createIndex("by_timestamp_and_message_id", ["channel_id", "message.timestamp"]); - } - }).then(database => { - db = database; - return database; - }).catch(error => { - handleDbFailure(error); - return null; - }); - } - - db = await dbPromise; - return db; -} - -async function withDb(fallback: T, callback: (database: IDBPDatabase) => Promise): Promise { - const database = await ensureDb(); - if (!database) return fallback; - - try { - return await callback(database); - } catch (error) { - handleDbFailure(error); - return fallback; - } -} - -async function cacheRecords(records: DBMessageRecord[], cacheAttachmentBlobs = true) { +// this is probably not the best way to do this +async function cacheRecords(records: DBMessageRecord[]) { for (const r of records) { cacheRecord(r); - if (!cacheAttachmentBlobs) continue; - for (const att of r.message.attachments) { const blobUrl = await getAttachmentBlobUrl(att); if (blobUrl) { @@ -103,7 +56,6 @@ async function cacheRecords(records: DBMessageRecord[], cacheAttachmentBlobs = t } } } - return records; } @@ -116,56 +68,57 @@ async function cacheRecord(record?: DBMessageRecord | null) { } export async function initIDB() { - await ensureDb(); + if (db) return; + db = await openDB(DB_NAME, DB_VERSION, { + upgrade(db) { + const messageStore = db.createObjectStore("messages", { keyPath: "message_id" }); + messageStore.createIndex("by_channel_id", "channel_id"); + messageStore.createIndex("by_status", "status"); + messageStore.createIndex("by_timestamp", "message.timestamp"); + messageStore.createIndex("by_timestamp_and_message_id", ["channel_id", "message.timestamp"]); + } + }); } -void initIDB(); +initIDB(); export async function hasMessageIDB(message_id: string) { - if (cachedMessages.has(message_id)) return true; - return withDb(false, database => database.count("messages", message_id).then(count => count > 0)); + return cachedMessages.has(message_id) || (await db.count("messages", message_id)) > 0; } export async function countMessagesIDB() { - if (!db) return cachedMessages.size; return db.count("messages"); } export async function countMessagesByStatusIDB(status: DBMessageStatus) { - return withDb(0, database => database.countFromIndex("messages", "by_status", status)); + return db.countFromIndex("messages", "by_status", status); } export async function getAllMessagesIDB() { - return withDb([], async database => cacheRecords(await database.getAll("messages"))); + return cacheRecords(await db.getAll("messages")); } export async function getMessagesForChannelIDB(channel_id: string) { - return withDb([], async database => cacheRecords(await database.getAllFromIndex("messages", "by_channel_id", channel_id))); + return cacheRecords(await db.getAllFromIndex("messages", "by_channel_id", channel_id)); } export async function getMessageIDB(message_id: string) { - return withDb(null, async database => { - const record = await database.get("messages", message_id); - await cacheRecord(record); - return record ?? null; - }); + return cacheRecord(await db.get("messages", message_id)); } export async function getMessagesByStatusIDB(status: DBMessageStatus) { - return withDb([], async database => cacheRecords(await database.getAllFromIndex("messages", "by_status", status))); + return cacheRecords(await db.getAllFromIndex("messages", "by_status", status)); } export async function getOldestMessagesIDB(limit: number) { - return withDb([], async database => cacheRecords(await database.getAllFromIndex("messages", "by_timestamp", undefined, limit))); + return cacheRecords(await db.getAllFromIndex("messages", "by_timestamp", undefined, limit)); } export async function* iterateAllMessagesIDB(batchSize = 100) { - if (!db && !(await ensureDb())) return; - let lastId: string | undefined; while (true) { const batch: DBMessageRecord[] = []; - - const tx = db!.transaction("messages"); + // new transaction for each batch to avoid timeouts during yield + const tx = db.transaction("messages"); const range = lastId ? IDBKeyRange.lowerBound(lastId, true) : undefined; let cursor = await tx.store.openCursor(range); @@ -177,6 +130,7 @@ export async function* iterateAllMessagesIDB(batchSize = 100) { if (batch.length === 0) break; lastId = batch[batch.length - 1].message_id; + yield await cacheRecords(batch); if (batch.length < batchSize) break; @@ -184,20 +138,22 @@ export async function* iterateAllMessagesIDB(batchSize = 100) { } export async function getOlderThanTimestampIDB(timestamp: string) { - return withDb([], async database => { - const tx = database.transaction("messages", "readonly"); - const index = tx.store.index("by_timestamp"); - const cursor = await index.openCursor(IDBKeyRange.upperBound(timestamp)); + const tx = db.transaction("messages", "readonly"); + const { store } = tx; + const index = store.index("by_timestamp"); - if (!cursor) return []; + const cursor = await index.openCursor(IDBKeyRange.upperBound(timestamp)); - const messages: DBMessageRecord[] = []; - for await (const c of cursor) { - messages.push(c.value); - } + if (!cursor) { + return []; + } - return cacheRecords(messages, false); - }); + const messages: DBMessageRecord[] = []; + for await (const c of cursor) { + messages.push(c.value); + } + + return cacheRecords(messages); } export async function getOlderThanTimestampForGuildsIDB(timestamp: string, currentChannelId?: string, preserveCurrentChannel?: boolean) { @@ -212,106 +168,102 @@ export async function getOlderThanTimestampForGuildsIDB(timestamp: string, curre } export async function getDateStortedMessagesByStatusIDB(newest: boolean, limit: number, status: DBMessageStatus) { - return withDb([], async database => { - const tx = database.transaction("messages", "readonly"); - const index = tx.store.index("by_status"); - const direction = newest ? "prev" : "next"; - const cursor = await index.openCursor(IDBKeyRange.only(status), direction); + const tx = db.transaction("messages", "readonly"); + const { store } = tx; + const index = store.index("by_status"); - if (!cursor) { - return []; - } + const direction = newest ? "prev" : "next"; + const cursor = await index.openCursor(IDBKeyRange.only(status), direction); - const messages: DBMessageRecord[] = []; - for await (const c of cursor) { - messages.push(c.value); - if (messages.length >= limit) break; - } + if (!cursor) { + console.log("No messages found"); + return []; + } - return cacheRecords(messages); - }); + const messages: DBMessageRecord[] = []; + for await (const c of cursor) { + messages.push(c.value); + if (messages.length >= limit) break; + } + + return cacheRecords(messages); } export async function getMessagesByChannelAndAfterTimestampIDB(channel_id: string, start: string) { - return withDb([], async database => { - const tx = database.transaction("messages", "readonly"); - const index = tx.store.index("by_timestamp_and_message_id"); - const cursor = await index.openCursor(IDBKeyRange.bound([channel_id, start], [channel_id, "\uffff"])); + const tx = db.transaction("messages", "readonly"); + const { store } = tx; + const index = store.index("by_timestamp_and_message_id"); - if (!cursor) { - return []; - } + const cursor = await index.openCursor(IDBKeyRange.bound([channel_id, start], [channel_id, "\uffff"])); - const messages: DBMessageRecord[] = []; - for await (const c of cursor) { - messages.push(c.value); - } + if (!cursor) { + console.log("No messages found in range"); + return []; + } - return cacheRecords(messages, false); - }); + const messages: DBMessageRecord[] = []; + for await (const c of cursor) { + messages.push(c.value); + } + + return cacheRecords(messages); } export async function addMessageIDB(message: LoggedMessageJSON, status: DBMessageStatus) { stripTransientRenderState(message); - cachedMessages.set(message.id, message); - return withDb(void 0, async database => { - await database.put("messages", { - channel_id: message.channel_id, - message_id: message.id, - status, - message, - }); + if (!db) await initIDB(); + await db.put("messages", { + channel_id: message.channel_id, + message_id: message.id, + status, + message, }); + + cachedMessages.set(message.id, message); } export async function addMessagesBulkIDB(messages: LoggedMessageJSON[], status?: DBMessageStatus) { messages.forEach(stripTransientRenderState); + + const tx = db.transaction("messages", "readwrite"); + const { store } = tx; + + await Promise.all([ + ...messages.map(message => store.add({ + channel_id: message.channel_id, + message_id: message.id, + status: status ?? getMessageStatus(message), + message, + })), + tx.done + ]); + messages.forEach(message => cachedMessages.set(message.id, message)); - - return withDb(void 0, async database => { - const tx = database.transaction("messages", "readwrite"); - const { store } = tx; - - await Promise.all([ - ...messages.map(message => store.add({ - channel_id: message.channel_id, - message_id: message.id, - status: status ?? getMessageStatus(message), - message, - })), - tx.done - ]); - }); } export async function deleteMessageIDB(message_id: string) { - cachedMessages.delete(message_id); + await db.delete("messages", message_id); - return withDb(void 0, database => database.delete("messages", message_id)); + cachedMessages.delete(message_id); } export async function deleteMessagesBulkIDB(message_ids: string[]) { + const tx = db.transaction("messages", "readwrite"); + const { store } = tx; + + await Promise.all([...message_ids.map(id => store.delete(id)), tx.done]); message_ids.forEach(id => cachedMessages.delete(id)); - - return withDb(void 0, async database => { - const tx = database.transaction("messages", "readwrite"); - const { store } = tx; - await Promise.all([ - ...message_ids.map(id => store.delete(id)), - tx.done - ]); - }); } -export async function clearMessagesIDB() { +export async function clearMessagesIDB(showToast = true) { cachedMessages.clear(); + await db.clear("messages"); + if (!showToast) return; - return withDb(void 0, database => database.clear("messages")).then(() => { - Toasts.show({ - type: Toasts.Type.MESSAGE, - message: "Cleared message log database and cache.", - id: Toasts.genId() - }); + Toasts.show({ + type: Toasts.Type.MESSAGE, + message: "Cleared message log database and cache.", + id: Toasts.genId() }); } diff --git a/src/equicordplugins/messageLoggerEnhanced/index.tsx b/src/equicordplugins/messageLoggerEnhanced/index.tsx index cf5a537f8..e8b3cdab9 100644 --- a/src/equicordplugins/messageLoggerEnhanced/index.tsx +++ b/src/equicordplugins/messageLoggerEnhanced/index.tsx @@ -36,8 +36,15 @@ export const Flogger = new Logger("MessageLoggerEnhanced", "#f26c6c"); export const cacheSentMessages = new LimitedMap(); export const cl = classNameFactory("vc-msg-logger-enhanced-"); +let didClearLogsOnStartup = false; + const cacheThing = findByPropsLazy("commit", "getOrCreate"); +export async function clearLogs(showToast = true) { + await idb.clearMessagesIDB(showToast); + cacheSentMessages.clear(); +} + let oldGetMessage: typeof MessageStore.getMessage; const handledMessageIds = new Set(); @@ -396,6 +403,15 @@ export default definePlugin({ Native.init(); + if (settings.store.clearLogsOnRestart && !didClearLogsOnStartup) { + try { + await clearLogs(false); + didClearLogsOnStartup = true; + } catch (e) { + Flogger.error("Failed to clear logs on restart", e); + } + } + const { imageCacheDir, logsDir } = await Native.getSettings(); settings.store.imageCacheDir = imageCacheDir; settings.store.logsDir = logsDir; diff --git a/src/equicordplugins/messageLoggerEnhanced/settings.tsx b/src/equicordplugins/messageLoggerEnhanced/settings.tsx index c573acff8..fce7de8b8 100644 --- a/src/equicordplugins/messageLoggerEnhanced/settings.tsx +++ b/src/equicordplugins/messageLoggerEnhanced/settings.tsx @@ -11,10 +11,9 @@ import { OptionType } from "@utils/types"; import { Alerts, useState } from "@webpack/common"; import { Settings } from "Vencord"; -import { Native } from "."; +import { clearLogs, Native } from "."; import { ImageCacheDir, LogsDir } from "./components/FolderSelectInput"; import { openLogModal } from "./components/LogsModal"; -import { clearMessagesIDB } from "./db"; import { blockedExts } from "./list"; import { DEFAULT_IMAGE_CACHE_DIR } from "./utils/constants"; import { exportLogs, importLogs } from "./utils/settingsUtils"; @@ -260,6 +259,13 @@ export const settings = definePluginSettings({ component: ExportLogsButton }, + clearLogsOnRestart: { + type: OptionType.BOOLEAN, + description: "Clear logs when Discord restarts.", + default: false, + restartNeeded: true, + }, + openLogs: { type: OptionType.COMPONENT, description: "Open Logs", @@ -298,7 +304,7 @@ export const settings = definePluginSettings({ confirmText: "Clear", cancelText: "Cancel", onConfirm: async () => { - await clearMessagesIDB(); + await clearLogs(); }, })} > diff --git a/src/equicordplugins/messageLoggerEnhanced/utils/LimitedMap.ts b/src/equicordplugins/messageLoggerEnhanced/utils/LimitedMap.ts index a0b381e75..c027d3869 100644 --- a/src/equicordplugins/messageLoggerEnhanced/utils/LimitedMap.ts +++ b/src/equicordplugins/messageLoggerEnhanced/utils/LimitedMap.ts @@ -33,4 +33,8 @@ export class LimitedMap { get(key: K) { return this.map.get(key); } + + clear() { + this.map.clear(); + } } diff --git a/src/equicordplugins/messageLoggerEnhanced/utils/misc.ts b/src/equicordplugins/messageLoggerEnhanced/utils/misc.ts index 43c5f4166..66a264abf 100644 --- a/src/equicordplugins/messageLoggerEnhanced/utils/misc.ts +++ b/src/equicordplugins/messageLoggerEnhanced/utils/misc.ts @@ -24,6 +24,7 @@ import { DBMessageStatus } from "../db"; import { LoggedMessageJSON } from "../types"; import { DEFAULT_IMAGE_CACHE_DIR } from "./constants"; import { DISCORD_EPOCH } from "./index"; +import { memoize } from "./memoize"; const MessageClass: any = findLazy(m => m?.prototype?.isEdited); const AuthorClass = findLazy(m => m?.prototype?.getAvatarURL); @@ -92,7 +93,7 @@ export const mapTimestamp = (m: any) => { return m; }; -export const messageJsonToMessageClass = (log: { message: LoggedMessageJSON; }) => { +export const messageJsonToMessageClass = memoize((log: { message: LoggedMessageJSON; }) => { // console.time("message populate"); if (!log?.message) return null; @@ -122,7 +123,7 @@ export const messageJsonToMessageClass = (log: { message: LoggedMessageJSON; }) // console.timeEnd("message populate"); return message; -}; +}); export function parseJSON(json?: string | null) { try { diff --git a/src/equicordplugins/messageLoggerEnhanced/utils/settingsUtils.ts b/src/equicordplugins/messageLoggerEnhanced/utils/settingsUtils.ts index 1f89770c5..64c6d9d03 100644 --- a/src/equicordplugins/messageLoggerEnhanced/utils/settingsUtils.ts +++ b/src/equicordplugins/messageLoggerEnhanced/utils/settingsUtils.ts @@ -21,8 +21,8 @@ import { chooseFile as chooseFileWeb } from "@utils/web"; import { Toasts } from "@webpack/common"; import { showSaveFilePicker } from "native-file-system-adapter"; -import { Native } from ".."; -import { addMessagesBulkIDB, clearMessagesIDB, iterateAllMessagesIDB } from "../db"; +import { Native, clearLogs } from ".."; +import { addMessagesBulkIDB, iterateAllMessagesIDB } from "../db"; import { LoggedMessageJSON } from "../types"; export async function importLogs() { @@ -34,7 +34,7 @@ export async function importLogs() { for await (const logItems of iterateLogItems()) { if (!cleared) { - await clearMessagesIDB(); + await clearLogs(false); cleared = true; } diff --git a/src/equicordplugins/messageTranslate/index.tsx b/src/equicordplugins/messageTranslate/index.tsx index 6384b6ee9..68adbbd0b 100644 --- a/src/equicordplugins/messageTranslate/index.tsx +++ b/src/equicordplugins/messageTranslate/index.tsx @@ -4,64 +4,111 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -import { definePluginSettings } from "@api/Settings"; -import { Devs } from "@utils/constants"; -import definePlugin, { OptionType } from "@utils/types"; +import "./styles.css"; -const settings = definePluginSettings( - { - targetLanguage: { - type: OptionType.STRING, - description: "The language messages should be translated to", - default: "en", - restartNeeded: true - }, - confidenceRequirement: { - type: OptionType.STRING, - description: "The confidence required to translated the message. Best not to edit unless you know what you're doing", - default: "0.8", - restartNeeded: true - }, - }); +import { EquicordDevs } from "@utils/constants"; +import { classNameFactory } from "@utils/css"; +import definePlugin from "@utils/types"; +import { ChannelStore, FluxDispatcher, MessageStore, UserStore } from "@webpack/common"; -async function translateAPI(sourceLang: string, targetLang: string, text: string): Promise { +import { getIgnoredChannels, getIgnoredGuilds, getIgnoredUsers, settings } from "./settings"; +import { MessageWithContent } from "./types"; +import { clearCache, getCached, hasFailed, isInProgress, translate } from "./utils/translate"; - const url = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=${sourceLang}&tl=${targetLang}&dt=t&dj=1&q=${encodeURIComponent(text)}`; - const response = await fetch(url); - if (!response.ok) { - throw new Error(`Failed to translate "${text}" from ${sourceLang} to ${targetLang}: ${response.status} ${response.statusText}`); +const cl = classNameFactory("mt-"); +const translatedMessages = new Map(); + +function shouldTranslate(message: MessageWithContent): boolean { + if (!message.content || typeof message.content !== "string") return false; + if (hasFailed(message.id, message.content)) return false; + + if (settings.store.skipOwnMessages) { + const currentUserId = UserStore.getCurrentUser()?.id; + if (currentUserId && message.author?.id === currentUserId) return false; } - return await response.json(); + if (settings.store.skipBotMessages && message.author?.bot) return false; + + if (message.author && getIgnoredUsers().has(message.author.id)) return false; + if (message.channel_id && getIgnoredChannels().has(message.channel_id)) return false; + + const guildId = message.channel_id ? ChannelStore.getChannel(message.channel_id)?.guild_id : null; + if (guildId && getIgnoredGuilds().has(guildId)) return false; + + return true; } -async function TranslateMessage(string) { - // there may be a better way to do this lmao - if (string.includes("(Translated)")) return string; +function triggerReRender(message: MessageWithContent) { + const current = MessageStore.getMessage(message.channel_id, message.id); + if (!current) return; - const response = await translateAPI("auto", settings.store.targetLanguage, string); - - if (response.src === settings.store.targetLanguage || response.confidence < settings.store.confidenceRequirement) return string; - - const { sentences }: { sentences: { trans?: string; }[]; } = await response; - const translatedText = sentences.map(s => s?.trans).filter(Boolean).join(""); - - return `${translatedText} *(Translated)*`; + FluxDispatcher.dispatch({ + type: "MESSAGE_UPDATE", + message: current, + }); } export default definePlugin({ name: "MessageTranslate", - description: "Auto translate messages to your language", - authors: [Devs.Samwich], + description: "Auto translate messages to your language with caching, per-channel toggles, and more options.", + authors: [EquicordDevs.creations], settings, - TranslateMessage: TranslateMessage, + patches: [ { find: '.CUSTOM_GIFT?""', - replacement: { - match: /renderContentOnly:\i}=\i;/, - replace: "$&$self.TranslateMessage(arguments[0].message.content).then(response => arguments[0].message.content = response);" - } + replacement: [ + { + match: /message:(\i),message:\{id:\i\}.{0,200}renderContentOnly:\i\}=\i;/, + replace: "$&$1=$self.transformMessage($1);", + }, + { + match: /childrenMessageContent:(\i),/g, + replace: "childrenMessageContent:$self.wrapContent($1,arguments[0].message.id),", + }, + ], }, - ] + ], + + transformMessage(message: MessageWithContent): MessageWithContent { + if (!settings.store.autoTranslate || !shouldTranslate(message)) { + translatedMessages.delete(message.id); + return message; + } + + const cached = getCached(message.id); + if (cached) { + if (cached.original !== message.content) { + clearCache(message.id); + translatedMessages.delete(message.id); + return message; + } + translatedMessages.set(message.id, cached.sourceLang); + return Object.assign(Object.create(Object.getPrototypeOf(message)), message, { + content: cached.translated, + }) as MessageWithContent; + } + + translatedMessages.delete(message.id); + if (!isInProgress(message.id)) { + translate(message.id, message.content).then(result => { + if (result) triggerReRender(message); + }); + } + + return message; + }, + + wrapContent(content: any, messageId: string) { + const sourceLang = translatedMessages.get(messageId); + if (!sourceLang) return content; + return ( + <> + {content} + {settings.store.showIndicator && ( +
translated from {sourceLang}
+ )} + + ); + }, }); diff --git a/src/equicordplugins/messageTranslate/settings.ts b/src/equicordplugins/messageTranslate/settings.ts new file mode 100644 index 000000000..491240737 --- /dev/null +++ b/src/equicordplugins/messageTranslate/settings.ts @@ -0,0 +1,72 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { definePluginSettings } from "@api/Settings"; +import { OptionType } from "@utils/types"; + +export const settings = definePluginSettings({ + targetLanguage: { + type: OptionType.STRING, + description: "Target language code for translations (e.g. en, es, fr, de, ja).", + default: "en", + }, + confidenceRequirement: { + type: OptionType.NUMBER, + description: "Minimum confidence (0 to 1) required to show a translation.", + default: 0.8, + }, + autoTranslate: { + type: OptionType.BOOLEAN, + description: "Automatically translate messages as they appear.", + default: true, + }, + skipOwnMessages: { + type: OptionType.BOOLEAN, + description: "Do not translate your own messages.", + default: true, + }, + skipBotMessages: { + type: OptionType.BOOLEAN, + description: "Do not translate bot messages.", + default: false, + }, + ignoredGuilds: { + type: OptionType.STRING, + description: "Comma-separated list of server IDs to not translate in.", + default: "", + }, + ignoredChannels: { + type: OptionType.STRING, + description: "Comma-separated list of channel IDs to not translate in.", + default: "", + }, + ignoredUsers: { + type: OptionType.STRING, + description: "Comma-separated list of user IDs to not translate.", + default: "", + }, + showIndicator: { + type: OptionType.BOOLEAN, + description: "Append a small (translated) indicator to translated messages.", + default: true, + }, +}); + +function parseIdList(value: string): Set { + return new Set(value.split(",").map(s => s.trim()).filter(Boolean)); +} + +export function getIgnoredGuilds(): Set { + return parseIdList(settings.store.ignoredGuilds); +} + +export function getIgnoredChannels(): Set { + return parseIdList(settings.store.ignoredChannels); +} + +export function getIgnoredUsers(): Set { + return parseIdList(settings.store.ignoredUsers); +} diff --git a/src/equicordplugins/messageTranslate/styles.css b/src/equicordplugins/messageTranslate/styles.css new file mode 100644 index 000000000..14cd820e6 --- /dev/null +++ b/src/equicordplugins/messageTranslate/styles.css @@ -0,0 +1,9 @@ +.mt-indicator { + color: var(--text-muted); + font-style: italic; + font-weight: 400; + font-size: 0.7rem; + line-height: 1; + margin-top: 0.15em; + opacity: 0.75; +} diff --git a/src/equicordplugins/messageTranslate/types.ts b/src/equicordplugins/messageTranslate/types.ts new file mode 100644 index 000000000..d6cb8f1f5 --- /dev/null +++ b/src/equicordplugins/messageTranslate/types.ts @@ -0,0 +1,21 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import type { Message } from "@vencord/discord-types"; + +export interface TranslateResponse { + src: string; + confidence: number; + sentences: { trans?: string; }[]; +} + +export interface CachedTranslation { + original: string; + translated: string; + sourceLang: string; +} + +export type MessageWithContent = Message & { content: string; }; diff --git a/src/equicordplugins/messageTranslate/utils/translate.ts b/src/equicordplugins/messageTranslate/utils/translate.ts new file mode 100644 index 000000000..612b26c1a --- /dev/null +++ b/src/equicordplugins/messageTranslate/utils/translate.ts @@ -0,0 +1,81 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Logger } from "@utils/Logger"; + +import { settings } from "../settings"; +import { CachedTranslation, TranslateResponse } from "../types"; + +const logger = new Logger("MessageTranslate"); + +const translationCache = new Map(); +const inProgress = new Set(); +const failed = new Map(); + +export function getCached(messageId: string): CachedTranslation | undefined { + return translationCache.get(messageId); +} + +export function hasFailed(messageId: string, text: string): boolean { + return failed.get(messageId) === text; +} + +export function isInProgress(messageId: string): boolean { + return inProgress.has(messageId); +} + +export function clearCache(messageId: string) { + translationCache.delete(messageId); + failed.delete(messageId); +} + +async function fetchTranslation(text: string, targetLang: string): Promise { + const url = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=${targetLang}&dt=t&dj=1&q=${encodeURIComponent(text)}`; + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`Translation API returned ${response.status} ${response.statusText}`); + } + + return await response.json(); +} + +export async function translate(messageId: string, text: string): Promise { + if (inProgress.has(messageId)) return null; + if (translationCache.has(messageId)) return translationCache.get(messageId)!; + + inProgress.add(messageId); + + try { + const targetLang = settings.store.targetLanguage; + const response = await fetchTranslation(text, targetLang); + + if (response.src === targetLang || response.confidence < settings.store.confidenceRequirement) { + failed.set(messageId, text); + return null; + } + + const translatedText = response.sentences.map(s => s.trans).filter(Boolean).join(""); + if (!translatedText || translatedText === text) { + failed.set(messageId, text); + return null; + } + + const entry: CachedTranslation = { + original: text, + translated: translatedText, + sourceLang: response.src, + }; + translationCache.set(messageId, entry); + return entry; + } catch (e) { + logger.error("Translation failed", e); + failed.set(messageId, text); + return null; + } finally { + inProgress.delete(messageId); + } +} diff --git a/src/equicordplugins/musicControls/spotify/spotifyStyles.css b/src/equicordplugins/musicControls/spotify/spotifyStyles.css index 8f54fa623..d33f4fd47 100644 --- a/src/equicordplugins/musicControls/spotify/spotifyStyles.css +++ b/src/equicordplugins/musicControls/spotify/spotifyStyles.css @@ -70,7 +70,7 @@ position: absolute; top: 50%; left: 50%; - transform: translate(-50%, -50%); + transform: translate(-55%, -50%); } .vc-spotify-button-row { diff --git a/src/equicordplugins/musicControls/tidal/tidalStyles.css b/src/equicordplugins/musicControls/tidal/tidalStyles.css index e5e840cb1..746f6c680 100644 --- a/src/equicordplugins/musicControls/tidal/tidalStyles.css +++ b/src/equicordplugins/musicControls/tidal/tidalStyles.css @@ -135,7 +135,7 @@ position: absolute; top: 50%; left: 50%; - transform: translate(-50%, -50%); + transform: translate(-55%, -50%); } #eq-tdl-info-wrapper { diff --git a/src/equicordplugins/questify/index.tsx b/src/equicordplugins/questify/index.tsx index 616f3ab16..cf0d1c4ab 100644 --- a/src/equicordplugins/questify/index.tsx +++ b/src/equicordplugins/questify/index.tsx @@ -1447,7 +1447,7 @@ export default definePlugin({ group: true, replacement: [ { - match: /(?<=\i\)&&"xs"===\i;)/, + match: /(?<=null&&"xs"===\i;)/, replace: "const shouldHideMembersListActivelyPlayingIcon=$self.shouldHideMembersListActivelyPlayingIcon();" }, { diff --git a/src/equicordplugins/vcNarratorCustom/util.ts b/src/equicordplugins/vcNarratorCustom/util.ts index 476d2d2e0..5d27f5e3e 100644 --- a/src/equicordplugins/vcNarratorCustom/util.ts +++ b/src/equicordplugins/vcNarratorCustom/util.ts @@ -198,9 +198,6 @@ const DB_VERSION = 2; const VOICES_STORE = "voices"; -import { Logger } from "@utils/Logger"; - -const logger = new Logger("VcNarratorCustom"); const META_STORE = "voices_meta"; type VoiceMeta = { @@ -209,76 +206,49 @@ type VoiceMeta = { lastAccess: number; }; -let openDbPromise: Promise | undefined; -let loggedFailure = false; +function openDB(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = event => { + const db = request.result; + const upgradeTx = request.transaction; + if (!db.objectStoreNames.contains(VOICES_STORE)) { + db.createObjectStore(VOICES_STORE); + } + if (!db.objectStoreNames.contains(META_STORE)) { + const meta = db.createObjectStore(META_STORE); + meta.createIndex("by_lastAccess", "lastAccess"); + } -function handleDbFailure(error: unknown) { - if (loggedFailure) return; - loggedFailure = true; - logger.error("Voice cache storage unavailable, falling back to memory.", error); -} - -function openDB(): Promise { - if (!openDbPromise) { - openDbPromise = new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, DB_VERSION); - request.onupgradeneeded = event => { - const db = request.result; - const upgradeTx = request.transaction; - if (!db.objectStoreNames.contains(VOICES_STORE)) { - db.createObjectStore(VOICES_STORE); - } - if (!db.objectStoreNames.contains(META_STORE)) { - const meta = db.createObjectStore(META_STORE); - meta.createIndex("by_lastAccess", "lastAccess"); - } - - if (upgradeTx && (event?.oldVersion ?? 0) < 2) { - const now = Date.now(); - const voices = upgradeTx.objectStore(VOICES_STORE); - const meta = upgradeTx.objectStore(META_STORE); - const cursorReq = voices.openCursor(); - cursorReq.onsuccess = () => { - const cursor = cursorReq.result; - if (!cursor) return; - const blob = cursor.value as Blob | undefined; - meta.put( - { - size: blob?.size ?? 0, - createdAt: now, - lastAccess: now, - } satisfies VoiceMeta, - String(cursor.key) - ); - cursor.continue(); - }; - } - }; - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); - }).then(db => db).catch(error => { - handleDbFailure(error); - return null; - }); - } - - return openDbPromise; -} - -async function withDb(fallback: T, callback: (db: IDBDatabase) => Promise): Promise { - const db = await openDB(); - if (!db) return fallback; - - try { - return await callback(db); - } catch (error) { - handleDbFailure(error); - return fallback; - } + if (upgradeTx && (event?.oldVersion ?? 0) < 2) { + const now = Date.now(); + const voices = upgradeTx.objectStore(VOICES_STORE); + const meta = upgradeTx.objectStore(META_STORE); + const cursorReq = voices.openCursor(); + cursorReq.onsuccess = () => { + const cursor = cursorReq.result; + if (!cursor) return; + const blob = cursor.value as Blob | undefined; + meta.put( + { + size: blob?.size ?? 0, + createdAt: now, + lastAccess: now, + } satisfies VoiceMeta, + String(cursor.key) + ); + cursor.continue(); + }; + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); } export async function getPersistentTtsCacheStats(): Promise<{ bytes: number; entries: number; }> { - return withDb<{ bytes: number; entries: number; }>({ bytes: 0, entries: 0 }, db => new Promise<{ bytes: number; entries: number; }>((resolve, reject) => { + const db = await openDB(); + return new Promise((resolve, reject) => { const tx = db.transaction(META_STORE, "readonly"); const store = tx.objectStore(META_STORE); @@ -298,7 +268,7 @@ export async function getPersistentTtsCacheStats(): Promise<{ bytes: number; ent cursor.continue(); }; cursorReq.onerror = () => reject(cursorReq.error); - })); + }); } export async function clearTtsCache(): Promise { @@ -309,17 +279,19 @@ export async function clearTtsCache(): Promise { } ttsCache.clear(); - await withDb(void 0, db => new Promise((resolve, reject) => { + const db = await openDB(); + return new Promise((resolve, reject) => { const tx = db.transaction([VOICES_STORE, META_STORE], "readwrite"); tx.objectStore(VOICES_STORE).clear(); tx.objectStore(META_STORE).clear(); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); - })); + }); } async function trimPersistentCacheToMaxBytes(maxBytes = PERSISTENT_TTS_CACHE_MAX_BYTES): Promise { - await withDb(void 0, db => new Promise((resolve, reject) => { + const db = await openDB(); + return new Promise((resolve, reject) => { const tx = db.transaction([VOICES_STORE, META_STORE], "readwrite"); const voicesStore = tx.objectStore(VOICES_STORE); const metaStore = tx.objectStore(META_STORE); @@ -372,11 +344,12 @@ async function trimPersistentCacheToMaxBytes(maxBytes = PERSISTENT_TTS_CACHE_MAX tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); - })); + }); } export async function getCachedVoiceFromDB(cacheKey: string): Promise { - return withDb(null, db => new Promise((resolve, reject) => { + const db = await openDB(); + return new Promise((resolve, reject) => { const tx = db.transaction([VOICES_STORE, META_STORE], "readwrite"); const voicesStore = tx.objectStore(VOICES_STORE); const metaStore = tx.objectStore(META_STORE); @@ -400,11 +373,12 @@ export async function getCachedVoiceFromDB(cacheKey: string): Promise reject(request.error); - })); + }); } export async function setCachedVoiceInDB(cacheKey: string, blob: Blob): Promise { - await withDb(void 0, db => new Promise((resolve, reject) => { + const db = await openDB(); + await new Promise((resolve, reject) => { const tx = db.transaction([VOICES_STORE, META_STORE], "readwrite"); const voicesStore = tx.objectStore(VOICES_STORE); const metaStore = tx.objectStore(META_STORE); @@ -418,7 +392,7 @@ export async function setCachedVoiceInDB(cacheKey: string, blob: Blob): Promise< tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); - })); + }); await trimPersistentCacheToMaxBytes(); } diff --git a/src/equicordplugins/wallpaperFree/index.tsx b/src/equicordplugins/wallpaperFree/index.tsx index 9545ef8e7..cfc8fe99e 100644 --- a/src/equicordplugins/wallpaperFree/index.tsx +++ b/src/equicordplugins/wallpaperFree/index.tsx @@ -39,7 +39,7 @@ export default definePlugin({ group: true, replacement: [ { - match: /return.{1,150},(?=keyboardModeEnabled)/, + match: /return.{1,200},(?=keyboardModeEnabled)/, replace: "const vcWallpaperFreeUrl=$self.WallpaperState(arguments[0].channel);$&vcWallpaperFreeUrl," }, { diff --git a/src/plugins/ircColors/index.ts b/src/plugins/ircColors/index.ts index bd3b72e8b..c1d41fbfe 100644 --- a/src/plugins/ircColors/index.ts +++ b/src/plugins/ircColors/index.ts @@ -21,7 +21,7 @@ import { getCustomColorString } from "@equicordplugins/customUserColors"; import { hash as h64 } from "@intrnl/xxhash64"; import { Devs } from "@utils/constants"; import definePlugin, { OptionType } from "@utils/types"; -import { useMemo } from "@webpack/common"; +import { useMemo, UserStore } from "@webpack/common"; // Calculate a CSS color string based on the user ID function calculateNameColorForUser(id?: string) { @@ -121,9 +121,25 @@ export default definePlugin({ return colorString; } - return (!settings.store.applyColorOnlyToUsersWithoutColor || !colorString) + const dmColor = (!settings.store.applyColorOnlyToUsersWithoutColor || !colorString) ? color : colorString; + + // guarantee minimum difference in dms + if (context?.channel?.isPrivate?.() && dmColor && userId) { + const currentUserId = UserStore.getCurrentUser()?.id; + if (currentUserId && userId !== currentUserId) { + const currentUserColor = Number(h64(currentUserId) % 360n); + const otherUserColor = Number(h64(userId) % 360n); + const colorDiff = Math.min(Math.abs(currentUserColor - otherUserColor), 360 - Math.abs(currentUserColor - otherUserColor)); + if (colorDiff < 45) { + const newColor = (otherUserColor + 180) % 360; + return `hsl(${newColor}, 100%, ${settings.store.lightness}%)`; + } + } + } + + return dmColor; }, calculateNameColorForListContext(context: any) { diff --git a/src/plugins/noDefaultHangStatus/README.md b/src/plugins/noDefaultHangStatus/README.md deleted file mode 100644 index e6bc9f83a..000000000 --- a/src/plugins/noDefaultHangStatus/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# NoDefaultHangStatus - -Disable the default hang status when joining voice channels - -![Visualization](https://github.com/Vendicated/Vencord/assets/24937357/329a9742-236f-48f7-94ff-c3510eca505a) diff --git a/src/plugins/noDefaultHangStatus/index.ts b/src/plugins/noDefaultHangStatus/index.ts deleted file mode 100644 index 1301f0d25..000000000 --- a/src/plugins/noDefaultHangStatus/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2024 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -import { migrateSettingToPlugin } from "@api/Settings"; -import { Devs } from "@utils/constants"; -import definePlugin from "@utils/types"; - -migrateSettingToPlugin("NoDefaultHangStatus", "EquicordHelper", "noDefaultHangStatus"); -export default definePlugin({ - name: "NoDefaultHangStatus", - description: "Disable the default hang status when joining voice channels", - authors: [Devs.D3SOX], - - patches: [ - { - find: ".CHILLING)", - replacement: { - match: /{enableHangStatus:(\i),/, - replace: "{_enableHangStatus:$1=false," - } - } - ] -}); diff --git a/src/plugins/noMosaic/index.ts b/src/plugins/noMosaic/index.ts index 4ec64cc39..c52e19a6b 100644 --- a/src/plugins/noMosaic/index.ts +++ b/src/plugins/noMosaic/index.ts @@ -29,8 +29,8 @@ export default definePlugin({ { find: '"PLAINTEXT_PREVIEW":"OTHER"', replacement: { - match: /=>"IMAGE"===\i\|\|"VIDEO"===\i(?:\|\|("VISUAL_PLACEHOLDER"===\i))?;/, - replace: (_, visualPlaceholderPred) => visualPlaceholderPred != null ? `=>${visualPlaceholderPred};` : "=>false;" + match: /return"IMAGE"===\i\|\|"VIDEO"===\i(?:\|\|("VISUAL_PLACEHOLDER"===\i)\|\|\i&&"CLIP"===\i)?/, + replace: (_, visualPlaceholderPred) => visualPlaceholderPred != null ? `return ${visualPlaceholderPred}` : "return false" } }, { diff --git a/src/plugins/viewIcons/index.tsx b/src/plugins/viewIcons/index.tsx index 5dc2d0eda..573757bbf 100644 --- a/src/plugins/viewIcons/index.tsx +++ b/src/plugins/viewIcons/index.tsx @@ -196,7 +196,7 @@ export default definePlugin({ patches: [ // Avatar component used in User DMs "User Profile" popup in the right and User Profile Modal pfp { - find: "imageClassName:null!=", + find: ".AVATAR),{trackUserProfileAction:", replacement: { match: /avatarSrc:(\i),eventHandlers:(\i).+?"div",.{0,100}className:\i,/, replace: "$&style:{cursor:\"pointer\"},onClick:()=>{$self.openAvatar($1)},", diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 1540f350b..96f90bd78 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1284,6 +1284,10 @@ export const EquicordDevs = Object.freeze({ name: "Ape", id: 722832640360841308n, }, + Kiri: { + name: "Kiri", + id: 310525496771346434n, + }, } satisfies Record); // iife so #__PURE__ works correctly diff --git a/src/webpack/common/components.ts b/src/webpack/common/components.ts index 2a1ca4efb..a5723c001 100644 --- a/src/webpack/common/components.ts +++ b/src/webpack/common/components.ts @@ -58,8 +58,8 @@ export const TooltipContainer = TooltipContainerComponent as never; export const TextInput = waitForComponent("TextInput", filters.componentByCode("#{intl::MAXIMUM_LENGTH_ERROR}", '"input"')); export const TextArea = waitForComponent("TextArea", filters.componentByCode("this.getPaddingRight()},id:")); -export const Select = waitForComponent("Select", filters.componentByCode('"Select"')); -export const SearchableSelect = waitForComponent("SearchableSelect", filters.componentByCode('"SearchableSelect"')); +export const Select = waitForComponent("Select", filters.componentByCode('selectionMode:"single",onSelectionChange:', "isSelected:")); +export const SearchableSelect = waitForComponent("SearchableSelect", filters.componentByCode('?"multiple":"single"')); export const Slider = waitForComponent("Slider", filters.componentByCode("markDash", "this.renderMark(")); export const Popout = waitForComponent("Popout", filters.componentByCode("ref:this.ref,", "renderPopout:this.renderPopout,")); export const Dialog = waitForComponent("Dialog", filters.componentByCode('role:"dialog",tabIndex:-1'));