Merge branch 'dev'

This commit is contained in:
thororen1234 2026-03-31 10:19:47 -04:00
commit 7dd6ff12b6
No known key found for this signature in database
51 changed files with 1642 additions and 1207 deletions

View file

@ -14,9 +14,9 @@
"license": "GPL-3.0-or-later",
"author": "Equicord",
"scripts": {
"build": "node --require=./scripts/suppressExperimentalWarnings.js scripts/build/build.mjs",
"build": "pnpm depCheck && node --require=./scripts/suppressExperimentalWarnings.js scripts/build/build.mjs",
"buildStandalone": "pnpm build --standalone",
"buildWeb": "node --require=./scripts/suppressExperimentalWarnings.js scripts/build/buildWeb.mjs",
"buildWeb": "pnpm depCheck && node --require=./scripts/suppressExperimentalWarnings.js scripts/build/buildWeb.mjs",
"buildWebStandalone": "pnpm buildWeb --standalone",
"buildReporter": "pnpm buildWebStandalone --reporter --skip-extension",
"buildReporterDesktop": "pnpm build --reporter",
@ -36,7 +36,8 @@
"lint:fix": "pnpm lint --fix",
"test": "pnpm buildStandalone && pnpm testTsc && pnpm lint:fix && pnpm lint-styles && pnpm generatePluginJson",
"testWeb": "pnpm lint:fix && pnpm buildWeb && pnpm testTsc",
"testTsc": "tsc --noEmit"
"testTsc": "tsc --noEmit",
"depCheck": "node scripts/depCheck.mjs"
},
"dependencies": {
"@ffmpeg/ffmpeg": "^0.12.10",
@ -96,7 +97,7 @@
"typescript-transform-paths": "^3.5.5",
"zip-local": "^0.3.5"
},
"packageManager": "pnpm@10.32.1",
"packageManager": "pnpm@10.33.0",
"pnpm": {
"patchedDependencies": {
"@types/less@3.0.8": "patches/@types__less@3.0.8.patch"

View file

@ -79,11 +79,12 @@ export async function buildOrWatchAll(buildConfigs) {
context(cfg).then(ctx => ctx.watch())
));
} else {
await Promise.all(buildConfigs.map(cfg => build(cfg)))
.catch(error => {
for (const cfg of buildConfigs) {
await build(cfg).catch(error => {
console.error(error.message);
process.exit(1); // exit immediately to skip the rest of the builds
process.exit(1);
});
}
}
}

33
scripts/depCheck.mjs Normal file
View file

@ -0,0 +1,33 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { execSync } from "child_process";
import { existsSync, readFileSync } from "fs";
import { createInterface } from "readline/promises";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const PackageJSON = JSON.parse(readFileSync(join(root, "package.json"), "utf-8"));
const allDeps = { ...PackageJSON.dependencies, ...PackageJSON.devDependencies };
const requiredDeps = Object.keys(allDeps);
const missing = requiredDeps.filter(d => {
const v = allDeps[d];
return !v.startsWith("link:") && !v.startsWith("workspace:") && !existsSync(join(root, "node_modules", d, "package.json"));
});
if (missing.length) {
console.error(`\x1b[31mMissing ${missing.length} package(s): ${missing.join(", ")}\x1b[0m`);
const rl = createInterface({ input: process.stdin, output: process.stderr });
rl.on("SIGINT", () => { rl.close(); process.exit(1); });
try {
const ans = await rl.question("\x1b[33mRun 'pnpm install'? [Y/n]: \x1b[0m");
if (!ans || /^y/i.test(ans)) execSync("pnpm install --frozen-lockfile", { cwd: root, stdio: "inherit" });
} finally { rl.close(); }
} else {
console.log("\x1b[32mAll dependencies are installed.\x1b[0m");
}

View file

@ -195,7 +195,7 @@ export const startPlugin = traceFunction("startPlugin", function startPlugin(p:
const {
name, commands, contextMenus, managedStyle, userProfileBadges,
onBeforeMessageEdit, onBeforeMessageSend, onMessageClick,
renderChatBarButton, chatBarButton, renderMemberListDecorator, renderMessageAccessory, renderMessageDecoration, renderMessagePopoverButton, messagePopoverButton,
chatBarButton, renderMemberListDecorator, renderMessageAccessory, renderMessageDecoration, messagePopoverButton,
// Custom
renderNicknameIcon, headerBarButton, audioProcessor, userAreaButton, renderProfileCollection
} = p;
@ -248,14 +248,10 @@ export const startPlugin = traceFunction("startPlugin", function startPlugin(p:
if (onMessageClick) addMessageClickListener(onMessageClick);
if (chatBarButton) addChatBarButton(name, chatBarButton.render, chatBarButton.icon);
// @ts-expect-error: legacy code doesn't have icon
else if (renderChatBarButton) addChatBarButton(name, renderChatBarButton);
if (renderMemberListDecorator) addMemberListDecorator(name, renderMemberListDecorator);
if (renderMessageDecoration) addMessageDecoration(name, renderMessageDecoration);
if (renderMessageAccessory) addMessageAccessory(name, renderMessageAccessory);
if (messagePopoverButton) addMessagePopoverButton(name, messagePopoverButton.render, messagePopoverButton.icon);
// @ts-expect-error: legacy code doesn't have icon
else if (renderMessagePopoverButton) addMessagePopoverButton(name, renderMessagePopoverButton);
// Custom
if (renderNicknameIcon) addNicknameIcon(name, renderNicknameIcon);
@ -277,7 +273,7 @@ export const stopPlugin = traceFunction("stopPlugin", function stopPlugin(p: Plu
const {
name, commands, contextMenus, managedStyle, userProfileBadges,
onBeforeMessageEdit, onBeforeMessageSend, onMessageClick,
renderChatBarButton, chatBarButton, renderMemberListDecorator, renderMessageAccessory, renderMessageDecoration, renderMessagePopoverButton, messagePopoverButton,
chatBarButton, renderMemberListDecorator, renderMessageAccessory, renderMessageDecoration, messagePopoverButton,
// Custom
renderNicknameIcon, headerBarButton, audioProcessor, userAreaButton, renderProfileCollection
} = p;
@ -327,11 +323,11 @@ export const stopPlugin = traceFunction("stopPlugin", function stopPlugin(p: Plu
if (onBeforeMessageSend) removeMessagePreSendListener(onBeforeMessageSend);
if (onMessageClick) removeMessageClickListener(onMessageClick);
if (chatBarButton || renderChatBarButton) removeChatBarButton(name);
if (chatBarButton) removeChatBarButton(name);
if (renderMemberListDecorator) removeMemberListDecorator(name);
if (renderMessageDecoration) removeMessageDecoration(name);
if (renderMessageAccessory) removeMessageAccessory(name);
if (messagePopoverButton || renderMessagePopoverButton) removeMessagePopoverButton(name);
if (messagePopoverButton) removeMessagePopoverButton(name);
// Custom
if (renderNicknameIcon) removeNicknameIcon(name);
@ -355,7 +351,7 @@ export const initPluginManager = onlyOnce(function init() {
const pluginKeysToBind: Array<keyof PluginDef & `${"on" | "render"}${string}`> = [
"onBeforeMessageEdit", "onBeforeMessageSend", "onMessageClick",
"renderChatBarButton", "renderMemberListDecorator", "renderMessageAccessory", "renderMessageDecoration", "renderMessagePopoverButton",
"renderMemberListDecorator", "renderMessageAccessory", "renderMessageDecoration",
// Custom
"renderNicknameIcon", "renderProfileCollection"
];
@ -387,11 +383,11 @@ export const initPluginManager = onlyOnce(function init() {
if (p.commands?.length) neededApiPlugins.add("CommandsAPI");
if (p.onBeforeMessageEdit || p.onBeforeMessageSend || p.onMessageClick) neededApiPlugins.add("MessageEventsAPI");
if (p.chatBarButton || p.renderChatBarButton) neededApiPlugins.add("ChatInputButtonAPI");
if (p.chatBarButton) neededApiPlugins.add("ChatInputButtonAPI");
if (p.renderMemberListDecorator) neededApiPlugins.add("MemberListDecoratorsAPI");
if (p.renderMessageAccessory) neededApiPlugins.add("MessageAccessoriesAPI");
if (p.renderMessageDecoration) neededApiPlugins.add("MessageDecorationsAPI");
if (p.messagePopoverButton || p.renderMessagePopoverButton) neededApiPlugins.add("MessagePopoverAPI");
if (p.messagePopoverButton) neededApiPlugins.add("MessagePopoverAPI");
if (p.userProfileBadge) neededApiPlugins.add("BadgeAPI");
// Custom

View file

@ -59,7 +59,7 @@ function ProfileCollections({ props }: { props: any; }) {
return Array.from(profileCollections)
.map(([id, { render: Collection }]) => (
<ErrorBoundary noop key={id} onError={e => logger.error(`Failed to render profile collection: ${id}`, e.error)}>
{Collection({ ...props, user: props.user ?? props.currentUser })}
<Collection {...props} user={props.user ?? props.currentUser} />
</ErrorBoundary>
));
}

View file

@ -4,316 +4,291 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./style.css";
import "./styles.css";
import { definePluginSettings } from "@api/Settings";
import { EquicordDevs } from "@utils/constants";
import { classNameFactory } from "@utils/css";
import definePlugin, { OptionType } from "@utils/types";
import { showToast, Toasts } from "@webpack/common";
import { ColorUtils, React, showToast, Toasts } from "@webpack/common";
const fileSizeLimit = 12e6;
const cl = classNameFactory("vc-better-audio-player-");
const CORS_PROXY = "https://cors.keiran0.workers.dev?url=";
const MAX_FILE_SIZE = 12e6;
function parseFileSize(size: string) {
const [value, unit] = size.split(" ");
const multiplier = {
B: 1,
KB: 1024,
MB: 1024 ** 2,
GB: 1024 ** 3,
TB: 1024 ** 4,
}[unit];
if (!multiplier) return;
return parseFloat(value) * multiplier;
interface PlayerInstance {
mediaRef: React.RefObject<HTMLAudioElement>;
props: { src: string; type: string; };
}
function getMetadata(audioElement: HTMLElement) {
const metadataElement = audioElement.querySelector("[class*='metadataContent']");
const nameElement = metadataElement?.querySelector("a");
const sizeElement = audioElement.querySelector("[class*='metadataContent'] [class*='metadataSize']");
const url = nameElement?.getAttribute("href");
const audioElementLink = audioElement.querySelector("audio");
function validateColor(value: string, key: string, fallback: string) {
if (/^\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}$/.test(value)) return;
if (!sizeElement?.textContent || !nameElement?.textContent || !url || !audioElementLink) return false;
const name = nameElement.textContent;
const size = parseFileSize(sizeElement.textContent);
if (size && size > fileSizeLimit) {
return false;
}
const elements = [metadataElement?.parentElement, audioElement.querySelector("[class*='audioControls']")];
const computedStyle = getComputedStyle(audioElement);
const parentBorderRadius = computedStyle.borderRadius;
if (settings.store.forceMoveBelow) {
elements.forEach(element => {
if (element) (element as HTMLElement).style.zIndex = "2";
});
}
return {
name,
size,
url,
audio: audioElementLink,
parentBorderRadius: parentBorderRadius,
};
}
async function addListeners(audioElement: HTMLAudioElement, url: string, parentBorderRadius: string) {
const madeURL = new URL(url);
madeURL.searchParams.set("t", Date.now().toString());
const corsProxyUrl = "https://cors.keiran0.workers.dev?url=" + encodeURIComponent(madeURL.href);
const response = await fetch(corsProxyUrl);
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const audioContext = new AudioContext();
const analyser = audioContext.createAnalyser();
analyser.fftSize = 2048;
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
const frequencyData = new Uint8Array(bufferLength);
const source = audioContext.createMediaElementSource(audioElement);
source.connect(analyser);
analyser.connect(audioContext.destination);
const canvas = document.createElement("canvas");
const canvasContext = canvas.getContext("2d");
if (!canvasContext) return;
canvas.classList.add("better-audio-visualizer");
audioElement.parentElement?.appendChild(canvas);
if (parentBorderRadius) canvas.style.borderRadius = parentBorderRadius;
function drawVisualizer() {
if (!audioElement.paused) {
requestAnimationFrame(drawVisualizer);
try {
const rgb = ColorUtils.hexToRgb(value.replace("#", ""));
if (rgb) {
settings.store[key] = `${rgb.r}, ${rgb.g}, ${rgb.b}`;
return;
}
} catch { /* invalid hex */ }
analyser.getByteTimeDomainData(dataArray);
analyser.getByteFrequencyData(frequencyData);
if (!canvasContext) return;
canvasContext.clearRect(0, 0, canvas.width, canvas.height);
if (settings.store.oscilloscope) drawOscilloscope(canvasContext, canvas, dataArray, bufferLength);
if (settings.store.spectrograph) drawSpectrograph(canvasContext, canvas, frequencyData, bufferLength);
}
audioElement.src = blobUrl;
audioElement.addEventListener("play", () => {
if (audioContext.state === "suspended") {
audioContext.resume();
}
drawVisualizer();
});
audioElement.addEventListener("pause", () => {
audioContext.suspend();
});
showToast(`Invalid color format for ${key}, use "R, G, B" or "#RRGGBB"`, Toasts.Type.FAILURE);
settings.store[key] = fallback;
}
function drawOscilloscope(canvasContext, canvas, dataArray, bufferLength) {
const sliceWidth = canvas.width / bufferLength;
function maxTypedArray(arr: Uint8Array<ArrayBufferLike>): number {
let max = 0;
for (let i = 0; i < arr.length; i++) {
if (arr[i] > max) max = arr[i];
}
return max;
}
function drawOscilloscope(ctx: CanvasRenderingContext2D, w: number, h: number, dataArray: Uint8Array<ArrayBufferLike>, bufferLength: number) {
const sliceWidth = w / bufferLength;
const [r, g, b] = settings.store.oscilloscopeColor.split(",").map(Number);
const amp = 3;
let x = 0;
const { oscilloscopeSolidColor, oscilloscopeColor } = settings.store;
const [r, g, b] = oscilloscopeColor.split(",").map(Number);
canvasContext.lineWidth = 2;
canvasContext.clearRect(0, 0, canvas.width, canvas.height);
canvasContext.beginPath();
ctx.lineWidth = 2;
ctx.beginPath();
for (let i = 0; i < bufferLength; i++) {
const v = dataArray[i] / 128.0;
const y = (v * canvas.height) / 2;
const v = (dataArray[i] - 128) / 128;
const y = (h / 2) - (v * amp * h / 2);
if (oscilloscopeSolidColor) {
canvasContext.strokeStyle = `rgb(${r}, ${g}, ${b})`;
if (settings.store.oscilloscopeSolidColor) {
ctx.strokeStyle = `rgb(${r}, ${g}, ${b})`;
} else {
const red = Math.min(r + (v * 100) + (i / bufferLength) * 155, 255);
const green = Math.min(g + (v * 50) + (i / bufferLength) * 155, 255);
const blue = Math.min(b + (v * 150) + (i / bufferLength) * 155, 255);
canvasContext.strokeStyle = `rgb(${red}, ${green}, ${blue})`;
}
if (i === 0) {
canvasContext.moveTo(x, y);
} else {
canvasContext.lineTo(x, y);
const absV = Math.abs(v);
ctx.strokeStyle = `rgb(${Math.min(r + absV * 100 + (i / bufferLength) * 155, 255)}, ${Math.min(g + absV * 50 + (i / bufferLength) * 155, 255)}, ${Math.min(b + absV * 150 + (i / bufferLength) * 155, 255)})`;
}
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
x += sliceWidth;
}
canvasContext.stroke();
ctx.stroke();
}
function drawSpectrograph(canvasContext, canvas, frequencyData, bufferLength) {
const { spectrographSolidColor, spectrographColor } = settings.store;
const maxHeight = canvas.height;
const barWidth = canvas.width / bufferLength;
function drawSpectrograph(ctx: CanvasRenderingContext2D, w: number, h: number, frequencyData: Uint8Array<ArrayBufferLike>, bufferLength: number) {
const barWidth = w / bufferLength;
const maxVal = maxTypedArray(frequencyData);
if (maxVal === 0) return;
const [r, g, b] = settings.store.spectrographColor.split(",").map(Number);
let x = 0;
const maxFrequencyValue = Math.max(...frequencyData);
if (maxFrequencyValue === 0 || !isFinite(maxFrequencyValue)) {
return;
}
for (let i = 0; i < bufferLength; i++) {
const normalizedHeight = (frequencyData[i] / maxFrequencyValue) * maxHeight;
const barH = (frequencyData[i] / maxVal) * h;
if (spectrographSolidColor) {
canvasContext.fillStyle = `rgb(${spectrographColor})`;
if (settings.store.spectrographSolidColor) {
ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
} else {
const [r, g, b] = spectrographColor.split(",").map(Number);
const red = Math.min(r + (i / bufferLength) * 155, 255);
const green = Math.min(g + (i / bufferLength) * 155, 255);
const blue = Math.min(b + (i / bufferLength) * 155, 255);
const gradient = canvasContext.createLinearGradient(x, canvas.height - normalizedHeight, x, canvas.height);
const gradient = ctx.createLinearGradient(x, h - barH, x, h);
gradient.addColorStop(0, `rgb(${red}, ${green}, ${blue})`);
const darkerColor = `rgb(${Math.max(red - 50, 0)},${Math.max(green - 50, 0)},${Math.max(blue - 50, 0)})`;
gradient.addColorStop(1, darkerColor);
canvasContext.fillStyle = gradient;
gradient.addColorStop(1, `rgb(${Math.max(red - 50, 0)}, ${Math.max(green - 50, 0)}, ${Math.max(blue - 50, 0)})`);
ctx.fillStyle = gradient;
}
canvasContext.fillRect(x, canvas.height - normalizedHeight, barWidth, normalizedHeight);
ctx.fillRect(x, h - barH, barWidth, barH);
x += barWidth + 0.5;
}
}
function scanForAudioElements(element: HTMLElement) {
element.querySelectorAll("[class*='wrapperAudio']:not([data-better-audio-processed])").forEach(audioElement => {
(audioElement as HTMLElement).dataset.betterAudioProcessed = "true";
const metadata = getMetadata(audioElement as HTMLElement);
async function fetchAudioBlob(src: string): Promise<string | null> {
const url = new URL(src);
url.searchParams.set("t", Date.now().toString());
if (!metadata) return;
const response = await fetch(CORS_PROXY + encodeURIComponent(url.href));
const contentLength = response.headers.get("content-length");
if (contentLength && Number(contentLength) > MAX_FILE_SIZE) return null;
addListeners(metadata.audio, metadata.url, metadata.parentBorderRadius);
});
const blob = await response.blob();
return URL.createObjectURL(blob);
}
function createObserver(targetNode: HTMLElement) {
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
if (mutation.type === "childList") {
mutation.addedNodes.forEach(addedNode => {
if (addedNode instanceof HTMLElement) {
scanForAudioElements(addedNode);
}
});
function Visualizer({ playerRef, src }: { playerRef: React.RefObject<HTMLAudioElement>; src: string; }) {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const audioCtxRef = React.useRef<AudioContext | null>(null);
const analyserRef = React.useRef<AnalyserNode | null>(null);
const animFrameRef = React.useRef(0);
const setupDoneRef = React.useRef(false);
const blobUrlRef = React.useRef<string | null>(null);
React.useEffect(() => {
const audio = playerRef.current;
const canvas = canvasRef.current;
if (!audio || !canvas) return () => { };
let cancelled = false;
const init = async () => {
const blobUrl = await fetchAudioBlob(src).catch(() => null);
if (cancelled || !blobUrl) return;
blobUrlRef.current = blobUrl;
const wasPlaying = !audio.paused;
const { currentTime } = audio;
audio.src = blobUrl;
audio.currentTime = currentTime;
const audioCtx = new AudioContext();
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 2048;
const source = audioCtx.createMediaElementSource(audio);
source.connect(analyser);
analyser.connect(audioCtx.destination);
audioCtxRef.current = audioCtx;
analyserRef.current = analyser;
setupDoneRef.current = true;
if (wasPlaying) {
audio.play().catch(() => { });
}
});
});
observer.observe(targetNode, {
childList: true,
subtree: true,
});
return observer;
}
};
function tryHexToRgb(hex) {
if (hex.startsWith("#")) {
const hexMatch = hex.match(/\w\w/g);
if (hexMatch) {
const [r, g, b] = hexMatch.map(x => parseInt(x, 16));
return `${r}, ${g}, ${b}`;
}
}
return hex;
}
const canvasCtx = canvas.getContext("2d");
let dataArray: Uint8Array<ArrayBuffer> | null = null;
let frequencyData: Uint8Array<ArrayBuffer> | null = null;
function handleColorChange(value, settingKey, defaultValue) {
const rgbPattern = /^(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})$/;
const draw = () => {
const analyser = analyserRef.current;
if (!canvasCtx || !analyser) return;
if (!value.match(rgbPattern)) {
const rgb = tryHexToRgb(value);
if (!dataArray || !frequencyData) {
const bufferLength = analyser.frequencyBinCount;
dataArray = new Uint8Array(bufferLength);
frequencyData = new Uint8Array(bufferLength);
}
if (rgb.match(rgbPattern)) {
settings.store[settingKey] = rgb;
} else {
showToast(`Invalid color format for ${settingKey}, make sure it's in the format 'R, G, B' or '#RRGGBB'`, Toasts.Type.FAILURE);
settings.store[settingKey] = defaultValue;
}
} else {
settings.store[settingKey] = value;
}
if (!audio.paused) animFrameRef.current = requestAnimationFrame(draw);
analyser.getByteTimeDomainData(dataArray);
analyser.getByteFrequencyData(frequencyData);
const { width, height } = canvas.getBoundingClientRect();
canvasCtx.clearRect(0, 0, width, height);
if (settings.store.oscilloscope) drawOscilloscope(canvasCtx, width, height, dataArray, dataArray.length);
if (settings.store.spectrograph) drawSpectrograph(canvasCtx, width, height, frequencyData, frequencyData.length);
};
const onPlay = () => {
if (!setupDoneRef.current) return;
if (audioCtxRef.current?.state === "suspended") {
audioCtxRef.current.resume();
}
draw();
};
const onPause = () => {
audioCtxRef.current?.suspend();
cancelAnimationFrame(animFrameRef.current);
};
audio.addEventListener("play", onPlay);
audio.addEventListener("pause", onPause);
init();
return () => {
cancelled = true;
audio.removeEventListener("play", onPlay);
audio.removeEventListener("pause", onPause);
cancelAnimationFrame(animFrameRef.current);
audioCtxRef.current?.close();
audioCtxRef.current = null;
analyserRef.current = null;
setupDoneRef.current = false;
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
};
}, [playerRef]);
React.useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return () => { };
const resize = () => {
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * window.devicePixelRatio;
canvas.height = rect.height * window.devicePixelRatio;
const ctx = canvas.getContext("2d");
ctx?.scale(window.devicePixelRatio, window.devicePixelRatio);
};
resize();
const observer = new ResizeObserver(resize);
observer.observe(canvas);
return () => observer.disconnect();
}, []);
return (
<canvas
className={cl("canvas")}
ref={canvasRef}
/>
);
}
const settings = definePluginSettings({
oscilloscope: {
type: OptionType.BOOLEAN,
description: "Enable oscilloscope visualizer",
description: "Enable oscilloscope visualizer.",
default: true,
},
spectrograph: {
type: OptionType.BOOLEAN,
description: "Enable spectrograph visualizer",
description: "Enable spectrograph visualizer.",
default: true,
},
oscilloscopeSolidColor: {
type: OptionType.BOOLEAN,
description: "Use solid color for oscilloscope",
description: "Use a solid color for the oscilloscope.",
default: false,
},
oscilloscopeColor: {
type: OptionType.STRING,
description: "Color for oscilloscope",
description: "Color for the oscilloscope (R, G, B or #hex).",
default: "255, 255, 255",
onChange: value => handleColorChange(value, "oscilloscopeColor", "255, 255, 255"),
onChange: value => validateColor(value, "oscilloscopeColor", "255, 255, 255"),
},
spectrographSolidColor: {
type: OptionType.BOOLEAN,
description: "Use solid color for spectrograph",
description: "Use a solid color for the spectrograph.",
default: false,
},
spectrographColor: {
type: OptionType.STRING,
description: "Color for spectrograph",
description: "Color for the spectrograph (R, G, B or #hex).",
default: "33, 150, 243",
onChange: value => handleColorChange(value, "spectrographColor", "33, 150, 243"),
},
forceMoveBelow: {
type: OptionType.BOOLEAN,
description: "Force the visualizer below the audio player",
default: true,
onChange: value => validateColor(value, "spectrographColor", "33, 150, 243"),
},
});
let observer: MutationObserver | null = null;
export default definePlugin({
name: "BetterAudioPlayer",
description: "Adds a spectrograph and oscilloscope visualizer to audio attachment players",
description: "Adds a spectrograph and oscilloscope visualizer to audio attachment players.",
authors: [EquicordDevs.creations],
settings,
start() {
const waitForContent = () => {
const targetNode = document.querySelector("[class*='content']");
if (targetNode) {
scanForAudioElements(targetNode as HTMLElement);
observer = createObserver(targetNode as HTMLElement);
} else {
requestAnimationFrame(waitForContent);
}
};
waitForContent();
},
stop() {
observer?.disconnect();
observer = null;
patches: [
{
find: "}renderPlayIcon(){",
replacement: {
match: /this\.renderAudio\(\):this\.renderVideo\(\)/,
replace: "$&,$self.renderVisualizer(this)",
},
},
],
renderVisualizer(player: PlayerInstance) {
if (player.props.type !== "AUDIO") return null;
return <Visualizer playerRef={player.mediaRef} src={player.props.src} key={player.props.src} />;
},
});

View file

@ -1,4 +1,4 @@
.better-audio-visualizer {
.vc-better-audio-player-canvas {
position: absolute;
top: 0;
left: 0;
@ -7,4 +7,5 @@
pointer-events: none;
z-index: 1;
border: none;
}
border-radius: inherit;
}

View file

@ -13,7 +13,7 @@ import { getGuildAcronym, getIntlMessage, getUniqueUsername } from "@utils/disco
import { classes } from "@utils/misc";
import { Channel, Guild, User } from "@vencord/discord-types";
import { findComponentByCodeLazy, findCssClassesLazy } from "@webpack";
import { Avatar, ChannelStore, ContextMenuApi, GuildStore, PresenceStore, ReadStateStore, ThemeStore, TypingStore, useDrag, useDrop, useEffect, useRef, UserStore, useState, useStateFromStores } from "@webpack/common";
import { ActiveJoinedThreadsStore, Avatar, ChannelStore, ContextMenuApi, GuildStore, PresenceStore, ReadStateStore, ThemeStore, TypingStore, useDrag, useDrop, useEffect, useRef, UserStore, useState, useStateFromStores } from "@webpack/common";
import { JSX } from "react";
import { TabContextMenu } from "./ContextMenus";
@ -75,19 +75,29 @@ function TypingIndicator({ isTyping }: { isTyping: boolean; }) {
: null;
}
function getChannelUnreadState(channelId: string) {
const channel = ChannelStore.getChannel(channelId);
const newForumPostCount = channel?.guild_id && channel.isForumLikeChannel?.()
? ActiveJoinedThreadsStore.getNewThreadCount(channel.guild_id, channel.id)
: 0;
const unreadCount = ReadStateStore.getUnreadCount(channelId) || newForumPostCount;
return {
channelId,
hasUnread: ReadStateStore.hasUnread(channelId) || newForumPostCount > 0,
mentionCount: ReadStateStore.getMentionCount(channelId),
unreadCount
};
}
export const NotificationDot = ({ channelIds }: { channelIds: string[]; }) => {
const userId = UserStore.getCurrentUser()?.id;
const { persistUnreadCountFallback } = settings.use(["persistUnreadCountFallback"]);
const [, forceUpdate] = useState(0);
const channelStateKey = channelIds.join(",");
const channelStates = useStateFromStores(
[ReadStateStore],
() => channelIds.map(channelId => ({
channelId,
hasUnread: ReadStateStore.hasUnread(channelId),
mentionCount: ReadStateStore.getMentionCount(channelId),
unreadCount: ReadStateStore.getUnreadCount(channelId)
}))
[ActiveJoinedThreadsStore, ReadStateStore],
() => channelIds.map(getChannelUnreadState)
);
const stateSignature = channelStates.map(state => `${state.channelId}:${Number(state.hasUnread)}:${state.mentionCount}:${state.unreadCount}`).join("|");
const { badgeText, hasMention, shouldShow } = getNotificationDotState(

View file

@ -1,71 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./style.css";
import { definePluginSettings } from "@api/Settings";
import ErrorBoundary from "@components/ErrorBoundary";
import { Devs, EquicordDevs } from "@utils/constants";
import { classNameFactory } from "@utils/css";
import definePlugin, { OptionType } from "@utils/types";
import { UserStore } from "@webpack/common";
const cl = classNameFactory("vc-char-counter-");
const settings = definePluginSettings({
colorEffects: {
type: OptionType.BOOLEAN,
description: "Turn on or off color effects for getting close to the character limit",
default: true,
},
});
export default definePlugin({
name: "CharacterCounter",
description: "Adds a character counter to the chat input",
authors: [EquicordDevs.creations, EquicordDevs.Panniku, Devs.thororen],
settings,
patches: [
{
find: ".CREATE_FORUM_POST||",
replacement: [
{
match: /(textValue:.{0,50}channelId:\i\.id\}\),)\i/,
replace: "$1$self.renderCharCounter(arguments[0].textValue)"
}
]
},
{
find: "#{intl::PREMIUM_MESSAGE_LENGTH_UPSELL_TOOLTIP}",
replacement: {
match: /return \i\?\i\(\):\i\(\)/,
replace: "return null"
}
}
],
renderCharCounter: ErrorBoundary.wrap(text => {
const premiumType = (UserStore.getCurrentUser()?.premiumType ?? 0);
const charMax = premiumType === 2 ? 4000 : 2000;
const { length } = text;
let color = "var(--primary-330)";
if (settings.store.colorEffects) {
const percentage = (length / charMax) * 100;
if (percentage < 50) color = "var(--text-muted)";
else if (percentage < 75) color = "var(--yellow-330)";
else if (percentage < 90) color = "var(--orange-330)";
else color = "var(--red-360)";
}
if (!length) return null;
return (
<div className={cl("counter")} style={{ color }}>
<span className={cl("count")} >{length}</span>/
<span className={cl("max")} >{charMax}</span>
</div>
);
}, { noop: true })
});

View file

@ -33,11 +33,8 @@ export default definePlugin({
Component: ComponentsTab,
Icon: ComponentsIcon
});
SettingsPlugin.settingsSectionMap.push(["EquicordDiscordComponents", "equicord_components"]);
},
stop() {
removeFromArray(SettingsPlugin.customEntries, e => e.key === "equicord_components");
removeFromArray(SettingsPlugin.settingsSectionMap, entry => entry[1] === "equicord_components");
},
});

View file

@ -155,7 +155,7 @@ export default definePlugin({
},
{
// Profile banner animation effect
find: "bannerAdjustment,noBorderRadius",
find: "bannerAdjustment,isHovering",
replacement: {
match: /\i=\i=>\{(?=.{0,50}\.useReducedMotion\))/,
replace: "$&return null;"

View file

@ -166,9 +166,9 @@ export default definePlugin({
patches: [
// Voice user rows (voice channel sidebar list)
{
find: "location:\"VoiceUser\"",
find: '"system:click_outside","user:escape"',
replacement: {
match: /(?<=previewIsOpen:.{0,100})"data-dnd-name":(\i)\.name,/,
match: /(?<=GuildChannelUserContextMenu.{0,100})"data-dnd-name":(\i)\.name,/,
replace: "$&\"data-dragify-user\":!0,\"data-user-id\":arguments[0].user?.id,draggable:!0,onDragStart:e=>$self.onUserDragStart(e,arguments[0].user),"
}
},

View file

@ -0,0 +1,284 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { isPluginEnabled } from "@api/PluginManager";
import { definePluginSettings } from "@api/Settings";
import fakeNitro from "@plugins/fakeNitro";
import { EquicordDevs } from "@utils/constants";
import { localStorage } from "@utils/localStorage";
import definePlugin, { OptionType } from "@utils/types";
interface StreamQualityOpts {
bitrateMin?: number;
bitrateMax?: number;
bitrateTarget?: number;
capture?: {
width?: number;
height?: number;
framerate?: number;
pixelCount?: number;
};
encode?: {
width?: number;
height?: number;
framerate?: number;
pixelCount?: number;
};
}
const settings = definePluginSettings({
unlockQualityOptions: {
type: OptionType.BOOLEAN,
description: "Unlock stream quality options regardless of Nitro status",
default: true,
restartNeeded: true,
},
removeResolutionCap: {
type: OptionType.BOOLEAN,
description: "Allow resolutions above 720p at 60fps",
default: true,
restartNeeded: true,
},
forceEncoderSettings: {
type: OptionType.BOOLEAN,
description: "Force encoder to use configured resolution/fps",
default: true,
restartNeeded: true,
},
preventDownscale: {
type: OptionType.BOOLEAN,
description: "Prevent Discord from downscaling stream resolution",
default: true,
restartNeeded: true,
},
keyframeInterval: {
type: OptionType.NUMBER,
description: "Keyframe interval in ms (0 = encoder default, 5000 = every 5s)",
default: 5000,
restartNeeded: true,
},
minBitrate: {
type: OptionType.NUMBER,
description: "Minimum encoder bitrate in kbps",
default: 500,
restartNeeded: true,
},
raiseBitrateCaps: {
type: OptionType.BOOLEAN,
description: "Raise default desktop bitrate caps (600kbps target → 10Mbps, 3.5Mbps max → 40Mbps)",
default: true,
restartNeeded: true,
},
preventFramerateReduction: {
type: OptionType.BOOLEAN,
description: "Prevent Discord from reducing stream framerate when not speaking",
default: true,
restartNeeded: true,
},
bitsPerPixelPct: {
type: OptionType.NUMBER,
description: "Bits per pixel percentage for target bitrate (8 = 0.08 bpp, 12 = 0.12bpp)",
default: 8,
restartNeeded: false,
},
});
export default definePlugin({
name: "EquibopStreamFixes",
description: "Tries to fix stream quality on Equibop by patching Discord's encoder and quality restrictions.",
authors: [EquicordDevs.creations],
settings,
patches: [
{
find: "this.getDefaultGoliveQuality()",
replacement: [
// override default stream quality with configured bitrate/resolution/fps
{
match: /(this\.goliveMaxQuality)=(this\.getDefaultGoliveQuality\(\))/,
replace: "$1=$self.patchStreamQuality($2)",
},
// inject configured quality when stream settings change mid-stream
{
match: /setGoliveQuality\((\i)\)\{/,
replace: "setGoliveQuality($1){$1=$self.patchGoliveArgs($1);",
},
// override encoder min/max bitrate limits
{
match:
/(\i)\.encodingVideoMinBitRate=\i\.bitrateMin,\i\.encodingVideoMaxBitRate=\i\.bitrateMax/,
replace: "$1.encodingVideoMinBitRate=$self.getMinBitrate(),$1.encodingVideoMaxBitRate=$self.getBitrateMax()",
},
],
},
// unlock stream quality options for all users regardless of nitro
{
find: "canUseCustomStickersEverywhere:",
replacement: [
{
match: /canStreamQuality:\i,/,
replace: "canStreamQuality:()=>true,",
},
{
match: /canUseHighVideoUploadQuality:\i,/,
replace: "canUseHighVideoUploadQuality:()=>true,",
},
],
predicate: () => settings.store.unlockQualityOptions && !isPluginEnabled(fakeNitro.name),
noWarn: true,
},
// remove guild premium tier restriction from stream fps options
{
find: "#{intl::STREAM_FPS_OPTION}",
replacement: {
match: /guildPremiumTier:\i\.\i\.TIER_\d,?/g,
replace: "",
},
predicate: () => settings.store.unlockQualityOptions && !isPluginEnabled(fakeNitro.name),
noWarn: true,
},
// allow resolutions above 720p at 60fps
{
find: ".RESOLUTION_720&&",
replacement: {
match: /\i===\i\.\i\.RESOLUTION_720&&\i!==\i\.\i\.FPS_60/,
replace: "false",
},
predicate: () => settings.store.removeResolutionCap,
},
// force encoder width/height/fps to configured values
{
find: "}setDesktopEncodingOptions(",
replacement: [
{
match: /setDesktopEncodingOptions\((\i),(\i),(\i)\)\{/,
replace: "$&$1=$self.getWidth();$2=$self.getHeight();$3=$self.getFps();",
predicate: () => settings.store.forceEncoderSettings,
},
// set keyframe interval for periodic quality refresh
{
match: /keyframeInterval=0/,
replace: "keyframeInterval=$self.getKeyframeInterval()",
},
],
},
// raise default desktop bitrate caps at the source
{
find: "desktopBitrate:{",
replacement: {
match: /desktopBitrate:\{min:5e5,max:35e5,target:6e5\}/,
replace: "desktopBitrate:{min:5e5,max:4e7,target:1e7}",
},
predicate: () => settings.store.raiseBitrateCaps,
},
// prevent framerate reduction when not speaking
{
find: "Reduced framerate after",
replacement: {
match: /this\.framerateReductionTimeout=setTimeout/,
replace: "this.framerateReductionTimeout=void 0&&setTimeout",
},
predicate: () => settings.store.preventFramerateReduction,
},
],
getStreamConfig() {
let quality: { frameRate?: string; resolution?: string; } | undefined;
try {
const state = JSON.parse(localStorage.getItem("EquibopState") ?? "{}");
quality = state.screenshareQuality;
} catch { }
const framerate = Number(quality?.frameRate ?? 30);
const height = Number(quality?.resolution ?? 720);
const width = Math.round(height * (16 / 9));
const pixelCount = width * height;
const pixelRate = pixelCount * framerate;
const bpp = (settings.store.bitsPerPixelPct ?? 8) / 100;
const bitrateTarget = Math.min(20000000, Math.max(1500000, Math.round(pixelRate * bpp)),);
const bitrateMax = Math.min(40000000, Math.max(bitrateTarget * 2, 8000000));
return { framerate, height, width, pixelCount, bitrateTarget, bitrateMax };
},
getWidth() {
return this.getStreamConfig().width;
},
getHeight() {
return this.getStreamConfig().height;
},
getFps() {
return this.getStreamConfig().framerate;
},
getBitrateMax() {
return this.getStreamConfig().bitrateMax;
},
coerceResolution(res: { height?: number; width?: number; }) {
if (!res) return res;
const config = this.getStreamConfig();
return {
...res,
height: Math.max(res.height ?? 0, config.height),
width: Math.max(res.width ?? 0, config.width),
};
},
coerceFps(fps: number | undefined) {
const config = this.getStreamConfig();
return typeof fps === "number"
? Math.max(fps, config.framerate)
: config.framerate;
},
getMinBitrate() {
return (settings.store.minBitrate ?? 500) * 1000;
},
getKeyframeInterval() {
return settings.store.keyframeInterval ?? 5000;
},
patchGoliveArgs(opts: StreamQualityOpts) {
const config = this.getStreamConfig();
return {
...opts,
bitrateTarget: config.bitrateTarget,
capture: {
...opts.capture,
width: config.width,
height: config.height,
framerate: config.framerate,
},
encode: {
...opts.encode,
width: config.width,
height: config.height,
framerate: config.framerate,
pixelCount: config.pixelCount,
},
};
},
patchStreamQuality(opts: StreamQualityOpts) {
const config = this.getStreamConfig();
Object.assign(opts, {
bitrateMin: this.getMinBitrate(),
bitrateMax: config.bitrateMax,
bitrateTarget: config.bitrateTarget,
});
if (opts?.encode) {
Object.assign(opts.encode, {
framerate: config.framerate,
width: config.width,
height: config.height,
pixelCount: config.pixelCount,
});
}
Object.assign((opts.capture ??= {}), {
framerate: config.framerate,
width: config.width,
height: config.height,
pixelCount: config.pixelCount,
});
return opts;
},
});

View file

@ -6,6 +6,10 @@
export const CORS_PROXY = "https://cors.keiran0.workers.dev";
export function toProxiedUrl(url: string): string {
return `${CORS_PROXY}?url=${encodeURIComponent(url)}`;
export function normalizeCorsProxyUrl(url?: string): string {
return (url?.trim() || CORS_PROXY).replace(/\/+$/, "");
}
export function toProxiedUrl(url: string, corsProxyUrl = CORS_PROXY): string {
return `${normalizeCorsProxyUrl(corsProxyUrl)}?url=${encodeURIComponent(url)}`;
}

View file

@ -12,6 +12,7 @@ import { useForceUpdater } from "@utils/react";
import { OptionType } from "@utils/types";
import { React, Select, showToast, TextArea, TextInput, Toasts } from "@webpack/common";
import { CORS_PROXY } from "./constants";
import { ServiceType } from "./types";
import { parseShareXConfig } from "./utils/sharex";
@ -196,6 +197,12 @@ export const settings = definePluginSettings({
default: false,
hidden: true
},
corsProxyUrl: {
type: OptionType.STRING,
description: "CORS proxy URL used for browser uploads",
default: CORS_PROXY,
hidden: true
},
apngToGif: {
type: OptionType.BOOLEAN,
description: "Convert APNG uploads to GIF",
@ -482,6 +489,18 @@ export function SettingsComponent() {
/>
</SettingsSection>
<SettingTextInput
name="CORS Proxy URL"
description="CORS proxy used for web uploads. Leave empty to use the default proxy"
value={store.corsProxyUrl || ""}
onChange={v => store.corsProxyUrl = v}
placeholder="https://your-cors-proxy.example.com"
/>
<SettingsSection name="Default CORS Proxy Source" description="Source code for the default CORS proxy">
<a href="https://codeberg.org/key/corsproxy" target="_blank" rel="noreferrer">codeberg.org/key/corsproxy</a>
</SettingsSection>
<SettingsSection tag="label" name="Convert APNG to GIF" description="Convert APNG files to GIF format" inlineSetting>
<Switch
checked={store.apngToGif}

View file

@ -18,6 +18,7 @@ type S3Store = {
s3PublicUrl?: string;
s3Prefix?: string;
s3ForcePathStyle?: boolean;
corsProxyUrl?: string;
};
const textEncoder = new TextEncoder();
@ -188,7 +189,7 @@ export async function uploadToS3(
throw new Error(result.error || "Upload failed");
}
} else {
const response = await fetch(toProxiedUrl(uploadUrl.toString()), {
const response = await fetch(toProxiedUrl(uploadUrl.toString(), settings.store.corsProxyUrl), {
method: "PUT",
headers: requestHeaders,
body: fileBlob

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { CORS_PROXY, toProxiedUrl } from "@equicordplugins/fileUpload/constants";
import { normalizeCorsProxyUrl, toProxiedUrl } from "@equicordplugins/fileUpload/constants";
import { settings } from "@equicordplugins/fileUpload/settings";
import { serviceLabels, ServiceType, ShareXUploaderConfig, UploadResponse } from "@equicordplugins/fileUpload/types";
import { copyToClipboard } from "@utils/clipboard";
@ -26,11 +26,13 @@ const Native = IS_DISCORD_DESKTOP
const logger = new Logger("FileUpload", "#7cb7ff");
function toProxyUrl(url: string): string {
if (Native || url.startsWith(`${CORS_PROXY}?url=`)) {
const corsProxyUrl = normalizeCorsProxyUrl((settings.store as { corsProxyUrl?: string; }).corsProxyUrl);
if (Native || url.startsWith(`${corsProxyUrl}?url=`)) {
return url;
}
return toProxiedUrl(url);
return toProxiedUrl(url, corsProxyUrl);
}
let isUploading = false;

View file

@ -25,7 +25,11 @@
justify-content: center;
margin-top: 2px;
padding-top: 12px;
border-top: 1px solid var(--profile-body-divider-color, var(--input-border-default, var(--input-border)));
border-top: 1px solid
var(
--profile-body-divider-color,
var(--input-border-default, var(--input-border))
);
}
.vc-github-repos-button,
@ -37,7 +41,11 @@
.vc-github-repos-card {
padding: 8px;
border-radius: var(--radius-xs);
border: 1px solid var(--profile-body-divider-color, var(--input-border-default, var(--input-border)));
border: 1px solid
var(
--profile-body-divider-color,
var(--input-border-default, var(--input-border))
);
}
.vc-github-repos-header {
@ -227,19 +235,19 @@
}
.vc-github-repos-header-repo {
width: 20%
width: 20%;
}
.vc-github-repos-header-description {
width: 50%
width: 50%;
}
.vc-github-repos-header-language {
width: 15%
width: 15%;
}
.vc-github-repos-header-stars {
width: 10%
width: 10%;
}
/* Tab variant styles */
@ -291,4 +299,4 @@
.vc-github-repos-container.vc-github-repos-tab .vc-github-repos-star-icon,
.vc-github-repos-container.vc-github-repos-tab .vc-github-repos-stars {
margin-left: 2px;
}
}

View file

@ -5,12 +5,14 @@
*/
import { Devs } from "@utils/constants";
import { classNameFactory } from "@utils/css";
import definePlugin from "@utils/types";
import { findComponentByCodeLazy, findStoreLazy } from "@webpack";
import { TypingStore, UserStore, useStateFromStores } from "@webpack/common";
const ThreeDots = findComponentByCodeLazy("Math.min(1,Math.max(", "dotRadius:");
const cl = classNameFactory("vc-home-typing-");
const ThreeDots = findComponentByCodeLazy("Math.min(1,Math.max(", "dotRadius:");
const PrivateChannelSortStore = findStoreLazy("PrivateChannelSortStore") as { getPrivateChannelIds: () => string[]; };
export default definePlugin({
@ -18,7 +20,7 @@ export default definePlugin({
description: "Changes the home button to a typing indicator if someone in your dms is typing",
authors: [Devs.Samwich],
TypingIcon() {
return <ThreeDots dotRadius={3} themed={true} />;
return <ThreeDots className={cl("dots")} dotRadius={3} themed={true} />;
},
isTyping() {
return useStateFromStores([TypingStore], () =>

View file

@ -31,7 +31,6 @@ export default definePlugin({
Component: () => <DataUI usersCollection={data.usersCollection} />,
Icon: EyeIcon
});
SettingsPlugin.settingsSectionMap.push(["EquicordIRememberYou", "equicord_i_remember_you"]);
const data = (this.dataManager = await new Data().withStart());
@ -46,7 +45,6 @@ export default definePlugin({
stop() {
removeFromArray(SettingsPlugin.customEntries, e => e.key === "equicord_i_remember_you");
removeFromArray(SettingsPlugin.settingsSectionMap, entry => entry[1] === "equicord_i_remember_you");
const dataManager = this.dataManager as Data;
removeMessagePreSendListener(dataManager._onMessagePreSend_preSend);

View file

@ -35,11 +35,8 @@ export default definePlugin({
Component: IconsTab,
Icon: MagnifyingGlassIcon
});
SettingsPlugin.settingsSectionMap.push(["EquicordDiscordIcons", "equicord_icon_viewer"]);
},
stop() {
removeFromArray(SettingsPlugin.customEntries, e => e.key === "equicord_icon_viewer");
removeFromArray(SettingsPlugin.settingsSectionMap, entry => entry[1] === "equicord_icon_viewer");
},
});

View file

@ -66,13 +66,11 @@ export default definePlugin({
Component: openQrModal,
Icon: QrCodeIcon
});
SettingsPlugin.settingsSectionMap.push(["EquicordLoginWithQR", "equicord_login_with_qr"]);
preload();
},
stop() {
removeFromArray(SettingsPlugin.customEntries, e => e.key === "equicord_login_with_qr");
removeFromArray(SettingsPlugin.settingsSectionMap, entry => entry[1] === "equicord_login_with_qr");
unload();
},
});

View file

@ -6,10 +6,12 @@
import { definePluginSettings } from "@api/Settings";
import { disableStyle, enableStyle } from "@api/Styles";
import { ButtonCompat } from "@components/Button";
import { HeadingSecondary } from "@components/Heading";
import { Paragraph } from "@components/Paragraph";
import { SettingsSection } from "@components/settings/tabs/plugins/components/Common";
import { makeRange, OptionType } from "@utils/types";
import { Button, MaskedLink, showToast, Toasts } from "@webpack/common";
import { MaskedLink, Select, showToast, TextInput, Toasts } from "@webpack/common";
import hoverOnlyStyle from "./hoverOnly.css?managed";
import { clearLyricsCache, removeTranslations } from "./spotify/lyrics/api";
@ -36,6 +38,46 @@ function InstallInstructions() {
);
}
function LyricsProviderSettings() {
const { store } = settings;
return (
<>
<SettingsSection name="Lyrics Provider" description="Where lyrics are fetched from.">
<Select
options={[
{ value: Provider.Lrclib, label: "LRCLIB", default: true },
{ value: Provider.Spotify, label: "Spotify (Musixmatch)" },
]}
isSelected={v => v === store.lyricsProvider}
select={v => { store.lyricsProvider = v as Provider; }}
serialize={v => v}
placeholder="Select a lyrics provider"
/>
</SettingsSection>
{store.lyricsProvider === Provider.Spotify && (
<SettingsSection
name="Spotify Lyrics API Base URL"
description="Custom instance base URL (for example: http://localhost:8080)."
>
<TextInput
type="text"
value={store.spotifyLyricsApiUrl}
onChange={v => {
store.spotifyLyricsApiUrl = v;
void clearLyricsCache();
showToast("Lyrics cache purged", Toasts.Type.SUCCESS);
}}
placeholder="https://spotify-lyrics-api-pi.vercel.app"
maxLength={null}
/>
</SettingsSection>
)}
</>
);
}
export const settings = definePluginSettings({
hoverControls: {
description: "Show controls on hover",
@ -60,9 +102,24 @@ export const settings = definePluginSettings({
description: "Where lyrics are fetched from",
type: OptionType.SELECT,
options: [
{ value: Provider.Spotify, label: "Spotify (Musixmatch)", default: true },
{ value: Provider.Lrclib, label: "LRCLIB" },
{ value: Provider.Lrclib, label: "LRCLIB", default: true },
{ value: Provider.Spotify, label: "Spotify (Musixmatch)" },
],
hidden: true,
},
spotifyLyricsApiUrl: {
type: OptionType.STRING,
description: "Spotify lyrics API base URL.",
hidden: true,
default: "https://spotify-lyrics-api-pi.vercel.app",
onChange: async () => {
await clearLyricsCache();
showToast("Lyrics cache purged", Toasts.Type.SUCCESS);
}
},
lyricsProviderSettings: {
type: OptionType.COMPONENT,
component: LyricsProviderSettings,
},
translateTo: {
description: "Translate lyrics to - Changing this will clear existing translations",
@ -102,15 +159,15 @@ export const settings = definePluginSettings({
description: "Purge the lyrics cache",
type: OptionType.COMPONENT,
component: () => (
<Button
color={Button.Colors.RED}
<ButtonCompat
color={ButtonCompat.Colors.RED}
onClick={() => {
clearLyricsCache();
showToast("Lyrics cache purged", Toasts.Type.SUCCESS);
}}
>
Purge Cache
</Button>
</ButtonCompat>
),
},
spotifySectionTitle: {
@ -164,4 +221,10 @@ export const settings = definePluginSettings({
type: OptionType.BOOLEAN,
default: false,
},
WebsocketURL: {
type: OptionType.STRING,
description: "Default is ws://localhost:24123",
default: "ws://localhost:24123",
restartNeeded: true,
}
});

View file

@ -22,7 +22,7 @@ interface NullLyricCacheEntry {
const nullLyricCache = new Map<string, NullLyricCacheEntry>();
export const lyricFetchers = {
[Provider.Spotify]: async (track: Track) => await getLyricsSpotify(track.id),
[Provider.Spotify]: async (track: Track) => await getLyricsSpotify(track.id, settings.store.spotifyLyricsApiUrl),
[Provider.Lrclib]: getLyricsLrclib,
};

View file

@ -19,8 +19,17 @@ interface Line {
endTimeMs: string;
}
export async function getLyricsSpotify(trackId: string): Promise<LyricsData | null> {
const resp = await fetch("https://spotify-lyrics-api-pi.vercel.app/?trackid=" + trackId);
const defaultSpotifyLyricsApiUrl = "https://spotify-lyrics-api-pi.vercel.app";
function makeSpotifyLyricsApiUrl(trackId: string, customBaseUrl?: string): string {
const normalizedBaseUrl = customBaseUrl?.trim().replace(/\/+$/, "") || defaultSpotifyLyricsApiUrl;
const url = new URL(normalizedBaseUrl);
url.searchParams.set("trackid", trackId);
return url.toString();
}
export async function getLyricsSpotify(trackId: string, customBaseUrl?: string): Promise<LyricsData | null> {
const resp = await fetch(makeSpotifyLyricsApiUrl(trackId, customBaseUrl));
if (!resp.ok) return null;
let data: LyricsAPIResp;
@ -30,6 +39,8 @@ export async function getLyricsSpotify(trackId: string): Promise<LyricsData | nu
return null;
}
if (data.error || !Array.isArray(data.lines) || data.lines.length < 2) return null;
const lyrics = data.lines;
if (lyrics[0].startTimeMs === "0" && lyrics[lyrics.length - 1].startTimeMs === "0") return null;

View file

@ -8,6 +8,8 @@ import { Logger } from "@utils/Logger";
import { proxyLazyWebpack } from "@webpack";
import { Flux, FluxDispatcher } from "@webpack/common";
import { settings } from "../settings";
export interface Track {
id: string;
name: string;
@ -91,7 +93,7 @@ class TidalSocket {
}
private initWs() {
const url = "ws://localhost:24123";
const url = settings.store.websocketURL || "ws://localhost:24123";
if (!url) {
return;
}

View file

@ -24,16 +24,7 @@ function getFreshPendingAvatar(section: PresetSection, guildId?: string): string
? UserProfileSettingsStore.getPendingChanges?.(guildId)
: UserProfileSettingsStore.getPendingChanges?.()) ?? {};
const pendingObj = pending as Record<string, unknown>;
const candidates = [
pendingObj.selectedAvatarRaw,
pendingObj.presetAvatarRaw,
pendingObj.pendingAvatar,
pendingObj.avatar,
pendingObj.selectedAvatarProcessed,
pendingObj.presetAvatarProcessed
];
const selected = candidates.find(isImageInput);
const selected = [pendingObj.pendingAvatar].find(isImageInput);
if (!selected) return null;
return typeof selected === "string" ? selected : selected.imageUri;
}

View file

@ -5,7 +5,7 @@
*/
import { getUserSettingLazy } from "@api/UserSettings";
import { CustomStatus, DisplayNameStyles, Nameplate, ProfileEffect, ProfilePreset } from "@vencord/discord-types";
import { AvatarDecorationData, CustomStatus, DisplayNameStyles, Nameplate, ProfileEffect, ProfilePreset } from "@vencord/discord-types";
import { findStoreLazy } from "@webpack";
import { FluxDispatcher, GuildMemberStore, IconUtils, UserProfileStore, UserStore } from "@webpack/common";
@ -13,15 +13,9 @@ const UserProfileSettingsStore = findStoreLazy("UserProfileSettingsStore");
const CustomStatusSettings = getUserSettingLazy("status", "customStatus")!;
type PendingChanges = Record<string, unknown> & {
selectedAvatarRaw?: ImageInput;
presetAvatarRaw?: ImageInput;
selectedAvatarProcessed?: ImageInput;
presetAvatarProcessed?: ImageInput;
pendingAvatar?: ImageInput;
avatar?: ImageInput;
pendingBanner?: ImageInput;
banner?: ImageInput;
pendingAvatarDecoration?: { asset: string; skuId: string; } | null;
pendingAvatarDecoration?: AvatarDecorationLike | null;
pendingProfileEffect?: ProfileEffect | null;
pendingNameplate?: Nameplate | null;
pendingDisplayNameStyles?: DisplayNameStyles | null;
@ -34,7 +28,11 @@ type PendingChanges = Record<string, unknown> & {
pendingPrimaryGuildId?: string | null;
};
type ImageInput = string | { imageUri: string; } | null | undefined;
type ImageInput = string | { imageUri: string; [key: string]: unknown; } | null | undefined;
type AvatarDecorationLike = AvatarDecorationData & {
label?: string;
type?: number;
};
type DisplayNameStylesLike = DisplayNameStyles & {
fontId?: number;
effectId?: number;
@ -55,6 +53,10 @@ function dispatch(type: string, payload: Record<string, unknown>) {
FluxDispatcher.dispatch({ type, ...payload });
}
function setPendingChanges(payload: Record<string, unknown>, guildId?: string) {
dispatch("USER_PROFILE_SETTINGS_SET_PENDING_CHANGES", guildId ? { guildId, ...payload } : payload);
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.length > 0;
}
@ -65,6 +67,15 @@ function hasImageInput(value: ImageInput): boolean {
return typeof value === "object" && isNonEmptyString(value?.imageUri);
}
function hasAvatarDecoration(value: unknown): value is AvatarDecorationLike {
return typeof value === "object"
&& value != null
&& "asset" in value
&& "skuId" in value
&& isNonEmptyString((value as { asset?: unknown; }).asset)
&& isNonEmptyString((value as { skuId?: unknown; }).skuId);
}
function normalizeDisplayNameStyles(value: DisplayNameStylesLike | null | undefined): DisplayNameStylesLike | null {
if (!value) return null;
const fontId = value.fontId ?? value.font_id;
@ -140,7 +151,6 @@ export async function getCurrentProfile(guildId?: string, options: CurrentProfil
const pendingChanges: PendingChanges = isGuildProfile && Object.keys(pendingChangesForGuild).length > 0
? pendingChangesForGuild
: pendingChangesDefault;
const customStatusSetting = CustomStatusSettings.getSetting();
const customStatus = isGuildProfile
? null
@ -151,11 +161,15 @@ export async function getCurrentProfile(guildId?: string, options: CurrentProfil
expiresAtMs: customStatusSetting?.expiresAtMs ?? "0"
};
const avatarDecorationSource = pendingChanges.pendingAvatarDecoration ?? userAny.avatarDecorationData;
const avatarDecoration = avatarDecorationSource ? {
asset: avatarDecorationSource.asset,
skuId: avatarDecorationSource.skuId
} : null;
const avatarDecorationSource = pendingChanges.pendingAvatarDecoration
?? (isGuildProfile ? guildMember?.avatarDecoration : userAny.avatarDecorationData);
const avatarDecoration = hasAvatarDecoration(avatarDecorationSource)
? {
...avatarDecorationSource,
asset: avatarDecorationSource.asset,
skuId: avatarDecorationSource.skuId
}
: null;
let profileEffect: ProfileEffect | null = null;
const effectToUse = pendingChanges.pendingProfileEffect ?? userProfile?.profileEffect;
@ -194,7 +208,8 @@ export async function getCurrentProfile(guildId?: string, options: CurrentProfil
}
}
const nameplateToUse = pendingChanges.pendingNameplate ?? userAny.collectibles?.nameplate;
const nameplateToUse = pendingChanges.pendingNameplate
?? (isGuildProfile ? guildMember?.collectibles?.nameplate : userAny.collectibles?.nameplate);
const nameplate = nameplateToUse ? {
skuId: nameplateToUse.skuId,
asset: nameplateToUse.asset,
@ -209,7 +224,7 @@ export async function getCurrentProfile(guildId?: string, options: CurrentProfil
const displayNameStylesToUse = pendingChanges.pendingDisplayNameStyles ?? savedDisplayNameStyles;
const displayNameStyles = normalizeDisplayNameStyles(displayNameStylesToUse);
const pendingAvatar = pendingChanges.pendingAvatar ?? pendingChanges.avatar;
const { pendingAvatar } = pendingChanges;
const avatarToUse: ImageInput = hasImageInput(pendingAvatar)
? pendingAvatar
: (isGuildProfile ? (guildMember?.avatar ?? currentUser.avatar ?? null) : (currentUser.avatar ?? null));
@ -222,7 +237,7 @@ export async function getCurrentProfile(guildId?: string, options: CurrentProfil
const avatarDataUrl = await processImage(avatarInput, currentUser.id, "avatar", effectiveGuildId, useGuildAvatar);
const resolvedAvatarDataUrl = avatarDataUrl ?? IconUtils.getDefaultAvatarURL(currentUser.id);
const pendingBanner = pendingChanges.pendingBanner ?? pendingChanges.banner;
const { pendingBanner } = pendingChanges;
const bannerToUse: ImageInput = hasImageInput(pendingBanner)
? pendingBanner
: (isGuildProfile ? (guildProfile?.banner ?? baseProfile?.banner) : baseProfile?.banner);
@ -239,7 +254,7 @@ export async function getCurrentProfile(guildId?: string, options: CurrentProfil
globalName: isGuildProfile
? (pendingChanges.pendingNickname ?? guildMember?.nick ?? null)
: (pendingChanges.pendingGlobalName ?? currentUser.globalName ?? null),
pronouns: isGuildProfile ? null : (pendingChanges.pendingPronouns ?? userProfile?.pronouns ?? null),
pronouns: pendingChanges.pendingPronouns ?? userProfile?.pronouns ?? null,
avatarDecoration,
profileEffect,
nameplate,
@ -267,15 +282,7 @@ function customStatusEq(a: CustomStatus | null | undefined, b: CustomStatus | nu
function resolvePendingAvatar(pendingChanges: PendingChanges | null): ImageInput {
if (!pendingChanges) return null;
const candidates: ImageInput[] = [
pendingChanges.selectedAvatarRaw,
pendingChanges.presetAvatarRaw,
pendingChanges.pendingAvatar,
pendingChanges.avatar,
pendingChanges.selectedAvatarProcessed,
pendingChanges.presetAvatarProcessed
];
return candidates.find(hasImageInput) ?? null;
return hasImageInput(pendingChanges.pendingAvatar) ? pendingChanges.pendingAvatar : null;
}
function normalizeImageValue(value: unknown): string | null {
@ -306,20 +313,16 @@ export async function loadPresetAsPending(preset: ProfilePreset, guildId?: strin
try {
const isGuild = options.isGuildProfile ?? Boolean(guildId);
if (isGuild && !guildId) return;
const currentUser = UserStore.getCurrentUser();
const baseProfile = UserProfileStore.getUserProfile(currentUser.id);
const guildProfile = isGuild && guildId ? UserProfileStore.getGuildMemberProfile(currentUser.id, guildId) : null;
const equippedProfile = guildProfile ?? baseProfile;
const current = await getCurrentProfile(guildId, {
isGuildProfile: isGuild
});
const pendingChanges = (isGuild && guildId
? UserProfileSettingsStore.getPendingChanges(guildId)
: UserProfileSettingsStore.getPendingChanges());
const setPending = (field: string, payload: Record<string, unknown>) => {
const setPending = (payload: Record<string, unknown>) => {
const cleanPayload = Object.fromEntries(Object.entries(payload).filter(([, v]) => v !== undefined));
if (!Object.keys(cleanPayload).length) return;
dispatch(`USER_PROFILE_SETTINGS_SET_PENDING_${field}`, isGuild ? { ...cleanPayload, guildId } : cleanPayload);
setPendingChanges(cleanPayload, isGuild ? guildId : undefined);
};
if ("avatarDataUrl" in preset) {
@ -327,72 +330,73 @@ export async function loadPresetAsPending(preset: ProfilePreset, guildId?: strin
const presetAvatar = normalizeImageValue(avatarValue);
const currentAvatar = normalizeImageValue(current.avatarDataUrl);
const pendingAvatar = normalizeImageValue(resolvePendingAvatar(pendingChanges));
if (isGuild || (presetAvatar !== currentAvatar && presetAvatar !== pendingAvatar)) {
if (presetAvatar !== currentAvatar && presetAvatar !== pendingAvatar) {
const avatarPayload =
typeof avatarValue === "string" && avatarValue.startsWith("data:")
avatarValue?.startsWith?.("data:")
? {
assetOrigin: "NEW_ASSET",
imageUri: avatarValue,
description: `profilesets-${preset.name ?? "preset"}`
}
: avatarValue;
setPending("AVATAR", { avatar: avatarPayload });
setPending({ pendingAvatar: avatarPayload });
}
}
if ("bannerDataUrl" in preset && preset.bannerDataUrl !== current.bannerDataUrl) {
setPending("BANNER", { banner: preset.bannerDataUrl });
const bannerPayload = preset.bannerDataUrl?.startsWith?.("data:")
? {
assetOrigin: "NEW_ASSET",
imageUri: preset.bannerDataUrl,
description: `profilesets-${preset.name ?? "preset"}`
}
: preset.bannerDataUrl;
setPending({ pendingBanner: bannerPayload });
}
if (!options.skipBio && preset?.bio !== current?.bio) {
const bioValue = preset.bio === null ? "" : preset.bio;
setPending("BIO", { bio: bioValue });
setPending({ pendingBio: preset.bio ?? "" });
}
if (!isGuild && !options.skipPronouns && preset?.pronouns !== current?.pronouns) {
const pronounsValue = preset.pronouns === null ? "" : preset.pronouns;
setPending("PRONOUNS", { pronouns: pronounsValue });
if (!options.skipPronouns && preset?.pronouns !== current?.pronouns) {
setPending({ pendingPronouns: preset.pronouns ?? "" });
}
if (!options.skipGlobalName && preset?.globalName !== current?.globalName) {
if (isGuild) {
setPending("NICKNAME", { nickname: preset.globalName });
} else {
setPending("GLOBAL_NAME", { globalName: preset.globalName });
}
setPending(isGuild ? { pendingNickname: preset.globalName } : { pendingGlobalName: preset.globalName });
}
if (preset.avatarDecoration !== undefined && !avatarDecorationEq(preset.avatarDecoration, current.avatarDecoration)) {
setPending("COLLECTIBLES_ITEM", {
item: { type: 0, value: preset.avatarDecoration }
setPending({
pendingAvatarDecoration: preset.avatarDecoration
});
}
if (preset.profileEffect !== undefined && !collectibleEqBySku(preset.profileEffect, current.profileEffect)) {
setPending("COLLECTIBLES_ITEM", {
item: { type: 1, value: preset.profileEffect }
setPending({
pendingProfileEffect: preset.profileEffect
});
}
if (preset.nameplate !== undefined && !nameplateEq(preset.nameplate, current.nameplate)) {
setPending("COLLECTIBLES_ITEM", {
item: { type: 2, value: preset.nameplate }
setPending({
pendingNameplate: preset.nameplate
});
}
if (preset.displayNameStyles) {
const presetDisplayNameStyles = normalizeDisplayNameStyles(preset.displayNameStyles);
if (!jsonEq(presetDisplayNameStyles, current.displayNameStyles)) {
setPending("DISPLAY_NAME_STYLES", { displayNameStyles: presetDisplayNameStyles });
setPending({ pendingDisplayNameStyles: presetDisplayNameStyles });
}
}
if (preset.themeColors && !jsonEq(preset.themeColors, current.themeColors)) {
setPending("THEME_COLORS", { themeColors: preset.themeColors });
setPending({ pendingThemeColors: preset.themeColors });
}
if (preset.primaryGuildId && !isGuild && preset.primaryGuildId !== current.primaryGuildId) {
dispatch("USER_SETTINGS_SET_PENDING_PRIMARY_GUILD_ID", { primaryGuildId: preset.primaryGuildId });
setPending({ pendingPrimaryGuildId: preset.primaryGuildId });
}
if (preset.customStatus && !isGuild && !customStatusEq(preset.customStatus, current.customStatus)) {

File diff suppressed because it is too large Load diff

View file

@ -31,12 +31,9 @@ export default definePlugin({
Component: require("./components/ThemeTab").default,
Icon: ColorPaletteIcon
});
SettingsPlugin.settingsSectionMap.push(["EquicordThemeLibrary", "equicord_theme_library"]);
},
stop() {
removeFromArray(SettingsPlugin.customEntries, e => e.key === "equicord_theme_library");
removeFromArray(SettingsPlugin.settingsSectionMap, entry => entry[1] === "equicord_theme_library");
},
});

View file

@ -23,7 +23,7 @@ export default definePlugin({
find: ".VOICE_PANEL}}",
replacement: [
{
match: /\[\i\.\i\]:\i\}\),children:\[/,
match: /\}\),children:\[(?=.{0,200}\.VOICE_CHANNEL_GAME_ACTIVITY_INDICATOR_VIEWED)/,
replace: "$&$self.renderButtons(arguments[0]?.user),"
}
]

View file

@ -13,7 +13,6 @@ export default definePlugin({
description: "Allows you to omit either width or height when opening an image modal",
patches: [
{
// TODO: bundler compat
find: ".renderLinkComponent",
replacement: {
// widthAndHeightPassed = w != null && w !== 0 && h == null || h === 0

View file

@ -35,12 +35,6 @@ export default definePlugin({
{
match: /(?<=,NOTICE_DISMISS:function\(\i\){)return null!=(\i)/,
replace: (m, notice) => `if(${notice}?.id=="EquicordNotice")return(${notice}=null,Vencord.Api.Notices.nextNotice(),true);${m}`
},
// FIXME(Bundler agressive inline): Remove the non used compability once enough time has passed
{
match: /(?<=function (\i)\(\i\){)return null!=(\i)(?=.+?NOTICE_DISMISS:\1)/,
replace: (m, _, notice) => `if(${notice}?.id=="EquicordNotice")return(${notice}=null,Vencord.Api.Notices.nextNotice(),true);${m}`,
noWarn: true
}
]
}

View file

@ -110,18 +110,6 @@ const settings = definePluginSettings({
}
});
const settingsSectionMap: [string, string][] = [
["EquicordSettings", "equicord_main_panel"],
["EquicordPlugins", "equicord_plugins_panel"],
["EquicordThemes", "equicord_themes_panel"],
["EquicordUpdater", "equicord_updater_panel"],
["EquicordChangelog", "equicord_changelog_panel"],
["EquicordCloud", "equicord_cloud_panel"],
["EquicordBackupAndRestore", "equicord_backup_restore_panel"],
["EquicordPatchHelper", "equicord_patch_helper_panel"],
["EquibopSettings", "equicord_equibop_settings_panel"],
];
export default definePlugin({
name: "Settings",
description: "Adds Settings UI and debug info",
@ -129,7 +117,6 @@ export default definePlugin({
required: true,
settings,
settingsSectionMap,
patches: [
{
@ -189,10 +176,6 @@ export default definePlugin({
});
},
getSettingsSectionMappings() {
return settingsSectionMap;
},
buildLayout(originalLayoutBuilder: SettingsLayoutBuilder) {
const layout = originalLayoutBuilder.buildLayout();
if (originalLayoutBuilder.key !== "$Root") return layout;

View file

@ -28,7 +28,7 @@ import { ChannelStore, FluxDispatcher, ReadStateStore } from "@webpack/common";
import { ReactNode } from "react";
import FolderSideBar from "./FolderSideBar";
import { areNestedRelated as areNestedRelatedInMap, getAncestorFolderIds as getAncestorFolderIdsFromMap, getChildFolderIds as getChildFolderIdsFromMap, getDescendantFolderIds as getDescendantFolderIdsFromMap, hasParentInChain as hasParentInChainInMap, sanitizeNestedFolderMap } from "./nestedFolders";
import { areNestedRelated as areNestedRelatedInMap, getChildFolderIds as getChildFolderIdsFromMap, getDescendantFolderIds as getDescendantFolderIdsFromMap, getGuildNavigationExpandTargets, hasParentInChain as hasParentInChainInMap, sanitizeNestedFolderMap } from "./nestedFolders";
enum FolderIconDisplay {
Never,
@ -67,13 +67,27 @@ const FolderUtils = findByPropsLazy("move", "toggleGuildFolderExpand");
const FolderItem = findComponentByCodeLazy("FolderItem", "onExpandCollapse", "folderButtonSize");
const MAX_TREE_FILTER_DEPTH = 1000;
let lastGuildId = null as string | null;
let dispatchingFoldersClose = false;
function getGuildFolder(id: string) {
return SortedGuildStore.getGuildFolders().find((folder: GuildFolder) => folder.guildIds.includes(id));
}
function expandGuildNavigationPath(guildId: string | null | undefined) {
if (!guildId || guildId === "@me") return;
for (const folderId of getGuildNavigationExpandTargets(
getNestedFolderMap(),
SortedGuildStore.getGuildFolders(),
guildId,
ExpandedGuildFolderStore.getExpandedFolders()
)) {
if (!ExpandedGuildFolderStore.isFolderExpanded(folderId)) {
FolderUtils.toggleGuildFolderExpand(folderId);
}
}
}
function closeFolders() {
for (const id of ExpandedGuildFolderStore.getExpandedFolders())
FolderUtils.toggleGuildFolderExpand(id);
@ -148,10 +162,6 @@ function getDescendantFolderIds(parentId: string | number): string[] {
return getDescendantFolderIdsFromMap(getNestedFolderMap(), parentId);
}
function getAncestorFolderIds(childId: string | number): string[] {
return getAncestorFolderIdsFromMap(getNestedFolderMap(), childId);
}
function nestFolder(childId: string, parentId: string) {
if (childId === parentId) return;
if (hasParentInChain(parentId, childId)) return;
@ -421,8 +431,8 @@ export default definePlugin({
replace: "canDrop:e=>$self.canDropOnFolder(e,arguments[1],arguments[3])"
},
{
match: /drop\(\i\)\{(?=.{0,25}!==\i\.\i\.FOLDER)/,
replace: "$&if($self.handleFolderDrop(arguments[0],arguments[1],arguments[2],arguments[3]))return;"
match: /(\i),(\i)\)\{let (\i)=arguments.*?,(\i)=arguments.{0,250}drop\(\i\)\{(?=.{0,25}!==\i\.\i\.FOLDER)/,
replace: "$&if($self.handleFolderDrop($1,$2,$3,$4))return;"
},
{
match: /\[\i\.\i\.GUILD\](?=.{0,250}#{intl::DND_DROP_COMBINE})/,
@ -440,27 +450,25 @@ export default definePlugin({
],
flux: {
GUILD_SELECT({ guildId }: { guildId: string | null; }) {
if (guildId == null) return;
expandGuildNavigationPath(guildId);
},
CHANNEL_SELECT(data) {
if (lastGuildId !== data.guildId) {
lastGuildId = data.guildId;
const guildFolder = getGuildFolder(data.guildId);
const guildFolder = getGuildFolder(data.guildId);
if (guildFolder?.folderId) {
for (const folderId of getAncestorFolderIds(guildFolder.folderId).reverse()) {
if (!ExpandedGuildFolderStore.isFolderExpanded(folderId)) {
FolderUtils.toggleGuildFolderExpand(folderId);
}
}
if (guildFolder?.folderId) {
expandGuildNavigationPath(data.guildId);
if (settings.store.forceOpen && !ExpandedGuildFolderStore.isFolderExpanded(guildFolder.folderId)) {
FolderUtils.toggleGuildFolderExpand(guildFolder.folderId);
}
if (settings.store.closeServerFolder && ExpandedGuildFolderStore.isFolderExpanded(guildFolder.folderId)) {
FolderUtils.toggleGuildFolderExpand(guildFolder.folderId);
}
} else if (settings.store.closeAllFolders) {
closeFolders();
if (settings.store.forceOpen && !ExpandedGuildFolderStore.isFolderExpanded(guildFolder.folderId)) {
FolderUtils.toggleGuildFolderExpand(guildFolder.folderId);
}
if (settings.store.closeServerFolder && ExpandedGuildFolderStore.isFolderExpanded(guildFolder.folderId)) {
FolderUtils.toggleGuildFolderExpand(guildFolder.folderId);
}
} else if (settings.store.closeAllFolders) {
closeFolders();
}
},
@ -644,23 +652,28 @@ export default definePlugin({
handleFolderDrop(dragItem: FolderDragItem, targetNode: GuildTreeNode, _moveToBelow: boolean, isCombine: boolean): boolean {
if (dragItem.type !== "folder") return false;
if (targetNode.type === "folder") {
const childId = dragItem.nodeId?.toString();
const parentId = targetNode.id?.toString();
if (childId == null || parentId == null) return false;
if (childId === parentId || hasParentInChain(parentId, childId)) return false;
try {
nestFolder(childId, parentId);
FluxDispatcher.dispatch({ type: "BETTER_FOLDERS_NESTED_UPDATE" });
return true;
} catch {
return false;
}
}
if (!isCombine) {
unnestFolder(dragItem.nodeId?.toString());
return false;
}
if (targetNode.type !== "folder") return false;
const childId = dragItem.nodeId?.toString();
const parentId = targetNode.id?.toString();
try {
nestFolder(childId, parentId);
FluxDispatcher.dispatch({ type: "BETTER_FOLDERS_NESTED_UPDATE" });
return true;
} catch {
return false;
}
return false;
},
shouldShowCombineTarget(noCombine: boolean, targetNode: GuildTreeNode): boolean {
@ -679,8 +692,7 @@ export default definePlugin({
if (dragItem.nodeId === targetNode.id) return false;
if (dragItem.type === "folder" && targetNode.type === "folder") {
if (isCombine) return !hasParentInChain(targetNode.id?.toString(), dragItem.nodeId?.toString());
return targetNode.parentId == null;
return !hasParentInChain(targetNode.id?.toString(), dragItem.nodeId?.toString());
}
if (isCombine && dragItem.type === "folder") return false;

View file

@ -18,6 +18,11 @@
export type NestedFolderMap = Record<string, string>;
export interface GuildFolderLike {
folderId?: string | number | null;
guildIds?: string[];
}
export function getChildFolderIds(map: NestedFolderMap, parentId: string | number): string[] {
return Object.entries(map)
.filter(([, id]) => id === String(parentId))
@ -87,3 +92,27 @@ export function sanitizeNestedFolderMap(map: NestedFolderMap, validFolderIds: Se
return sanitized;
}
export function getGuildFolderId(folders: GuildFolderLike[], guildId: string | null | undefined): string | undefined {
if (!guildId) return;
return folders
.find(folder => folder.guildIds?.includes(guildId))
?.folderId
?.toString();
}
export function getGuildNavigationExpandTargets(
map: NestedFolderMap,
folders: GuildFolderLike[],
guildId: string | null | undefined,
expandedFolderIds: Iterable<string | number>
): string[] {
const folderId = getGuildFolderId(folders, guildId);
if (!folderId) return [];
const expandedIds = new Set(Array.from(expandedFolderIds, id => String(id)));
const targetFolderIds = [...getAncestorFolderIds(map, folderId).reverse(), folderId];
return targetFolderIds.filter(id => !expandedIds.has(id));
}

View file

@ -0,0 +1,7 @@
# Character Counter
Adds a character counter to the message input box
![](https://github.com/user-attachments/assets/57f8c01e-a122-46d2-afda-679edb7a996b)
![](https://github.com/user-attachments/assets/640bcca4-77a3-4407-8aa0-15c490dd9256)

View file

@ -0,0 +1,74 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./style.css";
import { definePluginSettings } from "@api/Settings";
import ErrorBoundary from "@components/ErrorBoundary";
import { Devs } from "@utils/constants";
import { classNameFactory } from "@utils/css";
import definePlugin, { OptionType } from "@utils/types";
import { UserStore } from "@webpack/common";
const cl = classNameFactory("vc-charCounter-");
const settings = definePluginSettings({
colorEffects: {
type: OptionType.BOOLEAN,
description: "Enable yellow/red colouring as you get closer to the character limit",
default: true,
}
});
function getCounterColor(percentage: number) {
if (!settings.store.colorEffects) return "var(--primary-330)";
if (percentage < 50) return "var(--text-muted)";
if (percentage < 75) return "var(--yellow-330)";
if (percentage < 90) return "var(--orange-330)";
return "var(--red-360)";
}
export default definePlugin({
name: "CharacterCounter",
description: "Adds a character counter to the chat input",
authors: [Devs.thororen],
settings,
patches: [
{
find: ".CREATE_FORUM_POST||",
replacement: [
{
match: /(?<=textValue:(\i),editorHeight:\i,channelId:\i\.id\}\)),\i/,
replace: ",$self.renderCharCounter({text:$1})"
}
]
},
{
find: "#{intl::PREMIUM_MESSAGE_LENGTH_UPSELL_TOOLTIP}",
replacement: {
match: /return \i\?\i\(\):\i\(\)(?<=#{intl::PREMIUM_MESSAGE_LENGTH_UPSELL_TOOLTIP_WITHOUT_LINK}.{0,200}?)/,
replace: "return null"
}
}
],
renderCharCounter: ErrorBoundary.wrap(({ text }: { text: string; }) => {
if (!text.length) return null;
const premiumType = UserStore.getCurrentUser().premiumType ?? 0;
const charMax = premiumType === 2 ? 4000 : 2000;
const color = getCounterColor((text.length / charMax) * 100);
return (
<div className={cl("counter")} style={{ color }}>
<span className={cl("count")}>{text.length}</span>
/
<span className={cl("max")}>{charMax}</span>
</div>
);
}, { noop: true })
});

View file

@ -1,16 +1,15 @@
.vc-char-counter-counter {
.vc-charCounter-counter {
font-size: 12px;
color: var(--text-muted);
position: absolute;
bottom: 4px;
right: 6px;
right: 11px;
pointer-events: none;
z-index: 2;
background: transparent;
transition: color 0.3s ease;
margin-right: 5px;
}
.vc-char-counter-counter .vc-char-counter-count {
.vc-charCounter-count {
transition: color 0.3s ease;
}

View file

@ -92,8 +92,7 @@ export default definePlugin({
find: "Clear Treatment ",
replacement: [
{
// TODO: stable compat optional chaining remove once some time has passed
match: /\i\??\.isStaff\(\)/,
match: /\i\?\.isStaff\(\)/,
replace: "true"
},
// Fix some tricky experiments name causing a client crash

View file

@ -7,50 +7,146 @@
import "./styles.css";
import { Heading } from "@components/Heading";
import { classNameFactory } from "@utils/css";
import type { IPluginOptionComponentProps } from "@utils/types";
import type { Channel } from "@vencord/discord-types";
import { findComponentByCodeLazy } from "@webpack";
import { TextInput, useState } from "@webpack/common";
import { ChannelStore, IconUtils, Popout, SelectedChannelStore, TextInput, useRef, useState, useStateFromStores } from "@webpack/common";
import { settings } from ".";
type EmojiData = {
id?: string | null;
name: string;
};
export const MAX_ADDITIONAL_REACT_EMOJIS = 8;
const cl = classNameFactory("vc-message-click-actions-");
type EmojiSelectPayload = {
id: string | null;
name: string;
id?: string | null;
name?: string | null;
optionallyDiverseSequence?: string;
animated?: boolean;
};
const StatusEmojiPicker = findComponentByCodeLazy("setCustomStatusEmoji", "setIsEmojiPickerOpen", "selectedDefaultStatus");
export const MAX_ADDITIONAL_REACT_EMOJIS = 8;
type ReactionEmojiPickerProps = {
channel?: Channel | null;
closePopout(): void;
onSelectEmoji(selection: {
emoji: EmojiSelectPayload | null;
willClose: boolean;
}): void;
};
function getEmojiValue(emoji: EmojiData) {
return emoji.id ? `${emoji.name}:${emoji.id}` : emoji.name;
const ReactionEmojiPicker = findComponentByCodeLazy<ReactionEmojiPickerProps>(
"showAddEmojiButton:",
"pickerIntention:",
"messageId:"
);
function parseCustomEmoji(value: string) {
return value.match(/^(?:<(?:(a):)?|:)?([\w-]+?)(?:~\d+)?:([0-9]+)>?$/);
}
function parseEmojiValue(value: string): EmojiSelectPayload | null {
function getEmojiValue(emoji: EmojiSelectPayload | null | undefined) {
if (!emoji) return "";
if (emoji.id && emoji.name) return `${emoji.name}:${emoji.id}`;
if (emoji.optionallyDiverseSequence?.trim()) return emoji.optionallyDiverseSequence;
return emoji.name?.trim() ?? "";
}
function toRenderedEmoji(value: string) {
const trimmed = value.trim();
if (!trimmed) return null;
const customEmoji = trimmed.match(/^:?([\w-]+):(\d+)$/);
const customEmoji = parseCustomEmoji(trimmed);
if (customEmoji) {
return {
id: customEmoji[2],
name: customEmoji[1],
animated: false
kind: "custom" as const,
id: customEmoji[3],
name: customEmoji[2],
animated: customEmoji[1] === "a"
};
}
return {
id: null,
kind: "unicode" as const,
name: trimmed,
animated: false
};
}
function getCustomEmojiSources(id: string, animated: boolean) {
const staticUrl = IconUtils.getEmojiURL({ id, animated: false, size: 48 });
if (!animated) return [staticUrl];
const animatedUrl = IconUtils.getEmojiURL({ id, animated: true, size: 48 });
return animatedUrl === staticUrl ? [staticUrl] : [animatedUrl, staticUrl];
}
function CustomEmojiPreview({
id,
name,
animated
}: {
id: string;
name: string;
animated: boolean;
}) {
const sources = getCustomEmojiSources(id, animated);
const [srcIndex, setSrcIndex] = useState(0);
return (
<img
src={sources[srcIndex]}
alt={name}
width={34}
height={34}
className={cl("emoji-preview")}
onError={() => setSrcIndex(current => current < sources.length - 1 ? current + 1 : current)}
/>
);
}
function EmojiPickerButton({
onSelect,
children
}: {
onSelect(value: string): void;
children?: React.ReactNode;
}) {
const triggerRef = useRef<HTMLDivElement>(null);
const channel = useStateFromStores([SelectedChannelStore, ChannelStore], () => {
const channelId = SelectedChannelStore.getChannelId();
return channelId ? ChannelStore.getChannel(channelId) : null;
});
return (
<Popout
position="bottom"
align="left"
targetElementRef={triggerRef}
renderPopout={({ closePopout }) => (
<ReactionEmojiPicker
channel={channel}
closePopout={closePopout}
onSelectEmoji={({ emoji, willClose }) => {
const nextValue = getEmojiValue(emoji);
if (nextValue) onSelect(nextValue);
if (willClose) closePopout();
}}
/>
)}
>
{popoutProps => (
<div
{...popoutProps}
ref={triggerRef}
className={cl("emoji-trigger")}
>
{children ?? "Pick Emoji"}
</div>
)}
</Popout>
);
}
function parseEmojiList(value: string) {
return Array.from(new Set(
value
@ -60,37 +156,47 @@ function parseEmojiList(value: string) {
)).slice(0, MAX_ADDITIONAL_REACT_EMOJIS);
}
function addEmojiToList(list: string, emoji: string) {
const parsedList = parseEmojiList(list);
if (parsedList.includes(emoji)) return parsedList.join(", ");
return [...parsedList, emoji]
.slice(0, MAX_ADDITIONAL_REACT_EMOJIS)
.join(", ");
}
function EmojiPreview({ value }: { value: string; }) {
const renderedEmoji = toRenderedEmoji(value);
if (!renderedEmoji) return <>Pick Emoji</>;
if (renderedEmoji.kind === "custom") {
return (
<CustomEmojiPreview
id={renderedEmoji.id}
name={renderedEmoji.name}
animated={renderedEmoji.animated}
/>
);
}
return <span className={cl("unicode-preview")}>{renderedEmoji.name}</span>;
}
export function ReactEmojiSetting({ setValue }: IPluginOptionComponentProps) {
const [emoji, setEmoji] = useState(settings.store.reactEmoji ?? "💀");
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false);
return (
<div>
<Heading>Select Emoji For Reactions</Heading>
<div
className="vc-message-click-actions-primary-emoji-picker"
onClick={event => event.stopPropagation()}
onMouseDown={event => event.stopPropagation()}
>
<StatusEmojiPicker
customStatusEmoji={parseEmojiValue(emoji)}
setCustomStatusEmoji={(selectedEmoji: EmojiSelectPayload | null) => {
if (!selectedEmoji) {
setEmoji("");
setValue("");
setIsEmojiPickerOpen(false);
return;
}
const value = getEmojiValue(selectedEmoji);
setEmoji(value);
setValue(value);
setIsEmojiPickerOpen(false);
<div className={cl("primary-emoji-picker")}>
<EmojiPickerButton
onSelect={newValue => {
setEmoji(newValue);
setValue(newValue);
}}
selectedDefaultStatus={isEmojiPickerOpen ? null : "online"}
setIsEmojiPickerOpen={setIsEmojiPickerOpen}
defaultStatusVariant="small"
/>
>
<EmojiPreview value={emoji} />
</EmojiPickerButton>
</div>
</div>
);
@ -99,8 +205,6 @@ export function ReactEmojiSetting({ setValue }: IPluginOptionComponentProps) {
export function AdditionalReactEmojisSetting({ setValue }: IPluginOptionComponentProps) {
const { addAdditionalReacts } = settings.use(["addAdditionalReacts"]);
const [emojiList, setEmojiList] = useState(settings.store.additionalReactEmojis ?? "");
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false);
const [pickerEmoji, setPickerEmoji] = useState<EmojiSelectPayload | null>(parseEmojiValue("😀"));
if (!addAdditionalReacts) return null;
@ -122,8 +226,6 @@ export function AdditionalReactEmojisSetting({ setValue }: IPluginOptionComponen
onChange={newValue => {
setEmojiList(newValue);
setValue(newValue);
const [first] = parseEmojiList(newValue);
setPickerEmoji(parseEmojiValue(first ?? "😀"));
}}
onClick={event => event.stopPropagation()}
onMouseDown={event => event.stopPropagation()}
@ -131,39 +233,13 @@ export function AdditionalReactEmojisSetting({ setValue }: IPluginOptionComponen
flex: 1
}}
/>
<div
onClick={event => event.stopPropagation()}
onMouseDown={event => event.stopPropagation()}
style={{
display: "inline-flex",
transform: "scale(1.2)",
transformOrigin: "center"
<EmojiPickerButton
onSelect={newValue => {
const nextValue = addEmojiToList(emojiList, newValue);
setEmojiList(nextValue);
setValue(nextValue);
}}
>
<StatusEmojiPicker
customStatusEmoji={pickerEmoji}
setCustomStatusEmoji={(selectedEmoji: EmojiSelectPayload | null) => {
if (!selectedEmoji) {
setIsEmojiPickerOpen(false);
return;
}
const value = getEmojiValue(selectedEmoji);
const parsed = parseEmojiList(emojiList);
const merged = parsed.includes(value)
? parsed
: [...parsed, value].slice(0, MAX_ADDITIONAL_REACT_EMOJIS);
const nextValue = merged.join(", ");
setEmojiList(nextValue);
setValue(nextValue);
setPickerEmoji(selectedEmoji);
setIsEmojiPickerOpen(false);
}}
selectedDefaultStatus={isEmojiPickerOpen ? null : "online"}
setIsEmojiPickerOpen={setIsEmojiPickerOpen}
defaultStatusVariant="small"
/>
</div>
/>
</div>
</div>
);

View file

@ -287,9 +287,9 @@ function normalizeEmoji(emoji: string): string | null {
const trimmed = emoji.trim();
if (!trimmed) return null;
const customMatch = trimmed.match(/^:?([\w-]+):(\d+)$/);
const customMatch = trimmed.match(/^(?:<(?:(a):)?|:)?([\w-]+?)(?:~\d+)?:([0-9]+)>?$/);
if (customMatch) {
return `${customMatch[1]}:${customMatch[2]}`;
return `${customMatch[2]}:${customMatch[3]}`;
}
return trimmed;

View file

@ -1,7 +1,22 @@
.vc-message-click-actions-primary-emoji-picker {
margin-top: 8px;
margin-left: 6px;
.vc-message-click-actions-emoji-trigger {
min-width: 46px;
height: 46px;
padding: 0 8px;
display: inline-flex;
transform: scale(1.3);
transform-origin: top left;
align-items: center;
justify-content: center;
border-radius: 8px;
background: var(--background-secondary-alt);
border: 1px solid var(--input-border);
cursor: pointer;
line-height: 1;
}
.vc-message-click-actions-emoji-preview {
display: block;
}
.vc-message-click-actions-unicode-preview {
font-size: 28px;
line-height: 1;
}

View file

@ -6,6 +6,7 @@
import { BaseText } from "@components/BaseText";
import ErrorBoundary from "@components/ErrorBoundary";
import { FormSwitch } from "@components/FormSwitch";
import { TooltipContainer } from "@components/TooltipContainer";
import { classNameFactory } from "@utils/css";
import { Margins } from "@utils/margins";
@ -14,7 +15,7 @@ import { ModalCloseButton, ModalContent, ModalHeader, ModalProps, ModalRoot, Mod
import { findCssClassesLazy } from "@webpack";
import { TabBar, Timestamp, useState } from "@webpack/common";
import { parseEditContent } from ".";
import { parseEditContent, settings } from ".";
const CodeContainerClasses = findCssClassesLazy("markup", "codeContainer");
const MiscClasses = findCssClassesLazy("messageContent", "markupRtl");
@ -34,6 +35,7 @@ export function openHistoryModal(message: any) {
export function HistoryModal({ modalProps, message }: { modalProps: ModalProps; message: any; }) {
const [currentTab, setCurrentTab] = useState(message.editHistory.length);
const [showDiff, setShowDiff] = useState(settings.store.showEditDiffs);
const timestamps = [message.firstEditTimestamp, ...message.editHistory.map(m => m.timestamp)];
const contents = [...message.editHistory.map(m => m.content), message.content];
@ -45,6 +47,7 @@ export function HistoryModal({ modalProps, message }: { modalProps: ModalProps;
</ModalHeader>
<ModalContent className={cl("contents")}>
<FormSwitch title="Show Diff" value={showDiff} onChange={setShowDiff} />
<TabBar
type="top"
look="brand"
@ -86,7 +89,7 @@ export function HistoryModal({ modalProps, message }: { modalProps: ModalProps;
</TabBar>
<div className={classes(CodeContainerClasses.markup, MiscClasses.messageContent, Margins.top20)}>
{parseEditContent(contents[currentTab], message, currentTab === contents.length - 1 ? undefined : contents[contents.length - 1])}
{parseEditContent(contents[currentTab], message, showDiff ? currentTab === contents.length - 1 ? undefined : contents[contents.length - 1] : undefined)}
</div>
</ModalContent>
</ModalRoot>

View file

@ -321,7 +321,7 @@ export function parseEditContent(content: string, message: Message, previousCont
});
}
const settings = definePluginSettings({
export const settings = definePluginSettings({
deleteStyle: {
type: OptionType.SELECT,
description: "The style of deleted messages",
@ -363,6 +363,11 @@ const settings = definePluginSettings({
description: "Whether to ignore messages by yourself",
default: false,
},
ignoreSelfEdits: {
type: OptionType.BOOLEAN,
description: "Whether to ignore edits by yourself",
default: false,
},
ignoreUsers: {
type: OptionType.STRING,
description: "Comma-separated list of user IDs to ignore",
@ -402,6 +407,11 @@ const settings = definePluginSettings({
return !this.store.showEditDiffs;
},
},
ignoreSelfEdits: {
disabled() {
return this.store.ignoreSelf;
},
},
});
export default definePlugin({
@ -585,6 +595,7 @@ export default definePlugin({
const {
ignoreBots,
ignoreSelf,
ignoreSelfEdits,
ignoreUsers,
ignoreChannels,
ignoreGuilds,
@ -596,6 +607,7 @@ export default definePlugin({
return (
(ignoreBots && message.author?.bot) ||
(ignoreSelf && message.author?.id === myId) ||
(ignoreSelfEdits && isEdit && message.author?.id === myId) ||
ignoreUsers.includes(message.author?.id) ||
ignoreChannels.includes(message.channel_id) ||
ignoreChannels.includes(

View file

@ -138,8 +138,7 @@ export default definePlugin({
{
find: "MessageReactions.render:",
replacement: {
// FIXME: (?:medium|normal) is for stable compat
match: /tag:"strong",variant:"text-md\/(?:medium|normal)"(?<=onContextMenu:.{0,15}\((\i),(\i),\i\).+?)/,
match: /tag:"strong",variant:"text-md\/medium"(?<=onContextMenu:.{0,15}\((\i),(\i),\i\).+?)/,
replace: "$&,style:$self.getColorStyle($2?.id,$1?.channel?.id)"
},
predicate: () => settings.store.reactorsList,

View file

@ -35,10 +35,8 @@ export default definePlugin({
Component: StartupTimingPage,
Icon: ClockIcon
});
SettingsPlugin.settingsSectionMap.push(["VencordStartupTimings", "vencord_startup_timings"]);
},
stop() {
removeFromArray(SettingsPlugin.customEntries, e => e.key === "vencord_startup_timings");
removeFromArray(SettingsPlugin.settingsSectionMap, entry => entry[1] === "vencord_startup_timings");
},
});

View file

@ -46,17 +46,6 @@ export default definePlugin({
// if (inlinedCalculatePlayingCount(a,b) >= limit) return;
match: /(BURST_REACTION_EFFECT_PLAY:\i=>{.+?if\()(\(\(\i,\i\)=>.+?\(\i,\i\))>=5+?(?=\))/,
replace: (_, rest, playingCount) => `${rest}!$self.shouldPlayBurstReaction(${playingCount})`
},
// FIXME(Bundler agressive inline): Remove the non used compability once enough time has passed
{
/*
* var limit = 5
* ...
* if (calculatePlayingCount(a,b) >= limit) return;
*/
match: /((\i)=5.+?)if\((.{0,20}?)>=\2\)return;/,
replace: (_, rest, playingCount) => `${rest}if(!$self.shouldPlayBurstReaction(${playingCount}))return;`,
noWarn: true
}
]
},

View file

@ -1280,6 +1280,10 @@ export const EquicordDevs = Object.freeze({
name: "ScattrdBlade",
id: 678007540608532491n
},
Ape: {
name: "Ape",
id: 722832640360841308n,
},
} satisfies Record<string, Dev>);
// iife so #__PURE__ works correctly

View file

@ -18,14 +18,14 @@
import { AudioProcessor } from "@api/AudioPlayer";
import type { ProfileBadge } from "@api/Badges";
import type { ChatBarButtonData, ChatBarButtonFactory } from "@api/ChatButtons";
import type { ChatBarButtonData } from "@api/ChatButtons";
import type { NavContextMenuPatchCallback } from "@api/ContextMenu";
import { HeaderBarButtonData } from "@api/HeaderBar";
import type { MemberListDecoratorFactory } from "@api/MemberListDecorators";
import type { MessageAccessoryFactory } from "@api/MessageAccessories";
import type { MessageDecorationFactory } from "@api/MessageDecorations";
import type { MessageClickListener, MessageEditListener, MessageSendListener } from "@api/MessageEvents";
import type { MessagePopoverButtonData, MessagePopoverButtonFactory } from "@api/MessagePopover";
import type { MessagePopoverButtonData } from "@api/MessagePopover";
import type { NicknameIconFactory } from "@api/NicknameIcons";
import { ProfileCollectionFactory } from "@api/ProfileCollections";
import type { UserAreaButtonData } from "@api/UserArea";
@ -211,16 +211,6 @@ export interface PluginDef {
userAreaButton?: UserAreaButtonData;
renderProfileCollection?: ProfileCollectionFactory;
// TODO: Remove eventually
/**
* @deprecated Use {@link chatBarButton} instead
*/
renderChatBarButton?: ChatBarButtonFactory;
/**
* @deprecated Use {@link messagePopoverButton} instead
*/
renderMessagePopoverButton?: MessagePopoverButtonFactory;
/**
* A Vencord plugin that is modified for extra features in Equicord
*/

View file

@ -192,7 +192,7 @@ export const IconUtils: t.IconUtils = findByPropsLazy("getGuildBannerURL", "getU
export const ColorUtils = mapMangledModuleLazy("Invalid hex color format", {
rgbToHex: filters.byCode(".toString(16).slice(1)"),
hexToRgba: filters.byCode("rgba(", "??"),
hexToRgba: filters.byCode("`rgba(${"),
hexToRgb: filters.byCode(".rgb();return"),
rgbToHsl: filters.byCode("saturation:", "lightness:"),
mixColors: filters.byCode(".substring(1,3),16)"),