Merge branch 'dev'

This commit is contained in:
thororen1234 2026-03-19 18:01:01 -04:00
commit 0e83a2543c
No known key found for this signature in database
22 changed files with 250 additions and 263 deletions

View file

@ -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
View file

@ -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

View file

@ -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);
}

View file

@ -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={{

View file

@ -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();

View file

@ -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.

View file

@ -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) {

View file

@ -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:\[/,

View file

@ -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:"

View file

@ -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:)/,

View file

@ -1269,7 +1269,7 @@ export default definePlugin({
group: true,
replacement: [
{
match: /(?<=resetSortingFiltering\(\)},\[\]\);)/,
match: /(?<=scrollToQuest\(\i\)\}\)\},\[\]\);)/,
replace: "const shouldHideSponsoredQuestBanner=$self.shouldHideSponsoredQuestBanner();"
},
{

View file

@ -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})",

View file

@ -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...)"}

View file

@ -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) => {

View file

@ -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}}})`
},
{

View file

@ -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)]

View file

@ -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
{

View file

@ -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);
}
}
});

View file

@ -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

View file

@ -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!);
}

View file

@ -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>

View file

@ -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)