diff --git a/package.json b/package.json index 2e1878f1b..d8962f08a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "equicord", "private": "true", - "version": "1.14.5.1", + "version": "1.14.6.0", "description": "The other cutest Discord client mod", "homepage": "https://github.com/Equicord/Equicord#readme", "bugs": { @@ -82,6 +82,7 @@ "highlight.js": "11.11.1", "html-minifier-terser": "^7.2.0", "moment": "^2.22.2", + "p-limit": "^7.3.0", "puppeteer-core": "^24.30.0", "standalone-electron-types": "^34.2.0", "stylelint": "^16.25.0", @@ -95,7 +96,7 @@ "typescript-transform-paths": "^3.5.5", "zip-local": "^0.3.5" }, - "packageManager": "pnpm@10.30.3", + "packageManager": "pnpm@10.32.1", "pnpm": { "patchedDependencies": { "@types/less@3.0.8": "patches/@types__less@3.0.8.patch" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c59fd41ef..300150feb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,6 +140,9 @@ importers: moment: specifier: ^2.22.2 version: 2.30.1 + p-limit: + specifier: ^7.3.0 + version: 7.3.0 puppeteer-core: specifier: ^24.30.0 version: 24.30.0 @@ -1038,6 +1041,7 @@ packages: basic-ftp@5.0.5: resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==} engines: {node: '>=10.0.0'} + deprecated: Security vulnerability fixed in 5.2.0, please upgrade brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -2069,6 +2073,10 @@ packages: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-limit@7.3.0: + resolution: {integrity: sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==} + engines: {node: '>=20'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} @@ -2684,6 +2692,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + zip-local@0.3.5: resolution: {integrity: sha512-GRV3D5TJY+/PqyeRm5CYBs7xVrKTKzljBoEXvocZu0HJ7tPEcgpSOYa2zFIsCZWgKWMuc4U3yMFgFkERGFIB9w==} @@ -4669,6 +4681,10 @@ snapshots: dependencies: yocto-queue: 0.1.0 + p-limit@7.3.0: + dependencies: + yocto-queue: 1.2.2 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 @@ -5429,6 +5445,8 @@ snapshots: yocto-queue@0.1.0: {} + yocto-queue@1.2.2: {} + zip-local@0.3.5: dependencies: async: 1.5.2 diff --git a/src/components/BaseText.css b/src/components/BaseText.css index ebaba6455..f86bd1ba7 100644 --- a/src/components/BaseText.css +++ b/src/components/BaseText.css @@ -1,6 +1,8 @@ .vc-text-base { font-family: var(--font-primary); - color: var(--text-default); + line-height: normal; + + /* Discord puts an insane default margin on p tags, so reset that here */ margin: 0; } @@ -24,3 +26,7 @@ .vc-text-tabular-numbers { font-variant-numeric: tabular-nums; } + +.vc-text-defaultColor { + color: var(--text-default); +} \ No newline at end of file diff --git a/src/components/BaseText.tsx b/src/components/BaseText.tsx index 49a81b05c..dcc7512f1 100644 --- a/src/components/BaseText.tsx +++ b/src/components/BaseText.tsx @@ -82,6 +82,7 @@ export type BaseTextProps = ComponentPropsWithoutRe selectable?: boolean; lineClamp?: number; tabularNumbers?: boolean; + defaultColor?: boolean; }; export function BaseText(props: BaseTextProps): ReactNode { @@ -93,6 +94,7 @@ export function BaseText(props: BaseTextProps): Re selectable = false, lineClamp, tabularNumbers = false, + defaultColor = true, children, className, style, @@ -107,6 +109,7 @@ export function BaseText(props: BaseTextProps): Re lineClamp === 1 && textCls("line-clamp-1"), lineClamp != null && lineClamp > 1 && textCls("line-clamp"), tabularNumbers && textCls("tabular-numbers"), + defaultColor && textCls("defaultColor"), className )} style={{ diff --git a/src/components/settings/tabs/updater/Components.tsx b/src/components/settings/tabs/updater/Components.tsx index 8d9f34afb..d6f38bf4f 100644 --- a/src/components/settings/tabs/updater/Components.tsx +++ b/src/components/settings/tabs/updater/Components.tsx @@ -81,7 +81,6 @@ export function Updatable(props: CommonProps) { <> + + + )} + + ); +} + function Updater() { const settings = useSettings(["autoUpdate", "autoUpdateNotification"]); @@ -54,6 +85,7 @@ function Updater() { return ( + Update Preferences Control how Equicord keeps itself up to date. You can choose to update automatically in the background or be notified when new updates are available. diff --git a/src/debug/loadLazyChunks.ts b/src/debug/loadLazyChunks.ts index 32ab77041..44e483faf 100644 --- a/src/debug/loadLazyChunks.ts +++ b/src/debug/loadLazyChunks.ts @@ -9,6 +9,7 @@ import { canonicalizeMatch } from "@utils/patches"; import { ModuleFactory } from "@vencord/discord-types/webpack"; import * as Webpack from "@webpack"; import { wreq } from "@webpack"; +import pLimit from "p-limit"; import { AnyModuleFactory } from "webpack"; function getWebpackChunkMap() { @@ -29,20 +30,9 @@ function getWebpackChunkMap() { return v; } -async function promiseAllSettledBatched(tasks: (() => Promise)[], batchSize = 8): Promise { - const results: T[] = []; - for (let i = 0; i < tasks.length; i += batchSize) { - const batch = tasks.slice(i, i + batchSize); - const settled = await Promise.allSettled(batch.map(t => t())); - for (const r of settled) { - if (r.status === "fulfilled") results.push(r.value); - } - } - return results; -} - export async function loadLazyChunks() { const LazyChunkLoaderLogger = new Logger("LazyChunkLoader"); + const queue = pLimit(50); try { LazyChunkLoaderLogger.log("Loading all chunks..."); @@ -71,11 +61,15 @@ export async function loadLazyChunks() { const shouldForceDefer = false; - await promiseAllSettledBatched(Array.from(lazyChunks).map(([, rawChunkIds, entryPoint]) => async () => { - const chunkIds = rawChunkIds ? Array.from(rawChunkIds.matchAll(Webpack.ChunkIdsRegex)).map(m => { - const numChunkId = Number(m[1]); - return Number.isNaN(numChunkId) ? m[1] : numChunkId; - }) : []; + await Promise.all(Array.from(lazyChunks).map(async ([, rawChunkIds, entryPoint]) => { + const chunkIds = rawChunkIds + ?.matchAll(Webpack.ChunkIdsRegex) + .map(m => { + const numChunkId = Number(m[1]); + return Number.isNaN(numChunkId) ? m[1] : numChunkId; + }) + .toArray() + ?? []; if (chunkIds.length === 0) { return; @@ -96,9 +90,11 @@ export async function loadLazyChunks() { if (wreq.u(id) == null || wreq.u(id) === "undefined.js") continue; - const isWorkerAsset = await fetch(wreq.p + wreq.u(id)) - .then(r => r.text()) - .then(t => /importScripts\(|self\.postMessage/.test(t)); + const isWorkerAsset = await queue(() => + fetch(wreq.p + wreq.u(id)) + .then(r => r.text()) + .then(t => /importScripts\(|self\.postMessage/.test(t)) + ); if (isWorkerAsset) { invalidChunks.add(id); @@ -116,9 +112,9 @@ export async function loadLazyChunks() { })); // Loads all found valid chunk groups - await promiseAllSettledBatched( + await Promise.all( Array.from(validChunkGroups) - .map(([chunkIds]) => () => + .map(([chunkIds]) => Promise.all(chunkIds.map(id => wreq.e(id))) ) ); @@ -194,7 +190,7 @@ export async function loadLazyChunks() { return !(validChunks.has(id) || invalidChunks.has(id)); }); - await promiseAllSettledBatched(chunksLeft.map(id => async () => { + await Promise.all(chunksLeft.map(async id => queue(async () => { const isWorkerAsset = await fetch(wreq.p + wreq.u(id)) .then(r => r.text()) .then(t => /importScripts\(|self\.postMessage/.test(t)); @@ -203,7 +199,7 @@ export async function loadLazyChunks() { if (!isWorkerAsset) { await wreq.e(id); } - })); + }))); LazyChunkLoaderLogger.log("Finished loading all chunks!"); } catch (e) { diff --git a/src/equicordplugins/_api/userArea.ts b/src/equicordplugins/_api/userArea.ts index 53cf52cde..112ac9bce 100644 --- a/src/equicordplugins/_api/userArea.ts +++ b/src/equicordplugins/_api/userArea.ts @@ -19,7 +19,7 @@ export default definePlugin({ patches: [ { - find: ".DISPLAY_NAME_STYLES_COACHMARK),", + find: ".DISPLAY_NAME_STYLES_COACHMARK)", replacement: [ { match: /(?<=className:(\i)\.\i,style:\i,)children:\[/, diff --git a/src/equicordplugins/declutter/index.tsx b/src/equicordplugins/declutter/index.tsx index 0eb0d2a88..98d28384c 100644 --- a/src/equicordplugins/declutter/index.tsx +++ b/src/equicordplugins/declutter/index.tsx @@ -172,7 +172,7 @@ export default definePlugin({ }, { // Always show username - find: ".DISPLAY_NAME_STYLES_COACHMARK),", + find: ".DISPLAY_NAME_STYLES_COACHMARK)", replacement: { match: /hoverText:(\i),forceHover:\i,children:/g, replace: "hoverText:$1,forceHover:!0,children:" diff --git a/src/equicordplugins/musicControls/index.tsx b/src/equicordplugins/musicControls/index.tsx index dcf547e71..642a67cfb 100644 --- a/src/equicordplugins/musicControls/index.tsx +++ b/src/equicordplugins/musicControls/index.tsx @@ -52,7 +52,7 @@ export default definePlugin({ patches: [ { - find: ".DISPLAY_NAME_STYLES_COACHMARK),", + find: ".DISPLAY_NAME_STYLES_COACHMARK)", replacement: { // react.jsx)(AccountPanel, { ..., showTaglessAccountPanel: blah }) match: /(?<=\i\.jsxs?\)\()(\i),{(?=[^}]*?userTag:\i,occluded:)/, diff --git a/src/equicordplugins/questify/index.tsx b/src/equicordplugins/questify/index.tsx index 72f8d2da3..1390cc486 100644 --- a/src/equicordplugins/questify/index.tsx +++ b/src/equicordplugins/questify/index.tsx @@ -1269,7 +1269,7 @@ export default definePlugin({ group: true, replacement: [ { - match: /(?<=resetSortingFiltering\(\)},\[\]\);)/, + match: /(?<=scrollToQuest\(\i\)\}\)\},\[\]\);)/, replace: "const shouldHideSponsoredQuestBanner=$self.shouldHideSponsoredQuestBanner();" }, { diff --git a/src/equicordplugins/songSpotlight.desktop/index.tsx b/src/equicordplugins/songSpotlight.desktop/index.tsx index ed8d84c12..b6b44da53 100644 --- a/src/equicordplugins/songSpotlight.desktop/index.tsx +++ b/src/equicordplugins/songSpotlight.desktop/index.tsx @@ -25,7 +25,7 @@ export default definePlugin({ patches: [ // Personal profile popout { - find: ".WIDGETS_USER_PROFILE_ACCOUNT_POPOUT_NEW_BADGE]", + find: '"UserProfileAccountPopout"', replacement: { match: /user:(\i),bio:.{0,60}}\)/, replace: "$&,$self.renderProfileSongs({userId:$1.id})", diff --git a/src/equicordplugins/vcNarratorCustom/index.tsx b/src/equicordplugins/vcNarratorCustom/index.tsx index f4711d878..c9d80123c 100644 --- a/src/equicordplugins/vcNarratorCustom/index.tsx +++ b/src/equicordplugins/vcNarratorCustom/index.tsx @@ -28,6 +28,7 @@ import { import { wordsToTitle } from "@utils/text"; import definePlugin, { OptionType } from "@utils/types"; import type { User } from "@vencord/discord-types"; +import { ChannelType } from "@vencord/discord-types/enums"; import { Button as DiscordButton, ChannelStore, @@ -45,6 +46,7 @@ import { } from "@webpack/common"; import { + addUserToList, addUserToStateChangeFilterList, clean, clearTtsCache, @@ -53,7 +55,9 @@ import { getPersistentTtsCacheStats, getVoiceForUser, parseStateChangeFilterList, + parseUserIdList, parseUserVoiceMap, + removeUserFromList, removeUserFromStateChangeFilterList, removeUserVoiceFromMap, setCachedVoiceInDB, @@ -64,12 +68,6 @@ import { const cl = classNameFactory("vc-narrator-"); -/* - * TTS API maintained by example-git - * The original TikTok TTS API went offline, so I set up a new working cloudflare worker. - * I made sure it's intentionally rate-limited to keep it feasible. Please don't abuse it - * so it can stay available for plugins like this one. Thanks! - example-git - */ const API_BASE = "https://tiktok-tts-aio.exampleuser.workers.dev"; type LastApiCallStatus = { @@ -189,10 +187,6 @@ interface VoiceState { stream?: boolean; } -// Two-queue system for TTS playback: -// - mainQueue: Non-interruptable messages (user names, join/leave announcements) -// - stateQueue: Interruptable messages (mute/deafen/stream state changes) -// This allows rapid state changes to interrupt each other while preserving important announcements interface QueueItem { text: string; userId?: string; @@ -207,8 +201,6 @@ let currentAudio: HTMLAudioElement | null = null; let currentInterruptKey: string | undefined; let currentStop: (() => void) | null = null; -// Pre-cache common state action phrases on plugin start for faster playback -// Uses phonetic spellings for muted/deafened to ensure clear pronunciation across all voices const COMMON_ACTIONS = ["myooted", "un-myooted", "deafind", "un-deafind", "started streaming", "stopped streaming"]; const DEFAULT_VOICE = "en_us_001"; let preCacheInitialized = false; @@ -217,26 +209,21 @@ async function preCacheCommonActions() { if (preCacheInitialized) return; preCacheInitialized = true; - // Initial delay before starting pre-cache to not impact startup await new Promise(r => setTimeout(r, 3000)); - // Pre-fetch common action words in DEFAULT voice (universal for all users) for (const action of COMMON_ACTIONS) { const cacheKey = `${DEFAULT_VOICE}_${action}`; - // 1. Check in-memory cache first if (ttsCache.has(cacheKey)) continue; - // 2. Check persistent IndexedDB cache - load into memory if found try { const cachedBlob = await getCachedVoiceFromDB(cacheKey); if (cachedBlob) { ttsCache.set(cacheKey, URL.createObjectURL(cachedBlob)); continue; } - } catch { /* ignore */ } + } catch { } - // 3. Fetch from API and store in both memory and persistent DB try { const response = await fetch(`${API_BASE}/api/generate`, { method: "POST", @@ -252,18 +239,14 @@ async function preCacheCommonActions() { } const blob = new Blob([binaryData], { type: "audio/mpeg" }); - // Store in memory cache ttsCache.set(cacheKey, URL.createObjectURL(blob)); - // Persist to IndexedDB for future sessions await setCachedVoiceInDB(cacheKey, blob); } } catch (e) { recordApiError(e); - /* ignore pre-cache failures */ } - // Space out API requests to avoid rate limiting (2 seconds between each) await new Promise(r => setTimeout(r, 2000)); } } @@ -281,8 +264,6 @@ function interruptPlayback(key: string) { async function processQueue() { if (isSpeaking) return; - // Optimization: if main queue has intro and state queue has action with no other items, - // and they're for the same user, combine them into single API call if (mainQueue.length === 1 && stateQueue.length === 1 && mainQueue[0].userId === stateQueue[0].userId && !mainQueue[0].interruptKey) { @@ -291,8 +272,8 @@ async function processQueue() { const combined: QueueItem = { text: `${intro.text} ${action.text}`, userId: intro.userId, - interruptKey: action.interruptKey, // Keep action's interrupt key so rapid switches can interrupt - useDefaultVoice: false, // Combined uses user voice for the intro part + interruptKey: action.interruptKey, + useDefaultVoice: false, }; mainQueue.push(combined); } @@ -308,7 +289,6 @@ async function processQueue() { console.error("TTS Error:", e); } - // Delay between messages - longer for state changes to allow interruption const delay = item.interruptKey ? 800 : 500; setTimeout(() => { isSpeaking = false; @@ -320,10 +300,8 @@ async function processQueue() { function queueSpeak(text: string, userId?: string, interruptKey?: string, queue: "main" | "state" = "main", useDefaultVoice?: boolean) { if (text.trim().length === 0) return; - // Anti-spam: cap queue size const targetQueue = queue === "state" ? stateQueue : mainQueue; if (targetQueue.length >= 5) { - // If queue is full, drop new to stop the spam wave return; } @@ -352,13 +330,12 @@ async function speak(text: string, userId?: string, interruptKey?: string, useDe resolve(); }; - // Helper to play audio and resolve promise when done const playAudio = (url: string) => { const audio = new Audio(url); audio.volume = settings.store.volume; audio.playbackRate = settings.store.rate; audio.onended = onEnd; - audio.onerror = onEnd; // Resolve even on error to unblock queue + audio.onerror = onEnd; currentAudio = audio; currentInterruptKey = interruptKey; currentStop = onEnd; @@ -366,7 +343,6 @@ async function speak(text: string, userId?: string, interruptKey?: string, useDe }; void (async () => { - // Use default voice for universal actions, otherwise user-specific voice const voice = useDefaultVoice ? DEFAULT_VOICE : getVoiceForUser(userId, { @@ -375,16 +351,13 @@ async function speak(text: string, userId?: string, interruptKey?: string, useDe defaultVoice: DEFAULT_VOICE, }); - // Create a unique cache key using the voice and text. const cacheKey = `${voice}_${text}`; - // 1. Check the in-memory cache (fast check) if (ttsCache.has(cacheKey)) { playAudio(ttsCache.get(cacheKey)!); return; } - // 2. Check the persistent IndexedDB cache. try { const cachedBlob = await getCachedVoiceFromDB(cacheKey); if (cachedBlob) { @@ -397,7 +370,6 @@ async function speak(text: string, userId?: string, interruptKey?: string, useDe console.error("Error accessing IndexedDB:", err); } - // 3. Fetch from API try { const response = await fetch(`${API_BASE}/api/generate`, { method: "POST", @@ -415,7 +387,7 @@ async function speak(text: string, userId?: string, interruptKey?: string, useDe if (!response.ok) { console.error(`TTS failed: ${response.status}`); - resolve(); // Skip this message + resolve(); return; } @@ -441,9 +413,6 @@ async function speak(text: string, userId?: string, interruptKey?: string, useDe }); } -// For every user, channelId and oldChannelId will differ when moving channel. -// Only for the local user, channelId and oldChannelId will be the same when moving channel, -// for some ungodly reason let myLastChannelId: string | undefined; type NormalizedVoiceState = { @@ -452,6 +421,7 @@ type NormalizedVoiceState = { streaming: boolean; }; +const lastJoinLeaveTimestamp = new Map(); let trackedChannelId: string | null = null; let baselineReady = false; let baselineUpdateInProgress = false; @@ -464,7 +434,7 @@ function normalizeState(state: VoiceState): NormalizedVoiceState { return { muted: !!(state.mute || state.selfMute), deaf: !!(state.deaf || state.selfDeaf), - streaming: !!((state as any).selfStream || (state as any).stream), + streaming: !!(state.selfStream || state.stream), }; } @@ -480,16 +450,6 @@ function shouldAnnounce(key: string): boolean { type StateActionType = "stream" | "mute" | "deaf"; -/** - * Builds the intro and action segments for state change announcements. - * - * Format: "{USERNAME}" + "{ACTION}" - * Examples: - * - "JohnDoe" + "myooted" / "un-myooted" / "deafind" / "un-deafind" - * - "JohnDoe" + "started streaming" / "stopped streaming" - * - * Uses phonetic spellings for muted/deafened to ensure clear pronunciation. - */ function buildStateSegments( type: StateActionType, isOn: boolean, @@ -505,7 +465,6 @@ function buildStateSegments( }; } - // Mute and deaf - no verb, just name + phonetic action const action = type === "mute" ? (isOn ? "myooted" : "un-myooted") : (isOn ? "deafind" : "un-deafind"); @@ -516,14 +475,6 @@ function buildStateSegments( }; } -/** - * Queues a state change announcement as two parts: - * - Intro (username) goes to mainQueue - won't be interrupted - * - Action (state) goes to stateQueue - can be interrupted by rapid state changes - * - * Uses a shared interrupt key per user so rapid toggles (mute->unmute->mute) - * only announce the final state. - */ function queueStateSplitAnnouncement( userId: string, intro: string, @@ -536,7 +487,6 @@ function queueStateSplitAnnouncement( lastIntroSpokenAt.set(userId, Date.now()); } - // Handle interrupt key for action - remove any pending actions for this user const interruptKey = `${userId}:state:action`; for (let i = stateQueue.length - 1; i >= 0; i--) { if (stateQueue[i].interruptKey === interruptKey) { @@ -545,7 +495,6 @@ function queueStateSplitAnnouncement( } interruptPlayback(interruptKey); - // Add both items to queues BEFORE processing if (shouldQueueIntro) { mainQueue.push({ text: safeIntro, userId, interruptKey: undefined, useDefaultVoice: false }); } @@ -609,7 +558,7 @@ function playSample(tempSettings: any, type: string) { settingsobj[type + "Message"], currentUser.username, "general", - (currentUser as any).globalName ?? currentUser.username, + currentUser.globalName ?? currentUser.username, (myGuildId ? GuildMemberStore.getNick(myGuildId, currentUser.id) : null) ?? currentUser.username, settingsobj.latinOnly ) @@ -715,6 +664,18 @@ const settings = definePluginSettings({ description: "Comma-separated user IDs for whitelist/blacklist. Right-click users to add/remove.", default: "", }, + ignoredUsers: { + type: OptionType.STRING, + description: "Comma-separated user IDs to completely ignore (no join/leave/move/state announcements). Right-click users to add/remove.", + default: "", + }, + joinLeaveTimeout: { + type: OptionType.SLIDER, + description: "Per-user cooldown for join/leave/move announcements (seconds). Prevents spam from rapid rejoiners.", + default: 0, + markers: [0, 5, 10, 15, 30, 60], + stickToMarkers: false, + }, troubleshooting: { type: OptionType.COMPONENT, component: () => , @@ -725,7 +686,6 @@ interface UserContextProps { user: User; } -// Voice selection modal component function VoiceSelectModal({ modalProps, user }: { modalProps: ModalProps; user: User; }) { const DEFAULT_VALUE = "__default__"; @@ -744,10 +704,8 @@ function VoiceSelectModal({ modalProps, user }: { modalProps: ModalProps; user: setCurrentValue(map.get(user.id) ?? DEFAULT_VALUE); }, [user.id, settings.store.userVoiceMap]); - // Get the display name for preview - const displayName = (user as any).globalName ?? user.username; + const displayName = user.globalName ?? user.username; - // Preview function that speaks the user's name in the selected voice const previewVoice = async (text: string) => { setBusy(true); const voice = currentValue === DEFAULT_VALUE @@ -801,7 +759,6 @@ function VoiceSelectModal({ modalProps, user }: { modalProps: ModalProps; user: onChange={v => setCurrentValue(v as any)} /> - {/* Preview buttons */} Preview how this voice sounds with their name: @@ -869,7 +826,6 @@ function openVoiceSelectModal(user: User) { )); } -// Context menu to assign voice to user const UserContextMenuPatch: NavContextMenuPatchCallback = (children, { user }: UserContextProps) => { if (!user) return; @@ -879,6 +835,9 @@ const UserContextMenuPatch: NavContextMenuPatchCallback = (children, { user }: U ? VOICE_OPTIONS.find(v => v.value === currentVoice)?.label ?? currentVoice : "Default"; + const ignoredSet = parseUserIdList(settings.store.ignoredUsers); + const isIgnored = ignoredSet.has(user.id); + const filterMode = settings.store.stateChangeFilterMode ?? "off"; const filterSet = parseStateChangeFilterList(settings.store.stateChangeFilterList); const inFilter = filterSet.has(user.id); @@ -910,6 +869,18 @@ const UserContextMenuPatch: NavContextMenuPatchCallback = (children, { user }: U label={`Voice: ${voiceLabel}`} action={() => openVoiceSelectModal(user)} /> + { + if (isIgnored) { + settings.store.ignoredUsers = removeUserFromList(settings.store.ignoredUsers, user.id); + } else { + settings.store.ignoredUsers = addUserToList(settings.store.ignoredUsers, user.id); + } + }} + /> 1 && voiceStates.some(s => !("oldChannelId" in (s as any))); if (isBatchUpdate && myChanId) { - // Guild-open / bulk refresh: update baseline silently to avoid spam. await refreshBaseline(myChanId); return; } + const ignoredUsers = parseUserIdList(settings.store.ignoredUsers); + for (const state of voiceStates) { const { userId, channelId } = state; let { oldChannelId } = state; @@ -991,7 +962,6 @@ export default definePlugin({ const affectsMyChannel = channelId === myChanId || oldChannelId === myChanId; if (!isMe && !affectsMyChannel) continue; - // Keep snapshots in sync for join/leave/move without announcing state changes. if (myChanId) { if (oldChannelId !== myChanId && channelId === myChanId) { voiceStateSnapshot.set(userId, normalizeState(state)); @@ -1000,16 +970,34 @@ export default definePlugin({ } } - // Join/leave/move announcements (existing behavior) + const isIgnored = ignoredUsers.has(userId); + const shouldSkipAnnouncement = isIgnored || (isMe && settings.store.ignoreSelf); + const [type, id] = getTypeAndChannelId({ ...state, oldChannelId }, isMe); if (type) { - const template = settings.store[type + "Message"]; - const u = isMe && !settings.store.sayOwnName ? "" : UserStore.getUser(userId).username; - const displayName = u && ((UserStore.getUser(userId) as any).globalName ?? u); - const nickname = u && ((myGuildId ? GuildMemberStore.getNick(myGuildId, userId) : null) ?? displayName); - const channel = ChannelStore.getChannel(id)?.name ?? "channel"; + if (!shouldSkipAnnouncement) { + const timeout = settings.store.joinLeaveTimeout ?? 0; + let throttled = false; + if (!isMe && timeout > 0) { + const now = Date.now(); + const last = lastJoinLeaveTimestamp.get(userId) ?? 0; + if (now - last < timeout * 1000) { + throttled = true; + } else { + lastJoinLeaveTimestamp.set(userId, now); + } + } - queueSpeak(formatText(template, u, channel, displayName, nickname, settings.store.latinOnly), userId); + if (!throttled) { + const template = settings.store[type + "Message"]; + const u = isMe && !settings.store.sayOwnName ? "" : UserStore.getUser(userId).username; + const displayName = u && (UserStore.getUser(userId).globalName ?? u); + const nickname = u && ((myGuildId ? GuildMemberStore.getNick(myGuildId, userId) : null) ?? displayName); + const channel = ChannelStore.getChannel(id)?.name ?? "channel"; + + queueSpeak(formatText(template, u, channel, displayName, nickname, settings.store.latinOnly), userId); + } + } if (isMe && (type === "join" || type === "move") && id) { await refreshBaseline(id); @@ -1018,7 +1006,6 @@ export default definePlugin({ continue; } - // State-change announcements (mute/deafen/stream), only when user is in our current VC. if (channelId !== myChanId) continue; if (!baselineReady) continue; @@ -1027,10 +1014,11 @@ export default definePlugin({ voiceStateSnapshot.set(userId, next); if (!prev) continue; + if (shouldSkipAnnouncement) continue; if (!allowStateChange(userId, isMe)) continue; const userObj = isMe && !settings.store.sayOwnName ? "" : UserStore.getUser(userId).username; - const displayName = userObj && ((UserStore.getUser(userId) as any).globalName ?? userObj); + const displayName = userObj && (UserStore.getUser(userId).globalName ?? userObj); const nickname = userObj && ((myGuildId ? GuildMemberStore.getNick(myGuildId, userId) : null) ?? displayName); const preferredName = nickname || displayName || userObj || (isMe ? "You" : "Someone"); @@ -1042,8 +1030,6 @@ export default definePlugin({ } } - // Deafen takes priority over mute (you're always muted when deafened) - // Only announce mute if deaf state didn't change if (!isMe && settings.store.announceOthersDeafen && prev.deaf !== next.deaf) { const key = `${userId}:deaf`; if (shouldAnnounce(key)) { @@ -1077,7 +1063,6 @@ export default definePlugin({ return ( <> - {/* Author note - pinned at top */}
{authorAvatar && (
- {/* Preview sounds section */}
Preview Sounds {busy && "(playing...)"} diff --git a/src/equicordplugins/vcNarratorCustom/util.ts b/src/equicordplugins/vcNarratorCustom/util.ts index e6fe069b4..5d27f5e3e 100644 --- a/src/equicordplugins/vcNarratorCustom/util.ts +++ b/src/equicordplugins/vcNarratorCustom/util.ts @@ -49,36 +49,15 @@ export const VOICE_OPTIONS = [ { label: "Latin American: Spanish MX Male (es_mx_002)", value: "es_mx_002" }, ]; -/** - * In-memory TTS audio URL cache (`cacheKey` -> `blob:` object URL). - * - * - Cleared on restart. - * - Persistent caching lives in IndexedDB. - * - Call `clearTtsCache()` to clear both memory + persistent caches. - */ export const ttsCache = new Map(); -/** - * Maximum size for the persistent TTS cache stored in IndexedDB. - * - * Implemented as a rolling LRU: when writing new entries, older entries are evicted - * (by least-recent `lastAccess`) until we're back under the limit. - */ export const PERSISTENT_TTS_CACHE_MAX_BYTES = 100 * 1024 * 1024; -/** - * Parses the per-user voice map from settings. - * - * Supported formats: - * - Preferred: `"userId:voiceId,userId2:voiceId2"` (comma-separated) - * - Legacy: `"userId,voiceId\nuserId2,voiceId2"` (newline-separated) - */ export function parseUserVoiceMap(input?: string): Map { const map = new Map(); const trimmed = input?.trim(); if (!trimmed) return map; - // Preferred format (comma-separated pairs) if (trimmed.includes(":") || trimmed.includes("=")) { for (const entry of trimmed.split(",").map(s => s.trim()).filter(Boolean)) { const [userId, voiceId] = entry.split(/[:=]/).map(s => s.trim()); @@ -87,7 +66,6 @@ export function parseUserVoiceMap(input?: string): Map { return map; } - // Legacy format (newline-separated "userId,voiceId") for (const line of trimmed.split(/\n+/)) { const [userId, voiceId] = line.split(",").map(s => s.trim()); if (userId && voiceId) map.set(userId, voiceId); @@ -96,18 +74,12 @@ export function parseUserVoiceMap(input?: string): Map { return map; } -/** - * Serializes a per-user voice map to the preferred storage format. - */ export function serializeUserVoiceMap(map: Map): string { return Array.from(map.entries()) .map(([userId, voiceId]) => `${userId}:${voiceId}`) .join(","); } -/** - * Gets the voice id for a given user id, falling back to the configured/default voice. - */ export function getVoiceForUser( userId: string | undefined, options: { userVoiceMap?: string; customVoice?: string; defaultVoice?: string; } @@ -118,27 +90,46 @@ export function getVoiceForUser( return map.get(userId) ?? defaultVoice; } -/** - * Adds or updates a user voice mapping and returns the updated serialized map. - */ export function upsertUserVoiceMap(userVoiceMap: string | undefined, userId: string, voiceId: string): string { const map = parseUserVoiceMap(userVoiceMap ?? ""); map.set(userId, voiceId); return serializeUserVoiceMap(map); } -/** - * Removes a user voice mapping and returns the updated serialized map. - */ export function removeUserVoiceFromMap(userVoiceMap: string | undefined, userId: string): string { const map = parseUserVoiceMap(userVoiceMap ?? ""); map.delete(userId); return serializeUserVoiceMap(map); } -/** - * Parses the state-change filter list from a comma-separated string. - */ +export function parseUserIdList(input?: string): Set { + const set = new Set(); + const trimmed = input?.trim(); + if (!trimmed) return set; + + for (const entry of trimmed.split(",").map(s => s.trim()).filter(Boolean)) { + set.add(entry); + } + + return set; +} + +export function serializeUserIdList(list: Set): string { + return Array.from(list).join(","); +} + +export function addUserToList(input: string | undefined, userId: string): string { + const set = parseUserIdList(input); + set.add(userId); + return serializeUserIdList(set); +} + +export function removeUserFromList(input: string | undefined, userId: string): string { + const set = parseUserIdList(input); + set.delete(userId); + return serializeUserIdList(set); +} + export function parseStateChangeFilterList(input?: string): Set { const set = new Set(); const trimmed = input?.trim(); @@ -151,34 +142,22 @@ export function parseStateChangeFilterList(input?: string): Set { return set; } -/** - * Serializes the state-change filter list into a comma-separated string. - */ export function serializeStateChangeFilterList(list: Set): string { return Array.from(list).join(","); } -/** - * Adds a user id to the state-change filter list and returns the updated string. - */ export function addUserToStateChangeFilterList(input: string | undefined, userId: string): string { const set = parseStateChangeFilterList(input); set.add(userId); return serializeStateChangeFilterList(set); } -/** - * Removes a user id from the state-change filter list and returns the updated string. - */ export function removeUserFromStateChangeFilterList(input: string | undefined, userId: string): string { const set = parseStateChangeFilterList(input); set.delete(userId); return serializeStateChangeFilterList(set); } -/** - * Sanitizes a string for TTS usage (length cap + character allow list). - */ export function clean(str: string, latinOnly?: boolean) { const replacer = latinOnly ? /[^\p{Script=Latin}\p{Number}\p{Punctuation}\s]/gu @@ -192,9 +171,6 @@ export function clean(str: string, latinOnly?: boolean) { .slice(0, 128); } -/** - * Expands the template string used in announcements. - */ export function formatText( str: string, user: string, @@ -216,41 +192,20 @@ export function formatText( ); } -/** - * IndexedDB database name used for persistent caching. - */ const DB_NAME = "VcNarratorDB"; -/** - * IndexedDB schema version. - * - * v1: `voices` store only - * v2: adds `voices_meta` for rolling size limits / LRU eviction - */ const DB_VERSION = 2; -/** - * Object store holding cached audio blobs (`cacheKey` -> Blob). - */ const VOICES_STORE = "voices"; -/** - * Object store holding metadata (`cacheKey` -> { size, createdAt, lastAccess }). - */ const META_STORE = "voices_meta"; -/** - * Metadata for an entry in the persistent cache. - */ type VoiceMeta = { size: number; createdAt: number; lastAccess: number; }; -/** - * Opens (or creates) the IndexedDB database used for persistent TTS caching. - */ function openDB(): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); @@ -265,12 +220,6 @@ function openDB(): Promise { meta.createIndex("by_lastAccess", "lastAccess"); } - /** - * Migration: older DBs stored only Blobs in `voices` without metadata. - * - * On upgrade to v2, backfill `voices_meta` so size limits and LRU eviction - * can work immediately. - */ if (upgradeTx && (event?.oldVersion ?? 0) < 2) { const now = Date.now(); const voices = upgradeTx.objectStore(VOICES_STORE); @@ -297,9 +246,6 @@ function openDB(): Promise { }); } -/** - * Returns the persistent cache size + entry count (based on IndexedDB metadata). - */ export async function getPersistentTtsCacheStats(): Promise<{ bytes: number; entries: number; }> { const db = await openDB(); return new Promise((resolve, reject) => { @@ -325,14 +271,11 @@ export async function getPersistentTtsCacheStats(): Promise<{ bytes: number; ent }); } -/** - * Clears both the in-memory URL cache and the persistent IndexedDB cache. - */ export async function clearTtsCache(): Promise { for (const url of ttsCache.values()) { try { URL.revokeObjectURL(url); - } catch { /* ignore */ } + } catch { } } ttsCache.clear(); @@ -346,9 +289,6 @@ export async function clearTtsCache(): Promise { }); } -/** - * Enforces the persistent cache size limit by removing least-recently-used entries. - */ async function trimPersistentCacheToMaxBytes(maxBytes = PERSISTENT_TTS_CACHE_MAX_BYTES): Promise { const db = await openDB(); return new Promise((resolve, reject) => { @@ -376,7 +316,7 @@ async function trimPersistentCacheToMaxBytes(maxBytes = PERSISTENT_TTS_CACHE_MAX if (inMemoryUrl) { try { URL.revokeObjectURL(inMemoryUrl); - } catch { /* ignore */ } + } catch { } ttsCache.delete(cacheKey); } @@ -407,9 +347,6 @@ async function trimPersistentCacheToMaxBytes(maxBytes = PERSISTENT_TTS_CACHE_MAX }); } -/** - * Retrieves a cached TTS audio blob from IndexedDB and updates its LRU `lastAccess`. - */ export async function getCachedVoiceFromDB(cacheKey: string): Promise { const db = await openDB(); return new Promise((resolve, reject) => { @@ -439,9 +376,6 @@ export async function getCachedVoiceFromDB(cacheKey: string): Promise { const db = await openDB(); await new Promise((resolve, reject) => { diff --git a/src/plugins/accountPanelServerProfile/index.tsx b/src/plugins/accountPanelServerProfile/index.tsx index 7a06255f2..12f5f5cbb 100644 --- a/src/plugins/accountPanelServerProfile/index.tsx +++ b/src/plugins/accountPanelServerProfile/index.tsx @@ -75,11 +75,11 @@ export default definePlugin({ patches: [ { - find: ".WIDGETS_RTC_UPSELL_COACHMARK)", + find: "handleOpenSettingsContextMenu=", group: true, replacement: [ { - match: /(\.AVATAR,children:.+?renderPopout:\(?(\i).{0,5}=>){(.+?)}(?=,position)(?<=currentUser:(\i).+?)/, + match: /(\.AVATAR,children:.+?renderPopout:(\i)=>){(.+?)}(?=,position)(?<=currentUser:(\i).+?)/, replace: (_, rest, popoutProps, originalPopout, currentUser) => `${rest}$self.UserProfile({popoutProps:${popoutProps},currentUser:${currentUser},originalRenderPopout:()=>{${originalPopout}}})` }, { diff --git a/src/plugins/anonymiseFileNames/index.tsx b/src/plugins/anonymiseFileNames/index.tsx index 8e1536c40..f136d08a0 100644 --- a/src/plugins/anonymiseFileNames/index.tsx +++ b/src/plugins/anonymiseFileNames/index.tsx @@ -60,7 +60,7 @@ const settings = definePluginSettings({ randomisedLength: { description: "Random characters length", type: OptionType.NUMBER, - default: 10, + default: 7, disabled: () => settings.store.method !== Methods.Random, }, consistent: { @@ -134,7 +134,7 @@ export default definePlugin({ const newFilename = (() => { switch (settings.store.method) { case Methods.Random: - const chars = "ABCDEFabcdef0123456789"; + const chars = "0123456789bdfhjkmnpqrstvwxz"; const returnedName = Array.from( { length: settings.store.randomisedLength }, () => chars[Math.floor(Math.random() * chars.length)] diff --git a/src/plugins/decor/index.tsx b/src/plugins/decor/index.tsx index d32c50a0c..0a5ff0569 100644 --- a/src/plugins/decor/index.tsx +++ b/src/plugins/decor/index.tsx @@ -87,7 +87,7 @@ export default definePlugin({ }, // Current user area, at bottom of channels/dm list { - find: ".DISPLAY_NAME_STYLES_COACHMARK),", + find: ".DISPLAY_NAME_STYLES_COACHMARK)", replacement: [ // Use Decor avatar decoration hook { diff --git a/src/plugins/newGuildSettings/index.tsx b/src/plugins/newGuildSettings/index.tsx index ca00e5bc6..dd7f05a91 100644 --- a/src/plugins/newGuildSettings/index.tsx +++ b/src/plugins/newGuildSettings/index.tsx @@ -26,7 +26,7 @@ import { Devs } from "@utils/constants"; import definePlugin, { OptionType } from "@utils/types"; import { Guild } from "@vencord/discord-types"; import { findByCodeLazy, findByPropsLazy, findStoreLazy, mapMangledModuleLazy } from "@webpack"; -import { ChannelStore, Menu } from "@webpack/common"; +import { ChannelStore, Menu, UserStore } from "@webpack/common"; const { updateGuildNotificationSettings } = findByPropsLazy("updateGuildNotificationSettings"); const { toggleShowAllChannels } = mapMangledModuleLazy(".onboardExistingMember(", { @@ -175,5 +175,11 @@ export default definePlugin({ } ], settings, - applyDefaultSettings + applyDefaultSettings, + flux: { + GUILD_JOIN_REQUEST_UPDATE({ guildId, request, status }) { + if (status === "APPROVED" && request.user_id === UserStore.getCurrentUser().id) + applyDefaultSettings(guildId); + } + } }); diff --git a/src/plugins/reviewDB/components/ReviewComponent.tsx b/src/plugins/reviewDB/components/ReviewComponent.tsx index 20bd2e029..a29bd504c 100644 --- a/src/plugins/reviewDB/components/ReviewComponent.tsx +++ b/src/plugins/reviewDB/components/ReviewComponent.tsx @@ -24,7 +24,7 @@ import { canBlockReviewAuthor, canDeleteReview, canReportReview, cl, showToast } import { openUserProfile } from "@utils/discord"; import { classes } from "@utils/misc"; import { findCssClassesLazy } from "@webpack"; -import { Alerts, Parser, Timestamp, useState } from "@webpack/common"; +import { Alerts, IconUtils, Parser, Timestamp, useState } from "@webpack/common"; import { openBlockModal } from "./BlockedUserModal"; import { BlockButton, DeleteButton, ReportButton } from "./MessageButton"; @@ -116,8 +116,9 @@ export default function ReviewComponent({ review, refetch, profileId }: { review e.currentTarget.src = IconUtils.getDefaultAvatarURL(review.sender.discordID)} />
getReviews(discordId, (page - 1) * REVIEWS_PER_PAGE), { + const [reviewData] = useAwaiter(() => getReviews(discordId, { offset: (page - 1) * REVIEWS_PER_PAGE }), { fallbackValue: null, deps: [refetchSignal, signal, page], onSuccess: data => { if (settings.store.hideBlockedUsers) data!.reviews = data!.reviews?.filter(r => !RelationshipStore.isBlocked(r.sender.discordID)); + data!.reviews.reverse(); scrollToTop?.(); onFetchReviews(data!); } diff --git a/src/plugins/reviewDB/index.tsx b/src/plugins/reviewDB/index.tsx index b06468e2a..c3f4f21ac 100644 --- a/src/plugins/reviewDB/index.tsx +++ b/src/plugins/reviewDB/index.tsx @@ -29,7 +29,7 @@ import { useAwaiter } from "@utils/react"; import definePlugin from "@utils/types"; import { Guild, User } from "@vencord/discord-types"; import { findCssClassesLazy } from "@webpack"; -import { Alerts, Clickable, Menu, Parser } from "@webpack/common"; +import { Alerts, Clickable, IconUtils, Menu, Parser } from "@webpack/common"; import { Auth, initAuth, updateAuth } from "./auth"; import { openReviewsModal } from "./components/ReviewModal"; @@ -157,7 +157,7 @@ export default definePlugin({ }, renderProfileComponent: ErrorBoundary.wrap(({ user, isSideBar = false }: { user: User; isSideBar?: boolean; }) => { - const [reviewData] = useAwaiter(() => getReviews(user.id, 0, true), { deps: [user.id], fallbackValue: null }); + const [reviewData] = useAwaiter(() => getReviews(user.id, { limit: 4 }), { deps: [user.id], fallbackValue: null }); // Discord are masters at using a crap ton of html elements and css classes to create a simple ui that could have // been made with less than half of the number of elements, so we have to do this insanity to replicate their ui @@ -166,37 +166,43 @@ export default definePlugin({
  • !reviewData?.hasOptedOut && openReviewsModal(user.id, user.username, ReviewType.User)} >
    User Reviews - {reviewData?.hasOptedOut ? ( - User disabled reviews - ) : !reviewData?.reviewCount ? ( - No reviews yet - ) : !!reviewData?.reviewCount && ( -
    - {reviewData.reviews - .filter(review => review.id !== 0) - .slice(0, 4) - .reverse() - .map((review, idx) => { - const showCount = idx === 3 && reviewData.reviewCount > 4; + {!!reviewData?.reviewCount + ? ( +
    + {reviewData.reviews + .filter(review => review.id !== 0) + .slice(0, 4) + .reverse() + .map((review, idx) => { + const showCount = idx === 3 && reviewData.reviewCount > 4; - return ( -
    - {review.sender.username} - {showCount && ( -
    - +{reviewData.reviewCount - 3} -
    - )} -
    - ); - })} -
    - )} + return ( +
    + {review.sender.username} e.currentTarget.src = IconUtils.getDefaultAvatarURL(review.sender.discordID)} + /> + {showCount && ( +
    + + +{reviewData.reviewCount - 3} + +
    + )} +
    + ); + })} +
    + ) + : {reviewData?.hasOptedOut ? "User opted out" : "No reviews yet"} + }
  • diff --git a/src/plugins/reviewDB/reviewDbApi.ts b/src/plugins/reviewDB/reviewDbApi.ts index 29c1918b4..94c369770 100644 --- a/src/plugins/reviewDB/reviewDbApi.ts +++ b/src/plugins/reviewDB/reviewDbApi.ts @@ -29,10 +29,10 @@ export const REVIEWS_PER_PAGE = 50; export interface UserReviewsData { message: string; - hasNextPage: boolean; - reviewCount: number; reviews: Review[]; updated: boolean; + hasNextPage: boolean; + reviewCount: number; hasOptedOut: boolean; } @@ -48,15 +48,15 @@ async function rdbRequest(path: string, options: RequestInit = {}) { }); } -export async function getReviews(id: string, offset = 0, isProfileComponent = false): Promise { +export async function getReviews(id: string, { limit, offset = 0 }: { limit?: number; offset?: number; } = {}): Promise { let flags = 0; if (!settings.store.showWarning) flags |= WarningFlag; - const params = new URLSearchParams({ - flags: String(flags), - offset: String(offset), - ...(isProfileComponent ? { limit: "4" } : {}) - }); + const params = new URLSearchParams(); + if (flags) params.append("flags", String(flags)); + if (offset) params.append("offset", String(offset)); + if (limit) params.append("limit", String(limit)); + const req = await fetch(`${API_URL}/users/${id}/reviews?${params}`); const res = (req.ok)