Merge branch 'dev' into bun

This commit is contained in:
thororen1234 2026-05-07 11:29:24 -04:00
commit 86528e30f7
No known key found for this signature in database
696 changed files with 17261 additions and 20613 deletions

View file

@ -55,17 +55,20 @@ jobs:
rm release/package.json rm release/package.json
rm release/*.map rm release/*.map
- name: Upload Equicord Stable - name: Upload Equicord Stable
if: ${{ !vars.ACT }}
run: | run: |
gh release upload latest --clobber dist/release/* gh release upload latest --clobber dist/release/*
- name: Upload Plugin JSONs to Equibored repo - name: Upload Plugin JSONs to Equibored repo
if: github.repository == 'Equicord/Equicord' && !vars.ACT
run: | run: |
git config --global user.name "GitHub-Actions" git config --global user.name "GitHub-Actions"
git config --global user.email actions@github.com git config --global user.email actions@github.com
git clone https://$USERNAME:$GITHUB_TOKEN@github.com/$REPO.git plugins git clone https://$USERNAME:$GITHUB_TOKEN@github.com/$REPO.git plugins
cd plugins cd plugins
cp ../dist/release/*.json . cp ../dist/release/*plugins.json .
cp ../dist/release/devs.json .
git add -A git add -A
git commit -m "Plugins for https://github.com/$GITHUB_REPOSITORY/commit/$GITHUB_SHA" git commit -m "Plugins for https://github.com/$GITHUB_REPOSITORY/commit/$GITHUB_SHA"
git push --force https://$USERNAME:$GITHUB_TOKEN@github.com/$REPO.git git push --force https://$USERNAME:$GITHUB_TOKEN@github.com/$REPO.git

View file

@ -1,9 +1,10 @@
name: Build name: Build Branches
on: on:
push: push:
branches: branches:
- "**" - "**"
- "!pr-**"
workflow_dispatch: workflow_dispatch:
inputs: inputs:
branch: branch:
@ -28,6 +29,7 @@ jobs:
Build: Build:
name: Build Equicord name: Build Equicord
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.repository == 'Equicord/Equicord'
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -37,11 +39,11 @@ jobs:
- uses: pnpm/action-setup@v3 - uses: pnpm/action-setup@v3
if: github.ref_name != 'bun' if: github.ref_name != 'bun'
- name: Use Node.js 20 - name: Use Node.js 24
if: github.ref_name != 'bun' if: github.ref_name != 'bun'
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 24
cache: "pnpm" cache: "pnpm"
- name: Install dependencies - name: Install dependencies
@ -64,9 +66,8 @@ jobs:
pnpm generateVencordPluginJson dist/vencordplugins.json pnpm generateVencordPluginJson dist/vencordplugins.json
pnpm generateDevsList dist/devs.json pnpm generateDevsList dist/devs.json
- uses: actions/checkout@v4
if: github.ref_name == 'bun'
- uses: oven-sh/setup-bun@v2 - uses: oven-sh/setup-bun@v2
if: github.ref_name == 'bun'
with: with:
bun-version: latest bun-version: latest
- name: Install dependencies - name: Install dependencies
@ -90,6 +91,7 @@ jobs:
bun run generateDevsList dist/devs.json bun run generateDevsList dist/devs.json
- name: Upload to Equibuilds repo - name: Upload to Equibuilds repo
if: ${{ !vars.ACT }}
run: | run: |
BRANCH="${{ github.event.inputs.branch || github.ref_name }}" BRANCH="${{ github.event.inputs.branch || github.ref_name }}"
COMMIT_SHA=$(git rev-parse --short HEAD) COMMIT_SHA=$(git rev-parse --short HEAD)

View file

@ -41,7 +41,7 @@ jobs:
cp browser/browser.* release cp browser/browser.* release
cp Equicord.user.{js,js.LEGAL.txt} release cp Equicord.user.{js,js.LEGAL.txt} release
# copy the plugin data jsons, the extension zips and the desktop/vesktop asars # copy the plugin data jsons, the extension zips and the desktop/equibop asars
cp *.{json,zip,asar} release cp *.{json,zip,asar} release
# legacy un-asared files # legacy un-asared files
cp desktop/* release cp desktop/* release
@ -56,6 +56,7 @@ jobs:
id: date id: date
run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
- name: Upload Equicord Stable - name: Upload Equicord Stable
if: ${{ !vars.ACT }}
run: | run: |
gh release create ${{ steps.date.outputs.date }} --latest=false gh release create ${{ steps.date.outputs.date }} --latest=false
gh release upload ${{ steps.date.outputs.date }} --clobber dist/release/* gh release upload ${{ steps.date.outputs.date }} --clobber dist/release/*

View file

@ -74,12 +74,16 @@ jobs:
- name: Create sources zip - name: Create sources zip
if: steps.check.outputs.release == 'true' if: steps.check.outputs.release == 'true'
run: zip -r dist/extension-sources.zip . -x "node_modules/*" -x "dist/*" -x ".git/*" run: zip -r dist/extension-sources.zip . -x "node_modules/*" -x "dist/*" -x
".git/*"
- name: Publish extension - name: Publish extension
if: steps.check.outputs.release == 'true' if: steps.check.outputs.release == 'true' && !vars.ACT
run: bunx @equicord/publish-browser-extension@latest --chrome-zip dist/extension-chrome.zip --firefox-zip dist/extension-firefox.zip --firefox-sources-zip dist/extension-sources.zip --edge-zip dist/extension-chrome.zip run: bunx @equicord/publish-browser-extension@latest --chrome-zip
dist/extension-chrome.zip --firefox-zip
dist/extension-firefox.zip --firefox-sources-zip
dist/extension-sources.zip --edge-zip
dist/extension-chrome.zip
env: env:
CHROME_EXTENSION_ID: ${{ secrets.CHROME_EXTENSION_ID }} CHROME_EXTENSION_ID: ${{ secrets.CHROME_EXTENSION_ID }}
CHROME_PUBLISHER_ID: ${{ secrets.CHROME_PUBLISHER_ID }} CHROME_PUBLISHER_ID: ${{ secrets.CHROME_PUBLISHER_ID }}

View file

@ -12,7 +12,9 @@ on:
default: both default: both
webhook_url: webhook_url:
type: string type: string
description: "Webhook URL that the report will be posted to. This will be visible for everyone, so DO NOT pass sensitive webhooks like discord webhook. This is meant to be used by Venbot." description: "Webhook URL that the report will be posted to. This will be
visible for everyone, so DO NOT pass sensitive webhooks like
discord webhook. This is meant to be used by Venbot."
required: false required: false
schedule: schedule:
# # Every day at midnight # # Every day at midnight

3
.gitignore vendored
View file

@ -1,6 +1,9 @@
dist dist
node_modules node_modules
.VSCodeCounter .VSCodeCounter
.vars
.secrets
.input
*.exe *.exe
vencord_installer vencord_installer

View file

@ -136,6 +136,8 @@ export default defineConfig(
"use-isnan": "error", "use-isnan": "error",
"prefer-const": ["error", { destructuring: "all" }], "prefer-const": ["error", { destructuring: "all" }],
"prefer-spread": "error", "prefer-spread": "error",
// These are old deprecated browser globals which may be used by mistake, e.g. `addEventListener(e => console.log(event))`
"no-restricted-globals": ["error", "event", "name"],
// Plugin Rules // Plugin Rules
"simple-import-sort/imports": "error", "simple-import-sort/imports": "error",

View file

@ -1,7 +1,7 @@
{ {
"name": "equicord", "name": "equicord",
"private": "true", "private": "true",
"version": "1.14.6.2", "version": "1.14.12.0",
"description": "The other cutest Discord client mod", "description": "The other cutest Discord client mod",
"homepage": "https://github.com/Equicord/Equicord#readme", "homepage": "https://github.com/Equicord/Equicord#readme",
"bugs": { "bugs": {
@ -59,47 +59,47 @@
"gifenc": "github:mattdesl/gifenc#64842fca317b112a8590f8fef2bf3825da8f6fe3", "gifenc": "github:mattdesl/gifenc#64842fca317b112a8590f8fef2bf3825da8f6fe3",
"idb": "8.0.3", "idb": "8.0.3",
"jsqr": "1.4.0", "jsqr": "1.4.0",
"monaco-editor": "^0.54.0", "monaco-editor": "^0.55.1",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"native-file-system-adapter": "^3.0.1", "native-file-system-adapter": "^3.0.1",
"usercss-meta": "^0.12.0", "usercss-meta": "^0.12.0",
"virtual-merge": "^1.0.1" "virtual-merge": "^1.0.1",
"yaml-js": "^0.3.1"
}, },
"devDependencies": { "devDependencies": {
"@electron/asar": "^4.0.1", "@electron/asar": "^4.2.0",
"@stylistic/eslint-plugin": "^5.6.0", "@stylistic/eslint-plugin": "^5.10.0",
"@types/adm-zip": "^0.5.7",
"@types/bun": "^1.3.9", "@types/bun": "^1.3.9",
"@types/chrome": "^0.1.30", "@types/chrome": "^0.1.40",
"@types/diff": "^7.0.2", "@types/lodash": "^4.17.24",
"@types/lodash": "^4.17.20", "@types/node": "^25.6.0",
"@types/node": "^24.10.1",
"@types/react": "^19.0.10", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
"@types/yazl": "^3.3.0", "@types/yazl": "^3.3.1",
"@vencord/discord-types": "workspace:*", "@vencord/discord-types": "workspace:*",
"diff": "^8.0.2", "diff": "^9.0.0",
"esbuild": "^0.27.0", "esbuild": "^0.28.0",
"eslint": "9.39.1", "eslint": "10.2.0",
"eslint-import-resolver-alias": "^1.1.2", "eslint-import-resolver-alias": "^1.1.2",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-simple-header": "^1.2.1", "eslint-plugin-simple-header": "^1.2.1",
"eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-simple-import-sort": "^13.0.0",
"eslint-plugin-unused-imports": "^4.3.0", "eslint-plugin-unused-imports": "^4.4.1",
"highlight.js": "11.11.1", "highlight.js": "11.11.1",
"html-minifier-terser": "^7.2.0", "html-minifier-terser": "^7.2.0",
"moment": "^2.22.2", "moment": "^2.22.2",
"p-limit": "^7.3.0", "p-limit": "^7.3.0",
"puppeteer-core": "^24.30.0", "puppeteer-core": "^24.41.0",
"standalone-electron-types": "^34.2.0", "standalone-electron-types": "^41.2.1",
"stylelint": "^16.25.0", "stylelint": "^17.8.0",
"stylelint-config-standard": "^39.0.1", "stylelint-config-standard": "^40.0.0",
"svgo": "^4.0.1",
"ts-patch": "^3.3.0", "ts-patch": "^3.3.0",
"ts-pattern": "^5.6.0", "ts-pattern": "^5.9.0",
"type-fest": "^5.2.0", "type-fest": "^5.5.0",
"typescript": "^5.9.3", "typescript": "^6.0.2",
"typescript-eslint": "^8.47.0", "typescript-eslint": "^8.58.2",
"typescript-transform-paths": "^3.5.5", "typescript-transform-paths": "^3.5.6",
"zip-local": "^0.3.5" "zip-local": "^0.3.5"
}, },
"packageManager": "bun@1.3.9", "packageManager": "bun@1.3.9",

View file

@ -331,6 +331,15 @@ declare enum PopoutAnimation {
type PopoutPosition = "top" | "bottom" | "left" | "right" | "center" | "window_center"; type PopoutPosition = "top" | "bottom" | "left" | "right" | "center" | "window_center";
export interface PopoutProps {
position: PopoutPosition;
nudge: number;
isPositioned: boolean;
setPopoutRef(ref: any): void;
closePopout(): void;
updatePosition(): void;
}
export type Popout = ComponentType<{ export type Popout = ComponentType<{
children( children(
thing: { thing: {
@ -347,14 +356,8 @@ export type Popout = ComponentType<{
): ReactNode; ): ReactNode;
shouldShow?: boolean; shouldShow?: boolean;
targetElementRef: RefObject<any>; targetElementRef: RefObject<any>;
renderPopout(args: { renderPopout(props: PopoutProps): ReactNode;
closePopout(): void; preload?(): Promise<any>;
isPositioned: boolean;
nudge: number;
position: PopoutPosition;
setPopoutRef(ref: any): void;
updatePosition(): void;
}): ReactNode;
onRequestOpen?(): void; onRequestOpen?(): void;
onRequestClose?(): void; onRequestClose?(): void;
@ -536,4 +539,5 @@ export type ColorPicker = ComponentType<{
suggestedColors?: string[]; suggestedColors?: string[];
label?: ReactNode; label?: ReactNode;
onChange(value: number | null): void; onChange(value: number | null): void;
disabled?: boolean;
}>; }>;

View file

@ -78,7 +78,7 @@ const globNativesPlugin = {
}); });
build.onLoad({ filter, namespace: "import-natives" }, async () => { build.onLoad({ filter, namespace: "import-natives" }, async () => {
const pluginDirs = ["plugins", "userplugins", "equicordplugins"]; const pluginDirs = ["plugins", "equicordplugins", "userplugins"];
let code = ""; let code = "";
let natives = "\n"; let natives = "\n";
let i = 0; let i = 0;

View file

@ -138,7 +138,7 @@ export const globPlugins = kind => ({
}); });
build.onLoad({ filter, namespace: "import-plugins" }, async () => { build.onLoad({ filter, namespace: "import-plugins" }, async () => {
const pluginDirs = ["plugins/_api", "plugins/_core", "plugins", "userplugins", "equicordplugins", "equicordplugins/_api"]; const pluginDirs = ["plugins/_api", "plugins/_core", "plugins", "equicordplugins/_api", "equicordplugins/_core", "equicordplugins", "userplugins"];
let code = ""; let code = "";
let pluginsCode = "\n"; let pluginsCode = "\n";
let metaCode = "\n"; let metaCode = "\n";

View file

@ -31,11 +31,11 @@ import { getEntryPoint, isPluginFile, parseDevs, parseEquicordDevs, parseFile, P
let dirs: string[]; let dirs: string[];
if (equicordFlag) { if (equicordFlag) {
dirs = ["src/equicordplugins"]; dirs = ["src/equicordplugins/_core", "src/equicordplugins"];
} else if (vencordFlag) { } else if (vencordFlag) {
dirs = ["src/plugins", "src/plugins/_core"]; dirs = ["src/plugins", "src/plugins/_core"];
} else { } else {
dirs = ["src/plugins", "src/plugins/_core", "src/equicordplugins"]; dirs = ["src/plugins", "src/plugins/_core", "src/equicordplugins/_core", "src/equicordplugins"];
} }
const outputPath = args.find(a => !a.startsWith("--")) ?? null; const outputPath = args.find(a => !a.startsWith("--")) ?? null;

View file

@ -80,7 +80,9 @@ const IGNORED_DISCORD_ERRORS = [
"Downloading the full bad domains file", "Downloading the full bad domains file",
/\[GatewaySocket\].{0,110}Cannot access '/, /\[GatewaySocket\].{0,110}Cannot access '/,
"search for 'name' in undefined", "search for 'name' in undefined",
"Attempting to set fast connect zstd when unsupported" "Attempting to set fast connect zstd when unsupported",
/Cannot read properties of undefined \(reading 'call'\)/,
"Could not complete Remote Auth login, trying to restart with a new Remote Auth session."
] as Array<string | RegExp>; ] as Array<string | RegExp>;
function toCodeBlock(s: string, indentation = 0, isDiscord = false) { function toCodeBlock(s: string, indentation = 0, isDiscord = false) {

View file

@ -37,6 +37,7 @@ export interface PluginData {
name: string; name: string;
description: string; description: string;
tags: string[]; tags: string[];
searchTerms: string[];
authors: Dev[]; authors: Dev[];
dependencies: string[]; dependencies: string[];
hasPatches: boolean; hasPatches: boolean;
@ -151,7 +152,8 @@ export async function parseFile(fileName: string) {
enabledByDefault: false, enabledByDefault: false,
required: false, required: false,
isModified: false, isModified: false,
tags: [] as string[] tags: [] as string[],
searchTerms: [] as string[],
} as PluginData; } as PluginData;
for (const prop of pluginObj.properties) { for (const prop of pluginObj.properties) {
@ -207,9 +209,10 @@ export async function parseFile(fileName: string) {
}); });
break; break;
case "tags": case "tags":
if (!isArrayLiteralExpression(value)) throw fail("tags is not an array literal"); case "searchTerms":
data.tags = value.elements.map(e => { if (!isArrayLiteralExpression(value)) throw fail(`${key} is not an array literal`);
if (!isStringLiteral(e)) throw fail("tags array contains non-string literals"); data[key] = value.elements.map(e => {
if (!isStringLiteral(e)) throw fail(`${key} array contains non-string literals`);
return e.text; return e.text;
}); });
break; break;

View file

@ -41,8 +41,7 @@ import { popNotice, showNotice } from "./api/Notices";
import { NotificationData, showNotification } from "./api/Notifications"; import { NotificationData, showNotification } from "./api/Notifications";
import { initPluginManager, PMLogger, startAllPlugins } from "./api/PluginManager"; import { initPluginManager, PMLogger, startAllPlugins } from "./api/PluginManager";
import { PlainSettings, Settings, SettingsStore } from "./api/Settings"; import { PlainSettings, Settings, SettingsStore } from "./api/Settings";
import { getCloudSettings, putCloudSettings, shouldCloudSync } from "./api/SettingsSync/cloudSync"; import { areLocalSettingsDirty, getCloudSettings, getCloudSyncDirection, markLocalSettingsDirty, putCloudSettings, shouldCloudSync } from "./api/SettingsSync/cloudSync";
import { localStorage } from "./utils/localStorage";
import { relaunch } from "./utils/native"; import { relaunch } from "./utils/native";
import { checkForUpdates, isOutdated as getIsOutdated, update, UpdateLogger } from "./utils/updater"; import { checkForUpdates, isOutdated as getIsOutdated, update, UpdateLogger } from "./utils/updater";
import { onceReady } from "./webpack"; import { onceReady } from "./webpack";
@ -53,11 +52,6 @@ if (IS_REPORTER) {
} }
async function syncSettings() { async function syncSettings() {
// Check if cloud auth exists for current user before attempting sync
if (localStorage.Vencord_cloudSyncDirection === undefined) {
// by default, sync bi-directionally
localStorage.Vencord_cloudSyncDirection = "both";
}
const hasCloudAuth = await dsGet("Vencord_cloudSecret"); const hasCloudAuth = await dsGet("Vencord_cloudSecret");
if (!hasCloudAuth) { if (!hasCloudAuth) {
if (Settings.cloud.authenticated) { if (Settings.cloud.authenticated) {
@ -93,9 +87,9 @@ async function syncSettings() {
if ( if (
Settings.cloud.settingsSync && // if it's enabled Settings.cloud.settingsSync && // if it's enabled
Settings.cloud.authenticated && // if cloud integrations are enabled Settings.cloud.authenticated && // if cloud integrations are enabled
localStorage.Vencord_cloudSyncDirection !== "manual" // if we're not in manual mode getCloudSyncDirection() !== "manual" // if we're not in manual mode
) { ) {
if (localStorage.Vencord_settingsDirty && shouldCloudSync("push")) { if (areLocalSettingsDirty() && shouldCloudSync("push")) {
await putCloudSettings(); await putCloudSettings();
} else if (shouldCloudSync("pull") && await getCloudSettings(false)) { // if we synchronized something (false means no sync) } else if (shouldCloudSync("pull") && await getCloudSettings(false)) { // if we synchronized something (false means no sync)
// we show a notification here instead of allowing getCloudSettings() to show one to declutter the amount of // we show a notification here instead of allowing getCloudSettings() to show one to declutter the amount of
@ -118,7 +112,7 @@ async function syncSettings() {
}, 60_000); }, 60_000);
SettingsStore.addGlobalChangeListener(() => { SettingsStore.addGlobalChangeListener(() => {
localStorage.Vencord_settingsDirty = true; markLocalSettingsDirty();
saveSettingsOnFrequentAction(); saveSettingsOnFrequentAction();
}); });
} }

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
*/ */
import { findByCodeLazy, findLazy } from "webpack"; import { findByCodeLazy, findLazy } from "@webpack";
let defaultSounds: null | string[] = null; let defaultSounds: null | string[] = null;
const findDefaultSounds = findLazy(module => module.resolve && module.id && module.keys().some(key => key.endsWith(".mp3")), false); const findDefaultSounds = findLazy(module => module.resolve && module.id && module.keys().some(key => key.endsWith(".mp3")), false);

View file

@ -29,6 +29,10 @@ export const enum BadgePosition {
} }
export interface ProfileBadge { export interface ProfileBadge {
/**
* Badge id, unused by vencord, required by discord
*/
id: string,
/** The tooltip to show on hover. Required for image badges */ /** The tooltip to show on hover. Required for image badges */
description?: string; description?: string;
/** Custom component for the badge (tooltip not included) */ /** Custom component for the badge (tooltip not included) */

View file

@ -190,3 +190,22 @@ addContextMenuPatch("textarea-context", (children, args) => {
</Menu.MenuItem> </Menu.MenuItem>
); );
}); });
// Wrapper stuff is down here to avoid conflicts with above
export type ChatBarButtonWrapper = (buttons: ReactNode) => ReactNode;
/**
* Registry for plugins that need to wrap the entire chat bar button container
*/
export const ChatBarButtonWrappers = new Map<string, ChatBarButtonWrapper>();
export const addChatBarButtonWrapper = (id: string, wrapper: ChatBarButtonWrapper) => ChatBarButtonWrappers.set(id, wrapper);
export const removeChatBarButtonWrapper = (id: string) => ChatBarButtonWrappers.delete(id);
export function _wrapButtons(buttons: ReactNode) {
let wrapped = buttons;
for (const wrapper of ChatBarButtonWrappers.values()) {
wrapped = wrapper(wrapped);
}
return wrapped;
}

View file

@ -131,6 +131,8 @@ interface ContextMenuProps {
} }
export function _usePatchContextMenu(props: ContextMenuProps) { export function _usePatchContextMenu(props: ContextMenuProps) {
if (!Menu.MenuItem) return props; // Prevent crashes in case we fail to acquire menu items for some reason
props = { props = {
...props, ...props,
children: cloneMenuChildren(props.children), children: cloneMenuChildren(props.children),

View file

@ -18,97 +18,31 @@
* limitations under the License. * limitations under the License.
*/ */
import { Logger } from "@utils/Logger";
const logger = new Logger("DataStore");
let loggedFailure = false;
const memoryDatabases = new Map<string, Map<string, Map<IDBValidKey, unknown>>>();
interface MemoryStore {
__memoryStore: true;
get<T>(key: IDBValidKey): T | undefined;
put(value: unknown, key: IDBValidKey): void;
delete(key: IDBValidKey): void;
clear(): void;
getAllKeys<KeyType extends IDBValidKey>(): KeyType[];
getAll<T>(): T[];
entries<KeyType extends IDBValidKey, ValueType>(): [KeyType, ValueType][];
}
type StoreLike = IDBObjectStore | MemoryStore;
function handleFailure(error: unknown) {
if (loggedFailure) return;
loggedFailure = true;
logger.error("IndexedDB unavailable, using empty storage.", error);
}
function withFailureFallback<T>(promise: Promise<T>, fallback: T): Promise<T> {
return promise.catch(error => {
handleFailure(error);
return fallback;
});
}
function getMemoryStore(dbName: string, storeName: string): MemoryStore {
let database = memoryDatabases.get(dbName);
if (!database) {
database = new Map();
memoryDatabases.set(dbName, database);
}
let data = database.get(storeName);
if (!data) {
data = new Map<IDBValidKey, unknown>();
database.set(storeName, data);
}
return {
__memoryStore: true,
get: <T>(key: IDBValidKey) => data.get(key) as T | undefined,
put: (value, key) => { data.set(key, value); },
delete: key => { data.delete(key); },
clear: () => { data.clear(); },
getAllKeys: <KeyType extends IDBValidKey>() => Array.from(data.keys()) as KeyType[],
getAll: <T>() => Array.from(data.values()) as T[],
entries: <KeyType extends IDBValidKey, ValueType>() => Array.from(data.entries()) as [KeyType, ValueType][]
};
}
function isMemoryStore(store: StoreLike): store is MemoryStore {
return "__memoryStore" in store;
}
export function promisifyRequest<T = undefined>( export function promisifyRequest<T = undefined>(
request: IDBRequest<T> | IDBTransaction, request: IDBRequest<T> | IDBTransaction,
): Promise<T> { ): Promise<T> {
return new Promise<T>((resolve, reject) => { return new Promise<T>((resolve, reject) => {
const target = request as IDBRequest<T> & IDBTransaction; // @ts-expect-error - file size hacks
target.oncomplete = target.onsuccess = () => resolve(target.result); request.oncomplete = request.onsuccess = () => resolve(request.result);
target.onabort = target.onerror = () => reject(target.error); // @ts-expect-error - file size hacks
request.onabort = request.onerror = () => reject(request.error);
}); });
} }
export function createStore(dbName: string, storeName: string): UseStore { export function createStore(dbName: string, storeName: string): UseStore {
const request = indexedDB.open(dbName); const request = indexedDB.open(dbName);
request.onupgradeneeded = () => request.result.createObjectStore(storeName); request.onupgradeneeded = () => request.result.createObjectStore(storeName);
const dbp = promisifyRequest(request).catch(error => { const dbp = promisifyRequest(request);
handleFailure(error);
return null;
});
const memoryStore = getMemoryStore(dbName, storeName);
return async (txMode, callback) => { return (txMode, callback) =>
const db = await dbp; dbp.then(db =>
if (!db) return callback(memoryStore); callback(db.transaction(storeName, txMode).objectStore(storeName)),
);
return callback(db.transaction(storeName, txMode).objectStore(storeName));
};
} }
export type UseStore = <T>( export type UseStore = <T>(
txMode: IDBTransactionMode, txMode: IDBTransactionMode,
callback: (store: StoreLike) => T | PromiseLike<T>, callback: (store: IDBObjectStore) => T | PromiseLike<T>,
) => Promise<T>; ) => Promise<T>;
let defaultGetStoreFunc: UseStore | undefined; let defaultGetStoreFunc: UseStore | undefined;
@ -120,68 +54,88 @@ function defaultGetStore() {
return defaultGetStoreFunc; return defaultGetStoreFunc;
} }
/**
* Get a value by its key.
*
* @param key
* @param customStore Method to get a custom store. Use with caution (see the docs).
*/
export function get<T = any>( export function get<T = any>(
key: IDBValidKey, key: IDBValidKey,
customStore = defaultGetStore(), customStore = defaultGetStore(),
): Promise<T | undefined> { ): Promise<T | undefined> {
return withFailureFallback(customStore("readonly", store => { return customStore("readonly", store => promisifyRequest(store.get(key)));
if (isMemoryStore(store)) return store.get(key) as T | undefined;
return promisifyRequest(store.get(key));
}), undefined);
} }
/**
* Set a value with a key.
*
* @param key
* @param value
* @param customStore Method to get a custom store. Use with caution (see the docs).
*/
export function set( export function set(
key: IDBValidKey, key: IDBValidKey,
value: any, value: any,
customStore = defaultGetStore(), customStore = defaultGetStore(),
): Promise<void> { ): Promise<void> {
return withFailureFallback(customStore("readwrite", store => { return customStore("readwrite", store => {
store.put(value, key); store.put(value, key);
if (isMemoryStore(store)) return;
return promisifyRequest(store.transaction); return promisifyRequest(store.transaction);
}), undefined); });
} }
/**
* Set multiple values at once. This is faster than calling set() multiple times.
* It's also atomic – if one of the pairs can't be added, none will be added.
*
* @param entries Array of entries, where each entry is an array of `[key, value]`.
* @param customStore Method to get a custom store. Use with caution (see the docs).
*/
export function setMany( export function setMany(
entries: [IDBValidKey, any][], entries: [IDBValidKey, any][],
customStore = defaultGetStore(), customStore = defaultGetStore(),
): Promise<void> { ): Promise<void> {
return withFailureFallback(customStore("readwrite", store => { return customStore("readwrite", store => {
entries.forEach(entry => store.put(entry[1], entry[0])); entries.forEach(entry => store.put(entry[1], entry[0]));
if (isMemoryStore(store)) return;
return promisifyRequest(store.transaction); return promisifyRequest(store.transaction);
}), undefined); });
} }
/**
* Get multiple values by their keys
*
* @param keys
* @param customStore Method to get a custom store. Use with caution (see the docs).
*/
export function getMany<T = any>( export function getMany<T = any>(
keys: IDBValidKey[], keys: IDBValidKey[],
customStore = defaultGetStore(), customStore = defaultGetStore(),
): Promise<T[]> { ): Promise<T[]> {
return withFailureFallback(customStore("readonly", store => { return customStore("readonly", store =>
if (isMemoryStore(store)) { Promise.all(keys.map(key => promisifyRequest(store.get(key)))),
return keys.map(key => store.get(key) as T | undefined); );
}
return Promise.all(keys.map(key => promisifyRequest(store.get(key))));
}), []);
} }
/**
* Update a value. This lets you see the old value and update it as an atomic operation.
*
* @param key
* @param updater A callback that takes the old value and returns a new value.
* @param customStore Method to get a custom store. Use with caution (see the docs).
*/
export function update<T = any>( export function update<T = any>(
key: IDBValidKey, key: IDBValidKey,
updater: (oldValue: T | undefined) => T, updater: (oldValue: T | undefined) => T,
customStore = defaultGetStore(), customStore = defaultGetStore(),
): Promise<void> { ): Promise<void> {
return withFailureFallback(customStore( return customStore(
"readwrite", "readwrite",
store => { store =>
if (isMemoryStore(store)) { // Need to create the promise manually.
store.put(updater(store.get(key) as T | undefined), key); // If I try to chain promises, the transaction closes in browsers
return; // that use a promise polyfill (IE10/11).
} new Promise((resolve, reject) => {
return new Promise((resolve, reject) => {
store.get(key).onsuccess = function () { store.get(key).onsuccess = function () {
try { try {
store.put(updater(this.result), key); store.put(updater(this.result), key);
@ -190,42 +144,52 @@ export function update<T = any>(
reject(err); reject(err);
} }
}; };
}); }),
}, );
), undefined);
} }
/**
* Delete a particular key from the store.
*
* @param key
* @param customStore Method to get a custom store. Use with caution (see the docs).
*/
export function del( export function del(
key: IDBValidKey, key: IDBValidKey,
customStore = defaultGetStore(), customStore = defaultGetStore(),
): Promise<void> { ): Promise<void> {
return withFailureFallback(customStore("readwrite", store => { return customStore("readwrite", store => {
store.delete(key); store.delete(key);
if (isMemoryStore(store)) return;
return promisifyRequest(store.transaction); return promisifyRequest(store.transaction);
}), undefined); });
} }
/**
* Delete multiple keys at once.
*
* @param keys List of keys to delete.
* @param customStore Method to get a custom store. Use with caution (see the docs).
*/
export function delMany( export function delMany(
keys: IDBValidKey[], keys: IDBValidKey[],
customStore = defaultGetStore(), customStore = defaultGetStore(),
): Promise<void> { ): Promise<void> {
return withFailureFallback(customStore("readwrite", store => { return customStore("readwrite", (store: IDBObjectStore) => {
keys.forEach(key => store.delete(key)); keys.forEach((key: IDBValidKey) => store.delete(key));
if (isMemoryStore(store)) return;
return promisifyRequest(store.transaction); return promisifyRequest(store.transaction);
}), undefined); });
} }
/**
* Clear all values in the store.
*
* @param customStore Method to get a custom store. Use with caution (see the docs).
*/
export function clear(customStore = defaultGetStore()): Promise<void> { export function clear(customStore = defaultGetStore()): Promise<void> {
return withFailureFallback(customStore("readwrite", store => { return customStore("readwrite", store => {
store.clear(); store.clear();
if (isMemoryStore(store)) return;
return promisifyRequest(store.transaction); return promisifyRequest(store.transaction);
}), undefined); });
} }
function eachCursor( function eachCursor(
@ -240,12 +204,16 @@ function eachCursor(
return promisifyRequest(store.transaction); return promisifyRequest(store.transaction);
} }
/**
* Get all keys in the store.
*
* @param customStore Method to get a custom store. Use with caution (see the docs).
*/
export function keys<KeyType extends IDBValidKey>( export function keys<KeyType extends IDBValidKey>(
customStore = defaultGetStore(), customStore = defaultGetStore(),
): Promise<KeyType[]> { ): Promise<KeyType[]> {
return withFailureFallback(customStore("readonly", store => { return customStore("readonly", store => {
if (isMemoryStore(store)) return store.getAllKeys<KeyType>(); // Fast path for modern browsers
if (store.getAllKeys) { if (store.getAllKeys) {
return promisifyRequest( return promisifyRequest(
store.getAllKeys() as unknown as IDBRequest<KeyType[]>, store.getAllKeys() as unknown as IDBRequest<KeyType[]>,
@ -253,45 +221,59 @@ export function keys<KeyType extends IDBValidKey>(
} }
const items: KeyType[] = []; const items: KeyType[] = [];
return eachCursor(store, cursor => return eachCursor(store, cursor =>
items.push(cursor.key as KeyType), items.push(cursor.key as KeyType),
).then(() => items); ).then(() => items);
}), [] as KeyType[]); });
} }
/**
* Get all values in the store.
*
* @param customStore Method to get a custom store. Use with caution (see the docs).
*/
export function values<T = any>(customStore = defaultGetStore()): Promise<T[]> { export function values<T = any>(customStore = defaultGetStore()): Promise<T[]> {
return withFailureFallback(customStore("readonly", store => { return customStore("readonly", store => {
if (isMemoryStore(store)) return store.getAll<T>(); // Fast path for modern browsers
if (store.getAll) { if (store.getAll) {
return promisifyRequest(store.getAll() as IDBRequest<T[]>); return promisifyRequest(store.getAll() as IDBRequest<T[]>);
} }
const items: T[] = []; const items: T[] = [];
return eachCursor(store, cursor => items.push(cursor.value as T)).then( return eachCursor(store, cursor => items.push(cursor.value as T)).then(
() => items, () => items,
); );
}), [] as T[]); });
} }
/**
* Get all entries in the store. Each entry is an array of `[key, value]`.
*
* @param customStore Method to get a custom store. Use with caution (see the docs).
*/
export function entries<KeyType extends IDBValidKey, ValueType = any>( export function entries<KeyType extends IDBValidKey, ValueType = any>(
customStore = defaultGetStore(), customStore = defaultGetStore(),
): Promise<[KeyType, ValueType][]> { ): Promise<[KeyType, ValueType][]> {
return withFailureFallback(customStore("readonly", store => { return customStore("readonly", store => {
if (isMemoryStore(store)) return store.entries<KeyType, ValueType>(); // Fast path for modern browsers
// (although, hopefully we'll get a simpler path some day)
if (store.getAll && store.getAllKeys) { if (store.getAll && store.getAllKeys) {
return Promise.all([ return Promise.all([
promisifyRequest( promisifyRequest(
store.getAllKeys() as unknown as IDBRequest<KeyType[]>, store.getAllKeys() as unknown as IDBRequest<KeyType[]>,
), ),
promisifyRequest(store.getAll() as IDBRequest<ValueType[]>), promisifyRequest(store.getAll() as IDBRequest<ValueType[]>),
]).then(([keys, values]) => keys.map((key, i) => [key, values[i]] as [KeyType, ValueType])); ]).then(([keys, values]) => keys.map((key, i) => [key, values[i]]));
} }
const items: [KeyType, ValueType][] = []; const items: [KeyType, ValueType][] = [];
return eachCursor(store, cursor =>
items.push([cursor.key as KeyType, cursor.value]), return customStore("readonly", store =>
).then(() => items); eachCursor(store, cursor =>
}), [] as [KeyType, ValueType][]); items.push([cursor.key as KeyType, cursor.value]),
).then(() => items),
);
});
} }

View file

@ -17,6 +17,7 @@
*/ */
import ErrorBoundary from "@components/ErrorBoundary"; import ErrorBoundary from "@components/ErrorBoundary";
import { Logger } from "@utils/Logger";
import { JSX, ReactNode } from "react"; import { JSX, ReactNode } from "react";
export type MessageAccessoryFactory = (props: Record<string, any>) => ReactNode; export type MessageAccessoryFactory = (props: Record<string, any>) => ReactNode;
@ -46,23 +47,28 @@ export function _modifyAccessories(
elements: JSX.Element[], elements: JSX.Element[],
props: Record<string, any> props: Record<string, any>
) { ) {
for (const [key, accessory] of accessories.entries()) { try {
const res = ( for (const [key, accessory] of accessories.entries()) {
<ErrorBoundary noop message={`Failed to render ${key} Message Accessory`} key={key}> const res = (
<accessory.render {...props} /> <ErrorBoundary noop message={`Failed to render ${key} Message Accessory`} key={key}>
</ErrorBoundary> <accessory.render {...props} />
); </ErrorBoundary>
);
elements.splice( elements.splice(
accessory.position != null accessory.position != null
? accessory.position < 0 ? accessory.position < 0
? elements.length + accessory.position ? elements.length + accessory.position
: accessory.position : accessory.position
: elements.length, : elements.length,
0, 0,
res res
); );
}
return elements;
} catch (e) {
new Logger("MessageAccessories").error("Failed to modify message accessories", e);
return elements;
} }
return elements;
} }

View file

@ -15,7 +15,7 @@
position: absolute; position: absolute;
z-index: 2147483647; z-index: 2147483647;
right: 1rem; right: 1rem;
width: 25vw; width: min(25vw, 400px);
min-height: 10vh; min-height: 10vh;
} }

View file

@ -17,7 +17,7 @@
*/ */
import { addProfileBadge, removeProfileBadge } from "@api/Badges"; import { addProfileBadge, removeProfileBadge } from "@api/Badges";
import { addChatBarButton, removeChatBarButton } from "@api/ChatButtons"; import { addChatBarButton, addChatBarButtonWrapper, removeChatBarButton, removeChatBarButtonWrapper } from "@api/ChatButtons";
import { registerCommand, unregisterCommand } from "@api/Commands"; import { registerCommand, unregisterCommand } from "@api/Commands";
import { addContextMenuPatch, removeContextMenuPatch } from "@api/ContextMenu"; import { addContextMenuPatch, removeContextMenuPatch } from "@api/ContextMenu";
import { addMemberListDecorator, removeMemberListDecorator } from "@api/MemberListDecorators"; import { addMemberListDecorator, removeMemberListDecorator } from "@api/MemberListDecorators";
@ -28,10 +28,11 @@ import { addMessagePopoverButton, removeMessagePopoverButton } from "@api/Messag
import { addNicknameIcon, removeNicknameIcon } from "@api/NicknameIcons"; import { addNicknameIcon, removeNicknameIcon } from "@api/NicknameIcons";
import { Settings, SettingsStore } from "@api/Settings"; import { Settings, SettingsStore } from "@api/Settings";
import { disableStyle, enableStyle } from "@api/Styles"; import { disableStyle, enableStyle } from "@api/Styles";
import { traceFunction } from "@debug/Tracer";
import { Logger } from "@utils/Logger"; import { Logger } from "@utils/Logger";
import { onlyOnce } from "@utils/onlyOnce"; import { onlyOnce } from "@utils/onlyOnce";
import { canonicalizeFind, canonicalizeReplacement } from "@utils/patches"; import { canonicalizeFind, canonicalizeReplacement } from "@utils/patches";
import { Patch, Plugin, PluginDef, ReporterTestable, StartAt } from "@utils/types"; import { DefinedSettings, Patch, Plugin, PluginDef, PluginSettingDef, ReporterTestable, StartAt } from "@utils/types";
import { FluxEvents } from "@vencord/discord-types"; import { FluxEvents } from "@vencord/discord-types";
import { FluxDispatcher } from "@webpack/common"; import { FluxDispatcher } from "@webpack/common";
import { patches } from "@webpack/patcher"; import { patches } from "@webpack/patcher";
@ -39,10 +40,10 @@ import { patches } from "@webpack/patcher";
import Plugins from "~plugins"; import Plugins from "~plugins";
export { Plugins as plugins }; export { Plugins as plugins };
import { traceFunction } from "../debug/Tracer";
import { addAudioProcessor, removeAudioProcessor } from "./AudioPlayer"; import { addAudioProcessor, removeAudioProcessor } from "./AudioPlayer";
import { addChannelToolbarButton, addHeaderBarButton, removeChannelToolbarButton, removeHeaderBarButton } from "./HeaderBar"; import { addChannelToolbarButton, addHeaderBarButton, removeChannelToolbarButton, removeHeaderBarButton } from "./HeaderBar";
import { addProfileCollection, removeProfileCollection } from "./ProfileCollections"; import { addProfileCollection, removeProfileCollection } from "./ProfileCollections";
import { addProfileSection, removeProfileSection } from "./ProfileSections";
import { addUserAreaButton, removeUserAreaButton } from "./UserArea"; import { addUserAreaButton, removeUserAreaButton } from "./UserArea";
const logger = new Logger("PluginManager", "#a6d189"); const logger = new Logger("PluginManager", "#a6d189");
@ -60,14 +61,37 @@ export function isPluginEnabled(p: string) {
Settings.plugins[p]?.enabled Settings.plugins[p]?.enabled
) ?? false; ) ?? false;
} }
export function isPluginRequired(p: string) { export function isPluginRequired(p: string) {
return ( return (
Plugins[p]?.required Plugins[p]?.required
) ?? false; ) ?? false;
} }
export function isSettingHidden(settings: DefinedSettings, setting: PluginSettingDef) {
if (!("hidden" in setting)) return false;
return typeof setting.hidden === "function"
? setting.hidden.call(settings)
: Boolean(setting.hidden);
}
export function isSettingDisabled(settings: DefinedSettings, setting: PluginSettingDef) {
if (!("disabled" in setting)) return false;
return typeof setting.disabled === "function"
? setting.disabled.call(settings)
: Boolean(setting.disabled);
}
export function hasAnyVisibleSettings({ settings }: Plugin) {
return !!settings && Object.values(settings.def).some(s => !isSettingHidden(settings, s));
}
export function addPatch(newPatch: Omit<Patch, "plugin">, pluginName: string, pluginPath = `Vencord.Plugins.plugins[${JSON.stringify(pluginName)}]`) { export function addPatch(newPatch: Omit<Patch, "plugin">, pluginName: string, pluginPath = `Vencord.Plugins.plugins[${JSON.stringify(pluginName)}]`) {
// TODO: this causes crashes
if (pluginName === "Vesktop" && newPatch.find === ".STREAMING_AUTO_STREAMER_MODE,") return;
if (pluginName === "Equibop" && newPatch.find === ".STREAMING_AUTO_STREAMER_MODE,") return;
const patch = newPatch as Patch; const patch = newPatch as Patch;
patch.plugin = pluginName; patch.plugin = pluginName;
@ -197,7 +221,8 @@ export const startPlugin = traceFunction("startPlugin", function startPlugin(p:
onBeforeMessageEdit, onBeforeMessageSend, onMessageClick, onBeforeMessageEdit, onBeforeMessageSend, onMessageClick,
chatBarButton, renderMemberListDecorator, renderMessageAccessory, renderMessageDecoration, messagePopoverButton, chatBarButton, renderMemberListDecorator, renderMessageAccessory, renderMessageDecoration, messagePopoverButton,
// Custom // Custom
renderNicknameIcon, headerBarButton, audioProcessor, userAreaButton, renderProfileCollection renderNicknameIcon, headerBarButton, audioProcessor, userAreaButton, renderProfileCollection, chatBarButtonWrapper,
renderProfileSection
} = p; } = p;
if (p.start) { if (p.start) {
@ -265,6 +290,8 @@ export const startPlugin = traceFunction("startPlugin", function startPlugin(p:
if (audioProcessor) addAudioProcessor(name, audioProcessor); if (audioProcessor) addAudioProcessor(name, audioProcessor);
if (userAreaButton) addUserAreaButton(name, userAreaButton.render, userAreaButton.priority); if (userAreaButton) addUserAreaButton(name, userAreaButton.render, userAreaButton.priority);
if (renderProfileCollection) addProfileCollection(name, renderProfileCollection); if (renderProfileCollection) addProfileCollection(name, renderProfileCollection);
if (chatBarButtonWrapper) addChatBarButtonWrapper(name, chatBarButtonWrapper);
if (renderProfileSection) addProfileSection(name, renderProfileSection);
return true; return true;
}, p => `startPlugin ${p.name}`); }, p => `startPlugin ${p.name}`);
@ -275,7 +302,8 @@ export const stopPlugin = traceFunction("stopPlugin", function stopPlugin(p: Plu
onBeforeMessageEdit, onBeforeMessageSend, onMessageClick, onBeforeMessageEdit, onBeforeMessageSend, onMessageClick,
chatBarButton, renderMemberListDecorator, renderMessageAccessory, renderMessageDecoration, messagePopoverButton, chatBarButton, renderMemberListDecorator, renderMessageAccessory, renderMessageDecoration, messagePopoverButton,
// Custom // Custom
renderNicknameIcon, headerBarButton, audioProcessor, userAreaButton, renderProfileCollection renderNicknameIcon, headerBarButton, audioProcessor, userAreaButton, renderProfileCollection, chatBarButtonWrapper,
renderProfileSection
} = p; } = p;
if (p.stop) { if (p.stop) {
@ -341,6 +369,8 @@ export const stopPlugin = traceFunction("stopPlugin", function stopPlugin(p: Plu
if (audioProcessor) removeAudioProcessor(name); if (audioProcessor) removeAudioProcessor(name);
if (userAreaButton) removeUserAreaButton(name); if (userAreaButton) removeUserAreaButton(name);
if (renderProfileCollection) removeProfileCollection(name); if (renderProfileCollection) removeProfileCollection(name);
if (chatBarButtonWrapper) removeChatBarButtonWrapper(name);
if (renderProfileSection) removeProfileSection(name);
return true; return true;
}, p => `stopPlugin ${p.name}`); }, p => `stopPlugin ${p.name}`);
@ -396,6 +426,8 @@ export const initPluginManager = onlyOnce(function init() {
if (p.audioProcessor) neededApiPlugins.add("AudioPlayerAPI"); if (p.audioProcessor) neededApiPlugins.add("AudioPlayerAPI");
if (p.userAreaButton) neededApiPlugins.add("UserAreaAPI"); if (p.userAreaButton) neededApiPlugins.add("UserAreaAPI");
if (p.renderProfileCollection) neededApiPlugins.add("ProfileCollectionsAPI"); if (p.renderProfileCollection) neededApiPlugins.add("ProfileCollectionsAPI");
if (p.chatBarButtonWrapper) neededApiPlugins.add("ChatInputButtonAPI");
if (p.renderProfileSection) neededApiPlugins.add("ProfileSectionsAPI");
for (const key of pluginKeysToBind) { for (const key of pluginKeysToBind) {
p[key] &&= p[key].bind(p) as any; p[key] &&= p[key].bind(p) as any;
@ -409,22 +441,11 @@ export const initPluginManager = onlyOnce(function init() {
for (const p of pluginsValues) { for (const p of pluginsValues) {
if (p.settings) { if (p.settings) {
p.options ??= {};
p.settings.pluginName = p.name; p.settings.pluginName = p.name;
for (const name in p.settings.def) {
const def = p.settings.def[name];
const checks = p.settings.checks?.[name];
p.options[name] = { ...def, ...checks };
}
}
if (p.options) { for (const [key, def] of Object.entries(p.settings.def)) {
for (const name in p.options) { if (def.onChange)
const opt = p.options[name]; SettingsStore.addChangeListener(`plugins.${p.name}.${key}`, def.onChange);
if (opt.onChange != null) {
SettingsStore.addChangeListener(`plugins.${p.name}.${name}`, opt.onChange);
}
} }
} }

View file

@ -6,12 +6,13 @@
import ErrorBoundary from "@components/ErrorBoundary"; import ErrorBoundary from "@components/ErrorBoundary";
import { Logger } from "@utils/Logger"; import { Logger } from "@utils/Logger";
import { User } from "@vencord/discord-types";
import { useEffect, useState } from "@webpack/common"; import { useEffect, useState } from "@webpack/common";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
const logger = new Logger("ProfileCollectionAPI"); const logger = new Logger("ProfileCollectionAPI");
export type ProfileCollectionFactory = (props: any) => ReactNode | null; export type ProfileCollectionFactory = (props: { user: User; isSideBar: boolean; }) => ReactNode | Promise<ReactNode> | null;
interface CollectionEntry { interface CollectionEntry {
render: ProfileCollectionFactory; render: ProfileCollectionFactory;

View file

@ -0,0 +1,73 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import ErrorBoundary from "@components/ErrorBoundary";
import { Logger } from "@utils/Logger";
import { useEffect, useState } from "@webpack/common";
import type { ReactNode } from "react";
const logger = new Logger("ProfileSectionsAPI");
export type ProfileSectionFactory = (props: { userId: string; isSideBar: boolean; }) => ReactNode | Promise<ReactNode> | null;
interface SectionEntry {
render: ProfileSectionFactory;
priority: number;
}
const profileSections = new Map<string, SectionEntry>();
const profileSectionListeners = new Set<() => void>();
/**
* Adds a section to the user profile panel near the 'Member Since' area.
*
* @param id - Unique identifier for the section (e.g., "my-plugin-profile-section")
* @param render - Function that returns the section JSX.
* Receives `{ userId, isSideBar }` so you can style accordingly.
* @param priority - Higher values appear first. Default: 0
*
* @example
* addProfileSection("my-section", ({ userId, isSideBar }) => (
* <div>Custom content for {userId}</div>
* ));
*/
export function addProfileSection(id: string, render: ProfileSectionFactory, priority = 0) {
profileSections.set(id, { render, priority });
profileSectionListeners.forEach(listener => listener());
}
/**
* Removes a section from the user profile panel.
*
* @param id - The identifier used when adding the section
*/
export function removeProfileSection(id: string) {
profileSections.delete(id);
profileSectionListeners.forEach(listener => listener());
}
function ProfileSections({ userId, isSideBar }: { userId: string; isSideBar: boolean; }) {
const [, forceUpdate] = useState(0);
useEffect(() => {
const listener = () => forceUpdate(n => n + 1);
profileSectionListeners.add(listener);
return () => { profileSectionListeners.delete(listener); };
}, []);
return Array.from(profileSections)
.sort(([, a], [, b]) => b.priority - a.priority)
.map(([id, { render: Section }]) => (
<ErrorBoundary noop key={id} onError={e => logger.error(`Failed to render profile section: ${id}`, e.error)}>
<Section userId={userId} isSideBar={isSideBar} />
</ErrorBoundary>
));
}
/** @internal Injected by ProfileSectionsAPI patch (do NOT call directly) */
export function renderProfileSections(props: { userId: string; isSideBar: boolean; }) {
return <ProfileSections {...props} />;
}

View file

@ -25,11 +25,11 @@ export const enum ServerListRenderPosition {
Below, Below,
} }
const componentsIn = new Set<ComponentType>(); const componentsIn = new Map<ComponentType, number>();
const componentsAbove = new Set<ComponentType>(); const componentsAbove = new Map<ComponentType, number>();
const componentsBelow = new Set<ComponentType>(); const componentsBelow = new Map<ComponentType, number>();
function getRenderFunctions(position: ServerListRenderPosition) { function getRenderMap(position: ServerListRenderPosition) {
switch (position) { switch (position) {
case ServerListRenderPosition.Above: case ServerListRenderPosition.Above:
return componentsAbove; return componentsAbove;
@ -40,21 +40,20 @@ function getRenderFunctions(position: ServerListRenderPosition) {
} }
} }
export function addServerListElement(position: ServerListRenderPosition, renderFunction: ComponentType) { export function addServerListElement(position: ServerListRenderPosition, renderFunction: ComponentType, priority = 0) {
getRenderFunctions(position).add(renderFunction); getRenderMap(position).set(renderFunction, priority);
} }
export function removeServerListElement(position: ServerListRenderPosition, renderFunction: ComponentType) { export function removeServerListElement(position: ServerListRenderPosition, renderFunction: ComponentType) {
getRenderFunctions(position).delete(renderFunction); getRenderMap(position).delete(renderFunction);
} }
export const renderAll = (position: ServerListRenderPosition) => { export const renderAll = (position: ServerListRenderPosition) => {
return Array.from( return Array.from(getRenderMap(position).entries())
getRenderFunctions(position), .sort((a, b) => b[1] - a[1])
(Component, i) => ( .map(([Component], i) => (
<ErrorBoundary noop key={i}> <ErrorBoundary noop key={i}>
<Component /> <Component />
</ErrorBoundary> </ErrorBoundary>
) ));
);
}; };

View file

@ -113,7 +113,7 @@ const DefaultSettings: Settings = {
autoUpdateNotification: true, autoUpdateNotification: true,
useQuickCss: true, useQuickCss: true,
themeLinks: [], themeLinks: [],
eagerPatches: IS_REPORTER, eagerPatches: false, // Eagerly patching no longer works due to module factories with the same id being able to have different sources now.
enabledThemes: [], enabledThemes: [],
enabledThemeLinks: [], enabledThemeLinks: [],
enableOnlineThemes: true, enableOnlineThemes: true,
@ -178,7 +178,7 @@ export const SettingsStore = new SettingsStoreClass(settings, {
if (path.startsWith("plugins.")) { if (path.startsWith("plugins.")) {
const plugin = path.slice("plugins.".length); const plugin = path.slice("plugins.".length);
if (plugin in plugins) { if (plugin in plugins) {
const setting = plugins[plugin].options?.[key]; const setting = plugins[plugin].settings?.def[key];
if (!setting) return v; if (!setting) return v;
if ("default" in setting) if ("default" in setting)
@ -208,7 +208,7 @@ if (!IS_REPORTER) {
* Same as {@link Settings} but unproxied. You should treat this as readonly, * Same as {@link Settings} but unproxied. You should treat this as readonly,
* as modifying properties on this will not save to disk or call settings * as modifying properties on this will not save to disk or call settings
* listeners. * listeners.
* WARNING: default values specified in plugin.options will not be ensured here. In other words, * WARNING: default values specified in plugin.settings will not be ensured here. In other words,
* settings for which you specified a default value may be uninitialised. If you need proper * settings for which you specified a default value may be uninitialised. If you need proper
* handling for default values, use {@link Settings} * handling for default values, use {@link Settings}
*/ */
@ -353,7 +353,13 @@ export function definePluginSettings<
Checks extends SettingsChecks<Def>, Checks extends SettingsChecks<Def>,
PrivateSettings extends object = {} PrivateSettings extends object = {}
>(def: Def, checks?: Checks) { >(def: Def, checks?: Checks) {
const definedSettings: DefinedSettings<Def, Checks, PrivateSettings> = { if (checks) {
for (const [name, check] of Object.entries(checks)) {
Object.assign(def[name], check);
}
}
const definedSettings: DefinedSettings<Def, PrivateSettings> = {
get store() { get store() {
if (!definedSettings.pluginName) throw new Error("Cannot access settings before plugin is initialized"); if (!definedSettings.pluginName) throw new Error("Cannot access settings before plugin is initialized");
return Settings.plugins[definedSettings.pluginName] as any; return Settings.plugins[definedSettings.pluginName] as any;
@ -368,11 +374,10 @@ export function definePluginSettings<
: [`plugins.${definedSettings.pluginName}.*`] : [`plugins.${definedSettings.pluginName}.*`]
) as UseSettings<Settings>[]).plugins[definedSettings.pluginName] as any, ) as UseSettings<Settings>[]).plugins[definedSettings.pluginName] as any,
def, def,
checks: checks ?? {} as any,
pluginName: "", pluginName: "",
withPrivateSettings<T extends object>() { withPrivateSettings<T extends object>() {
return this as DefinedSettings<Def, Checks, T>; return this as DefinedSettings<Def, T>;
} }
}; };

View file

@ -20,17 +20,33 @@ import { ManifestEntry, SyncRequest, SyncResponse } from "./types";
const logger = new Logger("SettingsSync:Cloud", "#39b7e0"); const logger = new Logger("SettingsSync:Cloud", "#39b7e0");
const MANIFEST_STORE_KEY = "Vencord_cloudManifest"; const MANIFEST_STORE_KEY = "Vencord_cloudManifest";
const API_VERSION_STORE_KEY = "Vencord_cloudApiVersions";
type ApiVersion = "v2" | "v1"; type ApiVersion = "v2" | "v1";
let apiVersion: ApiVersion = "v2"; const SYNC_DIRECTION_KEY = "Vencord_cloudSyncDirection";
const SETTINGS_DIRTY_KEY = "Vencord_settingsDirty";
export const getCloudSyncDirection = () => localStorage.getItem(SYNC_DIRECTION_KEY) || "both";
export const setCloudSyncDirection = (direction: "push" | "pull" | "both" | "manual") => localStorage.setItem(SYNC_DIRECTION_KEY, direction);
export const areLocalSettingsDirty = () => localStorage.getItem(SETTINGS_DIRTY_KEY) === "true";
export const markLocalSettingsDirty = () => localStorage.setItem(SETTINGS_DIRTY_KEY, "true");
export const markLocalSettingsClean = () => localStorage.removeItem(SETTINGS_DIRTY_KEY);
async function loadApiVersionMap(): Promise<Record<string, ApiVersion>> {
return await DataStore.get<Record<string, ApiVersion>>(API_VERSION_STORE_KEY) ?? {};
}
async function getApiVersion(): Promise<ApiVersion> { async function getApiVersion(): Promise<ApiVersion> {
return apiVersion; const map = await loadApiVersionMap();
return map[getCloudUrl().origin] ?? "v2";
} }
async function setApiVersion(version: ApiVersion) { async function setApiVersion(version: ApiVersion) {
apiVersion = version; await DataStore.update<Record<string, ApiVersion>>(API_VERSION_STORE_KEY, map => {
map ??= {};
map[getCloudUrl().origin] = version;
return map;
});
} }
function toBase64(data: Uint8Array): string { function toBase64(data: Uint8Array): string {

View file

@ -35,6 +35,7 @@ import * as $Notifications from "./Notifications";
import * as $UserArea from "./UserArea"; import * as $UserArea from "./UserArea";
export * as PluginManager from "./PluginManager"; export * as PluginManager from "./PluginManager";
import * as $ProfileCollections from "./ProfileCollections"; import * as $ProfileCollections from "./ProfileCollections";
import * as $ProfileSections from "./ProfileSections";
import * as $ServerList from "./ServerList"; import * as $ServerList from "./ServerList";
import * as $Settings from "./Settings"; import * as $Settings from "./Settings";
import * as $Styles from "./Styles"; import * as $Styles from "./Styles";
@ -176,3 +177,8 @@ export const isEquicord = true;
* An API allowing you to add other collections where discords game collection is. * An API allowing you to add other collections where discords game collection is.
*/ */
export const ProfileCollections = $ProfileCollections; export const ProfileCollections = $ProfileCollections;
/**
* An API allowing you to add sections near the 'Member Since' area of user profile panels.
*/
export const ProfileSections = $ProfileSections;

View file

@ -16,13 +16,13 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { React, TextInput } from "@webpack/common"; import { React, TextInput, useState } from "@webpack/common";
interface TextInputProps { interface TextInputProps {
/** /**
* WARNING: Changing this between renders will have no effect! * WARNING: Changing this between renders will have no effect!
*/ */
value: string; initialValue: string;
/** /**
* This will only be called if the new value passed validate() * This will only be called if the new value passed validate()
*/ */
@ -41,13 +41,15 @@ interface TextInputProps {
* A very simple wrapper around Discord's TextInput that validates input and shows * A very simple wrapper around Discord's TextInput that validates input and shows
* the user an error message and only calls your onChange when the input is valid * the user an error message and only calls your onChange when the input is valid
*/ */
export function CheckedTextInput({ value: initialValue, onChange, validate, placeholder }: TextInputProps) { export function CheckedTextInput({ initialValue, onChange, validate, placeholder }: TextInputProps) {
const [value, setValue] = React.useState(initialValue); const [value, setValue] = useState(initialValue);
const [error, setError] = React.useState<string>(); const [error, setError] = useState<string>();
function handleChange(v: string) { function handleChange(v: string) {
setValue(v); setValue(v);
const res = validate(v); const res = validate(v);
if (res === true) { if (res === true) {
setError(void 0); setError(void 0);
onChange(v); onChange(v);

View file

@ -730,6 +730,34 @@ export function ClockIcon(props: IconProps) {
); );
} }
export function ChevronSmallDownIcon(props: IconProps) {
return (
<Icon
{...props}
viewBox="0 0 24 24"
>
<path
fill={props.fill || "currentColor"}
d="M5.3 9.3a1 1 0 0 1 1.4 0l5.3 5.29 5.3-5.3a1 1 0 1 1 1.4 1.42l-6 6a1 1 0 0 1-1.4 0l-6-6a1 1 0 0 1 0-1.42Z"
/>
</Icon>
);
}
export function ChevronSmallUpIcon(props: IconProps) {
return (
<Icon
{...props}
viewBox="0 0 24 24"
>
<path
fill={props.fill || "currentColor"}
d="M5.3 14.7a1 1 0 0 0 1.4 0l5.3-5.29 5.3 5.3a1 1 0 1 0 1.4-1.42l-6-6a1 1 0 0 0-1.4 0l-6 6a1 1 0 0 0 0 1.42Z"
/>
</Icon>
);
}
export function DownArrow(props: IconProps) { export function DownArrow(props: IconProps) {
return ( return (
<Icon <Icon
@ -797,3 +825,171 @@ export const ComponentsIcon = (props?: any) => {
</Icon> </Icon>
); );
}; };
export const LogsIcon = (props?: any) => {
return (
<Icon
{...props}
viewBox="0 0 24 24"
>
<path
fillRule="evenodd"
clipRule="evenodd"
fill={props.fill || "currentColor"}
d="M21 21.93V2.07a1 1 0 0 0-1.27-.97l-2.5.7a3 3 0 0 1-1.46.04l-3.12-.7a3 3 0 0 0-1.3 0l-3.12.7a3 3 0 0 1-1.45-.04l-2.51-.7A1 1 0 0 0 3 2.07v19.86a1 1 0 0 0 1.27.97l2.5-.7a3 3 0 0 1 1.46-.04l3.12.7a3 3 0 0 0 1.3 0l3.12-.7a3 3 0 0 1 1.45.04l2.51.7a1 1 0 0 0 1.27-.97ZM7 8a1 1 0 0 1 1-1h8a1 1 0 1 1 0 2H8a1 1 0 0 1-1-1Zm1 3a1 1 0 0 0 0 2h8a1 1 0 1 0 0-2H8Zm-1 5a1 1 0 0 1 1-1h3a1 1 0 0 1 0 2H8a1 1 0 0 1-1-1Zm8-1a1 1 0 0 0 0 2h1a1 1 0 1 0 0-2h-1Z"
/>
</Icon>
);
};
export const BookmarkIcon = (props?: any) => {
return (
<Icon
{...props}
viewBox="0 0 24 24"
>
<path
fill={props.fill || "currentColor"}
d="M4 5a3 3 0 0 1 3-3h10a3 3 0 0 1 3 3v16a1 1 0 0 1-1.67.74l-5.66-5.13a1 1 0 0 0-1.34 0l-5.66 5.13A1 1 0 0 1 4 20.99V5Z"
/>
</Icon>
);
};
export function RobotIcon(props: IconProps) {
return (
<Icon
{...props}
viewBox="0 0 24 24"
>
<path
fill={props.fill || "currentColor"}
d="M7.89 13.46a1 1 0 0 1-1.78-.9L7 13l-.9-.45.01-.01.01-.02a2.24 2.24 0 0 1 .14-.23c.1-.14.23-.31.4-.5.37-.36.98-.79 1.84-.79.86 0 1.47.43 1.83.8a3.28 3.28 0 0 1 .55.72v.02h.01v.01L10 13l.9-.45a1 1 0 0 1-1.79.9 1.28 1.28 0 0 0-.19-.25c-.14-.13-.28-.2-.42-.2-.14 0-.28.07-.42.2a1.28 1.28 0 0 0-.19.25ZM13.55 13.9a1 1 0 0 0 1.34-.44c0-.02.02-.04.04-.06.03-.05.08-.13.15-.2.14-.13.28-.2.42-.2.14 0 .28.07.42.2a1.28 1.28 0 0 1 .19.25 1 1 0 0 0 1.78-.9L17 13l.9-.45-.01-.01-.01-.02a2.1 2.1 0 0 0-.14-.23 3.28 3.28 0 0 0-.4-.5c-.37-.36-.98-.79-1.84-.79-.86 0-1.47.43-1.83.8a3.28 3.28 0 0 0-.55.72v.02h-.01v.01L14 13l-.9-.45a1 1 0 0 0 .45 1.34Z"
/>
<path
fill={props.fill || "currentColor"}
fillRule="evenodd"
d="M12 21c5.52 0 10-1.86 10-6 0-5.59-2.8-10.07-4.26-11.67a1 1 0 1 0-1.48 1.34 14.8 14.8 0 0 1 2.35 3.86A10.23 10.23 0 0 0 12 6C9.47 6 7.15 7.02 5.4 8.53a14.8 14.8 0 0 1 2.34-3.86 1 1 0 1 0-1.48-1.34A18.65 18.65 0 0 0 2 15c0 4.14 4.48 6 10 6Zm0-12c3.87 0 7 2 7 4.2S15.87 17 12 17s-7-1.6-7-3.8C5 11 8.13 9 12 9Z"
clipRule="evenodd"
/>
</Icon>
);
}
export function HeadphonesIcon(props: IconProps) {
return (
<Icon
{...props}
viewBox="0 0 24 24"
>
<path
fill={props.fill || "currentColor"}
d="M12 3a9 9 0 0 0-8.95 10h1.87a5 5 0 0 1 4.1 2.13l1.37 1.97a3.1 3.1 0 0 1-.17 3.78 2.85 2.85 0 0 1-3.55.74 11 11 0 1 1 10.66 0c-1.27.71-2.73.23-3.55-.74a3.1 3.1 0 0 1-.17-3.78l1.38-1.97a5 5 0 0 1 4.1-2.13h1.86A9 9 0 0 0 12 3Z"
/>
</Icon>
);
}
export function ShieldIcon(props: IconProps) {
return (
<Icon
{...props}
viewBox="0 0 24 24"
>
<path
fill={props.fill || "currentColor"}
d="M17.05 8.76a4.7 4.7 0 0 1-1.29-.88c-.36-.33-.7-.73-.88-1.13-.33-.73-.16-.77-1.08-.77h-3.64c-.91 0-.74.04-1.08.77a4.94 4.94 0 0 1-2.17 2.01c-.74.33-.85.14-.86.82 0 .88-.22 3.34 1.78 5.19a13.68 13.68 0 0 0 3.98 2.47c.09.03.08.04.17.04.09 0 .08 0 .17-.04.76-.25 2.67-1.25 3.98-2.47 2-1.85 1.78-4.3 1.78-5.19-.01-.68-.12-.49-.86-.82Z"
/>
<path
fill={props.fill || "currentColor"}
d="M19.91 5.41c-.84 0-1.52-.65-1.52-1.46v-.3c0-.9-.77-1.65-1.71-1.65H7.31c-.94 0-1.71.74-1.71 1.65v.3c0 .81-.68 1.46-1.52 1.46H3.7c-.94 0-1.7.73-1.7 1.64v3.52l.01.49c.05 3.11.94 4.69 2.92 6.63C6.72 19.46 11.58 22 11.99 22c.41 0 5.27-2.54 7.06-4.31 1.98-1.95 2.92-3.53 2.92-6.63L22 7.05c0-.9-.76-1.64-1.7-1.64h-.39Zm-2.18 10.74a21.76 21.76 0 0 1-5.58 3.38c-.1.03-.2.03-.31 0a21.76 21.76 0 0 1-5.58-3.38c-1.15-1.08-2.12-2.37-2.12-5.29v-.33l.03-2.57c0-.31.26-.57.59-.57a2.88 2.88 0 0 0 2.89-2.8v-.03c0-.3.26-.55.59-.55h7.56c.32 0 .59.25.59.55v.03c0 1.53 1.3 2.77 2.89 2.8.32 0 .59.26.59.57l.03 2.57v.34c0 2.92-.98 4.2-2.14 5.28h-.03Z"
/>
</Icon>
);
}
export function CircleQuestionIcon(props: IconProps) {
return (
<Icon
{...props}
viewBox="0 0 24 24"
>
<circle
cx="12"
cy="12"
r="10"
fill="transparent"
/>
<path
fill={props.fill || "currentColor"}
fillRule="evenodd"
d="M12 23a11 11 0 1 0 0-22 11 11 0 0 0 0 22Zm-.28-16c-.98 0-1.81.47-2.27 1.14A1 1 0 1 1 7.8 7.01 4.73 4.73 0 0 1 11.72 5c2.5 0 4.65 1.88 4.65 4.38 0 2.1-1.54 3.77-3.52 4.24l.14 1a1 1 0 0 1-1.98.27l-.28-2a1 1 0 0 1 .99-1.14c1.54 0 2.65-1.14 2.65-2.38 0-1.23-1.1-2.37-2.65-2.37ZM13 17.88a1.13 1.13 0 1 1-2.25 0 1.13 1.13 0 0 1 2.25 0Z"
clipRule="evenodd"
/>
</Icon>
);
}
export function AttachmentIcon(props: IconProps) {
return (
<Icon
{...props}
viewBox="0 0 24 24"
>
<path
fill={props.fill || "currentColor"}
d="M10.57 4.01a6.97 6.97 0 0 1 9.86 0l.54.55a6.99 6.99 0 0 1 0 9.88l-7.26 7.27a1 1 0 0 1-1.42-1.42l7.27-7.26a4.99 4.99 0 0 0 0-7.06L19 5.43a4.97 4.97 0 0 0-7.02 0l-8.02 8.02a3.24 3.24 0 1 0 4.58 4.58l6.24-6.24a1.12 1.12 0 0 0-1.58-1.58l-3.5 3.5a1 1 0 0 1-1.42-1.42l3.5-3.5a3.12 3.12 0 1 1 4.42 4.42l-6.24 6.24a5.24 5.24 0 0 1-7.42-7.42l8.02-8.02Z"
/>
</Icon>
);
}
export function StickerIcon(props: IconProps) {
return (
<Icon
{...props}
viewBox="0 0 24 24"
>
<path
fill={props.fill || "currentColor"}
fillRule="evenodd"
d="M6 2h12a4 4 0 0 1 4 4v7.5a.5.5 0 0 1-.5.5H19a5 5 0 0 0-5 5v2.5a.5.5 0 0 1-.5.5H6a4 4 0 0 1-4-4V6a4 4 0 0 1 4-4Zm.5 8a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM19 8.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm-9.91 2.94a1 1 0 0 0-1.66 1.12 5.5 5.5 0 0 0 9.14 0 1 1 0 0 0-1.66-1.12 3.5 3.5 0 0 1-5.82 0Z"
clipRule="evenodd"
/>
<path
fill={props.fill || "currentColor"}
d="M21.66 16c.03 0 .05.03.04.06a3 3 0 0 1-.58.82l-4.24 4.24a3 3 0 0 1-.82.58.04.04 0 0 1-.06-.04V19a3 3 0 0 1 3-3h2.66Z"
/>
</Icon>
);
}
export function GifIcon(props: IconProps) {
return (
<Icon
{...props}
viewBox="0 0 24 24"
>
<path
fill={props.fill || "currentColor"}
fillRule="evenodd"
d="M5 2a3 3 0 0 0-3 3v14a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3V5a3 3 0 0 0-3-3H5Zm2.18 13.87a2.9 2.9 0 0 1-1.66-.49 3.18 3.18 0 0 1-1.12-1.35A4.88 4.88 0 0 1 4 12c0-.75.14-1.42.42-2 .29-.59.7-1.04 1.24-1.37a3.7 3.7 0 0 1 1.95-.5c.64 0 1.21.14 1.72.4.5.28.89.67 1.15 1.16l-1.22.88a1.76 1.76 0 0 0-1.64-1.02c-.66 0-1.17.22-1.52.65a2.74 2.74 0 0 0-.53 1.8c0 .78.18 1.39.53 1.81.35.42.86.64 1.52.64.3 0 .57-.06.81-.16.25-.12.45-.27.58-.47v-.93H7.3v-1.4h3.24v4.23H9.27l-.2-.71c-.43.57-1.06.86-1.89.86Zm6.34-.15h-1.57V8.28h1.57v7.44Zm1.51 0h1.57v-2.83h2.76v-1.42H16.6V9.7H20V8.28h-4.97v7.44Z"
clipRule="evenodd"
/>
</Icon>
);
}
export function VideoIcon(props: IconProps) {
return (
<Icon
{...props}
viewBox="0 0 24 24"
>
<path
fill={props.fill || "currentColor"}
d="M4 4a3 3 0 0 0-3 3v10a3 3 0 0 0 3 3h11a3 3 0 0 0 3-3v-2.12a1 1 0 0 0 .55.9l3 1.5a1 1 0 0 0 1.45-.9V7.62a1 1 0 0 0-1.45-.9l-3 1.5a1 1 0 0 0-.55.9V7a3 3 0 0 0-3-3H4Z"
/>
</Icon>
);
}

View file

@ -9,6 +9,7 @@ import "./colorStyles.css";
import { classNameFactory } from "@utils/css"; import { classNameFactory } from "@utils/css";
import { findByCodeLazy } from "@webpack"; import { findByCodeLazy } from "@webpack";
import { ColorPicker, useMemo } from "@webpack/common"; import { ColorPicker, useMemo } from "@webpack/common";
import type tinycolor from "tinycolor2";
// TinyColor is completely unmangled and it's duplicated in two modules! Fun! // TinyColor is completely unmangled and it's duplicated in two modules! Fun!
const TinyColor: tinycolor.Constructor = findByCodeLazy("this._gradientType="); const TinyColor: tinycolor.Constructor = findByCodeLazy("this._gradientType=");

View file

@ -16,9 +16,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { BaseText } from "@components/BaseText";
import ErrorBoundary from "@components/ErrorBoundary"; import ErrorBoundary from "@components/ErrorBoundary";
import { handleComponentFailed } from "@components/handleComponentFailed"; import { handleComponentFailed } from "@components/handleComponentFailed";
import { ModalCloseButton, ModalContent, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal"; import { ModalCloseButton, ModalContent, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { onlyOnce } from "@utils/onlyOnce"; import { onlyOnce } from "@utils/onlyOnce";
import type { ComponentType, PropsWithChildren } from "react"; import type { ComponentType, PropsWithChildren } from "react";
@ -43,13 +44,17 @@ export function wrapTab(component: ComponentType<any>, tab: string) {
export function openSettingsTabModal(Tab: ComponentType<any>) { export function openSettingsTabModal(Tab: ComponentType<any>) {
try { try {
openModal(wrapTab((modalProps: ModalProps) => ( openModal(wrapTab((modalProps: ModalProps) => (
<ModalRoot {...modalProps} size={ModalSize.MEDIUM}> <ModalRoot {...modalProps} size={ModalSize.MEDIUM} className="vc-settings-modal-root">
<ModalHeader>
<BaseText size="lg" weight="semibold" style={{ flexGrow: 1 }}>{Tab.displayName?.replace("SettingsTab", "") || "Settings"}</BaseText>
<ModalCloseButton onClick={modalProps.onClose} />
</ModalHeader>
<ModalContent className="vc-settings-modal"> <ModalContent className="vc-settings-modal">
<ModalCloseButton onClick={modalProps.onClose} className="vc-settings-modal-close" />
<Tab /> <Tab />
</ModalContent> </ModalContent>
</ModalRoot> </ModalRoot>
), Tab.displayName || "Settings Tab")); ), Tab.displayName || "SettingsTab"));
} catch { } catch {
handleSettingsTabError(); handleSettingsTabError();
} }

View file

@ -5,14 +5,14 @@
*/ */
import { showNotice } from "@api/Notices"; import { showNotice } from "@api/Notices";
import { isPluginEnabled, pluginRequiresRestart, startDependenciesRecursive, startPlugin, stopPlugin } from "@api/PluginManager"; import { hasAnyVisibleSettings, isPluginEnabled, pluginRequiresRestart, startDependenciesRecursive, startPlugin, stopPlugin } from "@api/PluginManager";
import { Settings } from "@api/Settings";
import { CogWheel, InfoIcon } from "@components/Icons"; import { CogWheel, InfoIcon } from "@components/Icons";
import { AddonCard } from "@components/settings/AddonCard"; import { AddonCard } from "@components/settings/AddonCard";
import { classNameFactory } from "@utils/css"; import { classNameFactory } from "@utils/css";
import { Logger } from "@utils/Logger"; import { Logger } from "@utils/Logger";
import { OptionType, Plugin } from "@utils/types"; import { Plugin } from "@utils/types";
import { React, showToast, Toasts } from "@webpack/common"; import { React, showToast, Toasts } from "@webpack/common";
import { Settings } from "Vencord";
import { PluginMeta } from "~plugins"; import { PluginMeta } from "~plugins";
@ -146,7 +146,7 @@ export function PluginCard({ plugin, disabled, onRestartNeeded, onMouseEnter, on
onClick={() => openPluginModal(plugin, onRestartNeeded)} onClick={() => openPluginModal(plugin, onRestartNeeded)}
className={cl("info-button")} className={cl("info-button")}
> >
{plugin.settings?.def && Object.values(plugin.settings.def).some(s => s.type !== OptionType.CUSTOM && !s.hidden) {hasAnyVisibleSettings(plugin)
? <CogWheel className={cl("info-icon")} /> ? <CogWheel className={cl("info-icon")} />
: <InfoIcon className={cl("info-icon")} /> : <InfoIcon className={cl("info-icon")} />
} }

View file

@ -60,5 +60,25 @@
} }
.vc-plugin-modal-info { .vc-plugin-modal-info {
align-items: center; display: flex;
gap: 1em;
justify-content: space-between;
align-items: start;
}
.vc-plugin-modal-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-block: 8px 16px;
}
.vc-plugin-modal-tag {
align-items: center;
border: 1px solid var(--border-normal);
border-radius: 9999px;
display: flex;
justify-content: center;
padding: 6px 12px;
color: var(--text-default);
} }

View file

@ -19,6 +19,7 @@
import "./PluginModal.css"; import "./PluginModal.css";
import { generateId } from "@api/Commands"; import { generateId } from "@api/Commands";
import { hasAnyVisibleSettings, isSettingHidden } from "@api/PluginManager";
import { useSettings } from "@api/Settings"; import { useSettings } from "@api/Settings";
import { BaseText } from "@components/BaseText"; import { BaseText } from "@components/BaseText";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
@ -32,7 +33,7 @@ import { proxyLazy } from "@utils/lazy";
import { Margins } from "@utils/margins"; import { Margins } from "@utils/margins";
import { classes, isObjectEmpty } from "@utils/misc"; import { classes, isObjectEmpty } from "@utils/misc";
import { ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal"; import { ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { OptionType, Plugin } from "@utils/types"; import { OptionType, Plugin, PluginTag } from "@utils/types";
import { User } from "@vencord/discord-types"; import { User } from "@vencord/discord-types";
import { findComponentByCodeLazy, findCssClassesLazy } from "@webpack"; import { findComponentByCodeLazy, findCssClassesLazy } from "@webpack";
import { Clickable, FluxDispatcher, React, Toasts, Tooltip, useEffect, useMemo, UserStore, UserSummaryItem, UserUtils, useState } from "@webpack/common"; import { Clickable, FluxDispatcher, React, Toasts, Tooltip, useEffect, useMemo, UserStore, UserSummaryItem, UserUtils, useState } from "@webpack/common";
@ -74,9 +75,19 @@ export function makeDummyUser(user: { username: string; id?: string; avatar?: st
return newUser; return newUser;
} }
function PluginTags({ tags }: { tags: PluginTag[]; }) {
return (
<div className={cl("tags")}>
{tags.map(tag => (
<div key={tag} className={cl("tag")}>{tag}</div>
))}
</div>
);
}
export default function PluginModal({ plugin, onRestartNeeded, onClose, transitionState }: PluginModalProps) { export default function PluginModal({ plugin, onRestartNeeded, onClose, transitionState }: PluginModalProps) {
const pluginSettings = useSettings([`plugins.${plugin.name}.*`]).plugins[plugin.name]; const pluginSettings = useSettings([`plugins.${plugin.name}.*`]).plugins[plugin.name];
const hasSettings = Boolean(pluginSettings && plugin.options && !isObjectEmpty(plugin.options)); const hasSettings = hasAnyVisibleSettings(plugin);
// avoid layout shift by showing dummy users while loading users // avoid layout shift by showing dummy users while loading users
const fallbackAuthors = useMemo(() => [makeDummyUser({ username: "Loading...", id: "-1465912127305809920" })], []); const fallbackAuthors = useMemo(() => [makeDummyUser({ username: "Loading...", id: "-1465912127305809920" })], []);
@ -104,14 +115,17 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti
} }
function renderSettings() { function renderSettings() {
if (!hasSettings || !plugin.options) const { settings } = plugin;
if (!hasSettings || !settings)
return <Paragraph>There are no settings for this plugin.</Paragraph>; return <Paragraph>There are no settings for this plugin.</Paragraph>;
const options = Object.entries(plugin.options).map(([key, setting]) => { const options = Object.entries(settings.def).map(([key, setting]) => {
if (setting.type === OptionType.CUSTOM || setting.hidden) return null; if (setting.type === OptionType.CUSTOM) return null;
if (isSettingHidden(settings, setting)) return null;
function onChange(newValue: any) { function onChange(newValue: any) {
const option = plugin.options?.[key]; const option = plugin.settings!.def[key];
if (!option || option.type === OptionType.CUSTOM) return; if (!option || option.type === OptionType.CUSTOM) return;
pluginSettings[key] = newValue; pluginSettings[key] = newValue;
@ -124,10 +138,10 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti
<ErrorBoundary noop key={key}> <ErrorBoundary noop key={key}>
<Component <Component
id={key} id={key}
option={setting} setting={setting}
onChange={debounce(onChange)} onChange={debounce(onChange)}
pluginSettings={pluginSettings} pluginSettings={pluginSettings}
definedSettings={plugin.settings} definedSettings={settings}
/> />
</ErrorBoundary> </ErrorBoundary>
); );
@ -167,6 +181,7 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti
<div className={cl("header-content")}> <div className={cl("header-content")}>
<BaseText size="lg" weight="semibold" className={cl("title")}>{plugin.name}</BaseText> <BaseText size="lg" weight="semibold" className={cl("title")}>{plugin.name}</BaseText>
<BaseText size="sm" className={cl("description")}>{plugin.description}</BaseText> <BaseText size="sm" className={cl("description")}>{plugin.description}</BaseText>
{!!plugin.tags?.length && <PluginTags tags={plugin.tags} />}
{!!plugin.settingsAboutComponent && ( {!!plugin.settingsAboutComponent && (
<div className={Margins.top8}> <div className={Margins.top8}>
<ErrorBoundary message="An error occurred while rendering this plugin's custom Info Component"> <ErrorBoundary message="An error occurred while rendering this plugin's custom Info Component">

View file

@ -16,20 +16,21 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { isSettingDisabled } from "@api/PluginManager";
import { Switch } from "@components/Switch"; import { Switch } from "@components/Switch";
import { PluginOptionBoolean } from "@utils/types"; import { PluginSettingBooleanDef } from "@utils/types";
import { React, useState } from "@webpack/common"; import { React, useState } from "@webpack/common";
import { resolveError, SettingProps, SettingsSection } from "./Common"; import { resolveError, SettingProps, SettingsSection } from "./Common";
export function BooleanSetting({ option, pluginSettings, definedSettings, id, onChange }: SettingProps<PluginOptionBoolean>) { export function BooleanSetting({ setting, pluginSettings, definedSettings, id, onChange }: SettingProps<PluginSettingBooleanDef>) {
const def = pluginSettings[id] ?? option.default; const def = pluginSettings[id] ?? setting.default;
const [state, setState] = useState(def ?? false); const [state, setState] = useState(def ?? false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
function handleChange(newValue: boolean): void { function handleChange(newValue: boolean): void {
const isValid = option.isValid?.call(definedSettings, newValue) ?? true; const isValid = setting.isValid?.call(definedSettings, newValue) ?? true;
setState(newValue); setState(newValue);
setError(resolveError(isValid)); setError(resolveError(isValid));
@ -40,11 +41,11 @@ export function BooleanSetting({ option, pluginSettings, definedSettings, id, on
} }
return ( return (
<SettingsSection tag="label" name={id} description={option.description} error={error} inlineSetting> <SettingsSection tag="label" name={id} description={setting.description} error={error} inlineSetting>
<Switch <Switch
checked={state} checked={state}
onChange={handleChange} onChange={handleChange}
disabled={option.disabled?.call(definedSettings) ?? false} disabled={isSettingDisabled(definedSettings, setting)}
/> />
</SettingsSection> </SettingsSection>
); );

View file

@ -8,24 +8,24 @@ import { BaseText } from "@components/BaseText";
import { classNameFactory } from "@utils/css"; import { classNameFactory } from "@utils/css";
import { classes } from "@utils/misc"; import { classes } from "@utils/misc";
import { wordsFromCamel, wordsToTitle } from "@utils/text"; import { wordsFromCamel, wordsToTitle } from "@utils/text";
import { DefinedSettings, PluginOptionBase } from "@utils/types"; import { DefinedSettings, PluginSettingDefCommon } from "@utils/types";
import { PropsWithChildren } from "react"; import { PropsWithChildren } from "react";
export const cl = classNameFactory("vc-plugins-setting-"); export const cl = classNameFactory("vc-plugins-setting-");
interface SettingBaseProps<T> { interface SettingBaseProps<T> {
option: T; setting: T;
onChange(newValue: any): void; onChange(newValue: any): void;
pluginSettings: { pluginSettings: {
[setting: string]: any; [setting: string]: any;
enabled: boolean; enabled: boolean;
}; };
id: string; id: string;
definedSettings?: DefinedSettings; definedSettings: DefinedSettings;
} }
export type SettingProps<T extends PluginOptionBase> = SettingBaseProps<T>; export type SettingProps<T extends PluginSettingDefCommon> = SettingBaseProps<T>;
export type ComponentSettingProps<T extends Omit<PluginOptionBase, "description" | "placeholder">> = SettingBaseProps<T>; export type ComponentSettingProps<T extends Omit<PluginSettingDefCommon, "description" | "placeholder">> = SettingBaseProps<T>;
export function resolveError(isValidResult: boolean | string) { export function resolveError(isValidResult: boolean | string) {
if (typeof isValidResult === "string") return isValidResult; if (typeof isValidResult === "string") return isValidResult;

View file

@ -16,10 +16,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { PluginOptionComponent } from "@utils/types"; import { PluginSettingComponentDef } from "@utils/types";
import { ComponentSettingProps } from "./Common"; import { ComponentSettingProps } from "./Common";
export function ComponentSetting({ option, onChange }: ComponentSettingProps<PluginOptionComponent>) { export function ComponentSetting({ setting, onChange }: ComponentSettingProps<PluginSettingComponentDef>) {
return option.component({ setValue: onChange, option }); return setting.component({ setValue: onChange, option: setting });
} }

View file

@ -16,24 +16,25 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { OptionType, PluginOptionNumber } from "@utils/types"; import { isSettingDisabled } from "@api/PluginManager";
import { OptionType, PluginSettingBigIntDef, PluginSettingNumberDef } from "@utils/types";
import { React, TextInput, useState } from "@webpack/common"; import { React, TextInput, useState } from "@webpack/common";
import { resolveError, SettingProps, SettingsSection } from "./Common"; import { resolveError, SettingProps, SettingsSection } from "./Common";
const MAX_SAFE_NUMBER = BigInt(Number.MAX_SAFE_INTEGER); const MAX_SAFE_NUMBER = BigInt(Number.MAX_SAFE_INTEGER);
export function NumberSetting({ option, pluginSettings, definedSettings, id, onChange }: SettingProps<PluginOptionNumber>) { export function NumberSetting({ setting, pluginSettings, definedSettings, id, onChange }: SettingProps<PluginSettingNumberDef | PluginSettingBigIntDef>) {
function serialize(value: any) { function serialize(value: any) {
if (option.type === OptionType.BIGINT) return BigInt(value); if (setting.type === OptionType.BIGINT) return BigInt(value);
return Number(value); return Number(value);
} }
const [state, setState] = useState<any>(`${pluginSettings[id] ?? option.default ?? 0}`); const [state, setState] = useState<any>(`${pluginSettings[id] ?? setting.default ?? 0}`);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
function handleChange(newValue: any) { function handleChange(newValue: any) {
const isValid = option.isValid?.call(definedSettings, newValue) ?? true; const isValid = setting.isValid?.call(definedSettings, newValue) ?? true;
setError(resolveError(isValid)); setError(resolveError(isValid));
@ -41,7 +42,7 @@ export function NumberSetting({ option, pluginSettings, definedSettings, id, onC
onChange(serialize(newValue)); onChange(serialize(newValue));
} }
if (option.type === OptionType.NUMBER && BigInt(newValue) >= MAX_SAFE_NUMBER) { if (setting.type === OptionType.NUMBER && BigInt(newValue) >= MAX_SAFE_NUMBER) {
setState(`${Number.MAX_SAFE_INTEGER}`); setState(`${Number.MAX_SAFE_INTEGER}`);
} else { } else {
setState(newValue); setState(newValue);
@ -49,15 +50,15 @@ export function NumberSetting({ option, pluginSettings, definedSettings, id, onC
} }
return ( return (
<SettingsSection name={id} description={option.description} error={error}> <SettingsSection name={id} description={setting.description} error={error}>
<TextInput <TextInput
type="number" type="number"
pattern="-?[0-9]+" pattern="-?[0-9]+"
placeholder={option.placeholder ?? "Enter a number"} placeholder={setting.placeholder ?? "Enter a number"}
value={state} value={state}
onChange={handleChange} onChange={handleChange}
disabled={option.disabled?.call(definedSettings) ?? false} disabled={isSettingDisabled(definedSettings, setting)}
{...option.componentProps} {...setting.componentProps}
/> />
</SettingsSection> </SettingsSection>
); );

View file

@ -16,19 +16,20 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { PluginOptionSelect } from "@utils/types"; import { isSettingDisabled } from "@api/PluginManager";
import { PluginSettingSelectDef } from "@utils/types";
import { React, Select, useState } from "@webpack/common"; import { React, Select, useState } from "@webpack/common";
import { resolveError, SettingProps, SettingsSection } from "./Common"; import { resolveError, SettingProps, SettingsSection } from "./Common";
export function SelectSetting({ option, pluginSettings, definedSettings, onChange, id }: SettingProps<PluginOptionSelect>) { export function SelectSetting({ setting, pluginSettings, definedSettings, onChange, id }: SettingProps<PluginSettingSelectDef>) {
const def = pluginSettings[id] ?? option.options?.find(o => o.default)?.value; const def = pluginSettings[id] ?? setting.options?.find(o => o.default)?.value;
const [state, setState] = useState<any>(def ?? null); const [state, setState] = useState<any>(def ?? null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
function handleChange(newValue: any) { function handleChange(newValue: any) {
const isValid = option.isValid?.call(definedSettings, newValue) ?? true; const isValid = setting.isValid?.call(definedSettings, newValue) ?? true;
setState(newValue); setState(newValue);
setError(resolveError(isValid)); setError(resolveError(isValid));
@ -39,17 +40,17 @@ export function SelectSetting({ option, pluginSettings, definedSettings, onChang
} }
return ( return (
<SettingsSection name={id} description={option.description} error={error}> <SettingsSection name={id} description={setting.description} error={error}>
<Select <Select
placeholder={option.placeholder ?? "Select an option"} placeholder={setting.placeholder ?? "Select an option"}
options={option.options} options={setting.options}
maxVisibleItems={5} maxVisibleItems={5}
closeOnSelect={true} closeOnSelect={true}
select={handleChange} select={handleChange}
isSelected={v => v === state} isSelected={v => v === state}
serialize={v => String(v)} serialize={v => String(v)}
isDisabled={option.disabled?.call(definedSettings) ?? false} isDisabled={isSettingDisabled(definedSettings, setting)}
{...option.componentProps} {...setting.componentProps}
/> />
</SettingsSection> </SettingsSection>
); );

View file

@ -16,18 +16,19 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { PluginOptionSlider } from "@utils/types"; import { isSettingDisabled } from "@api/PluginManager";
import { PluginSettingSliderDef } from "@utils/types";
import { React, Slider, useState } from "@webpack/common"; import { React, Slider, useState } from "@webpack/common";
import { resolveError, SettingProps, SettingsSection } from "./Common"; import { resolveError, SettingProps, SettingsSection } from "./Common";
export function SliderSetting({ option, pluginSettings, definedSettings, id, onChange }: SettingProps<PluginOptionSlider>) { export function SliderSetting({ setting, pluginSettings, definedSettings, id, onChange }: SettingProps<PluginSettingSliderDef>) {
const def = pluginSettings[id] ?? option.default; const def = pluginSettings[id] ?? setting.default;
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
function handleChange(newValue: number): void { function handleChange(newValue: number): void {
const isValid = option.isValid?.call(definedSettings, newValue) ?? true; const isValid = setting.isValid?.call(definedSettings, newValue) ?? true;
setError(resolveError(isValid)); setError(resolveError(isValid));
@ -37,17 +38,17 @@ export function SliderSetting({ option, pluginSettings, definedSettings, id, onC
} }
return ( return (
<SettingsSection name={id} description={option.description} error={error}> <SettingsSection name={id} description={setting.description} error={error}>
<Slider <Slider
markers={option.markers} markers={setting.markers}
minValue={option.markers[0]} minValue={setting.markers[0]}
maxValue={option.markers[option.markers.length - 1]} maxValue={setting.markers[setting.markers.length - 1]}
initialValue={def} initialValue={def}
onValueChange={handleChange} onValueChange={handleChange}
onValueRender={(v: number) => String(v.toFixed(2))} onValueRender={(v: number) => String(v.toFixed(2))}
stickToMarkers={option.stickToMarkers ?? true} stickToMarkers={setting.stickToMarkers ?? true}
disabled={option.disabled?.call(definedSettings) ?? false} disabled={isSettingDisabled(definedSettings, setting)}
{...option.componentProps} {...setting.componentProps}
/> />
</SettingsSection> </SettingsSection>
); );

View file

@ -16,17 +16,18 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { PluginOptionString } from "@utils/types"; import { isSettingDisabled } from "@api/PluginManager";
import { PluginSettingStringDef } from "@utils/types";
import { React, TextArea, TextInput, useState } from "@webpack/common"; import { React, TextArea, TextInput, useState } from "@webpack/common";
import { resolveError, SettingProps, SettingsSection } from "./Common"; import { resolveError, SettingProps, SettingsSection } from "./Common";
export function TextSetting({ option, pluginSettings, definedSettings, id, onChange }: SettingProps<PluginOptionString>) { export function TextSetting({ setting, pluginSettings, definedSettings, id, onChange }: SettingProps<PluginSettingStringDef>) {
const [state, setState] = useState(pluginSettings[id] ?? option.default ?? null); const [state, setState] = useState(pluginSettings[id] ?? setting.default ?? null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
function handleChange(newValue: string) { function handleChange(newValue: string) {
const isValid = option.isValid?.call(definedSettings, newValue) ?? true; const isValid = setting.isValid?.call(definedSettings, newValue) ?? true;
setState(newValue); setState(newValue);
setError(resolveError(isValid)); setError(resolveError(isValid));
@ -37,22 +38,22 @@ export function TextSetting({ option, pluginSettings, definedSettings, id, onCha
} }
return ( return (
<SettingsSection name={id} description={option.description} error={error}> <SettingsSection name={id} description={setting.description} error={error}>
{option.multiline {setting.multiline
? <TextArea ? <TextArea
placeholder={option.placeholder ?? "Enter a value"} placeholder={setting.placeholder ?? "Enter a value"}
value={state} value={state}
onChange={handleChange} onChange={handleChange}
disabled={option.disabled?.call(definedSettings) ?? false} disabled={isSettingDisabled(definedSettings, setting)}
{...option.componentProps} /> {...setting.componentProps} />
: <TextInput : <TextInput
type="text" type="text"
placeholder={option.placeholder ?? "Enter a value"} placeholder={setting.placeholder ?? "Enter a value"}
value={state} value={state}
onChange={handleChange} onChange={handleChange}
maxLength={null} maxLength={null}
disabled={option.disabled?.call(definedSettings) ?? false} disabled={isSettingDisabled(definedSettings, setting)}
{...option.componentProps} {...setting.componentProps}
/> />
} }
</SettingsSection> </SettingsSection>

View file

@ -36,7 +36,8 @@ import { Logger } from "@utils/Logger";
import { Margins } from "@utils/margins"; import { Margins } from "@utils/margins";
import { classes } from "@utils/misc"; import { classes } from "@utils/misc";
import { useAwaiter, useIntersection } from "@utils/react"; import { useAwaiter, useIntersection } from "@utils/react";
import { Alerts, lodash, Parser, React, Select, TextInput, Toasts, Tooltip, useCallback, useMemo, useState } from "@webpack/common"; import { PluginTag, PluginTags } from "@utils/types";
import { Alerts, lodash, Parser, React, SearchableSelect, Select, TextInput, Toasts, Tooltip, useCallback, useMemo, useRef, useState } from "@webpack/common";
import { JSX } from "react"; import { JSX } from "react";
import Plugins, { ExcludedPlugins, PluginMeta } from "~plugins"; import Plugins, { ExcludedPlugins, PluginMeta } from "~plugins";
@ -143,7 +144,8 @@ function ExcludedPluginsList({ search }: { search: string; }) {
export default function PluginSettings() { export default function PluginSettings() {
const settings = useSettings(); const settings = useSettings();
const changes = React.useMemo(() => new ChangeList<string>(), []); const changeRef = useRef<ChangeList<string>>(null);
const changes = changeRef.current ??= new ChangeList<string>();
React.useEffect(() => { React.useEffect(() => {
return () => { return () => {
@ -194,33 +196,20 @@ export default function PluginSettings() {
const hasUserPlugins = useMemo(() => !IS_STANDALONE && Object.values(PluginMeta).some(m => m.userPlugin), []); const hasUserPlugins = useMemo(() => !IS_STANDALONE && Object.values(PluginMeta).some(m => m.userPlugin), []);
const [searchValue, setSearchValue] = useState({ value: "", status: SearchStatus.ALL }); const [searchValue, setSearchValue] = useState({ value: "", tags: [] as PluginTag[], status: SearchStatus.ALL });
const [searchInput, setSearchInput] = useState("");
const debouncedSetSearch = useMemo(
() => debounce((query: string) => setSearchValue(prev => ({ ...prev, value: query })), 150),
[]
);
const search = searchValue.value.toLowerCase(); const search = searchValue.value.toLowerCase();
const onSearch = useCallback((query: string) => { const onSearch = (query: string) => setSearchValue(prev => ({ ...prev, value: query }));
setSearchInput(query);
debouncedSetSearch(query);
}, [debouncedSetSearch]);
const onStatusChange = useCallback((status: SearchStatus) => {
setSearchValue(prev => ({ ...prev, status }));
}, []);
const pluginFilter = useCallback((plugin: typeof Plugins[keyof typeof Plugins], newPluginsSet: Set<string> | null) => { const pluginFilter = useCallback((plugin: typeof Plugins[keyof typeof Plugins], newPluginsSet: Set<string> | null) => {
const { status } = searchValue; const { status, tags } = searchValue;
const enabled = isPluginEnabled(plugin.name);
switch (status) { switch (status) {
case SearchStatus.DISABLED: case SearchStatus.DISABLED:
if (enabled) return false; if (isPluginEnabled(plugin.name)) return false;
break; break;
case SearchStatus.ENABLED: case SearchStatus.ENABLED:
if (!enabled) return false; if (!isPluginEnabled(plugin.name)) return false;
break; break;
case SearchStatus.EQUICORD: case SearchStatus.EQUICORD:
if (!PluginMeta[plugin.name].folderName.startsWith("src/equicordplugins/")) return false; if (!PluginMeta[plugin.name].folderName.startsWith("src/equicordplugins/")) return false;
@ -239,12 +228,15 @@ export default function PluginSettings() {
break; break;
} }
if (tags.length && tags.some(t => !plugin.tags?.includes(t))) return false;
if (!search.length) return true; if (!search.length) return true;
return ( return (
plugin.name.toLowerCase().includes(search.replace(/\s+/g, "")) || plugin.name.toLowerCase().includes(search.replace(/\s+/g, "")) ||
plugin.name.match(/[A-Z]/g)?.join("").toLowerCase().includes(search) || // acronyms like BF for BetterFolders
plugin.description.toLowerCase().includes(search) || plugin.description.toLowerCase().includes(search) ||
plugin.tags?.some(t => t.toLowerCase().includes(search)) plugin.searchTerms?.some(t => t.toLowerCase().includes(search))
); );
}, [searchValue, search]); }, [searchValue, search]);
@ -406,31 +398,45 @@ export default function PluginSettings() {
Filters Filters
</HeadingTertiary> </HeadingTertiary>
<div className={classes(Margins.bottom20, cl("filter-controls"))}> <ErrorBoundary noop>
<ErrorBoundary noop> <TextInput
<TextInput autoFocus value={searchInput} placeholder="Search for a plugin..." onChange={onSearch} /> inputClassName={cl("filter-control")}
</ErrorBoundary> placeholder="Search for a plugin..."
<div> value={searchValue.value}
<ErrorBoundary noop> onChange={onSearch}
<Select autoFocus
options={[ />
{ label: "Show All", value: SearchStatus.ALL, default: true }, </ErrorBoundary>
{ label: "Show Enabled", value: SearchStatus.ENABLED },
{ label: "Show Disabled", value: SearchStatus.DISABLED }, <ErrorBoundary noop>
{ label: "Show Equicord", value: SearchStatus.EQUICORD }, <div className={classes(Margins.bottom20, Margins.top8, cl("filter-controls"))}>
{ label: "Show Vencord", value: SearchStatus.VENCORD }, <Select
{ label: "Show New", value: SearchStatus.NEW }, options={[
hasUserPlugins && { label: "Show UserPlugins", value: SearchStatus.USER_PLUGINS }, { label: "Show All", value: SearchStatus.ALL, default: true },
{ label: "Show API Plugins", value: SearchStatus.API_PLUGINS }, { label: "Show Enabled", value: SearchStatus.ENABLED },
].filter(isTruthy)} { label: "Show Disabled", value: SearchStatus.DISABLED },
serialize={String} { label: "Show Equicord", value: SearchStatus.EQUICORD },
select={onStatusChange} { label: "Show Vencord", value: SearchStatus.VENCORD },
isSelected={v => v === searchValue.status} { label: "Show New", value: SearchStatus.NEW },
closeOnSelect={true} hasUserPlugins && { label: "Show UserPlugins", value: SearchStatus.USER_PLUGINS },
/> { label: "Show API Plugins", value: SearchStatus.API_PLUGINS },
</ErrorBoundary> ].filter(isTruthy)}
serialize={String}
select={status => setSearchValue(prev => ({ ...prev, status }))}
isSelected={v => v === searchValue.status}
closeOnSelect={true}
placeholder="Filter by Type"
/>
<SearchableSelect
options={PluginTags.map(tag => ({ label: tag, value: tag }))}
value={searchValue.tags}
onChange={tags => setSearchValue(prev => ({ ...prev, tags }))}
closeOnSelect={false}
placeholder="Filter by Tags"
multi
/>
</div> </div>
</div> </ErrorBoundary>
<HeadingTertiary className={Margins.top20}>Plugins</HeadingTertiary> <HeadingTertiary className={Margins.top20}>Plugins</HeadingTertiary>
@ -456,6 +462,7 @@ export default function PluginSettings() {
<HeadingTertiary className={classes(Margins.top20, Margins.bottom8)}> <HeadingTertiary className={classes(Margins.top20, Margins.bottom8)}>
Required Plugins Required Plugins
</HeadingTertiary> </HeadingTertiary>
<div className={cl("grid")}> <div className={cl("grid")}>
{requiredPlugins.length {requiredPlugins.length
? requiredPlugins ? requiredPlugins

View file

@ -45,7 +45,12 @@
display: grid; display: grid;
height: 40px; height: 40px;
gap: 10px; gap: 10px;
grid-template-columns: 1fr 150px; grid-template-columns: 1fr 1fr;
align-items: center;
}
.vc-plugins-filter-control {
height: 40px;
} }
.vc-plugins-dep-name { .vc-plugins-dep-name {

View file

@ -90,10 +90,10 @@
height: 1.25em; height: 1.25em;
} }
.vc-settings-modal { .vc-settings-modal-root {
padding: 1.5em !important; background: var(--background-base-low);
} }
.vc-settings-modal-close { .vc-settings-modal {
float: right; padding-bottom: 1em;
} }

View file

@ -137,7 +137,7 @@ function CloudTab() {
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<CheckedTextInput <CheckedTextInput
key={`backendUrl-${inputKey}`} key={`backendUrl-${inputKey}`}
value={cloud.url} initialValue={cloud.url}
onChange={async v => { onChange={async v => {
cloud.url = v; cloud.url = v;
cloud.authenticated = false; cloud.authenticated = false;

View file

@ -142,7 +142,7 @@ export function Updatable(props: CommonProps) {
) : isOutdated ? ( ) : isOutdated ? (
<> <>
<Paragraph> <Paragraph>
There {updates.length === 1 ? "is 1 update" : `are ${updates.length} updates`} available. Click the button below to download and install. There {updates.length === 1 ? "is 1 update" : `are ${updates.length} updates`} available. Click the button above to download and install.
</Paragraph> </Paragraph>
<Changes updates={updates} {...props} /> <Changes updates={updates} {...props} />
</> </>

View file

@ -20,14 +20,14 @@ import { Paragraph } from "@components/Paragraph";
import { openContributorModal, openPluginModal, SettingsTab, wrapTab } from "@components/settings"; import { openContributorModal, openPluginModal, SettingsTab, wrapTab } from "@components/settings";
import { QuickAction, QuickActionCard } from "@components/settings/QuickAction"; import { QuickAction, QuickActionCard } from "@components/settings/QuickAction";
import { SpecialCard } from "@components/settings/SpecialCard"; import { SpecialCard } from "@components/settings/SpecialCard";
import BadgeAPI from "@plugins/_api/badges";
import { gitRemote } from "@shared/vencordUserAgent"; import { gitRemote } from "@shared/vencordUserAgent";
import { DONOR_ROLE_ID, GUILD_ID, IS_MAC, IS_WINDOWS, VC_DONOR_ROLE_ID, VC_GUILD_ID } from "@utils/constants"; import { DONOR_ROLE_ID, GUILD_ID, IS_MAC, IS_WINDOWS, VC_DONOR_ROLE_ID, VC_GUILD_ID } from "@utils/constants";
import { classNameFactory } from "@utils/css"; import { classNameFactory } from "@utils/css";
import { Margins } from "@utils/margins"; import { Margins } from "@utils/margins";
import { identity, isAnyPluginDev } from "@utils/misc"; import { identity, isAnyPluginDev } from "@utils/misc";
import { relaunch } from "@utils/native"; import { relaunch } from "@utils/native";
import { GuildMemberStore, React, Select, UserStore } from "@webpack/common"; import { Alerts, GuildMemberStore, React, Select, UserStore } from "@webpack/common";
import BadgeAPI from "plugins/_api/badges";
import { DonateButtonComponent } from "./DonateButton"; import { DonateButtonComponent } from "./DonateButton";
import { openNotificationSettingsModal } from "./NotificationSettings"; import { openNotificationSettingsModal } from "./NotificationSettings";
@ -64,45 +64,38 @@ function EquicordSettings() {
title: string; title: string;
description?: string; description?: string;
restartRequired?: boolean; restartRequired?: boolean;
warning: { enabled: boolean; message?: string; }; warning?: string;
} }>
> = [ = [
{ {
key: "useQuickCss", key: "useQuickCss",
title: "Enable Custom CSS", title: "Enable Custom CSS",
description: "Load custom CSS from the QuickCSS editor. This allows you to customize Discord's appearance with your own styles.", description: "Load custom CSS from the QuickCSS editor. This allows you to customize Discord's appearance with your own styles.",
restartRequired: true,
warning: { enabled: false },
}, },
!IS_WEB && { !IS_WEB && {
key: "enableReactDevtools", key: "enableReactDevtools",
title: "Enable React Developer Tools", title: "Enable React Developer Tools",
description: "Enable the React Developer Tools extension for debugging Discord's React components. Useful for plugin development.", description: "Enable the React Developer Tools extension for debugging Discord's React components. Useful for plugin development.",
restartRequired: true, restartRequired: true,
warning: { enabled: false },
}, },
(!IS_WEB && !IS_DISCORD_DESKTOP || !IS_WINDOWS) && { (!IS_WEB && !IS_DISCORD_DESKTOP || !IS_WINDOWS) && {
key: "mainWindowFrameless", key: "mainWindowFrameless",
title: "Disable the Main Window Frame", title: "Disable the Main Window Frame",
description: "Remove the native window frame for a cleaner look. You can still move the window by dragging the title bar area.", description: "Remove the native window frame for a cleaner look. You can still move the window by dragging the title bar area.",
restartRequired: true, restartRequired: true,
warning: { enabled: false },
}, },
!IS_WEB && !IS_WEB && (!IS_DISCORD_DESKTOP || !IS_WINDOWS
(!IS_DISCORD_DESKTOP || !IS_WINDOWS
? { ? {
key: "frameless", key: "frameless",
title: "Disable All Window Frames", title: "Disable All Window Frames",
description: "Remove the native window frame for a cleaner look. You can still move the window by dragging the title bar area.", description: "Remove the native window frame for a cleaner look. You can still move the window by dragging the title bar area.",
restartRequired: true, restartRequired: true,
warning: { enabled: false },
} }
: { : {
key: "winNativeTitleBar", key: "winNativeTitleBar",
title: "Use Windows' native title bar instead of Discord's custom one", title: "Use Windows' native title bar instead of Discord's custom one",
description: "Replace Discord's custom title bar with the standard Windows title bar. This may improve compatibility with some window management tools.", description: "Replace Discord's custom title bar with the standard Windows title bar. This may improve compatibility with some window management tools.",
restartRequired: true, restartRequired: true,
warning: { enabled: false },
} }
), ),
!IS_WEB && { !IS_WEB && {
@ -110,27 +103,21 @@ function EquicordSettings() {
title: "Enable Window Transparency", title: "Enable Window Transparency",
description: "Make the Discord window transparent. A theme that supports transparency is required or this will do nothing.", description: "Make the Discord window transparent. A theme that supports transparency is required or this will do nothing.",
restartRequired: true, restartRequired: true,
warning: { warning: IS_WINDOWS
enabled: true, ? "This will stop the window from being resizable and prevents you from snapping the window to screen edges."
message: IS_WINDOWS : "This will stop the window from being resizable.",
? "This will stop the window from being resizable and prevents you from snapping the window to screen edges."
: "This will stop the window from being resizable.",
},
}, },
IS_DISCORD_DESKTOP && { IS_DISCORD_DESKTOP && {
key: "disableMinSize", key: "disableMinSize",
title: "Disable Minimum Window Size", title: "Disable Minimum Window Size",
description: "Allow the Discord window to be resized smaller than its default minimum size. Useful for tiling window managers or small screens.", description: "Allow the Discord window to be resized smaller than its default minimum size. Useful for tiling window managers or small screens.",
restartRequired: true, restartRequired: true,
warning: { enabled: false },
}, },
!IS_WEB && !IS_WEB && IS_WINDOWS && {
IS_WINDOWS && {
key: "winCtrlQ", key: "winCtrlQ",
title: "Register Ctrl+Q as shortcut to close Discord", title: "Register Ctrl+Q as shortcut to close Discord",
description: "Add Ctrl+Q as a keyboard shortcut to close Discord. This provides an alternative to Alt+F4 for quickly closing the application.", description: "Add Ctrl+Q as a keyboard shortcut to close Discord. This provides an alternative to Alt+F4 for quickly closing the application.",
restartRequired: true, restartRequired: true,
warning: { enabled: false },
}, },
]; ];
@ -249,14 +236,26 @@ function EquicordSettings() {
<FormSwitch <FormSwitch
key={s.key} key={s.key}
value={settings[s.key]} value={settings[s.key]}
onChange={v => (settings[s.key] = v)} onChange={v => {
settings[s.key] = v;
if (s.restartRequired) {
Alerts.show({
title: "Restart Required",
body: "A restart is required to apply this change",
confirmText: "Restart now",
cancelText: "Later!",
onConfirm: relaunch
});
}
}}
title={s.title} title={s.title}
description={ description={
s.warning.enabled ? ( s.warning ? (
<> <>
{s.description} {s.description}
<Notice.Warning className={Margins.top8} style={{ width: "100%" }}> <Notice.Warning className={Margins.top8} style={{ width: "100%" }}>
{s.warning.message} {s.warning}
</Notice.Warning> </Notice.Warning>
</> </>
) : ( ) : (

View file

@ -9,16 +9,16 @@ import { canonicalizeMatch } from "@utils/patches";
import { ModuleFactory } from "@vencord/discord-types/webpack"; import { ModuleFactory } from "@vencord/discord-types/webpack";
import * as Webpack from "@webpack"; import * as Webpack from "@webpack";
import { wreq } from "@webpack"; import { wreq } from "@webpack";
import { AnyModuleFactory } from "@webpack/types";
import pLimit from "p-limit"; import pLimit from "p-limit";
import { AnyModuleFactory } from "webpack";
function getWebpackChunkMap() { function getWebpackChunkMap() {
const sym = Symbol(); const sym = Symbol();
let v: Record<PropertyKey, string> | null = null; let chunksMap: unknown;
Object.defineProperty(Object.prototype, sym, { Object.defineProperty(Object.prototype, sym, {
get() { get() {
v = this; chunksMap = this;
return ""; return "";
}, },
configurable: true configurable: true
@ -27,7 +27,7 @@ function getWebpackChunkMap() {
wreq.u(sym); wreq.u(sym);
delete Object.prototype[sym]; delete Object.prototype[sym];
return v; return chunksMap as Record<PropertyKey, string> | null;
} }
export async function loadLazyChunks() { export async function loadLazyChunks() {
@ -54,7 +54,10 @@ export async function loadLazyChunks() {
async function searchAndLoadLazyChunks(factoryCode: string) { async function searchAndLoadLazyChunks(factoryCode: string) {
// Workaround to avoid loading the CSS debugging chunk which turns the app pink // Workaround to avoid loading the CSS debugging chunk which turns the app pink
const hasCssDebuggingLoad = foundCssDebuggingLoad ? false : (foundCssDebuggingLoad = factoryCode.includes(".cssDebuggingEnabled&&")); // const hasCssDebuggingLoad = foundCssDebuggingLoad ? false : (foundCssDebuggingLoad = factoryCode.includes(".cssDebuggingEnabled&&"));
// Disabled for now since this causes lots of chunks concatenated into the same module get marked as invalid, and thus not loaded.
const hasCssDebuggingLoad = foundCssDebuggingLoad = false;
const lazyChunks = factoryCode.matchAll(hasCssDebuggingLoad ? CompleteLazyChunkRegex : PartialLazyChunkRegex); const lazyChunks = factoryCode.matchAll(hasCssDebuggingLoad ? CompleteLazyChunkRegex : PartialLazyChunkRegex);
const validChunkGroups = new Set<[chunkIds: PropertyKey[], entryPoint: PropertyKey]>(); const validChunkGroups = new Set<[chunkIds: PropertyKey[], entryPoint: PropertyKey]>();
@ -66,7 +69,7 @@ export async function loadLazyChunks() {
?.matchAll(Webpack.ChunkIdsRegex) ?.matchAll(Webpack.ChunkIdsRegex)
.map(m => { .map(m => {
const numChunkId = Number(m[1]); const numChunkId = Number(m[1]);
return Number.isNaN(numChunkId) ? m[1] : numChunkId; return Number.isNaN(numChunkId) ? m[1] : String(numChunkId);
}) })
.toArray() .toArray()
?? []; ?? [];
@ -107,7 +110,7 @@ export async function loadLazyChunks() {
if (!invalidChunkGroup) { if (!invalidChunkGroup) {
const numEntryPoint = Number(entryPoint); const numEntryPoint = Number(entryPoint);
validChunkGroups.add([chunkIds, Number.isNaN(numEntryPoint) ? entryPoint : numEntryPoint]); validChunkGroups.add([chunkIds, Number.isNaN(numEntryPoint) ? entryPoint : String(numEntryPoint)]);
} }
})); }));
@ -178,10 +181,10 @@ export async function loadLazyChunks() {
} }
// All chunks Discord has mapped to asset files, even if they are not used anymore // All chunks Discord has mapped to asset files, even if they are not used anymore
const chunkMap = getWebpackChunkMap(); const chunksMap = getWebpackChunkMap();
if (!chunkMap) throw new Error("Failed to get chunk map"); if (!chunksMap) throw new Error("Failed to get chunk map");
const allChunks = Object.keys(chunkMap).map(id => Number.isNaN(Number(id)) ? id : Number(id)); const allChunks = Object.keys(chunksMap);
if (allChunks.length === 0) throw new Error("Failed to get all chunks"); if (allChunks.length === 0) throw new Error("Failed to get all chunks");
// Chunks which our regex could not catch to load // Chunks which our regex could not catch to load

View file

@ -26,6 +26,7 @@ export interface ReporterData {
}; };
failedWebpack: Record<TypeWebpackSearchHistory, string[][]>; failedWebpack: Record<TypeWebpackSearchHistory, string[][]>;
} }
export const reporterData: ReporterData = { export const reporterData: ReporterData = {
failedPatches: { failedPatches: {
foundNoModule: [], foundNoModule: [],

View file

@ -25,8 +25,8 @@ async function runReporter() {
addPatch({ addPatch({
find: '"Could not find app-mount"', find: '"Could not find app-mount"',
replacement: { replacement: {
match: /(?<="use strict";)/, match: /"Could not find app-mount"/,
replace: "Vencord.Webpack._initReporter();" replace: "(Vencord.Webpack._initReporter(),$&)"
} }
}, "Equicord Reporter"); }, "Equicord Reporter");
@ -39,6 +39,13 @@ async function runReporter() {
await loadLazyChunksDone; await loadLazyChunksDone;
// Manually require all modules to make sure all lazily required modules are patched
for (const moduleId of Object.keys(Webpack.wreq.m)) {
try {
Webpack.wreq(moduleId);
} catch { }
}
if (IS_REPORTER && IS_WEB && !IS_VESKTOP && !IS_EQUIBOP) { if (IS_REPORTER && IS_WEB && !IS_VESKTOP && !IS_EQUIBOP) {
console.log("[REPORTER_META]", { console.log("[REPORTER_META]", {
buildNumber: getBuildNumber(), buildNumber: getBuildNumber(),
@ -55,7 +62,7 @@ async function runReporter() {
} }
for (const [plugin, moduleId, match, totalTime] of patchTimings) { for (const [plugin, moduleId, match, totalTime] of patchTimings) {
if (totalTime > 5) { if (totalTime > 10) {
new Logger("WebpackPatcher").warn(`Patch by ${plugin} took ${Math.round(totalTime * 100) / 100}ms (Module id is ${String(moduleId)}): ${match}`); new Logger("WebpackPatcher").warn(`Patch by ${plugin} took ${Math.round(totalTime * 100) / 100}ms (Module id is ${String(moduleId)}): ${match}`);
} }
} }
@ -70,7 +77,6 @@ async function runReporter() {
else method = "find"; else method = "find";
} }
if (searchType === "waitForStore") method = "findStore"; if (searchType === "waitForStore") method = "findStore";
if (searchType === "waitForStore" && args[0] === "PermissionStore") continue;
let result: any; let result: any;
try { try {

View file

@ -13,7 +13,7 @@ export default definePlugin({
authors: [Devs.thororen], authors: [Devs.thororen],
patches: [ patches: [
{ {
find: /\.POPOUT,onClose:\i}\),nicknameIcons:.+?\.isProvisional/, find: "#{intl::USER_PROFILE_FRIEND_REQUEST_TOAST}",
replacement: { replacement: {
match: /user:\i,widgets:.{0,100}?\}\),/, match: /user:\i,widgets:.{0,100}?\}\),/,
replace: "$&Vencord.Api.ProfileCollections.renderProfileCollections(arguments[0]),", replace: "$&Vencord.Api.ProfileCollections.renderProfileCollections(arguments[0]),",

View file

@ -0,0 +1,40 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Devs } from "@utils/constants";
import definePlugin from "@utils/types";
export default definePlugin({
name: "ProfileSectionsAPI",
description: "API to add sections near the 'Member Since' area of user profile panels.",
authors: [Devs.thororen],
patches: [
// DM User Sidebar
{
find: "#{intl::PREMIUM_GIFTING_BUTTON}),action:",
replacement: {
match: /(#{intl::USER_PROFILE_MEMBER_SINCE}\),.{0,100}userId:(\i\.id)}\)}\))/,
replace: "$1,Vencord.Api.ProfileSections.renderProfileSections({userId:$2,isSideBar:true})",
}
},
// User Profile Modal
{
find: ",applicationRoleConnection:",
replacement: {
match: /(#{intl::USER_PROFILE_MEMBER_SINCE}\),.{0,100}userId:(\i\.id),.{0,100}}\)}\)),/,
replace: "$1,Vencord.Api.ProfileSections.renderProfileSections({userId:$2,isSideBar:false}),",
}
},
// User Profile Modal v2
{
find: ".MODAL_V2,onClose:",
replacement: {
match: /(#{intl::USER_PROFILE_MEMBER_SINCE}\),.{0,100}userId:(\i\.id),.{0,100}}\)}\)),/,
replace: "$1,Vencord.Api.ProfileSections.renderProfileSections({userId:$2,isSideBar:false}),",
}
}
]
});

View file

@ -22,8 +22,8 @@ export default definePlugin({
find: ".DISPLAY_NAME_STYLES_COACHMARK)", find: ".DISPLAY_NAME_STYLES_COACHMARK)",
replacement: [ replacement: [
{ {
match: /(?<=className:(\i)\.\i,style:\i,)children:\[/, match: /children:\[(?=.{0,50}accountContainerRef:\i)/,
replace: "children:[...$self.renderButtons(arguments[0],$1)," replace: "children:[...$self.renderButtons(arguments[0]),"
}, },
// fix discord weird shrink with extra buttons // fix discord weird shrink with extra buttons
{ {

View file

@ -0,0 +1,32 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { IconsDef } from "@equicordplugins/iconViewer/types";
import { Devs } from "@utils/constants";
import definePlugin from "@utils/types";
export let iconsModule: IconsDef;
export default definePlugin({
name: "ConcatenatedModules",
description: "Extract modules that have been concatenated by the bundler",
authors: [Devs.thororen],
required: true,
patches: [
{
find: "AngleBracketsIcon",
replacement: {
match: /\i\.\i\((\i)\),\i\.\i\(\i,\{AIcon/,
replace: "$self.iconsModule($1),$&"
}
}
],
iconsModule(value: IconsDef) {
iconsModule = value;
},
});

View file

@ -10,6 +10,7 @@ import definePlugin from "@utils/types";
export default definePlugin({ export default definePlugin({
name: "AltKrispSwitch", name: "AltKrispSwitch",
description: "Makes the Noise Suppression Popout switch between None and Krisp instead of Krisp and Standard", description: "Makes the Noise Suppression Popout switch between None and Krisp instead of Krisp and Standard",
tags: ["Customisation", "Voice"],
authors: [Devs.newwares], authors: [Devs.newwares],
patches: [ patches: [
{ {

View file

@ -5,24 +5,21 @@
*/ */
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import { openUserProfile } from "@utils/discord";
import definePlugin from "@utils/types"; import definePlugin from "@utils/types";
export default definePlugin({ export default definePlugin({
name: "AlwaysExpandProfiles", name: "AlwaysExpandProfiles",
description: "Always expands profile popouts to the full modal", description: "Always expands profile popouts to the full modal",
tags: ["Appearance", "Utility"],
authors: [Devs.thororen], authors: [Devs.thororen],
patches: [ patches: [
{ {
find: '"view-profile"', find: '"view-profile"',
replacement: { replacement: {
match: /("PRESS_VIEW_PROFILE".{0,200})return(?<=userId:(\i\.id).*?)/, match: /(\i)=.{0,45}\(0,\i\.openUserProfileModal.{0,300}(?=return)/,
replace: "$1return $self.openUserModal($2);" replace: "$&return $1();"
}, },
all: true all: true
}, },
], ],
openUserModal(userId: string) {
openUserProfile(userId);
}
}); });

View file

@ -193,6 +193,7 @@ async function playSound(buffer: AudioBuffer, volume: number) {
export default definePlugin({ export default definePlugin({
name: "Animalese", name: "Animalese",
description: "Plays animal crossing animalese for every message sent (they yap a lot)", description: "Plays animal crossing animalese for every message sent (they yap a lot)",
tags: ["Customisation", "Fun"],
authors: [EquicordDevs.ryanamay, EquicordDevs.Mocha], authors: [EquicordDevs.ryanamay, EquicordDevs.Mocha],
settings, settings,

View file

@ -13,6 +13,7 @@ export default definePlugin({
name: "AtSomeone", name: "AtSomeone",
authors: [Devs.Joona], authors: [Devs.Joona],
description: "Mention someone randomly", description: "Mention someone randomly",
tags: ["Chat", "Fun"],
patches: [ patches: [
{ {
find: ".LAUNCHABLE_APPLICATIONS;", find: ".LAUNCHABLE_APPLICATIONS;",

View file

@ -209,6 +209,7 @@ function handlePaste(event: ClipboardEvent) {
export default definePlugin({ export default definePlugin({
name: "AutoZipper", name: "AutoZipper",
description: "Automatically zips specified file types and folders before uploading to Discord", description: "Automatically zips specified file types and folders before uploading to Discord",
tags: ["Chat", "Organisation"],
authors: [EquicordDevs.SSnowly], authors: [EquicordDevs.SSnowly],
settings, settings,

View file

@ -44,6 +44,7 @@ const DATASTORE_KEY = "bannersEverywhere";
export default definePlugin({ export default definePlugin({
name: "BannersEverywhere", name: "BannersEverywhere",
description: "Displays banners in the member list ", description: "Displays banners in the member list ",
tags: ["Appearance", "Customisation"],
authors: [Devs.ImLvna, Devs.AutumnVN], authors: [Devs.ImLvna, Devs.AutumnVN],
settings, settings,
patches: [ patches: [

View file

@ -107,6 +107,8 @@ const settings = definePluginSettings({
export default definePlugin({ export default definePlugin({
name: "DecodeBase64", name: "DecodeBase64",
description: "Decode base64 content of any message and copy the decoded content.", description: "Decode base64 content of any message and copy the decoded content.",
dependencies: ["MessagePopoverAPI"],
tags: ["Appearance", "Customisation", "Chat"],
authors: [EquicordDevs.ThePirateStoner], authors: [EquicordDevs.ThePirateStoner],
settings, settings,
messagePopoverButton: { messagePopoverButton: {

View file

@ -11,7 +11,7 @@ import { cl } from "../utils";
import { Caret } from "./Caret"; import { Caret } from "./Caret";
export function CarouselControls({ activities, currentActivity, onActivityChange }: CarouselControlsProps) { export function CarouselControls({ activities, currentActivity, onActivityChange }: CarouselControlsProps) {
const currentIndex = activities.indexOf(currentActivity); const currentIndex = activities.findIndex(a => a.id === currentActivity.id);
return ( return (
<div <div
@ -48,7 +48,7 @@ export function CarouselControls({ activities, currentActivity, onActivityChange
<div <div
key={"dot--" + index} key={"dot--" + index}
onClick={() => onActivityChange(activity)} onClick={() => onActivityChange(activity)}
className={cl("controls-dot", currentActivity === activity && "controls-selected")} /> className={cl("controls-dot", currentActivity.id === activity.id && "controls-selected")} />
))} ))}
</div> </div>

View file

@ -20,7 +20,7 @@ export default definePlugin({
name: "BetterActivities", name: "BetterActivities",
description: "Shows activity icons in the member list and allows showing all activities", description: "Shows activity icons in the member list and allows showing all activities",
authors: [Devs.D3SOX, Devs.Arjix, Devs.AutumnVN, Devs.thororen], authors: [Devs.D3SOX, Devs.Arjix, Devs.AutumnVN, Devs.thororen],
tags: ["activity"], tags: ["Activity"],
settings, settings,
patchActivityList, patchActivityList,
showAllActivitiesComponent, showAllActivitiesComponent,
@ -30,7 +30,12 @@ export default definePlugin({
find: '"ActivityStatus"),', find: '"ActivityStatus"),',
replacement: [ replacement: [
{ {
match: /(?<=className:\i,children:\[).*?(?=\i\(\),\i&&)/g, match: /(?<=className:\i,children:\[).*?(?=\i\(\),\i&&)/,
replace: "",
predicate: () => settings.store.removeGameActivityStatus,
},
{
match: /(?<=className:\i,children:\[\i\(\),)null.*?tooltipClassName:\i\}\),/,
replace: "", replace: "",
predicate: () => settings.store.removeGameActivityStatus, predicate: () => settings.store.removeGameActivityStatus,
}, },
@ -45,7 +50,7 @@ export default definePlugin({
// Show all activities in the user popout/sidebar // Show all activities in the user popout/sidebar
find: /onOpenUserProfileModal:\i,onClose:\i\}\),null/, find: /onOpenUserProfileModal:\i,onClose:\i\}\),null/,
replacement: { replacement: {
match: /((\i)=.{0,10}(\i)\.id\).*?,onInteraction:\i\}\),).{0,250}onClose:\i\}\)/, match: /((\i)=.{0,10}(\i)\.id\).*?,onOpenUserProfileModal:\i\}\),).{0,250}onClose:\i\}\)/,
replace: "$1$self.showAllActivitiesComponent({ activity: $2, user: $3 })" replace: "$1$self.showAllActivitiesComponent({ activity: $2, user: $3 })"
}, },
predicate: () => settings.store.userPopout predicate: () => settings.store.userPopout

View file

@ -5,6 +5,7 @@
*/ */
import ErrorBoundary from "@components/ErrorBoundary"; import ErrorBoundary from "@components/ErrorBoundary";
import { Activity } from "@vencord/discord-types";
import { findComponentByCodeLazy } from "@webpack"; import { findComponentByCodeLazy } from "@webpack";
import { React, Tooltip } from "@webpack/common"; import { React, Tooltip } from "@webpack/common";
import { JSX } from "react"; import { JSX } from "react";
@ -18,11 +19,12 @@ import { cl, getApplicationIcons } from "../utils";
// Discord no longer shows an icon here by default but we use the one from the popout now here // Discord no longer shows an icon here by default but we use the one from the popout now here
const DefaultActivityIcon = findComponentByCodeLazy("M5 2a3 3 0 0 0-3 3v14a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3V5a3 3 0 0 0-3-3H5Zm6.81 7c-.54 0-1 .26-1.23.61A1 1 0 0 1 8.92 8.5 3.49 3.49 0 0 1 11.82 7c1.81 0 3.43 1.38 3.43 3.25 0 1.45-.98 2.61-2.27 3.06a1 1 0 0 1-1.96.37l-.19-1a1 1 0 0 1 .98-1.18c.87 0 1.44-.63 1.44-1.25S12.68 9 11.81 9ZM13 16a1 1 0 1 1-2 0 1 1 0 0 1 2 0Zm7-10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0ZM18.5 20a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM7 18.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0ZM5.5 7a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"); const DefaultActivityIcon = findComponentByCodeLazy("M5 2a3 3 0 0 0-3 3v14a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3V5a3 3 0 0 0-3-3H5Zm6.81 7c-.54 0-1 .26-1.23.61A1 1 0 0 1 8.92 8.5 3.49 3.49 0 0 1 11.82 7c1.81 0 3.43 1.38 3.43 3.25 0 1.45-.98 2.61-2.27 3.06a1 1 0 0 1-1.96.37l-.19-1a1 1 0 0 1 .98-1.18c.87 0 1.44-.63 1.44-1.25S12.68 9 11.81 9ZM13 16a1 1 0 1 1-2 0 1 1 0 0 1 2 0Zm7-10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0ZM18.5 20a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM7 18.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0ZM5.5 7a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z");
export function patchActivityList({ activities, user, hideTooltip }: ActivityListProps): JSX.Element | null { export function patchActivityList({ activities: rawActivities, user, hideTooltip }: ActivityListProps): JSX.Element | null {
const icons: ActivityListIcon[] = []; const icons: ActivityListIcon[] = [];
if (user.bot || settings.store.hideTooltip && hideTooltip) return null; if (user.bot || settings.store.hideTooltip && hideTooltip) return null;
const activities = rawActivities.filter((a): a is Activity => a != null);
const applicationIcons = getApplicationIcons(activities); const applicationIcons = getApplicationIcons(activities);
if (applicationIcons.length) { if (applicationIcons.length) {
const compareImageSource = (a: ApplicationIcon, b: ApplicationIcon) => { const compareImageSource = (a: ApplicationIcon, b: ApplicationIcon) => {

View file

@ -24,7 +24,7 @@ export function showAllActivitiesComponent({ activity, user, ...props }: Readonl
const activities = useStateFromStores( const activities = useStateFromStores(
[PresenceStore], [PresenceStore],
() => PresenceStore.getActivities(user.id).filter((activity: Activity) => activity.type !== 4) () => PresenceStore.getActivities(user.id).filter((activity: Activity) => activity != null && activity.type !== 4)
); );
useEffect(() => { useEffect(() => {
@ -33,8 +33,12 @@ export function showAllActivitiesComponent({ activity, user, ...props }: Readonl
return; return;
} }
if (!currentActivity || !activities.includes(currentActivity)) const existing = currentActivity && activities.find(a => a.id === currentActivity.id);
if (!existing) {
setCurrentActivity(activities[0]); setCurrentActivity(activities[0]);
} else if (existing !== currentActivity) {
setCurrentActivity(existing);
}
}, [activities]); }, [activities]);
// we use these for other activities, it would be better to somehow get the corresponding activity props // we use these for other activities, it would be better to somehow get the corresponding activity props
@ -47,6 +51,7 @@ export function showAllActivitiesComponent({ activity, user, ...props }: Readonl
if (!activities.length) return null; if (!activities.length) return null;
if (settings.store.allActivitiesStyle === "carousel") { if (settings.store.allActivitiesStyle === "carousel") {
if (!currentActivity) return null;
return ( return (
<ErrorBoundary noop> <ErrorBoundary noop>
<div style={{ display: "flex", flexDirection: "column" }}> <div style={{ display: "flex", flexDirection: "column" }}>

View file

@ -40,7 +40,7 @@ export function getActivityApplication(activity: Activity | null) {
export function getApplicationIcons(activities: Activity[], preferSmall = false): ApplicationIcon[] { export function getApplicationIcons(activities: Activity[], preferSmall = false): ApplicationIcon[] {
const applicationIcons: ApplicationIcon[] = []; const applicationIcons: ApplicationIcon[] = [];
const applications = activities.filter(activity => activity.application_id || activity.platform || activity?.id?.startsWith("spotify:")); const applications = activities.filter(activity => activity != null && (activity.application_id || activity.platform || activity.id?.startsWith("spotify:")));
for (const activity of applications) { for (const activity of applications) {
const { assets, application_id, platform, id } = activity; const { assets, application_id, platform, id } = activity;

View file

@ -274,6 +274,7 @@ const settings = definePluginSettings({
export default definePlugin({ export default definePlugin({
name: "BetterAudioPlayer", name: "BetterAudioPlayer",
description: "Adds a spectrograph and oscilloscope visualizer to audio attachment players.", description: "Adds a spectrograph and oscilloscope visualizer to audio attachment players.",
tags: ["Appearance", "Media", "Voice"],
authors: [EquicordDevs.creations], authors: [EquicordDevs.creations],
settings, settings,

View file

@ -9,48 +9,82 @@ import "./styles.css";
import { definePluginSettings } from "@api/Settings"; import { definePluginSettings } from "@api/Settings";
import { Heading } from "@components/Heading"; import { Heading } from "@components/Heading";
import { DeleteIcon, PlusIcon } from "@components/Icons"; import { DeleteIcon, PlusIcon } from "@components/Icons";
import { Devs } from "@utils/constants"; import { Devs, EquicordDevs } from "@utils/constants";
import { classNameFactory } from "@utils/css"; import { classNameFactory } from "@utils/css";
import definePlugin, { OptionType } from "@utils/types"; import definePlugin, { OptionType } from "@utils/types";
import { Button, TextInput } from "@webpack/common"; import { Button, Select, TextInput, useState } from "@webpack/common";
const cl = classNameFactory("vc-bbr-"); const cl = classNameFactory("vc-bbr-");
interface BanReason {
text: string;
deleteSeconds?: number;
}
const DELETE_DURATION_OPTIONS = [
{ label: "Discord default", value: null },
{ label: "Don't delete any", value: 0 },
{ label: "Previous hour", value: 3600 },
{ label: "Previous 6 hours", value: 21600 },
{ label: "Previous 12 hours", value: 43200 },
{ label: "Previous 24 hours", value: 86400 },
{ label: "Previous 3 days", value: 259200 },
{ label: "Previous 7 days", value: 604800 },
];
function normalizeReason(r: BanReason | string): BanReason {
return typeof r === "string" ? { text: r } : r;
}
function getStoredReasons(): BanReason[] {
return (settings.store.reasons as (BanReason | string)[]).map(normalizeReason);
}
function ReasonsComponent() { function ReasonsComponent() {
const { reasons } = settings.store; const [reasons, setReasons] = useState<BanReason[]>(getStoredReasons);
const update = (next: BanReason[]) => {
setReasons(next);
settings.store.reasons = next;
};
return ( return (
<section> <section>
<Heading>Reasons</Heading> <Heading>Reasons</Heading>
{reasons.map((r, i) => ( {reasons.map((r, i) => (
<div <div key={i} className={cl("reason-wrapper")}>
key={i}
className={cl("reason-wrapper")}
>
<TextInput <TextInput
value={r} value={r.text}
onChange={v => { onChange={v => update(reasons.map((x, j) => j === i ? { ...x, text: v } : x))}
reasons[i] = v;
settings.store.reasons = reasons;
}}
placeholder="Reason" placeholder="Reason"
/> />
<Button <Button
className={cl("remove-button")} className={cl("remove-button")}
color={Button.Colors.TRANSPARENT} color={Button.Colors.TRANSPARENT}
onClick={() => { onClick={() => update(reasons.filter((_, j) => j !== i))}
reasons.splice(i, 1);
settings.store.reasons = reasons;
}}
look={Button.Looks.FILLED} look={Button.Looks.FILLED}
size={Button.Sizes.MIN} size={Button.Sizes.MIN}
> >
<DeleteIcon /> <DeleteIcon />
</Button> </Button>
<div className={cl("duration-row")}>
<span className="vc-text-base vc-text-sm vc-text-normal vc-text-defaultColor vc-plugins-setting-description">Message Auto-delete duration</span>
<Select
options={DELETE_DURATION_OPTIONS}
select={v => update(reasons.map((x, j) => j === i ? { ...x, deleteSeconds: v === null ? undefined : v } : x))}
isSelected={v => (v ?? null) === (r.deleteSeconds ?? null)}
serialize={v => v == null ? "default" : String(v)}
/>
</div>
</div> </div>
))} ))}
<div className={cl("reason-wrapper")}> <div className={cl("reason-wrapper")}>
<Button onClick={() => settings.store.reasons.push("")} className={cl("add-button")} size={Button.Sizes.LARGE} color={Button.Colors.TRANSPARENT}> <Button
onClick={() => update([...reasons, { text: "" }])}
className={cl("add-button")}
size={Button.Sizes.LARGE}
color={Button.Colors.TRANSPARENT}
>
<PlusIcon /> Add another reason <PlusIcon /> Add another reason
</Button> </Button>
</div> </div>
@ -62,7 +96,7 @@ const settings = definePluginSettings({
reasons: { reasons: {
description: "Your custom reasons", description: "Your custom reasons",
type: OptionType.COMPONENT, type: OptionType.COMPONENT,
default: [] as string[], default: [] as BanReason[],
component: ReasonsComponent, component: ReasonsComponent,
}, },
isTextInputDefault: { isTextInputDefault: {
@ -74,30 +108,57 @@ const settings = definePluginSettings({
export default definePlugin({ export default definePlugin({
name: "BetterBanReasons", name: "BetterBanReasons",
description: "Create custom reasons to use in the Discord ban modal, and/or show a text input by default instead of the options.", description: "Create custom reasons to use in the Discord ban modal, and/or show a text input by default instead of the options.",
authors: [Devs.Inbestigator], tags: ["Appearance", "Customisation"],
authors: [Devs.Inbestigator, EquicordDevs.yonn2222],
durationSetter: null as ((v: number) => void) | null,
patches: [ patches: [
{ {
find: "#{intl::BAN_REASON_OPTION_SPAM_ACCOUNT}", find: "#{intl::BAN_REASON_OPTION_SPAM_ACCOUNT}",
replacement: [{ replacement: [
match: /(\[\{name:\i\.\i\.\i\(\i\.\i\.\i\),.+?"other"\}\])/, {
replace: "$self.getReasons($1)" match: /(\[\{name:\i\.\i\.\i\(\i\.\i\.\i\),.+?"other"\}\])/,
}, replace: "$self.getReasons($1)"
{ },
match: /useState\(null\)(?=.{0,300}targetUserId:)/, {
replace: "useState($self.getDefaultState())" match: /useState\(null\)(?=.{0,300}targetUserId:)/,
}] replace: "useState($self.getDefaultState())"
},
{
match: /(\[\i,\i\])=(\i)\.useState\((null!=\i\?\i:\i)\)/,
replace: "$1=$self.captureDeleteState($2.useState,$3)"
},
{
match: /\i=\i\.useCallback\((\i)=>\{.{0,10},\i\(null\)(?=\},\[\]\))/,
replace: "$&,$self.onReasonSelect($1)"
}
]
} }
], ],
getReasons(defaults) {
const storedReasons = settings.store.reasons.filter((r: string) => r.trim()); getReasons(defaults: { name: string; value: string; }[]) {
const reasons: string[] = storedReasons.length const stored = getStoredReasons().filter(r => r.text.trim());
? storedReasons
: [];
return [ return [
...reasons.map(s => ({ name: s, value: s })), ...stored.map(r => ({ name: r.text, value: r.text })),
...defaults ...defaults,
]; ];
}, },
getDefaultState: () => settings.store.isTextInputDefault ? 1 : 0, getDefaultState: () => settings.store.isTextInputDefault ? 1 : 0,
captureDeleteState(hook: (initial: number) => [number, (v: number) => void], initial: number) {
const result = hook(initial);
this.durationSetter = result[1];
return result;
},
onReasonSelect(value: string) {
const reason = getStoredReasons().find(r => r.text === value);
if (reason?.deleteSeconds !== undefined) {
this.durationSetter?.(reason.deleteSeconds);
}
},
settings, settings,
}); });

View file

@ -8,6 +8,13 @@
.vc-bbr-remove-button { .vc-bbr-remove-button {
color: var(--text-muted); color: var(--text-muted);
align-self: start;
}
.vc-bbr-duration-row {
display: flex;
flex-direction: column;
gap: 4px;
} }
.vc-bbr-remove-button:hover { .vc-bbr-remove-button:hover {

View file

@ -17,21 +17,23 @@ let updateFunc = (v: any) => { };
export default definePlugin({ export default definePlugin({
name: "BetterBlockedUsers", name: "BetterBlockedUsers",
description: "Allows you to search in blocked users list and makes names selectable in settings.", description: "Allows you to search in blocked users list and makes names selectable in settings.",
tags: ["Appearance", "Shortcuts"],
authors: [EquicordDevs.TheArmagan], authors: [EquicordDevs.TheArmagan],
patches: [ patches: [
{ {
find: '"],{numberOfBlockedUsers:', find: '"],{numberOfBlockedUsers:',
group: true,
replacement: [ replacement: [
{ {
match: /(?<=\}=(\i).*?\]\}\))/, match: /(?<=\(0,\i\.jsx\)\(\i,\{listType:(\i),numberOfUsers:\i\.length\}\),)/,
replace: ",$1.listType==='blocked'?$self.renderSearchInput():null" replace: "$1==='blocked'?$self.renderSearchInput():null,"
}, },
{ {
match: /(?<=userId:(\i).*?\}\)\]\}\),)(\(.*?loading:\i\}\))/, match: /(?<=\{userId:(\i).*?\.globalName.{0,25}\}\)\]\}\),)(\(.*?loading:\i\}\))/,
replace: "$self.renderUser($1,$2)", replace: "$self.renderUser($1,$2)",
}, },
{ {
match: /(?<=\}=(\i).{0,10}(\i).useState.{0,5};)/, match: /(?<=userIds:\i,listType:\i\}=(\i).{0,30}(\i)\.useState\(\d+\);)/,
replace: "let [searchResults,setSearchResults]=$2.useState([]);$self.setUpdateFunc($1,setSearchResults);" replace: "let [searchResults,setSearchResults]=$2.useState([]);$self.setUpdateFunc($1,setSearchResults);"
}, },
{ {
@ -49,17 +51,19 @@ export default definePlugin({
updateFunc(searchResults); updateFunc(searchResults);
}, []); }, []);
return <TextInput return <div className="vc-bbu-search">
placeholder="Search users..." <TextInput
style={{ width: "200px" }} placeholder="Search users..."
onInput={e => { style={{ width: "200px" }}
const search = (e.target as HTMLInputElement).value.toLowerCase().trim(); onInput={e => {
setValue(search); const search = (e.target as HTMLInputElement).value.toLowerCase().trim();
lastSearch = search; setValue(search);
const searchResults = this.getFilteredUsers(search); lastSearch = search;
updateFunc(searchResults); const searchResults = this.getFilteredUsers(search);
}} value={value} updateFunc(searchResults);
></TextInput>; }} value={value}
/>
</div>;
}, },
renderUser(userId: string, rest: any) { renderUser(userId: string, rest: any) {
return ( return (

View file

@ -1,3 +1,8 @@
[class*="usersList"] [class*="text"] { [class*="usersList"] [class*="text"] {
user-select: text !important; user-select: text !important;
} }
.vc-bbu-search,
.vc-bbu-search * {
border-radius: 0 !important;
}

View file

@ -33,6 +33,8 @@ function fetchIndex(target: object) {
export default definePlugin({ export default definePlugin({
name: "BetterCommands", name: "BetterCommands",
description: "Enhances the command system with miscellaneous improvements.", description: "Enhances the command system with miscellaneous improvements.",
dependencies: ["CommandsAPI"],
tags: ["Appearance", "Commands", "Shortcuts"],
authors: [Devs.thororen], authors: [Devs.thororen],
settings, settings,
patches: [ patches: [

View file

@ -34,13 +34,14 @@ function lurk(id: string) {
export default definePlugin({ export default definePlugin({
name: "BetterInvites", name: "BetterInvites",
description: "See invites expiration date, view inviter profile and preview servers before joining by clicking the name", description: "See invites expiration date, view inviter profile and preview servers before joining by clicking the name",
tags: ["Appearance", "Customisation", "Chat", "Servers"],
authors: [EquicordDevs.iamme, Devs.thororen], authors: [EquicordDevs.iamme, Devs.thororen],
patches: [ patches: [
{ {
find: "#{intl::xdCLeM::raw}", find: "#{intl::xdCLeM::raw}",
replacement: [ replacement: [
{ {
match: /profile:\i\}\),.{0,15}profile:\i/, match: /profile:\i\}\),.{0,20}profile:\i(?=.{0,20}profile:\i)/,
replace: "$&,invite:arguments[0].invite" replace: "$&,invite:arguments[0].invite"
} }
] ]
@ -85,8 +86,11 @@ export default definePlugin({
); );
}, },
Header(inviter: User | undefined, guildName: string) { Header(inviter: User | undefined, guildName: string) {
if (!inviter) return null;
const userId = UserStore.getCurrentUser().id; const userId = UserStore.getCurrentUser().id;
if (!inviter || userId === inviter.id) return null; const isSelf = userId === inviter.id;
return ( return (
<div className="vc-bi-header-inner"> <div className="vc-bi-header-inner">
<img <img
@ -98,7 +102,9 @@ export default definePlugin({
: "/assets/1f0bfc0865d324c2587920a7d80c609b.png?size=128"} : "/assets/1f0bfc0865d324c2587920a7d80c609b.png?size=128"}
/> />
<div className="vc-bi-header-text"> <div className="vc-bi-header-text">
{inviter.global_name || inviter.username} invited you to {guildName} {isSelf
? `You sent an invite to join ${guildName}`
: `${inviter.global_name || inviter.username} invited you to ${guildName}`}
</div> </div>
</div> </div>
); );

View file

@ -1,7 +1,7 @@
.vc-bi-inviter-avatar { .vc-bi-inviter-avatar {
margin: 0; margin: 0;
height: 14px; height: 18px;
width: 14px; width: 18px;
position: static; position: static;
flex-shrink: 0; flex-shrink: 0;
} }
@ -10,10 +10,10 @@
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
gap: 8px; gap: 4px;
margin: 0; margin: 0;
margin-bottom: 5px; margin-bottom: 5px;
font-size: 12px; font-size: 14px;
} }
.vc-bi-header-text { .vc-bi-header-text {
@ -24,7 +24,7 @@
.vc-bi-tooltip-icon { .vc-bi-tooltip-icon {
color: var(--text-muted); color: var(--text-muted);
width: 16px; width: 18px;
height: 16px; height: 18px;
margin-top: 1px; margin-top: 1.5px;
} }

View file

@ -12,6 +12,7 @@ export default definePlugin({
name: "BetterPlusReacts", name: "BetterPlusReacts",
authors: [Devs.Joona], authors: [Devs.Joona],
description: "The amount of plus before :emoji: is the message to add it to", description: "The amount of plus before :emoji: is the message to add it to",
tags: ["Chat", "Emotes"],
patches: [ patches: [
{ {
find: ".SLASH_COMMAND_USED,", find: ".SLASH_COMMAND_USED,",

View file

@ -70,6 +70,7 @@ export function containsBlockedKeywords(message: Message) {
export default definePlugin({ export default definePlugin({
name: "BlockKeywords", name: "BlockKeywords",
description: "Blocks messages containing specific user-defined keywords, as if the user sending them was blocked.", description: "Blocks messages containing specific user-defined keywords, as if the user sending them was blocked.",
tags: ["Appearance", "Customisation", "Privacy"],
authors: [EquicordDevs.catcraft], authors: [EquicordDevs.catcraft],
patches: [ patches: [
{ {
@ -80,19 +81,26 @@ export default definePlugin({
replace: "$&$1=$self.blockMessagesWithKeywords($1);" replace: "$&$1=$self.blockMessagesWithKeywords($1);"
} }
}, },
...[ {
'"MessageStore"', find: '"MessageStore"',
'"ReadStateStore"'
].map(find => ({
find,
predicate: () => settings.store.ignoreBlockedMessages && settings.store.blockedWords !== "", predicate: () => settings.store.ignoreBlockedMessages && settings.store.blockedWords !== "",
replacement: [ replacement: [
{ {
match: /(?<=function (\i)\((\i)\){)(?=.*MESSAGE_CREATE:\1)/, match: /(?<=MESSAGE_CREATE:function\((\i)\){)/,
replace: (_, _funcName, props) => `if($self.containsBlockedKeywords(${props}.message))return;` replace: (_, props) => `if($self.containsBlockedKeywords(${props}.message))return;`
} }
] ]
})), },
{
find: '"ReadStateStore"',
predicate: () => settings.store.ignoreBlockedMessages && settings.store.blockedWords !== "",
replacement: [
{
match: /(?<=MESSAGE_CREATE:function\((\i)\){)/,
replace: (_, props) => `if($self.containsBlockedKeywords(${props}.message))return;`
}
]
},
], ],
settings, settings,

View file

@ -10,6 +10,7 @@ import definePlugin from "@utils/types";
export default definePlugin({ export default definePlugin({
name: "BlockKrisp", name: "BlockKrisp",
description: "Prevent Krisp from loading", description: "Prevent Krisp from loading",
tags: ["Privacy", "Utility", "Voice"],
authors: [Devs.D3SOX], authors: [Devs.D3SOX],
patches: [ patches: [
// Block loading modules on Desktop // Block loading modules on Desktop
@ -24,8 +25,8 @@ export default definePlugin({
{ {
find: "krisp_browser_models", find: "krisp_browser_models",
replacement: { replacement: {
match: /(\i,\{\i:\(\)=>)\i/, match: /if\(this._noiseCancellation\)/,
replace: "$1null" replace: "if(false)"
} }
}, },
// Set Krisp to not supported // Set Krisp to not supported

View file

@ -10,6 +10,7 @@ import definePlugin from "@utils/types";
export default definePlugin({ export default definePlugin({
name: "BypassPinPrompt", name: "BypassPinPrompt",
description: "Bypass the pin prompt when using the pin functions", description: "Bypass the pin prompt when using the pin functions",
tags: ["Shortcuts"],
authors: [Devs.thororen], authors: [Devs.thororen],
patches: [ patches: [
...[ ...[

View file

@ -157,6 +157,7 @@ const settings = definePluginSettings({
export default definePlugin({ export default definePlugin({
name: "BypassStatus", name: "BypassStatus",
description: "Still get notifications from specific sources when in do not disturb mode. Right-click on users/channels/guilds to set them to bypass do not disturb mode.", description: "Still get notifications from specific sources when in do not disturb mode. Right-click on users/channels/guilds to set them to bypass do not disturb mode.",
tags: ["Activity", "Customisation", "Notifications", "Servers"],
authors: [Devs.Inbestigator], authors: [Devs.Inbestigator],
dependencies: ["AudioPlayerAPI"], dependencies: ["AudioPlayerAPI"],
flux: { flux: {

View file

@ -32,6 +32,7 @@ function renderBadge(id: number, title: string) {
export default definePlugin({ export default definePlugin({
name: "ChannelBadges", name: "ChannelBadges",
description: "Adds badges to channels based on their type", description: "Adds badges to channels based on their type",
tags: ["Appearance", "Customisation", "Servers"],
authors: [EquicordDevs.creations, Devs.thororen], authors: [EquicordDevs.creations, Devs.thororen],
settings, settings,
patches: [ patches: [

View file

@ -7,7 +7,7 @@
import { BaseText } from "@components/BaseText"; import { BaseText } from "@components/BaseText";
import { ChannelTabsProps, closeTab, ensureUnreadFallbackCountsLoaded, getNotificationDotState, getUnreadFallbackCounts, isTabSelected, moveDraggedTabs, moveToTab, openedTabs, settings, updateUnreadFallbackCounts } from "@equicordplugins/channelTabs/util"; import { ChannelTabsProps, closeTab, ensureUnreadFallbackCountsLoaded, getNotificationDotState, getUnreadFallbackCounts, isTabSelected, moveDraggedTabs, moveToTab, openedTabs, settings, updateUnreadFallbackCounts } from "@equicordplugins/channelTabs/util";
import { ActivityIcon, CircleQuestionIcon, DiscoveryIcon, EnvelopeIcon, FriendsIcon, ICYMIIcon, NitroIcon, QuestIcon, ShopIcon } from "@equicordplugins/channelTabs/util/icons"; import { ActivityIcon, CircleQuestionIcon, DiscoveryIcon, EnvelopeIcon, FriendsIcon, ICYMIIcon, NitroIcon, QuestIcon, ShopIcon } from "@equicordplugins/channelTabs/util/icons";
import { activeQuestIntervals } from "@equicordplugins/questify"; // sorry murphy! import { getActiveAutoCompletes } from "@equicordplugins/questify/utils/completion";
import { classNameFactory } from "@utils/css"; import { classNameFactory } from "@utils/css";
import { getGuildAcronym, getIntlMessage, getUniqueUsername } from "@utils/discord"; import { getGuildAcronym, getIntlMessage, getUniqueUsername } from "@utils/discord";
import { classes } from "@utils/misc"; import { classes } from "@utils/misc";
@ -467,20 +467,19 @@ export default function ChannelTab(props: ChannelTabsProps & { index: number; })
}), []); }), []);
drag(drop(ref)); drag(drop(ref));
// check if quests running (questify momentLet) const hasActiveQuests = getActiveAutoCompletes().length > 0;
const hasActiveQuests = activeQuestIntervals.size > 0;
return <div return <div
className={cl("tab", { className={cl("tab", {
"tab-compact": compact, "tab-compact": compact,
"tab-selected": isTabSelected(id), "tab-selected": isTabSelected(id),
"tab-entering": isEntering, "tab-entering": isEntering,
"tab-closing": isClosing, "tab-closing": isClosing,
"tab-dragging": isDragging, "tab-dragging": isDragging,
"tab-drop-target": isDropTarget, "tab-drop-target": isDropTarget,
"tab-nitro": channelId === "__nitro__", "tab-nitro": channelId === "__nitro__",
"tab-quests-active": channelId === "__quests__" && hasActiveQuests, "tab-quests-active": channelId === "__quests__" && hasActiveQuests,
wider: settings.store.widerTabsAndBookmarks wider: settings.store.widerTabsAndBookmarks
})} })}
key={index} key={index}
ref={ref} ref={ref}
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}

View file

@ -33,6 +33,7 @@ export default function ChannelsTabsContainer(props: BasicChannelTabsProps) {
showBookmarkBar, showBookmarkBar,
widerTabsAndBookmarks, widerTabsAndBookmarks,
tabWidthScale, tabWidthScale,
tabHeightScale,
enableNumberKeySwitching, enableNumberKeySwitching,
numberKeySwitchCount, numberKeySwitchCount,
enableCloseTabShortcut, enableCloseTabShortcut,
@ -65,6 +66,7 @@ export default function ChannelsTabsContainer(props: BasicChannelTabsProps) {
"showBookmarkBar", "showBookmarkBar",
"widerTabsAndBookmarks", "widerTabsAndBookmarks",
"tabWidthScale", "tabWidthScale",
"tabHeightScale",
"enableNumberKeySwitching", "enableNumberKeySwitching",
"numberKeySwitchCount", "numberKeySwitchCount",
"enableCloseTabShortcut", "enableCloseTabShortcut",
@ -288,6 +290,16 @@ export default function ChannelsTabsContainer(props: BasicChannelTabsProps) {
if (isFullscreen) return null; if (isFullscreen) return null;
const shouldFollowNewTabButton = newTabButtonBehavior && !tabsOverflow;
const newTabButton = (
<button
onClick={() => createTab(props, true)}
className={cl("button", "new-button", "hoverable")}
>
<PlusSmallIcon />
</button>
);
return ( return (
<div <div
className={classes( className={classes(
@ -312,7 +324,7 @@ export default function ChannelsTabsContainer(props: BasicChannelTabsProps) {
!compactAutoExpandOnHover && cl("no-compact-hover-expand") !compactAutoExpandOnHover && cl("no-compact-hover-expand")
)} )}
ref={ref} ref={ref}
style={{ "--tab-width-scale": tabWidthScale / 100 } as React.CSSProperties} style={{ "--tab-width-scale": tabWidthScale / 100, "--tab-height-scale": tabHeightScale / 100 } as React.CSSProperties}
onContextMenu={e => ContextMenuApi.openContextMenu(e, () => <BasicContextMenu />)} onContextMenu={e => ContextMenuApi.openContextMenu(e, () => <BasicContextMenu />)}
> >
{showBookmarkBar && <> {showBookmarkBar && <>
@ -322,20 +334,16 @@ export default function ChannelsTabsContainer(props: BasicChannelTabsProps) {
<div className={cl("tab-container")}> <div className={cl("tab-container")}>
<HorizontalScroller <HorizontalScroller
customRef={node => { scrollerRef.current = node; }} customRef={node => { scrollerRef.current = node; }}
className={cl("tab-scroller", newTabButtonBehavior && !tabsOverflow && "tab-scroller-following")} className={cl("tab-scroller", shouldFollowNewTabButton && "tab-scroller-following")}
> >
{openedTabs.filter(tab => tab != null).map((tab, i) => {openedTabs.filter(tab => tab != null).map((tab, i) =>
<ChannelTab {...tab} index={i} key={tab.id} /> <ChannelTab {...tab} index={i} key={tab.id} />
)} )}
{GhostTabs} {GhostTabs}
{shouldFollowNewTabButton && newTabButton}
</HorizontalScroller> </HorizontalScroller>
<button {!shouldFollowNewTabButton && newTabButton}
onClick={() => createTab(props, true)}
className={cl("button", "new-button", "hoverable")}
>
<PlusSmallIcon />
</button>
</div > </div >
</div> </div>

View file

@ -15,8 +15,8 @@ import { ChannelStore, Menu } from "@webpack/common";
import { JSX } from "react"; import { JSX } from "react";
import ChannelsTabsContainer from "./components/ChannelTabsContainer"; import ChannelsTabsContainer from "./components/ChannelTabsContainer";
import { BasicChannelTabsProps, createTab, handleChannelSwitch, settings } from "./util";
import * as ChannelTabsUtils from "./util"; import * as ChannelTabsUtils from "./util";
import { BasicChannelTabsProps, createTab, handleChannelSwitch, settings } from "./util";
const contextMenuPatch: NavContextMenuPatchCallback = (children, props: { channel: Channel, messageId?: string; }) => { const contextMenuPatch: NavContextMenuPatchCallback = (children, props: { channel: Channel, messageId?: string; }) => {
const { channel, messageId } = props; const { channel, messageId } = props;
@ -47,7 +47,8 @@ const contextMenuPatch: NavContextMenuPatchCallback = (children, props: { channe
export default definePlugin({ export default definePlugin({
name: "ChannelTabs", name: "ChannelTabs",
description: "Group your commonly visited channels in tabs, like a browser", description: "Group your commonly visited channels in tabs, like a browser",
authors: [Devs.TheSun, Devs.TheKodeToad, EquicordDevs.keifufu, Devs.Nickyux, EquicordDevs.DiabeloDEV, EquicordDevs.justjxke], tags: ["Appearance", "Customisation", "Organisation", "Servers"],
authors: [Devs.TheSun, Devs.TheKodeToad, EquicordDevs.keifufu, Devs.Nickyux, EquicordDevs.DiabeloDEV, EquicordDevs.justjxke, EquicordDevs.keircn],
dependencies: ["ContextMenuAPI"], dependencies: ["ContextMenuAPI"],
contextMenus: { contextMenus: {
"channel-mention-context": contextMenuPatch, "channel-mention-context": contextMenuPatch,

View file

@ -212,7 +212,8 @@
border-color 0.15s ease, border-color 0.15s ease,
box-shadow 0.15s ease, box-shadow 0.15s ease,
opacity 0.15s ease; opacity 0.15s ease;
padding: 6px 8px; min-height: calc(38px * var(--tab-height-scale, 1));
padding: calc(6px * var(--tab-height-scale, 1)) 8px;
position: relative; position: relative;
cursor: grab; cursor: grab;
flex-shrink: 0; flex-shrink: 0;

View file

@ -39,11 +39,9 @@ function AnimationSettings(): JSX.Element {
{ label: "Active Quests Gradient", value: "quests-active", selected: settings.store.animationQuestsActive } { label: "Active Quests Gradient", value: "quests-active", selected: settings.store.animationQuestsActive }
]; ];
const [currentValue, setCurrentValue] = useState(animationOptions.filter(option => option.selected)); const [currentValue, setCurrentValue] = useState(animationOptions.filter(option => option.selected).map(option => option.value));
function updateSettingsTruthy(enabled: DynamicDropdownSettingOption[]) {
const enabledValues = enabled.map(option => option.value);
function updateSettingsTruthy(enabledValues: string[]) {
animationOptions.forEach(option => { animationOptions.forEach(option => {
option.selected = enabledValues.includes(option.value); option.selected = enabledValues.includes(option.value);
}); });
@ -64,23 +62,24 @@ function AnimationSettings(): JSX.Element {
settings.store.animationResizeHandle = enabledValues.includes("resize-handle"); settings.store.animationResizeHandle = enabledValues.includes("resize-handle");
settings.store.animationQuestsActive = enabledValues.includes("quests-active"); settings.store.animationQuestsActive = enabledValues.includes("quests-active");
setCurrentValue(enabled); setCurrentValue(enabledValues);
} }
function handleChange(values: Array<DynamicDropdownSettingOption | string>) { function handleChange(values: Array<DynamicDropdownSettingOption | string>) {
if (values.length === 0) { const valueStrings = values.map(v => typeof v === "string" ? v : v.value);
updateSettingsTruthy([]); const toggled = valueStrings.length > currentValue.length
? valueStrings.find(v => !currentValue.includes(v))
: currentValue.find(v => !valueStrings.includes(v));
if (toggled == null) {
updateSettingsTruthy(valueStrings);
return; return;
} }
const stringlessValues = values.filter(v => typeof v !== "string") as DynamicDropdownSettingOption[]; if (currentValue.includes(toggled)) {
const selectedOption = values.find(v => typeof v === "string") as string; updateSettingsTruthy(currentValue.filter(v => v !== toggled));
const option = animationOptions.find(option => option.value === selectedOption) as DynamicDropdownSettingOption;
if (option.selected) {
updateSettingsTruthy(stringlessValues.filter(v => v.value !== selectedOption));
} else { } else {
updateSettingsTruthy([...stringlessValues, option]); updateSettingsTruthy([...currentValue, toggled]);
} }
} }
@ -179,6 +178,12 @@ export const settings = definePluginSettings({
hidden: true, hidden: true,
restartNeeded: false restartNeeded: false
}, },
tabHeightScale: {
type: OptionType.NUMBER,
description: "Tab height scale (percentage).",
default: 100,
restartNeeded: false
},
renderAllTabs: { renderAllTabs: {
type: OptionType.BOOLEAN, type: OptionType.BOOLEAN,
description: "Keep all tabs cached in memory for faster switching (caches scroll position and state)", description: "Keep all tabs cached in memory for faster switching (caches scroll position and state)",

View file

@ -4,10 +4,8 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
*/ */
import { iconsModule } from "@equicordplugins/_core/concatenatedModules";
import { Icon } from "@vencord/discord-types"; import { Icon } from "@vencord/discord-types";
import { findByPropsLazy } from "@webpack";
const iconsModule = findByPropsLazy("AngleBracketsIcon", "StaffBadgeIcon") as Record<string, Icon>;
let iconNames: string[] | undefined; let iconNames: string[] | undefined;

View file

@ -7,38 +7,75 @@
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import definePlugin from "@utils/types"; import definePlugin from "@utils/types";
import { Channel } from "@vencord/discord-types"; import { Channel } from "@vencord/discord-types";
import { ChannelStore } from "@webpack/common";
const SMALL_CAPS: Record<string, string> = {
"ᴀ": "a", "ʙ": "b", "ᴄ": "c", "ᴅ": "d", "ᴇ": "e", "ꜰ": "f", "ɢ": "g", "ʜ": "h", "ɪ": "i", "ᴊ": "j",
"ᴋ": "k", "ʟ": "l", "ᴍ": "m", "ɴ": "n", "ᴏ": "o", "ᴘ": "p", "ǫ": "q", "ʀ": "r", "ꜱ": "s", "ᴛ": "t",
"ᴜ": "u", "ᴠ": "v", "ᴡ": "w", "x": "x", "ʏ": "y", "ᴢ": "z",
};
const ORIGINAL_NAME = Symbol("cleanChannelName.original");
let editingChannelId: string | null = null;
function computeClean(name: string, type: number): string {
const cleaned = name
.normalize("NFKC")
.replace(/[ᴀʙᴄᴅᴇꜰɢʜɪᴊᴋʟᴍɴᴏᴘǫʀꜱᴛᴜᴠᴡxʏᴢ]/g, m => SMALL_CAPS[m])
.replace(/[^ -~]?\p{Extended_Pictographic}[^ -~]?/ug, "")
.replace(/-?[^\p{Letter} -~]-?/ug, [2, 4].includes(type) ? " " : "-")
.replace(/(^-|-$)/g, "")
.replace(/-+/g, "-");
return cleaned || name;
}
export default definePlugin({ export default definePlugin({
name: "CleanChannelName", name: "CleanChannelName",
authors: [Devs.AutumnVN], authors: [Devs.AutumnVN],
description: "remove all emoji and decor shit from channel names", description: "Remove emoji and decoration from channel names. Reverts to the original while you're editing the channel.",
tags: ["Appearance", "Customisation", "Chat", "Emotes", "Servers"],
patches: [ patches: [
{ {
find: "loadAllGuildAndPrivateChannelsFromDisk(){", find: "loadAllGuildAndPrivateChannelsFromDisk(){",
replacement: { replacement: {
match: /(?<=getChannel\(\i\)\{if\(null!=\i\)return )\i\(\i\)/, match: /(?<=getChannel\(\i\)\{if\(null!=\i\)return )\i\(\i\)/,
replace: "$self.cleanChannelName($&)", replace: "$self.cleanChannelName($&)",
} },
} },
], ],
flux: {
CHANNEL_SETTINGS_INIT({ channelId }: { channelId: string; }) {
editingChannelId = channelId;
(ChannelStore as any).emitChange?.();
},
CHANNEL_SETTINGS_CLOSE() {
editingChannelId = null;
(ChannelStore as any).emitChange?.();
},
},
cleanChannelName(channel?: Channel) { cleanChannelName(channel?: Channel) {
if (channel) { if (channel == null) return channel;
channel.name = channel.name const c = channel as any;
.normalize("NFKC")
.replace(/[ᴀʙᴄᴅᴇꜰɢʜɪᴊᴋʟᴍɴᴏᴘǫʀꜱᴛᴜᴠᴡxʏᴢ]/g, match => { if (c[ORIGINAL_NAME] !== undefined) return channel;
const smallCapsToNormal = {
"ᴀ": "a", "ʙ": "b", "ᴄ": "c", "ᴅ": "d", "ᴇ": "e", "ꜰ": "f", "ɢ": "g", "ʜ": "h", "ɪ": "i", "ᴊ": "j", c[ORIGINAL_NAME] = channel.name;
"ᴋ": "k", "ʟ": "l", "ᴍ": "m", "ɴ": "n", "ᴏ": "o", "ᴘ": "p", "ǫ": "q", "ʀ": "r", "ꜱ": "s", "ᴛ": "t",
"ᴜ": "u", "ᴠ": "v", "ᴡ": "w", "x": "x", "ʏ": "y", "ᴢ": "z" Object.defineProperty(channel, "name", {
}; configurable: true,
return smallCapsToNormal[match]; enumerable: true,
}) get() {
.replace(/[^\u0020-\u007E]?\p{Extended_Pictographic}[^\u0020-\u007E]?/ug, "") if (editingChannelId === channel.id) return c[ORIGINAL_NAME];
.replace(/-?[^\p{Letter}\u0020-\u007E]-?/ug, [2, 4].includes(channel.type) ? " " : "-") return computeClean(c[ORIGINAL_NAME], channel.type);
.replace(/(^-|-$)/g, "") },
.replace(/-+/g, "-"); set(value: string) {
} c[ORIGINAL_NAME] = value;
},
});
return channel; return channel;
} },
}); });

View file

@ -10,6 +10,7 @@ import definePlugin from "@utils/types";
export default definePlugin({ export default definePlugin({
name: "CleanerChannelGroups", name: "CleanerChannelGroups",
description: "Hides all channels in collapsed categories, even if they have unread messages.", description: "Hides all channels in collapsed categories, even if they have unread messages.",
tags: ["Appearance", "Customisation", "Chat", "Organisation", "Servers"],
authors: [EquicordDevs.justjxke], authors: [EquicordDevs.justjxke],
patches: [ patches: [
{ {

View file

@ -146,13 +146,14 @@ const WrappedClickableRole = ErrorBoundary.wrap(ClickableRole, { noop: true });
export default definePlugin({ export default definePlugin({
name: "ClickableRoles", name: "ClickableRoles",
description: "Click on roles in user profiles and the member list to see which members have them.", description: "Click on roles in user profiles and the member list to see which members have them.",
tags: ["Appearance", "Roles"],
authors: [Devs.prism], authors: [Devs.prism],
patches: [ patches: [
{ {
find: "#{intl::T+3Adg::raw}", find: "#{intl::zr0Y5R::raw}",
replacement: { replacement: {
match: /(colors:\i\}\);)return(.*?enableTooltip:!1\}\):null,\i\]\}\))/, match: /(\.colorString\?\?\i;)return(.*?enableTooltip:!1\}\):null,\i\]\}\))/,
replace: "$1return $self.wrapRolePill(arguments[0],()=>$2)", replace: "$1return $self.wrapRolePill(arguments[0],()=>$2)",
}, },
}, },

View file

@ -142,7 +142,8 @@ function activeNowView(cards) {
export default definePlugin({ export default definePlugin({
name: "ClientSideBlock", name: "ClientSideBlock",
description: "Allows you to locally hide almost all content from any user", description: "Allows you to locally hide almost all content from any user",
tags: ["blocked", "block", "hide", "hidden", "noblockedmessages"], tags: ["Utility"],
searchTerms: ["blocked", "block", "hide", "hidden", "noblockedmessages"],
authors: [Devs.Samwich, EquicordDevs.KamiRu], authors: [Devs.Samwich, EquicordDevs.KamiRu],
settings, settings,
activeNowView, activeNowView,
@ -224,8 +225,8 @@ export default definePlugin({
{ {
find: "ACTIVE_NOW_COLUMN)", find: "ACTIVE_NOW_COLUMN)",
replacement: { replacement: {
match: /(\i\.\i),\{(?=\}\)\])/, match: /(__invalid_consentCard.{0,40}\()(\i),\{/,
replace: '"div",{children:$self.activeNowView($1())' replace: '$1"div",{children:$self.activeNowView($2())'
} }
}, },
// mutual friends list in user profile // mutual friends list in user profile

View file

@ -37,6 +37,12 @@ const settings = definePluginSettings({
default: true, default: true,
restartNeeded: true restartNeeded: true
}, },
enableSpeakingIndicators: {
type: OptionType.BOOLEAN,
description: "Enable speaking indicators",
default: true,
restartNeeded: true
},
enableAdvancedSignals: { enableAdvancedSignals: {
type: OptionType.BOOLEAN, type: OptionType.BOOLEAN,
description: "Enable advanced clip signals (auto-clip triggers)", description: "Enable advanced clip signals (auto-clip triggers)",
@ -72,6 +78,7 @@ migratePluginSettings("ClipsEnhancements", "TimelessClips");
export default definePlugin({ export default definePlugin({
name: "ClipsEnhancements", name: "ClipsEnhancements",
description: "Add more Clip FPS and duration options, custom clip length, RPC tagging and more", description: "Add more Clip FPS and duration options, custom clip length, RPC tagging and more",
tags: ["Activity", "Media", "Utility"],
authors: [Devs.niko, Devs.Joona, EquicordDevs.keircn], authors: [Devs.niko, Devs.Joona, EquicordDevs.keircn],
settings, settings,
patches: [ patches: [
@ -100,8 +107,8 @@ export default definePlugin({
{ {
find: "2026-03-clips-experiment", find: "2026-03-clips-experiment",
replacement: { replacement: {
match: /defaultConfig:\{enableClips:!\d,ignorePlatformRestriction:!\d,showClipsHeaderEntrypoint:!\d,enableScreenshotKeybind:!\d,enableVoiceOnlyClips:!\d,enableAdvancedSignals:!\d\}/, match: /defaultConfig:\{enableClips:!\d,ignorePlatformRestriction:!\d,enableScreenshotKeybind:!\d,enableVoiceOnlyClips:!\d,enableSpeakingIndicators:!\d,enableAdvancedSignals:!\d\}/,
replace: "defaultConfig:{enableClips:!0,ignorePlatformRestriction:$self.settings.store.ignorePlatformRestriction,showClipsHeaderEntrypoint:!0,enableScreenshotKeybind:$self.settings.store.enableScreenshotKeybind,enableVoiceOnlyClips:$self.settings.store.enableVoiceOnlyClips,enableAdvancedSignals:$self.settings.store.enableAdvancedSignals}" replace: "defaultConfig:{enableClips:!0,ignorePlatformRestriction:$self.settings.store.ignorePlatformRestriction,enableScreenshotKeybind:$self.settings.store.enableScreenshotKeybind,enableVoiceOnlyClips:$self.settings.store.enableVoiceOnlyClips,enableSpeakingIndicators:$self.settings.store.enableSpeakingIndicators,enableAdvancedSignals:$self.settings.store.enableAdvancedSignals}"
} }
} }
], ],

View file

@ -0,0 +1,142 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { ChannelToolbarButton } from "@api/HeaderBar";
import ErrorBoundary from "@components/ErrorBoundary";
import { EquicordDevs } from "@utils/constants";
import { classNameFactory } from "@utils/css";
import { classes } from "@utils/misc";
import definePlugin from "@utils/types";
import { ContextMenuApi, Menu } from "@webpack/common";
import type { ReactNode, SVGProps } from "react";
import { PanelId, panelRegistry, settings, toolbarPanelOrder } from "./settings";
import managedStyle from "./style.css?managed";
const cl = classNameFactory("vc-collapsible-ui-");
type StateStore = ReturnType<typeof settings.use>;
function PanelsIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" {...props}>
<path fill="currentColor" d="M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v3H3V5Zm0 5h6v11H5a2 2 0 0 1-2-2V10Zm8 0h10v9a2 2 0 0 1-2 2H11V10Zm2-5h8v3h-8V5Z" />
</svg>
);
}
function getStateClasses(store: StateStore) {
const classNames = [cl("state"), cl(`collapsed-size-${store.collapsedSize}`), cl(`transition-${store.transitionMs}`)];
for (const panelId of toolbarPanelOrder) {
const panel = panelRegistry[panelId];
if (store[panel.collapsedKey]) classNames.push(cl(`${panel.classId}-collapsed`));
}
return classes(...classNames);
}
function togglePanel(panelId: PanelId) {
const key = panelRegistry[panelId].collapsedKey;
settings.store[key] = !settings.store[key];
}
const ToolbarMenu = ErrorBoundary.wrap(({ onClose }: { onClose(): void; }) => {
const store = settings.use();
return (
<Menu.Menu navId="vc-collapsible-ui-toolbar-menu" onClose={onClose} aria-label="Collapsible UI">
{toolbarPanelOrder.map(panelId => {
const panel = panelRegistry[panelId];
const collapsed = store[panel.collapsedKey];
return (
<Menu.MenuCheckboxItem
key={panelId}
id={`vc-collapsible-ui-${panel.classId}`}
label={panel.label}
checked={!collapsed}
action={() => togglePanel(panelId)}
/>
);
})}
</Menu.Menu>
);
}, { noop: true });
const ToolbarButtons = ErrorBoundary.wrap(() => {
const store = settings.use();
const anyCollapsed = toolbarPanelOrder.some(panelId => store[panelRegistry[panelId].collapsedKey]);
return (
<>
<span aria-hidden className={getStateClasses(store)} style={{ display: "none" }} />
<ChannelToolbarButton
icon={PanelsIcon}
tooltip="Collapsible UI"
aria-label="Collapsible UI"
selected={anyCollapsed}
onClick={event => ContextMenuApi.openContextMenu(event, () => <ToolbarMenu onClose={ContextMenuApi.closeContextMenu} />)}
onContextMenu={event => ContextMenuApi.openContextMenu(event, () => <ToolbarMenu onClose={ContextMenuApi.closeContextMenu} />)}
/>
</>
);
}, { noop: true });
const ChatButtonsRow = ErrorBoundary.wrap(({ buttons }: { buttons: ReactNode[]; }) => {
const { chatButtonsCollapsed } = settings.use();
if (buttons.length === 0) return <>{buttons}</>;
return (
<div
className={classes(
cl("chat-buttons"),
chatButtonsCollapsed && cl("chat-buttons-collapsed")
)}
>
<div className={cl("chat-buttons-items")}>
{buttons}
</div>
</div>
);
}, { noop: true });
export default definePlugin({
name: "CollapsibleUI",
description: "Native collapsible channel, member, chat button, and user area surfaces.",
tags: ["Appearance", "Customisation", "Chat", "Servers"],
dependencies: ["HeaderBarAPI", "ChatInputButtonAPI"],
authors: [EquicordDevs.benjii],
searchTerms: ["ui", "sidebar", "layout"],
managedStyle,
settings,
headerBarButton: {
icon: PanelsIcon,
location: "channeltoolbar",
priority: 25,
render: () => <ToolbarButtons />,
},
patches: [
{
find: ".DISPLAY_NAME_STYLES_COACHMARK)",
replacement: {
match: /className:(\i\.\i),(?=style:(\i),children:\[)/,
replace: "className:$self.userAreaControlsClass($1),"
}
},
],
chatBarButtonWrapper: (buttons: ReactNode) => {
if (!Array.isArray(buttons) || buttons.length === 0) return buttons;
return <ChatButtonsRow buttons={buttons} />;
},
userAreaControlsClass(className: string) {
return classes(className, cl("user-area-controls"));
},
});

View file

@ -0,0 +1,110 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { OptionType } from "@utils/types";
export const transitionMarkers = [0, 80, 120, 160, 200, 260, 320];
export const collapsedSizeMarkers = [8, 12, 16, 20, 24, 28, 32, 40, 48];
export const panelRegistry = {
guildBar: {
id: "guildBar",
classId: "guild-bar",
label: "Guild Bar",
collapsedKey: "guildBarCollapsed",
},
channelList: {
id: "channelList",
classId: "channel-list",
label: "Channel List",
collapsedKey: "channelListCollapsed",
},
membersList: {
id: "membersList",
classId: "members-list",
label: "Members List",
collapsedKey: "membersListCollapsed",
},
chatButtons: {
id: "chatButtons",
classId: "chat-buttons",
label: "Message Buttons",
collapsedKey: "chatButtonsCollapsed",
},
titleBar: {
id: "titleBar",
classId: "title-bar",
label: "Title Bar",
collapsedKey: "titleBarCollapsed",
},
headerBar: {
id: "headerBar",
classId: "header-bar",
label: "Header Bar",
collapsedKey: "headerBarCollapsed",
},
userArea: {
id: "userArea",
classId: "user-area",
label: "User Area",
collapsedKey: "userAreaCollapsed",
},
} as const;
export type PanelId = keyof typeof panelRegistry;
export const toolbarPanelOrder = ["guildBar", "channelList", "membersList", "chatButtons", "titleBar", "headerBar", "userArea"] as const satisfies readonly PanelId[];
export const settings = definePluginSettings({
collapsedSize: {
type: OptionType.SLIDER,
description: "Collapsed hover area size in pixels.",
default: 12,
markers: collapsedSizeMarkers,
},
transitionMs: {
type: OptionType.SLIDER,
description: "Panel transition speed in milliseconds.",
default: 160,
markers: transitionMarkers,
},
guildBarCollapsed: {
type: OptionType.BOOLEAN,
description: "Persist the guild bar as collapsed.",
default: false,
},
channelListCollapsed: {
type: OptionType.BOOLEAN,
description: "Persist the channel list as collapsed.",
default: false,
},
membersListCollapsed: {
type: OptionType.BOOLEAN,
description: "Persist the members list as collapsed.",
default: false,
},
chatButtonsCollapsed: {
type: OptionType.BOOLEAN,
description: "Persist the message button row as collapsed.",
default: false,
},
titleBarCollapsed: {
type: OptionType.BOOLEAN,
description: "Persist the title bar as collapsed.",
default: false,
},
headerBarCollapsed: {
type: OptionType.BOOLEAN,
description: "Persist the header bar as collapsed.",
default: false,
},
userAreaCollapsed: {
type: OptionType.BOOLEAN,
description: "Persist the user area as collapsed.",
default: false,
},
});

View file

@ -0,0 +1,416 @@
@property --vc-cui-collapsed-size {
syntax: "<length>";
inherits: true;
initial-value: 12px;
}
@property --vc-cui-transition {
syntax: "<time>";
inherits: true;
initial-value: 160ms;
}
:root {
--vc-cui-chat-buttons-width: min(320px, 36vw);
--vc-cui-hover-peek: var(--vc-cui-collapsed-size);
--vc-cui-hover-delay: 120ms;
--vc-cui-header-bar-height: 32px;
--vc-cui-user-area-collapsed-width: 52px;
--vc-cui-ease: cubic-bezier(.2, 0, 0, 1);
--vc-cui-fade-transition: opacity var(--vc-cui-transition) var(--vc-cui-ease);
--vc-cui-inline-transition:
width var(--vc-cui-transition) var(--vc-cui-ease),
inline-size var(--vc-cui-transition) var(--vc-cui-ease),
min-width var(--vc-cui-transition) var(--vc-cui-ease),
min-inline-size var(--vc-cui-transition) var(--vc-cui-ease),
max-width var(--vc-cui-transition) var(--vc-cui-ease),
max-inline-size var(--vc-cui-transition) var(--vc-cui-ease),
flex-basis var(--vc-cui-transition) var(--vc-cui-ease);
--vc-cui-block-transition:
height var(--vc-cui-transition) var(--vc-cui-ease),
block-size var(--vc-cui-transition) var(--vc-cui-ease),
min-height var(--vc-cui-transition) var(--vc-cui-ease),
min-block-size var(--vc-cui-transition) var(--vc-cui-ease),
max-height var(--vc-cui-transition) var(--vc-cui-ease),
max-block-size var(--vc-cui-transition) var(--vc-cui-ease),
padding var(--vc-cui-transition) var(--vc-cui-ease)
}
body:has(.vc-collapsible-ui-collapsed-size-8) {
--vc-cui-collapsed-size: 8px
}
body:has(.vc-collapsible-ui-collapsed-size-12) {
--vc-cui-collapsed-size: 12px
}
body:has(.vc-collapsible-ui-collapsed-size-16) {
--vc-cui-collapsed-size: 16px
}
body:has(.vc-collapsible-ui-collapsed-size-20) {
--vc-cui-collapsed-size: 20px
}
body:has(.vc-collapsible-ui-collapsed-size-24) {
--vc-cui-collapsed-size: 24px
}
body:has(.vc-collapsible-ui-collapsed-size-28) {
--vc-cui-collapsed-size: 28px
}
body:has(.vc-collapsible-ui-collapsed-size-32) {
--vc-cui-collapsed-size: 32px
}
body:has(.vc-collapsible-ui-collapsed-size-40) {
--vc-cui-collapsed-size: 40px
}
body:has(.vc-collapsible-ui-collapsed-size-48) {
--vc-cui-collapsed-size: 48px
}
body:has(.vc-collapsible-ui-transition-0) {
--vc-cui-transition: 0ms
}
body:has(.vc-collapsible-ui-transition-80) {
--vc-cui-transition: 80ms
}
body:has(.vc-collapsible-ui-transition-120) {
--vc-cui-transition: 120ms
}
body:has(.vc-collapsible-ui-transition-160) {
--vc-cui-transition: 160ms
}
body:has(.vc-collapsible-ui-transition-200) {
--vc-cui-transition: 200ms
}
body:has(.vc-collapsible-ui-transition-260) {
--vc-cui-transition: 260ms
}
body:has(.vc-collapsible-ui-transition-320) {
--vc-cui-transition: 320ms
}
body:has(.vc-collapsible-ui-guild-bar-collapsed) nav[class*="guilds_"]:not(:is(.vc-betterFolders-sidebar nav[class*="guilds_"])) {
position: relative;
z-index: 1;
overflow: hidden !important;
transition: var(--vc-cui-inline-transition) !important
}
body:has(.vc-collapsible-ui-guild-bar-collapsed) nav[class*="guilds_"]:not(:is(.vc-betterFolders-sidebar nav[class*="guilds_"])):is(:hover, :focus-within) {
z-index: 3
}
body:has(.vc-collapsible-ui-guild-bar-collapsed) nav[class*="guilds_"]:not(:is(.vc-betterFolders-sidebar nav[class*="guilds_"]), :is(:hover, :focus-within), :is(.vc-betterFolders-sidebar-grid:has(.vc-betterFolders-sidebar:is(:hover, :focus-within)) nav[class*="guilds_"])) {
background: var(--background-gradient-low, var(--background-base-lowest)) !important;
border-start-end-radius: var(--radius-sm, 8px);
width: var(--vc-cui-collapsed-size) !important;
inline-size: var(--vc-cui-collapsed-size) !important;
min-width: var(--vc-cui-collapsed-size) !important;
min-inline-size: var(--vc-cui-collapsed-size) !important;
max-width: var(--vc-cui-collapsed-size) !important;
max-inline-size: var(--vc-cui-collapsed-size) !important;
flex: 0 0 var(--vc-cui-collapsed-size) !important
}
body:has(.vc-collapsible-ui-guild-bar-collapsed) .vc-betterFolders-sidebar {
position: relative;
z-index: 1;
overflow: hidden !important;
transition: var(--vc-cui-inline-transition) !important
}
body:has(.vc-collapsible-ui-guild-bar-collapsed) .vc-betterFolders-sidebar-grid:has(nav[class*="guilds_"]:is(:hover, :focus-within), .vc-betterFolders-sidebar:is(:hover, :focus-within)) .vc-betterFolders-sidebar {
z-index: 3;
transition-delay: var(--vc-cui-hover-delay)
}
body:has(.vc-collapsible-ui-guild-bar-collapsed) .vc-betterFolders-sidebar-grid:not(:has(nav[class*="guilds_"]:is(:hover, :focus-within), .vc-betterFolders-sidebar:is(:hover, :focus-within))) .vc-betterFolders-sidebar {
width: 0 !important;
inline-size: 0 !important;
min-width: 0 !important;
min-inline-size: 0 !important;
max-width: 0 !important;
max-inline-size: 0 !important;
flex: 0 0 0 !important
}
body:has(.vc-collapsible-ui-channel-list-collapsed) [class*="sidebarList_"] {
overflow: hidden !important;
transition: var(--vc-cui-inline-transition) !important
}
body:has(.vc-collapsible-ui-channel-list-collapsed) [class*="sidebarList_"]:is(:hover, :focus-within) {
transition-delay: var(--vc-cui-hover-delay)
}
body:has(.vc-collapsible-ui-channel-list-collapsed) [class*="sidebarList_"]:is(:hover, :focus-within) > * {
opacity: 1;
pointer-events: auto;
transition: var(--vc-cui-fade-transition);
transition-delay: var(--vc-cui-hover-delay)
}
body:has(.vc-collapsible-ui-channel-list-collapsed):not(:has([class*="sidebar_"] > [class*="panels_"]:is(:hover, :focus-within))) [class*="sidebarList_"]:not(:is(:hover, :focus-within)) {
width: var(--vc-cui-collapsed-size) !important;
inline-size: var(--vc-cui-collapsed-size) !important;
min-width: var(--vc-cui-collapsed-size) !important;
min-inline-size: var(--vc-cui-collapsed-size) !important;
max-width: var(--vc-cui-collapsed-size) !important;
max-inline-size: var(--vc-cui-collapsed-size) !important;
flex: 0 0 var(--vc-cui-collapsed-size) !important
}
body:has(.vc-collapsible-ui-channel-list-collapsed) [class*="sidebar_"] > [class*="panels_"] {
overflow: hidden !important;
transition: var(--vc-cui-inline-transition) !important
}
body:has(.vc-collapsible-ui-channel-list-collapsed) [class*="sidebar_"] > [class*="panels_"]:not(:is(:hover, :focus-within)) {
width: var(--vc-cui-user-area-collapsed-width) !important;
inline-size: var(--vc-cui-user-area-collapsed-width) !important;
min-width: var(--vc-cui-user-area-collapsed-width) !important;
min-inline-size: var(--vc-cui-user-area-collapsed-width) !important;
max-width: var(--vc-cui-user-area-collapsed-width) !important;
max-inline-size: var(--vc-cui-user-area-collapsed-width) !important
}
body:has(.vc-collapsible-ui-channel-list-collapsed) [class*="sidebar_"] > [class*="panels_"]:not(:is(:hover, :focus-within)) .vc-collapsible-ui-user-area-controls {
max-width: 0 !important;
min-width: 0 !important;
overflow: hidden !important;
opacity: 0;
pointer-events: none;
transition:
max-width var(--vc-cui-transition) var(--vc-cui-ease),
opacity var(--vc-cui-transition) var(--vc-cui-ease) !important
}
body:has(.vc-collapsible-ui-channel-list-collapsed) [class*="sidebar_"] > [class*="panels_"]:is(:hover, :focus-within) .vc-collapsible-ui-user-area-controls {
opacity: 1;
pointer-events: auto
}
body:has(.vc-collapsible-ui-members-list-collapsed) [class*="membersWrap"] {
overflow: hidden !important;
transition: var(--vc-cui-inline-transition) !important
}
body:has(.vc-collapsible-ui-members-list-collapsed) [class*="membersWrap"]:is(:hover, :focus-within) {
transition-delay: var(--vc-cui-hover-delay)
}
body:has(.vc-collapsible-ui-members-list-collapsed) [class*="membersWrap"]:is(:hover, :focus-within) > * {
opacity: 1;
pointer-events: auto;
transition: var(--vc-cui-fade-transition);
transition-delay: var(--vc-cui-hover-delay)
}
body:has(.vc-collapsible-ui-members-list-collapsed) [class*="membersWrap"]:not(:is(:hover, :focus-within)) {
width: var(--vc-cui-collapsed-size) !important;
min-width: var(--vc-cui-collapsed-size) !important;
max-width: var(--vc-cui-collapsed-size) !important
}
body:has(.vc-collapsible-ui-members-list-collapsed) [class*="membersWrap"]:not(:is(:hover, :focus-within)) > * {
opacity: 0;
pointer-events: none;
transition: var(--vc-cui-fade-transition)
}
body:has(.vc-collapsible-ui-user-area-collapsed) [class*="sidebar_"] > [class*="panels_"]:is(:hover, :focus-within) > * {
opacity: 1;
pointer-events: auto;
transition: var(--vc-cui-fade-transition)
}
body:has(.vc-collapsible-ui-user-area-collapsed) [class*="sidebar_"] > [class*="panels_"]:not(:is(:hover, :focus-within)) {
height: var(--vc-cui-hover-peek) !important;
block-size: var(--vc-cui-hover-peek) !important;
min-height: var(--vc-cui-hover-peek) !important;
min-block-size: var(--vc-cui-hover-peek) !important;
max-height: var(--vc-cui-hover-peek) !important;
max-block-size: var(--vc-cui-hover-peek) !important;
overflow: hidden !important;
border-top-width: 0 !important;
padding-block: 0 !important;
transition: var(--vc-cui-block-transition) !important
}
body:has(.vc-collapsible-ui-user-area-collapsed) [class*="sidebar_"] > [class*="panels_"]:not(:is(:hover, :focus-within)) > * {
opacity: 0;
pointer-events: none;
transition: var(--vc-cui-fade-transition)
}
body:has(.vc-collapsible-ui-guild-bar-collapsed):has(.vc-collapsible-ui-channel-list-collapsed) [class*="sidebar_"] > [class*="panels_"]:not(:is(:hover, :focus-within)) {
width: calc(var(--vc-cui-hover-peek) + 4px) !important;
inline-size: calc(var(--vc-cui-hover-peek) + 4px) !important;
min-width: calc(var(--vc-cui-hover-peek) + 4px) !important;
min-inline-size: calc(var(--vc-cui-hover-peek) + 4px) !important;
max-width: calc(var(--vc-cui-hover-peek) + 4px) !important;
max-inline-size: calc(var(--vc-cui-hover-peek) + 4px) !important
}
body:has(.vc-collapsible-ui-title-bar-collapsed) section[class*="title_"][class*="container__"] {
overflow: hidden !important;
transition: var(--vc-cui-block-transition) !important
}
body:has(.vc-collapsible-ui-title-bar-collapsed) section[class*="title_"][class*="container__"]:is(:hover, :focus-within) {
transition-delay: var(--vc-cui-hover-delay)
}
body:has(.vc-collapsible-ui-title-bar-collapsed) section[class*="title_"][class*="container__"]:is(:hover, :focus-within) > * {
opacity: 1;
pointer-events: auto;
transition: var(--vc-cui-fade-transition);
transition-delay: var(--vc-cui-hover-delay)
}
body:has(.vc-collapsible-ui-title-bar-collapsed) section[class*="title_"][class*="container__"]:not(:is(:hover, :focus-within)) {
height: var(--vc-cui-hover-peek) !important;
block-size: var(--vc-cui-hover-peek) !important;
min-height: var(--vc-cui-hover-peek) !important;
min-block-size: var(--vc-cui-hover-peek) !important;
max-height: var(--vc-cui-hover-peek) !important;
max-block-size: var(--vc-cui-hover-peek) !important;
padding-block: 0 !important
}
body:has(.vc-collapsible-ui-title-bar-collapsed) section[class*="title_"][class*="container__"]:not(:is(:hover, :focus-within)) > * {
opacity: 0;
pointer-events: none;
transition: var(--vc-cui-fade-transition)
}
/* stylelint-disable no-descending-specificity */
body:has(.vc-collapsible-ui-header-bar-collapsed) [class*="bar_"]:has(> [class*="title_"]) {
position: relative;
z-index: 3;
overflow: hidden !important;
transition: var(--vc-cui-block-transition) !important
}
body:has(.vc-collapsible-ui-header-bar-collapsed) [class*="base_"]:has(> [class*="bar_"] > [class*="title_"]) {
/* stylelint-disable-next-line value-keyword-case */
grid-template-rows: [top] var(--vc-cui-hover-peek) [titleBarEnd] auto [noticeEnd] minmax(0, 1fr) [end] !important
}
body:has(.vc-collapsible-ui-header-bar-collapsed) [class*="base_"]:has(> [class*="bar_"]:is(:hover, :focus-within) > [class*="title_"]) {
/* stylelint-disable-next-line value-keyword-case */
grid-template-rows: [top] var(--vc-cui-header-bar-height) [titleBarEnd] auto [noticeEnd] minmax(0, 1fr) [end] !important
}
body:has(.vc-collapsible-ui-header-bar-collapsed) [class*="bar_"]:has(> [class*="title_"]):is(:hover, :focus-within) {
height: var(--vc-cui-header-bar-height) !important;
block-size: var(--vc-cui-header-bar-height) !important;
min-height: var(--vc-cui-header-bar-height) !important;
min-block-size: var(--vc-cui-header-bar-height) !important;
max-height: var(--vc-cui-header-bar-height) !important;
max-block-size: var(--vc-cui-header-bar-height) !important;
transition-delay: var(--vc-cui-hover-delay)
}
body:has(.vc-collapsible-ui-header-bar-collapsed) [class*="bar_"]:has(> [class*="title_"]):is(:hover, :focus-within) > * {
opacity: 1;
pointer-events: auto;
transition: var(--vc-cui-fade-transition);
transition-delay: var(--vc-cui-hover-delay)
}
body:has(.vc-collapsible-ui-header-bar-collapsed) [class*="bar_"]:has(> [class*="title_"]):not(:is(:hover, :focus-within)) {
height: var(--vc-cui-hover-peek) !important;
block-size: var(--vc-cui-hover-peek) !important;
min-height: var(--vc-cui-hover-peek) !important;
min-block-size: var(--vc-cui-hover-peek) !important;
max-height: var(--vc-cui-hover-peek) !important;
max-block-size: var(--vc-cui-hover-peek) !important;
padding-block: 0 !important
}
body:has(.vc-collapsible-ui-header-bar-collapsed) [class*="bar_"]:has(> [class*="title_"]):not(:is(:hover, :focus-within)) > * {
opacity: 0;
pointer-events: none;
transition: var(--vc-cui-fade-transition)
}
/* stylelint-enable no-descending-specificity */
/* stylelint-disable no-descending-specificity */
body:has(.vc-collapsible-ui-guild-bar-collapsed) .vc-betterFolders-sidebar-grid:has(nav[class*="guilds_"]:is(:hover, :focus-within), .vc-betterFolders-sidebar:is(:hover, :focus-within)) .vc-betterFolders-sidebar > *,
body:has(.vc-collapsible-ui-guild-bar-collapsed) nav[class*="guilds_"]:not(:is(.vc-betterFolders-sidebar nav[class*="guilds_"])):is(:hover, :focus-within) > * {
opacity: 1;
pointer-events: auto;
transition: var(--vc-cui-fade-transition);
transition-delay: calc(var(--vc-cui-hover-delay) + var(--vc-cui-transition))
}
body:has(.vc-collapsible-ui-guild-bar-collapsed) nav[class*="guilds_"]:not(:is(.vc-betterFolders-sidebar nav[class*="guilds_"])):is(:hover, :focus-within) > [class*="unreadMentionsIndicator"] {
pointer-events: none
}
body:has(.vc-collapsible-ui-guild-bar-collapsed) .vc-betterFolders-sidebar-grid:not(:has(nav[class*="guilds_"]:is(:hover, :focus-within), .vc-betterFolders-sidebar:is(:hover, :focus-within))) .vc-betterFolders-sidebar > *,
body:has(.vc-collapsible-ui-guild-bar-collapsed) nav[class*="guilds_"]:not(:is(.vc-betterFolders-sidebar nav[class*="guilds_"]), :is(:hover, :focus-within), :is(.vc-betterFolders-sidebar-grid:has(.vc-betterFolders-sidebar:is(:hover, :focus-within)) nav[class*="guilds_"])) > * {
opacity: 0;
pointer-events: none;
transition: var(--vc-cui-fade-transition)
}
body:has(.vc-collapsible-ui-channel-list-collapsed):not(:has([class*="sidebar_"] > [class*="panels_"]:is(:hover, :focus-within))) [class*="sidebarList_"]:not(:is(:hover, :focus-within)) > * {
opacity: 0;
pointer-events: none;
transition: var(--vc-cui-fade-transition)
}
/* stylelint-enable no-descending-specificity */
.vc-collapsible-ui-chat-buttons {
display: flex;
align-items: center;
min-inline-size: 0
}
.vc-collapsible-ui-chat-buttons-collapsed {
min-inline-size: var(--vc-cui-hover-peek)
}
.vc-collapsible-ui-chat-buttons-items {
display: flex;
align-items: center;
min-inline-size: 0;
max-inline-size: var(--vc-cui-chat-buttons-width);
overflow: hidden;
transition:
max-inline-size var(--vc-cui-transition) var(--vc-cui-ease),
opacity var(--vc-cui-transition) var(--vc-cui-ease),
margin var(--vc-cui-transition) var(--vc-cui-ease)
}
.vc-collapsible-ui-chat-buttons-collapsed .vc-collapsible-ui-chat-buttons-items {
max-inline-size: 0;
opacity: 0;
pointer-events: none
}
.vc-collapsible-ui-chat-buttons-collapsed:is(:hover, :focus-within) .vc-collapsible-ui-chat-buttons-items {
max-inline-size: var(--vc-cui-chat-buttons-width);
opacity: 1;
pointer-events: auto;
transition-delay: var(--vc-cui-hover-delay)
}
@media (prefers-reduced-motion: reduce) {
body:has(.vc-collapsible-ui-state) {
--vc-cui-transition: 0ms
}
}

View file

@ -7,8 +7,6 @@
import { isPluginEnabled, plugins } from "@api/PluginManager"; import { isPluginEnabled, plugins } from "@api/PluginManager";
import { NotesIcon } from "@components/Icons"; import { NotesIcon } from "@components/Icons";
import { toggleEnabled } from "@equicordplugins/equicordHelper/utils"; import { toggleEnabled } from "@equicordplugins/equicordHelper/utils";
import { noteHandler } from "@equicordplugins/holyNotes/NoteHandler";
import type { Note } from "@equicordplugins/holyNotes/types";
import type { ScheduledMessage } from "@equicordplugins/scheduledMessages/types"; import type { ScheduledMessage } from "@equicordplugins/scheduledMessages/types";
import { addScheduledMessage, getChannelDisplayInfo, getScheduledMessages, removeScheduledMessage, sendScheduledMessageNow, updateScheduledMessageTime } from "@equicordplugins/scheduledMessages/utils"; import { addScheduledMessage, getChannelDisplayInfo, getScheduledMessages, removeScheduledMessage, sendScheduledMessageNow, updateScheduledMessageTime } from "@equicordplugins/scheduledMessages/utils";
import { sleep } from "@utils/misc"; import { sleep } from "@utils/misc";
@ -21,17 +19,11 @@ import { DISCORD_INTERNAL_HOSTS, executeCommand, getCommandById } from "../regis
import { makeIconFromUrl } from "../ui/iconFromUrl"; import { makeIconFromUrl } from "../ui/iconFromUrl";
import { sendMessageToChannel, sendMessageToUser } from "./sendMessageAction"; import { sendMessageToChannel, sendMessageToUser } from "./sendMessageAction";
interface ResolvedHolyNote {
notebook: string;
note: Note;
}
interface StagedRescheduledMessage { interface StagedRescheduledMessage {
id: string; id: string;
label: string; label: string;
} }
let stagedMoveNote: ResolvedHolyNote | null = null;
let stagedRescheduleMessage: StagedRescheduledMessage | null = null; let stagedRescheduleMessage: StagedRescheduledMessage | null = null;
let pendingCancelScheduledMessageId: string | null = null; let pendingCancelScheduledMessageId: string | null = null;
@ -559,198 +551,6 @@ function filterScheduledMessages(target: string): ScheduledMessage[] {
}); });
} }
function getNotebookNames(): string[] {
return Object.keys(noteHandler.getAllNotes());
}
function filterNotebookNames(target: string, includeMain: boolean): string[] {
const normalizedTarget = normalizeText(target);
return getNotebookNames()
.filter(notebook => includeMain || notebook !== "Main")
.filter(notebook => !normalizedTarget || notebook.toLowerCase().includes(normalizedTarget))
.sort((left, right) => left.localeCompare(right));
}
function getResolvedNotes(target: string): ResolvedHolyNote[] {
const normalizedTarget = normalizeText(target);
const notebooks = noteHandler.getAllNotes();
const resolved: ResolvedHolyNote[] = [];
for (const [notebook, notes] of Object.entries(notebooks)) {
for (const note of Object.values(notes)) {
const content = note.content?.trim() ?? "";
if (normalizedTarget && !content.toLowerCase().includes(normalizedTarget)) continue;
resolved.push({ notebook, note });
}
}
return resolved;
}
function toNoteSnippet(note: Note): string {
const content = note.content?.replace(/\s+/g, " ").trim();
if (!content) return "(No text)";
if (content.length <= 56) return content;
return `${content.slice(0, 53)}...`;
}
function buildCreateNotebookCandidates(target: string): QueryActionCandidate[] {
const notebookName = target.trim();
if (!notebookName) {
return [{
id: "query-create-notebook-invalid",
label: "Create notebook",
description: "Notebook name is required.",
badge: "Query",
icon: NotesIcon,
run: () => {
showToast("Notebook name is required.", Toasts.Type.FAILURE);
return false;
}
}];
}
return [{
id: `query-create-notebook-${notebookName.toLowerCase()}`,
label: `Create notebook ${notebookName}`,
badge: "Query",
icon: NotesIcon,
run: async () => {
if (!await ensureHolyNotesEnabled()) return false;
if (noteHandler.getNotes(notebookName)) {
showToast(`Notebook ${notebookName} already exists`, Toasts.Type.FAILURE);
return false;
}
noteHandler.newNoteBook(notebookName);
}
}];
}
function buildDeleteNotebookCandidates(target: string): QueryActionCandidate[] {
const notebooks = filterNotebookNames(target, false);
if (notebooks.length === 0) {
return [{
id: "query-delete-notebook-none",
label: "Delete notebook",
description: "No deletable notebook found.",
badge: "Query",
icon: NotesIcon,
run: () => {
showToast("No deletable notebook found.", Toasts.Type.MESSAGE);
return false;
}
}];
}
return notebooks.slice(0, 8).map(notebook => ({
id: `query-delete-notebook-${notebook.toLowerCase()}`,
label: notebook,
description: "Notebook",
badge: "Query",
icon: NotesIcon,
run: async () => {
if (!await ensureHolyNotesEnabled()) return false;
noteHandler.deleteNotebook(notebook);
}
}));
}
function buildJumpNoteCandidates(target: string): QueryActionCandidate[] {
const notes = getResolvedNotes(target).slice(0, 10);
if (notes.length === 0) {
return [{
id: "query-jump-note-none",
label: "Jump to note",
description: "No matching note found.",
badge: "Query",
icon: NotesIcon,
run: () => {
showToast("No matching note found.", Toasts.Type.FAILURE);
return false;
}
}];
}
return notes.map(({ notebook, note }) => ({
id: `query-jump-note-${notebook}-${note.id}`,
label: `[${notebook}] ${toNoteSnippet(note)}`,
description: "Open original message.",
badge: "Query",
icon: NotesIcon,
run: async () => {
if (!await ensureHolyNotesEnabled()) return false;
NavigationRouter.transitionTo(`/channels/${note.guild_id || "@me"}/${note.channel_id}/${note.id}`);
}
}));
}
function buildMoveNoteCandidates(target: string): QueryActionCandidate[] {
if (stagedMoveNote) {
const destinationNotebooks = filterNotebookNames(target, true)
.filter(notebook => notebook !== stagedMoveNote?.notebook);
if (destinationNotebooks.length === 0) {
return [{
id: "query-move-note-no-destination",
label: "Move note",
description: "No destination notebook found.",
badge: "Query",
icon: NotesIcon,
run: () => {
showToast("No destination notebook found.", Toasts.Type.FAILURE);
return false;
}
}];
}
return destinationNotebooks.slice(0, 8).map(destinationNotebook => ({
id: `query-move-note-destination-${destinationNotebook.toLowerCase()}`,
label: `[${stagedMoveNote!.notebook}] ${toNoteSnippet(stagedMoveNote!.note)} -> Move to ${destinationNotebook}`,
description: "Move note",
badge: "Query",
icon: NotesIcon,
run: async () => {
if (!stagedMoveNote) return;
if (!await ensureHolyNotesEnabled()) return false;
if (!stagedMoveNote) return;
noteHandler.moveNote(stagedMoveNote.note, stagedMoveNote.notebook, destinationNotebook);
stagedMoveNote = null;
}
}));
}
const notes = getResolvedNotes(target).slice(0, 10);
if (notes.length === 0) {
return [{
id: "query-move-note-none",
label: "Move note",
description: "No matching note found.",
badge: "Query",
icon: NotesIcon,
run: () => {
showToast("No matching note found.", Toasts.Type.FAILURE);
return false;
}
}];
}
return notes.map(({ notebook, note }) => ({
id: `query-move-note-select-${notebook}-${note.id}`,
label: `[${notebook}] ${toNoteSnippet(note)}`,
description: "Select destination notebook.",
badge: "Query",
icon: NotesIcon,
run: () => {
return ensureHolyNotesEnabled().then(enabled => {
if (!enabled) return false;
stagedMoveNote = { notebook, note };
showToast("Note selected. Choose destination notebook.", Toasts.Type.MESSAGE);
return false;
});
}
}));
}
function buildQuickScheduleMessageCandidates(target: string): QueryActionCandidate[] { function buildQuickScheduleMessageCandidates(target: string): QueryActionCandidate[] {
const parsed = parseQuickScheduleTarget(target); const parsed = parseQuickScheduleTarget(target);
if (!parsed) return []; if (!parsed) return [];
@ -926,15 +726,11 @@ function buildCancelScheduledMessageCandidates(target: string): QueryActionCandi
export function buildQueryResolution(query: string): QueryResolution { export function buildQueryResolution(query: string): QueryResolution {
const parsed = parseQuery(query); const parsed = parseQuery(query);
if (!parsed) { if (!parsed) {
stagedMoveNote = null;
stagedRescheduleMessage = null; stagedRescheduleMessage = null;
pendingCancelScheduledMessageId = null; pendingCancelScheduledMessageId = null;
return { type: "none" }; return { type: "none" };
} }
if (parsed.intent !== "move_note") {
stagedMoveNote = null;
}
if (parsed.intent !== "reschedule_message") { if (parsed.intent !== "reschedule_message") {
stagedRescheduleMessage = null; stagedRescheduleMessage = null;
} }
@ -971,14 +767,6 @@ export function buildQueryResolution(query: string): QueryResolution {
return { type: "candidates", candidates: buildTogglePluginCandidates(parsed.target) }; return { type: "candidates", candidates: buildTogglePluginCandidates(parsed.target) };
case "open_url": case "open_url":
return { type: "candidates", candidates: buildOpenUrlCandidates(parsed.target) }; return { type: "candidates", candidates: buildOpenUrlCandidates(parsed.target) };
case "create_notebook":
return { type: "candidates", candidates: buildCreateNotebookCandidates(parsed.target) };
case "delete_notebook":
return { type: "candidates", candidates: buildDeleteNotebookCandidates(parsed.target) };
case "move_note":
return { type: "candidates", candidates: buildMoveNoteCandidates(parsed.target) };
case "jump_note":
return { type: "candidates", candidates: buildJumpNoteCandidates(parsed.target) };
case "schedule_message": case "schedule_message":
return { type: "candidates", candidates: buildQuickScheduleMessageCandidates(parsed.target) }; return { type: "candidates", candidates: buildQuickScheduleMessageCandidates(parsed.target) };
case "reschedule_message": case "reschedule_message":

View file

@ -1,161 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { isPluginEnabled, plugins } from "@api/PluginManager";
import { toggleEnabled } from "@equicordplugins/equicordHelper/utils";
import { noteHandler } from "@equicordplugins/holyNotes/NoteHandler";
import { Message } from "@vencord/discord-types";
import { MessageStore, SelectedChannelStore, Toasts } from "@webpack/common";
import { DEFAULT_CATEGORY_ID } from "../../metadata/categories";
import { TAG_NAVIGATION, TAG_PLUGINS, TAG_UTILITY } from "../../metadata/tags";
import type { CommandEntry } from "../../registry";
import type { HolyNotesPlugin } from "../types";
function showToast(message: string, type: (typeof Toasts.Type)[keyof typeof Toasts.Type]) {
Toasts.show({ message, type, id: Toasts.genId(), options: { position: Toasts.Position.BOTTOM } });
}
function getHolyNotesPlugin(): HolyNotesPlugin | null {
const plugin = plugins.HolyNotes as HolyNotesPlugin | undefined;
return plugin ?? null;
}
async function ensureHolyNotesPluginEnabled() {
const plugin = getHolyNotesPlugin();
if (!plugin) {
showToast("HolyNotes plugin is unavailable.", Toasts.Type.FAILURE);
return false;
}
if (isPluginEnabled(plugin.name)) return true;
const success = await toggleEnabled(plugin.name);
if (!success || !isPluginEnabled(plugin.name)) {
showToast("Failed to enable HolyNotes.", Toasts.Type.FAILURE);
return false;
}
showToast("Enabled HolyNotes.", Toasts.Type.SUCCESS);
return true;
}
async function runHolyNotesOpen() {
if (!await ensureHolyNotesPluginEnabled()) return;
const plugin = getHolyNotesPlugin();
const openNotes = plugin?.toolboxActions?.["Open Notes"];
if (typeof openNotes !== "function") {
showToast("HolyNotes action is unavailable.", Toasts.Type.FAILURE);
return;
}
await Promise.resolve(openNotes());
}
function getLastNonSystemMessageInChannel(channelId: string): Message | null {
const messageCollection = MessageStore.getMessages?.(channelId) as { _array?: Message[]; } | undefined;
const messages = messageCollection?._array;
if (!messages?.length) return null;
for (let index = messages.length - 1; index >= 0; index--) {
const message = messages[index] as Message & { deleted?: boolean; type?: number; };
if (!message || message.deleted) continue;
if (typeof message.type === "number" && message.type !== 0) continue;
return message;
}
return null;
}
async function runHolyNotesQuickSaveLastMessage() {
if (!await ensureHolyNotesPluginEnabled()) return;
const channelId = SelectedChannelStore.getChannelId();
if (!channelId) {
showToast("No active channel available.", Toasts.Type.FAILURE);
return;
}
const message = getLastNonSystemMessageInChannel(channelId);
if (!message) {
showToast("No message available to save.", Toasts.Type.MESSAGE);
return;
}
noteHandler.addNote(message, "Main");
}
export function createHolyNotesExtensionCommands(): CommandEntry[] {
return [
{
id: "extension-holy-notes-open",
label: "Open Notes",
description: "Open the HolyNotes notebook modal.",
keywords: ["holy", "notes", "notebook", "bookmark", "open", "plugin", "extension"],
categoryId: DEFAULT_CATEGORY_ID,
tags: [TAG_PLUGINS, TAG_UTILITY, TAG_NAVIGATION],
handler: runHolyNotesOpen
},
{
id: "extension-holy-notes-create-notebook-query",
label: "Create Notebook",
description: "Create a HolyNotes notebook.",
keywords: ["holy", "notes", "notebook", "create", "new", "plugin", "extension"],
categoryId: DEFAULT_CATEGORY_ID,
tags: [TAG_PLUGINS, TAG_UTILITY],
closeAfterExecute: true,
queryTemplate: "create notebook ",
queryPlaceholder: "Notebook name",
handler: () => undefined
},
{
id: "extension-holy-notes-delete-notebook-query",
label: "Delete Notebook",
description: "Delete a HolyNotes notebook.",
keywords: ["holy", "notes", "notebook", "delete", "remove", "plugin", "extension"],
categoryId: DEFAULT_CATEGORY_ID,
tags: [TAG_PLUGINS, TAG_UTILITY],
closeAfterExecute: true,
queryTemplate: "delete notebook ",
queryPlaceholder: "Notebook name",
handler: () => undefined
},
{
id: "extension-holy-notes-move-note-query",
label: "Move Note",
description: "Move a HolyNotes note to another notebook.",
keywords: ["holy", "notes", "note", "move", "notebook", "plugin", "extension"],
categoryId: DEFAULT_CATEGORY_ID,
tags: [TAG_PLUGINS, TAG_UTILITY],
closeAfterExecute: true,
queryTemplate: "move note ",
queryPlaceholder: "Find note by content, then destination",
handler: () => undefined
},
{
id: "extension-holy-notes-jump-note-query",
label: "Jump To Note",
description: "Jump to the original message for a HolyNotes note.",
keywords: ["holy", "notes", "jump", "note", "message", "plugin", "extension"],
categoryId: DEFAULT_CATEGORY_ID,
tags: [TAG_PLUGINS, TAG_NAVIGATION],
closeAfterExecute: true,
queryTemplate: "jump to note ",
queryPlaceholder: "Search note content",
handler: () => undefined
},
{
id: "extension-holy-notes-quick-save-last-message",
label: "Quick Save Last Message",
description: "Save the latest message in this channel to Main.",
keywords: ["holy", "notes", "save", "last", "message", "bookmark", "main", "plugin", "extension"],
categoryId: DEFAULT_CATEGORY_ID,
tags: [TAG_PLUGINS, TAG_UTILITY],
handler: runHolyNotesQuickSaveLastMessage
}
];
}

View file

@ -69,18 +69,6 @@ export const EXTENSIONS_CATALOG: ExtensionDefinition[] = [
tags: [TAG_PLUGINS, TAG_UTILITY], tags: [TAG_PLUGINS, TAG_UTILITY],
keywords: ["extension", "plugin", "random", "voice", "join", "channel", "vc"] keywords: ["extension", "plugin", "random", "voice", "join", "channel", "vc"]
}, },
{
id: HOLY_NOTES_EXTENSION_ID,
label: "HolyNotes",
description: "Control HolyNotes from the command palette.",
detailCategoryId: HOLY_NOTES_EXTENSION_DETAIL_CATEGORY_ID,
commandId: "extension-holy-notes-open",
commandLabel: "Open Notes",
commandDescription: "Open the HolyNotes notebook modal.",
sourcePath: "src/equicordplugins/holyNotes",
tags: [TAG_PLUGINS, TAG_UTILITY],
keywords: ["extension", "plugin", "holy", "notes", "notebook", "bookmark"]
},
{ {
id: SILENT_MESSAGE_TOGGLE_EXTENSION_ID, id: SILENT_MESSAGE_TOGGLE_EXTENSION_ID,
label: "SilentMessageToggle", label: "SilentMessageToggle",

View file

@ -9,7 +9,6 @@ import { Toasts } from "@webpack/common";
import { TAG_DEVELOPER, TAG_NAVIGATION, TAG_PLUGINS, TAG_UTILITY } from "../metadata/tags"; import { TAG_DEVELOPER, TAG_NAVIGATION, TAG_PLUGINS, TAG_UTILITY } from "../metadata/tags";
import type { CommandEntry, ExtensionDefinition } from "../registry"; import type { CommandEntry, ExtensionDefinition } from "../registry";
import { createHolyNotesExtensionCommands } from "./actions/holyNotes";
import { createRandomVoiceExtensionCommand } from "./actions/randomVoice"; import { createRandomVoiceExtensionCommand } from "./actions/randomVoice";
import { createScheduledMessagesExtensionCommands } from "./actions/scheduledMessages"; import { createScheduledMessagesExtensionCommands } from "./actions/scheduledMessages";
import { createSilentMessageToggleExtensionCommand } from "./actions/silentMessageToggle"; import { createSilentMessageToggleExtensionCommand } from "./actions/silentMessageToggle";
@ -19,7 +18,6 @@ import {
EXTENSIONS_CATALOG_CATEGORY_ID, EXTENSIONS_CATALOG_CATEGORY_ID,
EXTENSIONS_DETAIL_PROVIDER_ID, EXTENSIONS_DETAIL_PROVIDER_ID,
EXTENSIONS_PACK_PROVIDER_ID, EXTENSIONS_PACK_PROVIDER_ID,
HOLY_NOTES_EXTENSION_ID,
RANDOM_VOICE_EXTENSION_ID, RANDOM_VOICE_EXTENSION_ID,
SCHEDULED_MESSAGES_EXTENSION_ID, SCHEDULED_MESSAGES_EXTENSION_ID,
SILENT_MESSAGE_TOGGLE_EXTENSION_ID, SILENT_MESSAGE_TOGGLE_EXTENSION_ID,
@ -55,10 +53,6 @@ function createInstalledExtensionCommands(extensionsState: ExtensionsState): Com
entries.push(createRandomVoiceExtensionCommand(extensionsState.extensionKeybinds)); entries.push(createRandomVoiceExtensionCommand(extensionsState.extensionKeybinds));
} }
if (extensionsState.installedExtensionIds.has(HOLY_NOTES_EXTENSION_ID)) {
entries.push(...createHolyNotesExtensionCommands());
}
if (extensionsState.installedExtensionIds.has(SILENT_MESSAGE_TOGGLE_EXTENSION_ID)) { if (extensionsState.installedExtensionIds.has(SILENT_MESSAGE_TOGGLE_EXTENSION_ID)) {
entries.push(createSilentMessageToggleExtensionCommand(extensionsState.extensionKeybinds)); entries.push(createSilentMessageToggleExtensionCommand(extensionsState.extensionKeybinds));
} }

Some files were not shown because too many files have changed in this diff Show more