Merge branch 'dev'
This commit is contained in:
commit
0e83a2543c
22 changed files with 250 additions and 263 deletions
|
|
@ -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"
|
||||
|
|
|
|||
18
pnpm-lock.yaml
generated
18
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -82,6 +82,7 @@ export type BaseTextProps<Tag extends TextTag = "div"> = ComponentPropsWithoutRe
|
|||
selectable?: boolean;
|
||||
lineClamp?: number;
|
||||
tabularNumbers?: boolean;
|
||||
defaultColor?: boolean;
|
||||
};
|
||||
|
||||
export function BaseText<T extends TextTag = "div">(props: BaseTextProps<T>): ReactNode {
|
||||
|
|
@ -93,6 +94,7 @@ export function BaseText<T extends TextTag = "div">(props: BaseTextProps<T>): Re
|
|||
selectable = false,
|
||||
lineClamp,
|
||||
tabularNumbers = false,
|
||||
defaultColor = true,
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
|
|
@ -107,6 +109,7 @@ export function BaseText<T extends TextTag = "div">(props: BaseTextProps<T>): Re
|
|||
lineClamp === 1 && textCls("line-clamp-1"),
|
||||
lineClamp != null && lineClamp > 1 && textCls("line-clamp"),
|
||||
tabularNumbers && textCls("tabular-numbers"),
|
||||
defaultColor && textCls("defaultColor"),
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -81,7 +81,6 @@ export function Updatable(props: CommonProps) {
|
|||
<>
|
||||
<Flex className={Margins.bottom8} gap="8px">
|
||||
<Button
|
||||
size="small"
|
||||
disabled={isUpdating || isChecking}
|
||||
onClick={runWithDispatch(setIsChecking, async () => {
|
||||
const outdated = await checkForUpdates();
|
||||
|
|
|
|||
|
|
@ -17,12 +17,15 @@
|
|||
*/
|
||||
|
||||
import { useSettings } from "@api/Settings";
|
||||
import { Button } from "@components/Button";
|
||||
import { Card } from "@components/Card";
|
||||
import { Divider } from "@components/Divider";
|
||||
import { Flex } from "@components/Flex";
|
||||
import { FormSwitch } from "@components/FormSwitch";
|
||||
import { Heading } from "@components/Heading";
|
||||
import { Heading, HeadingSecondary } from "@components/Heading";
|
||||
import { Link } from "@components/Link";
|
||||
import { Paragraph } from "@components/Paragraph";
|
||||
import { SettingsTab, wrapTab } from "@components/settings";
|
||||
import { SettingsTab, wrapTab } from "@components/settings/tabs/BaseTab";
|
||||
import { Margins } from "@utils/margins";
|
||||
import { useAwaiter } from "@utils/react";
|
||||
import { getRepo, isNewer, UpdateLogger } from "@utils/updater";
|
||||
|
|
@ -37,6 +40,34 @@ interface CommonProps {
|
|||
repoPending: boolean;
|
||||
}
|
||||
|
||||
function EquibopSection() {
|
||||
if (!IS_EQUIBOP) return null;
|
||||
|
||||
const [isEquibopOutdated] = useAwaiter<boolean>(VesktopNative.app.isOutdated, { fallbackValue: false });
|
||||
|
||||
return (
|
||||
<Flex className={Margins.bottom20} flexDirection="column" gap="1em">
|
||||
<Card variant="brand">
|
||||
<HeadingSecondary>Equibop & Equicord</HeadingSecondary>
|
||||
<Paragraph>Equibop and Equicord are two separate things. This updater is for Equicord.</Paragraph>
|
||||
<Paragraph className={Margins.top8}>
|
||||
You receive separate popups for Equibop updates. You can also manually update by installing the <Link href="https://equibop.org/install">latest version</Link>.
|
||||
</Paragraph>
|
||||
</Card>
|
||||
|
||||
{isEquibopOutdated && (
|
||||
<Card variant="warning">
|
||||
<HeadingSecondary>Equibop Outdated</HeadingSecondary>
|
||||
<Flex flexDirection="column" gap="0.5em">
|
||||
<Paragraph>Your version of Equibop is outdated!</Paragraph>
|
||||
<Button variant="link" onClick={() => VesktopNative.app.openUpdater()}>Open Equibop Updater</Button>
|
||||
</Flex>
|
||||
</Card>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
function Updater() {
|
||||
const settings = useSettings(["autoUpdate", "autoUpdateNotification"]);
|
||||
|
||||
|
|
@ -54,6 +85,7 @@ function Updater() {
|
|||
|
||||
return (
|
||||
<SettingsTab>
|
||||
<EquibopSection />
|
||||
<Heading className={Margins.top16}>Update Preferences</Heading>
|
||||
<Paragraph className={Margins.bottom20}>
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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<T>(tasks: (() => Promise<T>)[], batchSize = 8): Promise<T[]> {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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:\[/,
|
||||
|
|
|
|||
|
|
@ -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:"
|
||||
|
|
|
|||
|
|
@ -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:)/,
|
||||
|
|
|
|||
|
|
@ -1269,7 +1269,7 @@ export default definePlugin({
|
|||
group: true,
|
||||
replacement: [
|
||||
{
|
||||
match: /(?<=resetSortingFiltering\(\)},\[\]\);)/,
|
||||
match: /(?<=scrollToQuest\(\i\)\}\)\},\[\]\);)/,
|
||||
replace: "const shouldHideSponsoredQuestBanner=$self.shouldHideSponsoredQuestBanner();"
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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})",
|
||||
|
|
|
|||
|
|
@ -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<string, number>();
|
||||
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: () => <TroubleshootingSettings />,
|
||||
|
|
@ -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 */}
|
||||
<Forms.FormText className={cl("preview-hint")}>
|
||||
Preview how this voice sounds with their name:
|
||||
</Forms.FormText>
|
||||
|
|
@ -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)}
|
||||
/>
|
||||
<Menu.MenuItem
|
||||
key="ignore"
|
||||
id="vc-narrator-ignore"
|
||||
label={isIgnored ? "Unignore User" : "Ignore User"}
|
||||
action={() => {
|
||||
if (isIgnored) {
|
||||
settings.store.ignoredUsers = removeUserFromList(settings.store.ignoredUsers, user.id);
|
||||
} else {
|
||||
settings.store.ignoredUsers = addUserToList(settings.store.ignoredUsers, user.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Menu.MenuItem
|
||||
key="filter"
|
||||
id="vc-narrator-state-filter"
|
||||
|
|
@ -932,7 +903,6 @@ export default definePlugin({
|
|||
},
|
||||
|
||||
start() {
|
||||
// Pre-cache common action words in background for faster state change announcements
|
||||
preCacheCommonActions();
|
||||
},
|
||||
|
||||
|
|
@ -960,7 +930,7 @@ export default definePlugin({
|
|||
return filterMode === "whitelist" ? inList : !inList;
|
||||
};
|
||||
|
||||
if (myChanId && ChannelStore.getChannel(myChanId)?.type === 13 /* Stage Channel */) return;
|
||||
if (myChanId && ChannelStore.getChannel(myChanId)?.type === ChannelType.GUILD_STAGE_VOICE) return;
|
||||
|
||||
if (!myChanId) {
|
||||
trackedChannelId = null;
|
||||
|
|
@ -972,11 +942,12 @@ export default definePlugin({
|
|||
|
||||
const isBatchUpdate = voiceStates.length > 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 */}
|
||||
<div className={cl("author-note")}>
|
||||
{authorAvatar && (
|
||||
<img
|
||||
|
|
@ -1096,7 +1081,6 @@ export default definePlugin({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview sounds section */}
|
||||
<div className={cl("preview-section")}>
|
||||
<Forms.FormTitle tag="h3" className={cl("preview-title")}>
|
||||
Preview Sounds {busy && "(playing...)"}
|
||||
|
|
|
|||
|
|
@ -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<string, string>();
|
||||
|
||||
/**
|
||||
* 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<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
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<string, string> {
|
|||
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<string, string> {
|
|||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a per-user voice map to the preferred storage format.
|
||||
*/
|
||||
export function serializeUserVoiceMap(map: Map<string, string>): 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<string> {
|
||||
const set = new Set<string>();
|
||||
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>): 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<string> {
|
||||
const set = new Set<string>();
|
||||
const trimmed = input?.trim();
|
||||
|
|
@ -151,34 +142,22 @@ export function parseStateChangeFilterList(input?: string): Set<string> {
|
|||
return set;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes the state-change filter list into a comma-separated string.
|
||||
*/
|
||||
export function serializeStateChangeFilterList(list: Set<string>): 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<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
|
@ -265,12 +220,6 @@ function openDB(): Promise<IDBDatabase> {
|
|||
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<IDBDatabase> {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
for (const url of ttsCache.values()) {
|
||||
try {
|
||||
URL.revokeObjectURL(url);
|
||||
} catch { /* ignore */ }
|
||||
} catch { }
|
||||
}
|
||||
ttsCache.clear();
|
||||
|
||||
|
|
@ -346,9 +289,6 @@ export async function clearTtsCache(): Promise<void> {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforces the persistent cache size limit by removing least-recently-used entries.
|
||||
*/
|
||||
async function trimPersistentCacheToMaxBytes(maxBytes = PERSISTENT_TTS_CACHE_MAX_BYTES): Promise<void> {
|
||||
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<Blob | null> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
|
|
@ -439,9 +376,6 @@ export async function getCachedVoiceFromDB(cacheKey: string): Promise<Blob | nul
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a TTS audio blob in IndexedDB, updates metadata, then trims cache to max size.
|
||||
*/
|
||||
export async function setCachedVoiceInDB(cacheKey: string, blob: Blob): Promise<void> {
|
||||
const db = await openDB();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
|
|
|
|||
|
|
@ -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}}})`
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
|||
<img
|
||||
className={classes(AvatarClasses.avatar, AvatarClasses.clickable)}
|
||||
onClick={openModal}
|
||||
src={review.sender.profilePhoto || "/assets/1f0bfc0865d324c2587920a7d80c609b.png?size=128"}
|
||||
src={review.sender.profilePhoto || IconUtils.getDefaultAvatarURL(review.sender.discordID)}
|
||||
style={{ left: "0px", zIndex: 0 }}
|
||||
onError={e => e.currentTarget.src = IconUtils.getDefaultAvatarURL(review.sender.discordID)}
|
||||
/>
|
||||
<div style={{ display: "inline-flex", justifyContent: "center", alignItems: "center" }}>
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -62,13 +62,14 @@ export default function ReviewsView({
|
|||
}: Props) {
|
||||
const [signal, refetch] = useForceUpdater(true);
|
||||
|
||||
const [reviewData] = useAwaiter(() => 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!);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<ul className={ProfileCardClasses.cardsList} tabIndex={-1}>
|
||||
<li className={ProfileCardClasses.firstCardContainer}>
|
||||
<Clickable
|
||||
className={classes(ProfileCardContainerClasses.breadcrumb, reviewData?.hasOptedOut ? cl("profile-popout-disabled") : null)}
|
||||
className={classes(ProfileCardContainerClasses.breadcrumb, reviewData?.hasOptedOut && cl("profile-popout-disabled"))}
|
||||
onClick={() => !reviewData?.hasOptedOut && openReviewsModal(user.id, user.username, ReviewType.User)}
|
||||
>
|
||||
<div className={classes(ProfileCardOverlayClasses.overlay, ProfileCardContainerClasses.innerContainer, ProfileCardClasses.card)}>
|
||||
<Paragraph size={isSideBar ? "sm" : "xs"} weight="medium">User Reviews</Paragraph>
|
||||
{reviewData?.hasOptedOut ? (
|
||||
<Paragraph size={isSideBar ? "sm" : "xs"}>User disabled reviews</Paragraph>
|
||||
) : !reviewData?.reviewCount ? (
|
||||
<Paragraph size={isSideBar ? "sm" : "xs"}>No reviews yet</Paragraph>
|
||||
) : !!reviewData?.reviewCount && (
|
||||
<div className={ProfileCardContainerClasses.icons}>
|
||||
{reviewData.reviews
|
||||
.filter(review => review.id !== 0)
|
||||
.slice(0, 4)
|
||||
.reverse()
|
||||
.map((review, idx) => {
|
||||
const showCount = idx === 3 && reviewData.reviewCount > 4;
|
||||
{!!reviewData?.reviewCount
|
||||
? (
|
||||
<div className={ProfileCardContainerClasses.icons}>
|
||||
{reviewData.reviews
|
||||
.filter(review => review.id !== 0)
|
||||
.slice(0, 4)
|
||||
.reverse()
|
||||
.map((review, idx) => {
|
||||
const showCount = idx === 3 && reviewData.reviewCount > 4;
|
||||
|
||||
return (
|
||||
<div className={ProfileCardContainerClasses.icon} key={review.id}>
|
||||
<img src={review.sender.profilePhoto} alt={review.sender.username} className={showCount ? ProfileCardContainerClasses.displayCount : undefined} />
|
||||
{showCount && (
|
||||
<div className={ProfileCardContainerClasses.displayCountText}>
|
||||
<Span className={ProfileCardContainerClasses.displayCountTextColor} size="xs" weight="medium">+{reviewData.reviewCount - 3}</Span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<div className={ProfileCardContainerClasses.icon} key={review.id}>
|
||||
<img
|
||||
src={review.sender.profilePhoto}
|
||||
alt={review.sender.username}
|
||||
className={showCount ? ProfileCardContainerClasses.displayCount : undefined}
|
||||
onError={e => e.currentTarget.src = IconUtils.getDefaultAvatarURL(review.sender.discordID)}
|
||||
/>
|
||||
{showCount && (
|
||||
<div className={ProfileCardContainerClasses.displayCountText}>
|
||||
<Span className={ProfileCardContainerClasses.displayCountTextColor} size="xs" weight="medium" defaultColor={false}>
|
||||
+{reviewData.reviewCount - 3}
|
||||
</Span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
: <Paragraph size={isSideBar ? "sm" : "xs"}>{reviewData?.hasOptedOut ? "User opted out" : "No reviews yet"}</Paragraph>
|
||||
}
|
||||
</div>
|
||||
</Clickable>
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -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<UserReviewsData> {
|
||||
export async function getReviews(id: string, { limit, offset = 0 }: { limit?: number; offset?: number; } = {}): Promise<UserReviewsData> {
|
||||
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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue