Merge branch 'dev' into tray-stuff

This commit is contained in:
thororen1234 2026-05-07 11:32:45 -04:00
commit 03006efd08
No known key found for this signature in database
699 changed files with 18386 additions and 21366 deletions

View file

@ -23,10 +23,10 @@ jobs:
- uses: pnpm/action-setup@v3
- name: Use Node.js 20
- name: Use Node.js 24
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 24
cache: "pnpm"
- name: Install dependencies
@ -68,17 +68,20 @@ jobs:
rm release/*.map
- name: Upload Equicord Stable
if: ${{ !vars.ACT }}
run: |
gh release upload latest --clobber dist/release/*
- name: Upload Plugin JSONs to Equibored repo
if: github.repository == 'Equicord/Equicord' && !vars.ACT
run: |
git config --global user.name "GitHub-Actions"
git config --global user.email actions@github.com
git clone https://$USERNAME:$GITHUB_TOKEN@github.com/$REPO.git plugins
cd plugins
cp ../dist/release/*.json .
cp ../dist/release/*plugins.json .
cp ../dist/release/devs.json .
git add -A
git commit -m "Plugins for https://github.com/$GITHUB_REPOSITORY/commit/$GITHUB_SHA"

View file

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

View file

@ -20,10 +20,10 @@ jobs:
- uses: pnpm/action-setup@v3
- name: Use Node.js 20
- name: Use Node.js 24
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 24
cache: "pnpm"
- name: Install dependencies
@ -50,7 +50,7 @@ jobs:
cp browser/browser.* 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
# legacy un-asared files
@ -69,6 +69,7 @@ jobs:
run: echo "::set-output name=date::$(date +'%Y-%m-%d')"
- name: Upload Equicord Stable
if: ${{ !vars.ACT }}
run: |
gh release create ${{ steps.date.outputs.date }} --latest=false
gh release upload ${{ steps.date.outputs.date }} --clobber dist/release/*

View file

@ -62,11 +62,11 @@ jobs:
if: steps.check.outputs.release == 'true'
uses: pnpm/action-setup@v3
- name: Use Node.js 20
- name: Use Node.js 24
if: steps.check.outputs.release == 'true'
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 24
cache: "pnpm"
- name: Install dependencies
@ -79,12 +79,16 @@ jobs:
- name: Create sources zip
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
if: steps.check.outputs.release == 'true'
run: pnpx @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
if: steps.check.outputs.release == 'true' && !vars.ACT
run: pnpx @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:
CHROME_EXTENSION_ID: ${{ secrets.CHROME_EXTENSION_ID }}
CHROME_PUBLISHER_ID: ${{ secrets.CHROME_PUBLISHER_ID }}

View file

@ -33,10 +33,10 @@ jobs:
- uses: pnpm/action-setup@v3 # Install pnpm using packageManager key in package.json
- name: Use Node.js 20
- name: Use Node.js 24
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 24
cache: "pnpm"
- name: Install dependencies

View file

@ -18,10 +18,10 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3 # Install pnpm using packageManager key in package.json
- name: Use Node.js 20
- name: Use Node.js 24
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 24
cache: "pnpm"
- name: Install dependencies

3
.gitignore vendored
View file

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

View file

@ -136,6 +136,8 @@ export default defineConfig(
"use-isnan": "error",
"prefer-const": ["error", { destructuring: "all" }],
"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
"simple-import-sort/imports": "error",

View file

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

View file

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

1857
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

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

View file

@ -147,7 +147,7 @@ export const globPlugins = kind => ({
});
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 pluginsCode = "\n";
let metaCode = "\n";

View file

@ -31,11 +31,11 @@ import { getEntryPoint, isPluginFile, parseDevs, parseEquicordDevs, parseFile, P
let dirs: string[];
if (equicordFlag) {
dirs = ["src/equicordplugins"];
dirs = ["src/equicordplugins/_core", "src/equicordplugins"];
} else if (vencordFlag) {
dirs = ["src/plugins", "src/plugins/_core"];
} 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;

View file

@ -81,7 +81,9 @@ const IGNORED_DISCORD_ERRORS = [
"Downloading the full bad domains file",
/\[GatewaySocket\].{0,110}Cannot access '/,
"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>;
function toCodeBlock(s: string, indentation = 0, isDiscord = false) {

View file

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

View file

@ -41,8 +41,7 @@ import { showNotice } from "./api/Notices";
import { NotificationData, showNotification } from "./api/Notifications";
import { initPluginManager, PMLogger, startAllPlugins } from "./api/PluginManager";
import { PlainSettings, Settings, SettingsStore } from "./api/Settings";
import { getCloudSettings, putCloudSettings, shouldCloudSync } from "./api/SettingsSync/cloudSync";
import { localStorage } from "./utils/localStorage";
import { areLocalSettingsDirty, getCloudSettings, getCloudSyncDirection, markLocalSettingsDirty, putCloudSettings, shouldCloudSync } from "./api/SettingsSync/cloudSync";
import { relaunch } from "./utils/native";
import { checkForUpdates, update, UpdateLogger } from "./utils/updater";
import { onceReady } from "./webpack";
@ -53,11 +52,6 @@ if (IS_REPORTER) {
}
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");
if (!hasCloudAuth) {
if (Settings.cloud.authenticated) {
@ -93,9 +87,9 @@ async function syncSettings() {
if (
Settings.cloud.settingsSync && // if it's 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();
} 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
@ -118,7 +112,7 @@ async function syncSettings() {
}, 60_000);
SettingsStore.addGlobalChangeListener(() => {
localStorage.Vencord_settingsDirty = true;
markLocalSettingsDirty();
saveSettingsOnFrequentAction();
});
}

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { findByCodeLazy, findLazy } from "webpack";
import { findByCodeLazy, findLazy } from "@webpack";
let defaultSounds: null | string[] = null;
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 {
/**
* Badge id, unused by vencord, required by discord
*/
id: string,
/** The tooltip to show on hover. Required for image badges */
description?: string;
/** Custom component for the badge (tooltip not included) */

View file

@ -190,3 +190,22 @@ addContextMenuPatch("textarea-context", (children, args) => {
</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) {
if (!Menu.MenuItem) return props; // Prevent crashes in case we fail to acquire menu items for some reason
props = {
...props,
children: cloneMenuChildren(props.children),

View file

@ -18,97 +18,31 @@
* 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>(
request: IDBRequest<T> | IDBTransaction,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const target = request as IDBRequest<T> & IDBTransaction;
target.oncomplete = target.onsuccess = () => resolve(target.result);
target.onabort = target.onerror = () => reject(target.error);
// @ts-expect-error - file size hacks
request.oncomplete = request.onsuccess = () => resolve(request.result);
// @ts-expect-error - file size hacks
request.onabort = request.onerror = () => reject(request.error);
});
}
export function createStore(dbName: string, storeName: string): UseStore {
const request = indexedDB.open(dbName);
request.onupgradeneeded = () => request.result.createObjectStore(storeName);
const dbp = promisifyRequest(request).catch(error => {
handleFailure(error);
return null;
});
const memoryStore = getMemoryStore(dbName, storeName);
const dbp = promisifyRequest(request);
return async (txMode, callback) => {
const db = await dbp;
if (!db) return callback(memoryStore);
return callback(db.transaction(storeName, txMode).objectStore(storeName));
};
return (txMode, callback) =>
dbp.then(db =>
callback(db.transaction(storeName, txMode).objectStore(storeName)),
);
}
export type UseStore = <T>(
txMode: IDBTransactionMode,
callback: (store: StoreLike) => T | PromiseLike<T>,
callback: (store: IDBObjectStore) => T | PromiseLike<T>,
) => Promise<T>;
let defaultGetStoreFunc: UseStore | undefined;
@ -120,68 +54,88 @@ function defaultGetStore() {
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>(
key: IDBValidKey,
customStore = defaultGetStore(),
): Promise<T | undefined> {
return withFailureFallback(customStore("readonly", store => {
if (isMemoryStore(store)) return store.get(key) as T | undefined;
return promisifyRequest(store.get(key));
}), undefined);
return customStore("readonly", store => promisifyRequest(store.get(key)));
}
/**
* 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(
key: IDBValidKey,
value: any,
customStore = defaultGetStore(),
): Promise<void> {
return withFailureFallback(customStore("readwrite", store => {
return customStore("readwrite", store => {
store.put(value, key);
if (isMemoryStore(store)) return;
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(
entries: [IDBValidKey, any][],
customStore = defaultGetStore(),
): Promise<void> {
return withFailureFallback(customStore("readwrite", store => {
return customStore("readwrite", store => {
entries.forEach(entry => store.put(entry[1], entry[0]));
if (isMemoryStore(store)) return;
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>(
keys: IDBValidKey[],
customStore = defaultGetStore(),
): Promise<T[]> {
return withFailureFallback(customStore("readonly", store => {
if (isMemoryStore(store)) {
return keys.map(key => store.get(key) as T | undefined);
}
return Promise.all(keys.map(key => promisifyRequest(store.get(key))));
}), []);
return customStore("readonly", store =>
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>(
key: IDBValidKey,
updater: (oldValue: T | undefined) => T,
customStore = defaultGetStore(),
): Promise<void> {
return withFailureFallback(customStore(
return customStore(
"readwrite",
store => {
if (isMemoryStore(store)) {
store.put(updater(store.get(key) as T | undefined), key);
return;
}
return new Promise((resolve, reject) => {
store =>
// Need to create the promise manually.
// If I try to chain promises, the transaction closes in browsers
// that use a promise polyfill (IE10/11).
new Promise((resolve, reject) => {
store.get(key).onsuccess = function () {
try {
store.put(updater(this.result), key);
@ -190,42 +144,52 @@ export function update<T = any>(
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(
key: IDBValidKey,
customStore = defaultGetStore(),
): Promise<void> {
return withFailureFallback(customStore("readwrite", store => {
return customStore("readwrite", store => {
store.delete(key);
if (isMemoryStore(store)) return;
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(
keys: IDBValidKey[],
customStore = defaultGetStore(),
): Promise<void> {
return withFailureFallback(customStore("readwrite", store => {
keys.forEach(key => store.delete(key));
if (isMemoryStore(store)) return;
return customStore("readwrite", (store: IDBObjectStore) => {
keys.forEach((key: IDBValidKey) => store.delete(key));
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> {
return withFailureFallback(customStore("readwrite", store => {
return customStore("readwrite", store => {
store.clear();
if (isMemoryStore(store)) return;
return promisifyRequest(store.transaction);
}), undefined);
});
}
function eachCursor(
@ -240,12 +204,16 @@ function eachCursor(
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>(
customStore = defaultGetStore(),
): Promise<KeyType[]> {
return withFailureFallback(customStore("readonly", store => {
if (isMemoryStore(store)) return store.getAllKeys<KeyType>();
return customStore("readonly", store => {
// Fast path for modern browsers
if (store.getAllKeys) {
return promisifyRequest(
store.getAllKeys() as unknown as IDBRequest<KeyType[]>,
@ -253,45 +221,59 @@ export function keys<KeyType extends IDBValidKey>(
}
const items: KeyType[] = [];
return eachCursor(store, cursor =>
items.push(cursor.key as KeyType),
).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[]> {
return withFailureFallback(customStore("readonly", store => {
if (isMemoryStore(store)) return store.getAll<T>();
return customStore("readonly", store => {
// Fast path for modern browsers
if (store.getAll) {
return promisifyRequest(store.getAll() as IDBRequest<T[]>);
}
const items: T[] = [];
return eachCursor(store, cursor => items.push(cursor.value as T)).then(
() => 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>(
customStore = defaultGetStore(),
): Promise<[KeyType, ValueType][]> {
return withFailureFallback(customStore("readonly", store => {
if (isMemoryStore(store)) return store.entries<KeyType, ValueType>();
return customStore("readonly", store => {
// Fast path for modern browsers
// (although, hopefully we'll get a simpler path some day)
if (store.getAll && store.getAllKeys) {
return Promise.all([
promisifyRequest(
store.getAllKeys() as unknown as IDBRequest<KeyType[]>,
),
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][] = [];
return eachCursor(store, cursor =>
items.push([cursor.key as KeyType, cursor.value]),
).then(() => items);
}), [] as [KeyType, ValueType][]);
return customStore("readonly", store =>
eachCursor(store, cursor =>
items.push([cursor.key as KeyType, cursor.value]),
).then(() => items),
);
});
}

View file

@ -17,6 +17,7 @@
*/
import ErrorBoundary from "@components/ErrorBoundary";
import { Logger } from "@utils/Logger";
import { JSX, ReactNode } from "react";
export type MessageAccessoryFactory = (props: Record<string, any>) => ReactNode;
@ -46,23 +47,28 @@ export function _modifyAccessories(
elements: JSX.Element[],
props: Record<string, any>
) {
for (const [key, accessory] of accessories.entries()) {
const res = (
<ErrorBoundary noop message={`Failed to render ${key} Message Accessory`} key={key}>
<accessory.render {...props} />
</ErrorBoundary>
);
try {
for (const [key, accessory] of accessories.entries()) {
const res = (
<ErrorBoundary noop message={`Failed to render ${key} Message Accessory`} key={key}>
<accessory.render {...props} />
</ErrorBoundary>
);
elements.splice(
accessory.position != null
? accessory.position < 0
? elements.length + accessory.position
: accessory.position
: elements.length,
0,
res
);
elements.splice(
accessory.position != null
? accessory.position < 0
? elements.length + accessory.position
: accessory.position
: elements.length,
0,
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;
z-index: 2147483647;
right: 1rem;
width: 25vw;
width: min(25vw, 400px);
min-height: 10vh;
}

View file

@ -17,7 +17,7 @@
*/
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 { addContextMenuPatch, removeContextMenuPatch } from "@api/ContextMenu";
import { addMemberListDecorator, removeMemberListDecorator } from "@api/MemberListDecorators";
@ -28,10 +28,11 @@ import { addMessagePopoverButton, removeMessagePopoverButton } from "@api/Messag
import { addNicknameIcon, removeNicknameIcon } from "@api/NicknameIcons";
import { Settings, SettingsStore } from "@api/Settings";
import { disableStyle, enableStyle } from "@api/Styles";
import { traceFunction } from "@debug/Tracer";
import { Logger } from "@utils/Logger";
import { onlyOnce } from "@utils/onlyOnce";
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 { FluxDispatcher } from "@webpack/common";
import { patches } from "@webpack/patcher";
@ -39,10 +40,10 @@ import { patches } from "@webpack/patcher";
import Plugins from "~plugins";
export { Plugins as plugins };
import { traceFunction } from "../debug/Tracer";
import { addAudioProcessor, removeAudioProcessor } from "./AudioPlayer";
import { addChannelToolbarButton, addHeaderBarButton, removeChannelToolbarButton, removeHeaderBarButton } from "./HeaderBar";
import { addProfileCollection, removeProfileCollection } from "./ProfileCollections";
import { addProfileSection, removeProfileSection } from "./ProfileSections";
import { addUserAreaButton, removeUserAreaButton } from "./UserArea";
const logger = new Logger("PluginManager", "#a6d189");
@ -60,14 +61,37 @@ export function isPluginEnabled(p: string) {
Settings.plugins[p]?.enabled
) ?? false;
}
export function isPluginRequired(p: string) {
return (
Plugins[p]?.required
) ?? 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)}]`) {
// 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;
patch.plugin = pluginName;
@ -197,7 +221,8 @@ export const startPlugin = traceFunction("startPlugin", function startPlugin(p:
onBeforeMessageEdit, onBeforeMessageSend, onMessageClick,
chatBarButton, renderMemberListDecorator, renderMessageAccessory, renderMessageDecoration, messagePopoverButton,
// Custom
renderNicknameIcon, headerBarButton, audioProcessor, userAreaButton, renderProfileCollection
renderNicknameIcon, headerBarButton, audioProcessor, userAreaButton, renderProfileCollection, chatBarButtonWrapper,
renderProfileSection
} = p;
if (p.start) {
@ -265,6 +290,8 @@ export const startPlugin = traceFunction("startPlugin", function startPlugin(p:
if (audioProcessor) addAudioProcessor(name, audioProcessor);
if (userAreaButton) addUserAreaButton(name, userAreaButton.render, userAreaButton.priority);
if (renderProfileCollection) addProfileCollection(name, renderProfileCollection);
if (chatBarButtonWrapper) addChatBarButtonWrapper(name, chatBarButtonWrapper);
if (renderProfileSection) addProfileSection(name, renderProfileSection);
return true;
}, p => `startPlugin ${p.name}`);
@ -275,7 +302,8 @@ export const stopPlugin = traceFunction("stopPlugin", function stopPlugin(p: Plu
onBeforeMessageEdit, onBeforeMessageSend, onMessageClick,
chatBarButton, renderMemberListDecorator, renderMessageAccessory, renderMessageDecoration, messagePopoverButton,
// Custom
renderNicknameIcon, headerBarButton, audioProcessor, userAreaButton, renderProfileCollection
renderNicknameIcon, headerBarButton, audioProcessor, userAreaButton, renderProfileCollection, chatBarButtonWrapper,
renderProfileSection
} = p;
if (p.stop) {
@ -341,6 +369,8 @@ export const stopPlugin = traceFunction("stopPlugin", function stopPlugin(p: Plu
if (audioProcessor) removeAudioProcessor(name);
if (userAreaButton) removeUserAreaButton(name);
if (renderProfileCollection) removeProfileCollection(name);
if (chatBarButtonWrapper) removeChatBarButtonWrapper(name);
if (renderProfileSection) removeProfileSection(name);
return true;
}, p => `stopPlugin ${p.name}`);
@ -396,6 +426,8 @@ export const initPluginManager = onlyOnce(function init() {
if (p.audioProcessor) neededApiPlugins.add("AudioPlayerAPI");
if (p.userAreaButton) neededApiPlugins.add("UserAreaAPI");
if (p.renderProfileCollection) neededApiPlugins.add("ProfileCollectionsAPI");
if (p.chatBarButtonWrapper) neededApiPlugins.add("ChatInputButtonAPI");
if (p.renderProfileSection) neededApiPlugins.add("ProfileSectionsAPI");
for (const key of pluginKeysToBind) {
p[key] &&= p[key].bind(p) as any;
@ -409,22 +441,11 @@ export const initPluginManager = onlyOnce(function init() {
for (const p of pluginsValues) {
if (p.settings) {
p.options ??= {};
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 name in p.options) {
const opt = p.options[name];
if (opt.onChange != null) {
SettingsStore.addChangeListener(`plugins.${p.name}.${name}`, opt.onChange);
}
for (const [key, def] of Object.entries(p.settings.def)) {
if (def.onChange)
SettingsStore.addChangeListener(`plugins.${p.name}.${key}`, def.onChange);
}
}

View file

@ -6,12 +6,13 @@
import ErrorBoundary from "@components/ErrorBoundary";
import { Logger } from "@utils/Logger";
import { User } from "@vencord/discord-types";
import { useEffect, useState } from "@webpack/common";
import type { ReactNode } from "react";
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 {
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,
}
const componentsIn = new Set<ComponentType>();
const componentsAbove = new Set<ComponentType>();
const componentsBelow = new Set<ComponentType>();
const componentsIn = new Map<ComponentType, number>();
const componentsAbove = new Map<ComponentType, number>();
const componentsBelow = new Map<ComponentType, number>();
function getRenderFunctions(position: ServerListRenderPosition) {
function getRenderMap(position: ServerListRenderPosition) {
switch (position) {
case ServerListRenderPosition.Above:
return componentsAbove;
@ -40,21 +40,20 @@ function getRenderFunctions(position: ServerListRenderPosition) {
}
}
export function addServerListElement(position: ServerListRenderPosition, renderFunction: ComponentType) {
getRenderFunctions(position).add(renderFunction);
export function addServerListElement(position: ServerListRenderPosition, renderFunction: ComponentType, priority = 0) {
getRenderMap(position).set(renderFunction, priority);
}
export function removeServerListElement(position: ServerListRenderPosition, renderFunction: ComponentType) {
getRenderFunctions(position).delete(renderFunction);
getRenderMap(position).delete(renderFunction);
}
export const renderAll = (position: ServerListRenderPosition) => {
return Array.from(
getRenderFunctions(position),
(Component, i) => (
return Array.from(getRenderMap(position).entries())
.sort((a, b) => b[1] - a[1])
.map(([Component], i) => (
<ErrorBoundary noop key={i}>
<Component />
</ErrorBoundary>
)
);
));
};

View file

@ -113,7 +113,7 @@ const DefaultSettings: Settings = {
autoUpdateNotification: true,
useQuickCss: true,
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: [],
enabledThemeLinks: [],
enableOnlineThemes: true,
@ -178,7 +178,7 @@ export const SettingsStore = new SettingsStoreClass(settings, {
if (path.startsWith("plugins.")) {
const plugin = path.slice("plugins.".length);
if (plugin in plugins) {
const setting = plugins[plugin].options?.[key];
const setting = plugins[plugin].settings?.def[key];
if (!setting) return v;
if ("default" in setting)
@ -208,7 +208,7 @@ if (!IS_REPORTER) {
* 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
* 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
* handling for default values, use {@link Settings}
*/
@ -353,7 +353,13 @@ export function definePluginSettings<
Checks extends SettingsChecks<Def>,
PrivateSettings extends object = {}
>(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() {
if (!definedSettings.pluginName) throw new Error("Cannot access settings before plugin is initialized");
return Settings.plugins[definedSettings.pluginName] as any;
@ -368,11 +374,10 @@ export function definePluginSettings<
: [`plugins.${definedSettings.pluginName}.*`]
) as UseSettings<Settings>[]).plugins[definedSettings.pluginName] as any,
def,
checks: checks ?? {} as any,
pluginName: "",
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 MANIFEST_STORE_KEY = "Vencord_cloudManifest";
const API_VERSION_STORE_KEY = "Vencord_cloudApiVersions";
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> {
return apiVersion;
const map = await loadApiVersionMap();
return map[getCloudUrl().origin] ?? "v2";
}
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 {

View file

@ -35,6 +35,7 @@ import * as $Notifications from "./Notifications";
import * as $UserArea from "./UserArea";
export * as PluginManager from "./PluginManager";
import * as $ProfileCollections from "./ProfileCollections";
import * as $ProfileSections from "./ProfileSections";
import * as $ServerList from "./ServerList";
import * as $Settings from "./Settings";
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.
*/
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/>.
*/
import { React, TextInput } from "@webpack/common";
import { React, TextInput, useState } from "@webpack/common";
interface TextInputProps {
/**
* WARNING: Changing this between renders will have no effect!
*/
value: string;
initialValue: string;
/**
* 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
* the user an error message and only calls your onChange when the input is valid
*/
export function CheckedTextInput({ value: initialValue, onChange, validate, placeholder }: TextInputProps) {
const [value, setValue] = React.useState(initialValue);
const [error, setError] = React.useState<string>();
export function CheckedTextInput({ initialValue, onChange, validate, placeholder }: TextInputProps) {
const [value, setValue] = useState(initialValue);
const [error, setError] = useState<string>();
function handleChange(v: string) {
setValue(v);
const res = validate(v);
if (res === true) {
setError(void 0);
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) {
return (
<Icon
@ -797,3 +825,171 @@ export const ComponentsIcon = (props?: any) => {
</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 { findByCodeLazy } from "@webpack";
import { ColorPicker, useMemo } from "@webpack/common";
import type tinycolor from "tinycolor2";
// TinyColor is completely unmangled and it's duplicated in two modules! Fun!
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/>.
*/
import { BaseText } from "@components/BaseText";
import ErrorBoundary from "@components/ErrorBoundary";
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 type { ComponentType, PropsWithChildren } from "react";
@ -43,13 +44,17 @@ export function wrapTab(component: ComponentType<any>, tab: string) {
export function openSettingsTabModal(Tab: ComponentType<any>) {
try {
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">
<ModalCloseButton onClick={modalProps.onClose} className="vc-settings-modal-close" />
<Tab />
</ModalContent>
</ModalRoot>
), Tab.displayName || "Settings Tab"));
), Tab.displayName || "SettingsTab"));
} catch {
handleSettingsTabError();
}

View file

@ -5,14 +5,14 @@
*/
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 { AddonCard } from "@components/settings/AddonCard";
import { classNameFactory } from "@utils/css";
import { Logger } from "@utils/Logger";
import { OptionType, Plugin } from "@utils/types";
import { Plugin } from "@utils/types";
import { React, showToast, Toasts } from "@webpack/common";
import { Settings } from "Vencord";
import { PluginMeta } from "~plugins";
@ -146,7 +146,7 @@ export function PluginCard({ plugin, disabled, onRestartNeeded, onMouseEnter, on
onClick={() => openPluginModal(plugin, onRestartNeeded)}
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")} />
: <InfoIcon className={cl("info-icon")} />
}

View file

@ -60,5 +60,25 @@
}
.vc-plugin-modal-info {
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 { generateId } from "@api/Commands";
import { hasAnyVisibleSettings, isSettingHidden } from "@api/PluginManager";
import { useSettings } from "@api/Settings";
import { BaseText } from "@components/BaseText";
import { Button } from "@components/Button";
@ -32,7 +33,7 @@ import { proxyLazy } from "@utils/lazy";
import { Margins } from "@utils/margins";
import { classes, isObjectEmpty } from "@utils/misc";
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 { findComponentByCodeLazy, findCssClassesLazy } from "@webpack";
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;
}
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) {
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
const fallbackAuthors = useMemo(() => [makeDummyUser({ username: "Loading...", id: "-1465912127305809920" })], []);
@ -104,14 +115,17 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti
}
function renderSettings() {
if (!hasSettings || !plugin.options)
const { settings } = plugin;
if (!hasSettings || !settings)
return <Paragraph>There are no settings for this plugin.</Paragraph>;
const options = Object.entries(plugin.options).map(([key, setting]) => {
if (setting.type === OptionType.CUSTOM || setting.hidden) return null;
const options = Object.entries(settings.def).map(([key, setting]) => {
if (setting.type === OptionType.CUSTOM) return null;
if (isSettingHidden(settings, setting)) return null;
function onChange(newValue: any) {
const option = plugin.options?.[key];
const option = plugin.settings!.def[key];
if (!option || option.type === OptionType.CUSTOM) return;
pluginSettings[key] = newValue;
@ -124,10 +138,10 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti
<ErrorBoundary noop key={key}>
<Component
id={key}
option={setting}
setting={setting}
onChange={debounce(onChange)}
pluginSettings={pluginSettings}
definedSettings={plugin.settings}
definedSettings={settings}
/>
</ErrorBoundary>
);
@ -167,6 +181,7 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti
<div className={cl("header-content")}>
<BaseText size="lg" weight="semibold" className={cl("title")}>{plugin.name}</BaseText>
<BaseText size="sm" className={cl("description")}>{plugin.description}</BaseText>
{!!plugin.tags?.length && <PluginTags tags={plugin.tags} />}
{!!plugin.settingsAboutComponent && (
<div className={Margins.top8}>
<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/>.
*/
import { isSettingDisabled } from "@api/PluginManager";
import { Switch } from "@components/Switch";
import { PluginOptionBoolean } from "@utils/types";
import { PluginSettingBooleanDef } from "@utils/types";
import { React, useState } from "@webpack/common";
import { resolveError, SettingProps, SettingsSection } from "./Common";
export function BooleanSetting({ option, pluginSettings, definedSettings, id, onChange }: SettingProps<PluginOptionBoolean>) {
const def = pluginSettings[id] ?? option.default;
export function BooleanSetting({ setting, pluginSettings, definedSettings, id, onChange }: SettingProps<PluginSettingBooleanDef>) {
const def = pluginSettings[id] ?? setting.default;
const [state, setState] = useState(def ?? false);
const [error, setError] = useState<string | null>(null);
function handleChange(newValue: boolean): void {
const isValid = option.isValid?.call(definedSettings, newValue) ?? true;
const isValid = setting.isValid?.call(definedSettings, newValue) ?? true;
setState(newValue);
setError(resolveError(isValid));
@ -40,11 +41,11 @@ export function BooleanSetting({ option, pluginSettings, definedSettings, id, on
}
return (
<SettingsSection tag="label" name={id} description={option.description} error={error} inlineSetting>
<SettingsSection tag="label" name={id} description={setting.description} error={error} inlineSetting>
<Switch
checked={state}
onChange={handleChange}
disabled={option.disabled?.call(definedSettings) ?? false}
disabled={isSettingDisabled(definedSettings, setting)}
/>
</SettingsSection>
);

View file

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

View file

@ -16,10 +16,10 @@
* 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";
export function ComponentSetting({ option, onChange }: ComponentSettingProps<PluginOptionComponent>) {
return option.component({ setValue: onChange, option });
export function ComponentSetting({ setting, onChange }: ComponentSettingProps<PluginSettingComponentDef>) {
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/>.
*/
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 { resolveError, SettingProps, SettingsSection } from "./Common";
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) {
if (option.type === OptionType.BIGINT) return BigInt(value);
if (setting.type === OptionType.BIGINT) return BigInt(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);
function handleChange(newValue: any) {
const isValid = option.isValid?.call(definedSettings, newValue) ?? true;
const isValid = setting.isValid?.call(definedSettings, newValue) ?? true;
setError(resolveError(isValid));
@ -41,7 +42,7 @@ export function NumberSetting({ option, pluginSettings, definedSettings, id, onC
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}`);
} else {
setState(newValue);
@ -49,15 +50,15 @@ export function NumberSetting({ option, pluginSettings, definedSettings, id, onC
}
return (
<SettingsSection name={id} description={option.description} error={error}>
<SettingsSection name={id} description={setting.description} error={error}>
<TextInput
type="number"
pattern="-?[0-9]+"
placeholder={option.placeholder ?? "Enter a number"}
placeholder={setting.placeholder ?? "Enter a number"}
value={state}
onChange={handleChange}
disabled={option.disabled?.call(definedSettings) ?? false}
{...option.componentProps}
disabled={isSettingDisabled(definedSettings, setting)}
{...setting.componentProps}
/>
</SettingsSection>
);

View file

@ -16,19 +16,20 @@
* 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 { resolveError, SettingProps, SettingsSection } from "./Common";
export function SelectSetting({ option, pluginSettings, definedSettings, onChange, id }: SettingProps<PluginOptionSelect>) {
const def = pluginSettings[id] ?? option.options?.find(o => o.default)?.value;
export function SelectSetting({ setting, pluginSettings, definedSettings, onChange, id }: SettingProps<PluginSettingSelectDef>) {
const def = pluginSettings[id] ?? setting.options?.find(o => o.default)?.value;
const [state, setState] = useState<any>(def ?? null);
const [error, setError] = useState<string | null>(null);
function handleChange(newValue: any) {
const isValid = option.isValid?.call(definedSettings, newValue) ?? true;
const isValid = setting.isValid?.call(definedSettings, newValue) ?? true;
setState(newValue);
setError(resolveError(isValid));
@ -39,17 +40,17 @@ export function SelectSetting({ option, pluginSettings, definedSettings, onChang
}
return (
<SettingsSection name={id} description={option.description} error={error}>
<SettingsSection name={id} description={setting.description} error={error}>
<Select
placeholder={option.placeholder ?? "Select an option"}
options={option.options}
placeholder={setting.placeholder ?? "Select an option"}
options={setting.options}
maxVisibleItems={5}
closeOnSelect={true}
select={handleChange}
isSelected={v => v === state}
serialize={v => String(v)}
isDisabled={option.disabled?.call(definedSettings) ?? false}
{...option.componentProps}
isDisabled={isSettingDisabled(definedSettings, setting)}
{...setting.componentProps}
/>
</SettingsSection>
);

View file

@ -16,18 +16,19 @@
* 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 { resolveError, SettingProps, SettingsSection } from "./Common";
export function SliderSetting({ option, pluginSettings, definedSettings, id, onChange }: SettingProps<PluginOptionSlider>) {
const def = pluginSettings[id] ?? option.default;
export function SliderSetting({ setting, pluginSettings, definedSettings, id, onChange }: SettingProps<PluginSettingSliderDef>) {
const def = pluginSettings[id] ?? setting.default;
const [error, setError] = useState<string | null>(null);
function handleChange(newValue: number): void {
const isValid = option.isValid?.call(definedSettings, newValue) ?? true;
const isValid = setting.isValid?.call(definedSettings, newValue) ?? true;
setError(resolveError(isValid));
@ -37,17 +38,17 @@ export function SliderSetting({ option, pluginSettings, definedSettings, id, onC
}
return (
<SettingsSection name={id} description={option.description} error={error}>
<SettingsSection name={id} description={setting.description} error={error}>
<Slider
markers={option.markers}
minValue={option.markers[0]}
maxValue={option.markers[option.markers.length - 1]}
markers={setting.markers}
minValue={setting.markers[0]}
maxValue={setting.markers[setting.markers.length - 1]}
initialValue={def}
onValueChange={handleChange}
onValueRender={(v: number) => String(v.toFixed(2))}
stickToMarkers={option.stickToMarkers ?? true}
disabled={option.disabled?.call(definedSettings) ?? false}
{...option.componentProps}
stickToMarkers={setting.stickToMarkers ?? true}
disabled={isSettingDisabled(definedSettings, setting)}
{...setting.componentProps}
/>
</SettingsSection>
);

View file

@ -16,17 +16,18 @@
* 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 { resolveError, SettingProps, SettingsSection } from "./Common";
export function TextSetting({ option, pluginSettings, definedSettings, id, onChange }: SettingProps<PluginOptionString>) {
const [state, setState] = useState(pluginSettings[id] ?? option.default ?? null);
export function TextSetting({ setting, pluginSettings, definedSettings, id, onChange }: SettingProps<PluginSettingStringDef>) {
const [state, setState] = useState(pluginSettings[id] ?? setting.default ?? null);
const [error, setError] = useState<string | null>(null);
function handleChange(newValue: string) {
const isValid = option.isValid?.call(definedSettings, newValue) ?? true;
const isValid = setting.isValid?.call(definedSettings, newValue) ?? true;
setState(newValue);
setError(resolveError(isValid));
@ -37,22 +38,22 @@ export function TextSetting({ option, pluginSettings, definedSettings, id, onCha
}
return (
<SettingsSection name={id} description={option.description} error={error}>
{option.multiline
<SettingsSection name={id} description={setting.description} error={error}>
{setting.multiline
? <TextArea
placeholder={option.placeholder ?? "Enter a value"}
placeholder={setting.placeholder ?? "Enter a value"}
value={state}
onChange={handleChange}
disabled={option.disabled?.call(definedSettings) ?? false}
{...option.componentProps} />
disabled={isSettingDisabled(definedSettings, setting)}
{...setting.componentProps} />
: <TextInput
type="text"
placeholder={option.placeholder ?? "Enter a value"}
placeholder={setting.placeholder ?? "Enter a value"}
value={state}
onChange={handleChange}
maxLength={null}
disabled={option.disabled?.call(definedSettings) ?? false}
{...option.componentProps}
disabled={isSettingDisabled(definedSettings, setting)}
{...setting.componentProps}
/>
}
</SettingsSection>

View file

@ -36,7 +36,8 @@ import { Logger } from "@utils/Logger";
import { Margins } from "@utils/margins";
import { classes } from "@utils/misc";
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 Plugins, { ExcludedPlugins, PluginMeta } from "~plugins";
@ -143,7 +144,8 @@ function ExcludedPluginsList({ search }: { search: string; }) {
export default function PluginSettings() {
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(() => {
return () => {
@ -194,33 +196,20 @@ export default function PluginSettings() {
const hasUserPlugins = useMemo(() => !IS_STANDALONE && Object.values(PluginMeta).some(m => m.userPlugin), []);
const [searchValue, setSearchValue] = useState({ value: "", status: SearchStatus.ALL });
const [searchInput, setSearchInput] = useState("");
const debouncedSetSearch = useMemo(
() => debounce((query: string) => setSearchValue(prev => ({ ...prev, value: query })), 150),
[]
);
const [searchValue, setSearchValue] = useState({ value: "", tags: [] as PluginTag[], status: SearchStatus.ALL });
const search = searchValue.value.toLowerCase();
const onSearch = useCallback((query: string) => {
setSearchInput(query);
debouncedSetSearch(query);
}, [debouncedSetSearch]);
const onStatusChange = useCallback((status: SearchStatus) => {
setSearchValue(prev => ({ ...prev, status }));
}, []);
const onSearch = (query: string) => setSearchValue(prev => ({ ...prev, value: query }));
const pluginFilter = useCallback((plugin: typeof Plugins[keyof typeof Plugins], newPluginsSet: Set<string> | null) => {
const { status } = searchValue;
const enabled = isPluginEnabled(plugin.name);
const { status, tags } = searchValue;
switch (status) {
case SearchStatus.DISABLED:
if (enabled) return false;
if (isPluginEnabled(plugin.name)) return false;
break;
case SearchStatus.ENABLED:
if (!enabled) return false;
if (!isPluginEnabled(plugin.name)) return false;
break;
case SearchStatus.EQUICORD:
if (!PluginMeta[plugin.name].folderName.startsWith("src/equicordplugins/")) return false;
@ -239,12 +228,15 @@ export default function PluginSettings() {
break;
}
if (tags.length && tags.some(t => !plugin.tags?.includes(t))) return false;
if (!search.length) return true;
return (
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.tags?.some(t => t.toLowerCase().includes(search))
plugin.searchTerms?.some(t => t.toLowerCase().includes(search))
);
}, [searchValue, search]);
@ -406,31 +398,45 @@ export default function PluginSettings() {
Filters
</HeadingTertiary>
<div className={classes(Margins.bottom20, cl("filter-controls"))}>
<ErrorBoundary noop>
<TextInput autoFocus value={searchInput} placeholder="Search for a plugin..." onChange={onSearch} />
</ErrorBoundary>
<div>
<ErrorBoundary noop>
<Select
options={[
{ label: "Show All", value: SearchStatus.ALL, default: true },
{ label: "Show Enabled", value: SearchStatus.ENABLED },
{ label: "Show Disabled", value: SearchStatus.DISABLED },
{ label: "Show Equicord", value: SearchStatus.EQUICORD },
{ label: "Show Vencord", value: SearchStatus.VENCORD },
{ label: "Show New", value: SearchStatus.NEW },
hasUserPlugins && { label: "Show UserPlugins", value: SearchStatus.USER_PLUGINS },
{ label: "Show API Plugins", value: SearchStatus.API_PLUGINS },
].filter(isTruthy)}
serialize={String}
select={onStatusChange}
isSelected={v => v === searchValue.status}
closeOnSelect={true}
/>
</ErrorBoundary>
<ErrorBoundary noop>
<TextInput
inputClassName={cl("filter-control")}
placeholder="Search for a plugin..."
value={searchValue.value}
onChange={onSearch}
autoFocus
/>
</ErrorBoundary>
<ErrorBoundary noop>
<div className={classes(Margins.bottom20, Margins.top8, cl("filter-controls"))}>
<Select
options={[
{ label: "Show All", value: SearchStatus.ALL, default: true },
{ label: "Show Enabled", value: SearchStatus.ENABLED },
{ label: "Show Disabled", value: SearchStatus.DISABLED },
{ label: "Show Equicord", value: SearchStatus.EQUICORD },
{ label: "Show Vencord", value: SearchStatus.VENCORD },
{ label: "Show New", value: SearchStatus.NEW },
hasUserPlugins && { label: "Show UserPlugins", value: SearchStatus.USER_PLUGINS },
{ label: "Show API Plugins", value: SearchStatus.API_PLUGINS },
].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>
</ErrorBoundary>
<HeadingTertiary className={Margins.top20}>Plugins</HeadingTertiary>
@ -456,6 +462,7 @@ export default function PluginSettings() {
<HeadingTertiary className={classes(Margins.top20, Margins.bottom8)}>
Required Plugins
</HeadingTertiary>
<div className={cl("grid")}>
{requiredPlugins.length
? requiredPlugins

View file

@ -45,7 +45,12 @@
display: grid;
height: 40px;
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 {

View file

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

View file

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

View file

@ -142,7 +142,7 @@ export function Updatable(props: CommonProps) {
) : isOutdated ? (
<>
<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>
<Changes updates={updates} {...props} />
</>

View file

@ -20,14 +20,14 @@ import { Paragraph } from "@components/Paragraph";
import { openContributorModal, openPluginModal, SettingsTab, wrapTab } from "@components/settings";
import { QuickAction, QuickActionCard } from "@components/settings/QuickAction";
import { SpecialCard } from "@components/settings/SpecialCard";
import BadgeAPI from "@plugins/_api/badges";
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 { classNameFactory } from "@utils/css";
import { Margins } from "@utils/margins";
import { identity, isAnyPluginDev } from "@utils/misc";
import { relaunch } from "@utils/native";
import { GuildMemberStore, React, Select, UserStore } from "@webpack/common";
import BadgeAPI from "plugins/_api/badges";
import { Alerts, GuildMemberStore, React, Select, UserStore } from "@webpack/common";
import { DonateButtonComponent } from "./DonateButton";
import { openNotificationSettingsModal } from "./NotificationSettings";
@ -64,45 +64,38 @@ function EquicordSettings() {
title: string;
description?: string;
restartRequired?: boolean;
warning: { enabled: boolean; message?: string; };
}
> = [
warning?: string;
}>
= [
{
key: "useQuickCss",
title: "Enable Custom CSS",
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 && {
key: "enableReactDevtools",
title: "Enable React Developer Tools",
description: "Enable the React Developer Tools extension for debugging Discord's React components. Useful for plugin development.",
restartRequired: true,
warning: { enabled: false },
},
(!IS_WEB && !IS_DISCORD_DESKTOP || !IS_WINDOWS) && {
key: "mainWindowFrameless",
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.",
restartRequired: true,
warning: { enabled: false },
},
!IS_WEB &&
(!IS_DISCORD_DESKTOP || !IS_WINDOWS
!IS_WEB && (!IS_DISCORD_DESKTOP || !IS_WINDOWS
? {
key: "frameless",
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.",
restartRequired: true,
warning: { enabled: false },
}
: {
key: "winNativeTitleBar",
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.",
restartRequired: true,
warning: { enabled: false },
}
),
!IS_WEB && {
@ -110,27 +103,21 @@ function EquicordSettings() {
title: "Enable Window Transparency",
description: "Make the Discord window transparent. A theme that supports transparency is required or this will do nothing.",
restartRequired: true,
warning: {
enabled: true,
message: IS_WINDOWS
? "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.",
},
warning: IS_WINDOWS
? "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 && {
key: "disableMinSize",
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.",
restartRequired: true,
warning: { enabled: false },
},
!IS_WEB &&
IS_WINDOWS && {
!IS_WEB && IS_WINDOWS && {
key: "winCtrlQ",
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.",
restartRequired: true,
warning: { enabled: false },
},
];
@ -249,14 +236,26 @@ function EquicordSettings() {
<FormSwitch
key={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}
description={
s.warning.enabled ? (
s.warning ? (
<>
{s.description}
<Notice.Warning className={Margins.top8} style={{ width: "100%" }}>
{s.warning.message}
{s.warning}
</Notice.Warning>
</>
) : (

View file

@ -9,16 +9,16 @@ import { canonicalizeMatch } from "@utils/patches";
import { ModuleFactory } from "@vencord/discord-types/webpack";
import * as Webpack from "@webpack";
import { wreq } from "@webpack";
import { AnyModuleFactory } from "@webpack/types";
import pLimit from "p-limit";
import { AnyModuleFactory } from "webpack";
function getWebpackChunkMap() {
const sym = Symbol();
let v: Record<PropertyKey, string> | null = null;
let chunksMap: unknown;
Object.defineProperty(Object.prototype, sym, {
get() {
v = this;
chunksMap = this;
return "";
},
configurable: true
@ -27,7 +27,7 @@ function getWebpackChunkMap() {
wreq.u(sym);
delete Object.prototype[sym];
return v;
return chunksMap as Record<PropertyKey, string> | null;
}
export async function loadLazyChunks() {
@ -54,7 +54,10 @@ export async function loadLazyChunks() {
async function searchAndLoadLazyChunks(factoryCode: string) {
// 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 validChunkGroups = new Set<[chunkIds: PropertyKey[], entryPoint: PropertyKey]>();
@ -66,7 +69,7 @@ export async function loadLazyChunks() {
?.matchAll(Webpack.ChunkIdsRegex)
.map(m => {
const numChunkId = Number(m[1]);
return Number.isNaN(numChunkId) ? m[1] : numChunkId;
return Number.isNaN(numChunkId) ? m[1] : String(numChunkId);
})
.toArray()
?? [];
@ -107,7 +110,7 @@ export async function loadLazyChunks() {
if (!invalidChunkGroup) {
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
const chunkMap = getWebpackChunkMap();
if (!chunkMap) throw new Error("Failed to get chunk map");
const chunksMap = getWebpackChunkMap();
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");
// Chunks which our regex could not catch to load

View file

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

View file

@ -25,8 +25,8 @@ async function runReporter() {
addPatch({
find: '"Could not find app-mount"',
replacement: {
match: /(?<="use strict";)/,
replace: "Vencord.Webpack._initReporter();"
match: /"Could not find app-mount"/,
replace: "(Vencord.Webpack._initReporter(),$&)"
}
}, "Equicord Reporter");
@ -39,6 +39,13 @@ async function runReporter() {
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) {
console.log("[REPORTER_META]", {
buildNumber: getBuildNumber(),
@ -55,7 +62,7 @@ async function runReporter() {
}
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}`);
}
}
@ -70,7 +77,6 @@ async function runReporter() {
else method = "find";
}
if (searchType === "waitForStore") method = "findStore";
if (searchType === "waitForStore" && args[0] === "PermissionStore") continue;
let result: any;
try {

View file

@ -13,7 +13,7 @@ export default definePlugin({
authors: [Devs.thororen],
patches: [
{
find: /\.POPOUT,onClose:\i}\),nicknameIcons:.+?\.isProvisional/,
find: "#{intl::USER_PROFILE_FRIEND_REQUEST_TOAST}",
replacement: {
match: /user:\i,widgets:.{0,100}?\}\),/,
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)",
replacement: [
{
match: /(?<=className:(\i)\.\i,style:\i,)children:\[/,
replace: "children:[...$self.renderButtons(arguments[0],$1),"
match: /children:\[(?=.{0,50}accountContainerRef:\i)/,
replace: "children:[...$self.renderButtons(arguments[0]),"
},
// 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({
name: "AltKrispSwitch",
description: "Makes the Noise Suppression Popout switch between None and Krisp instead of Krisp and Standard",
tags: ["Customisation", "Voice"],
authors: [Devs.newwares],
patches: [
{

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -20,7 +20,7 @@ export default definePlugin({
name: "BetterActivities",
description: "Shows activity icons in the member list and allows showing all activities",
authors: [Devs.D3SOX, Devs.Arjix, Devs.AutumnVN, Devs.thororen],
tags: ["activity"],
tags: ["Activity"],
settings,
patchActivityList,
showAllActivitiesComponent,
@ -30,7 +30,12 @@ export default definePlugin({
find: '"ActivityStatus"),',
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: "",
predicate: () => settings.store.removeGameActivityStatus,
},
@ -45,7 +50,7 @@ export default definePlugin({
// Show all activities in the user popout/sidebar
find: /onOpenUserProfileModal:\i,onClose:\i\}\),null/,
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 })"
},
predicate: () => settings.store.userPopout

View file

@ -5,6 +5,7 @@
*/
import ErrorBoundary from "@components/ErrorBoundary";
import { Activity } from "@vencord/discord-types";
import { findComponentByCodeLazy } from "@webpack";
import { React, Tooltip } from "@webpack/common";
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
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[] = [];
if (user.bot || settings.store.hideTooltip && hideTooltip) return null;
const activities = rawActivities.filter((a): a is Activity => a != null);
const applicationIcons = getApplicationIcons(activities);
if (applicationIcons.length) {
const compareImageSource = (a: ApplicationIcon, b: ApplicationIcon) => {

View file

@ -24,7 +24,7 @@ export function showAllActivitiesComponent({ activity, user, ...props }: Readonl
const activities = useStateFromStores(
[PresenceStore],
() => PresenceStore.getActivities(user.id).filter((activity: Activity) => activity.type !== 4)
() => PresenceStore.getActivities(user.id).filter((activity: Activity) => activity != null && activity.type !== 4)
);
useEffect(() => {
@ -33,8 +33,12 @@ export function showAllActivitiesComponent({ activity, user, ...props }: Readonl
return;
}
if (!currentActivity || !activities.includes(currentActivity))
const existing = currentActivity && activities.find(a => a.id === currentActivity.id);
if (!existing) {
setCurrentActivity(activities[0]);
} else if (existing !== currentActivity) {
setCurrentActivity(existing);
}
}, [activities]);
// 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 (settings.store.allActivitiesStyle === "carousel") {
if (!currentActivity) return null;
return (
<ErrorBoundary noop>
<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[] {
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) {
const { assets, application_id, platform, id } = activity;

View file

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

View file

@ -9,48 +9,82 @@ import "./styles.css";
import { definePluginSettings } from "@api/Settings";
import { Heading } from "@components/Heading";
import { DeleteIcon, PlusIcon } from "@components/Icons";
import { Devs } from "@utils/constants";
import { Devs, EquicordDevs } from "@utils/constants";
import { classNameFactory } from "@utils/css";
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-");
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() {
const { reasons } = settings.store;
const [reasons, setReasons] = useState<BanReason[]>(getStoredReasons);
const update = (next: BanReason[]) => {
setReasons(next);
settings.store.reasons = next;
};
return (
<section>
<Heading>Reasons</Heading>
{reasons.map((r, i) => (
<div
key={i}
className={cl("reason-wrapper")}
>
<div key={i} className={cl("reason-wrapper")}>
<TextInput
value={r}
onChange={v => {
reasons[i] = v;
settings.store.reasons = reasons;
}}
value={r.text}
onChange={v => update(reasons.map((x, j) => j === i ? { ...x, text: v } : x))}
placeholder="Reason"
/>
<Button
className={cl("remove-button")}
color={Button.Colors.TRANSPARENT}
onClick={() => {
reasons.splice(i, 1);
settings.store.reasons = reasons;
}}
onClick={() => update(reasons.filter((_, j) => j !== i))}
look={Button.Looks.FILLED}
size={Button.Sizes.MIN}
>
<DeleteIcon />
</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 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
</Button>
</div>
@ -62,7 +96,7 @@ const settings = definePluginSettings({
reasons: {
description: "Your custom reasons",
type: OptionType.COMPONENT,
default: [] as string[],
default: [] as BanReason[],
component: ReasonsComponent,
},
isTextInputDefault: {
@ -74,30 +108,57 @@ const settings = definePluginSettings({
export default definePlugin({
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.",
authors: [Devs.Inbestigator],
tags: ["Appearance", "Customisation"],
authors: [Devs.Inbestigator, EquicordDevs.yonn2222],
durationSetter: null as ((v: number) => void) | null,
patches: [
{
find: "#{intl::BAN_REASON_OPTION_SPAM_ACCOUNT}",
replacement: [{
match: /(\[\{name:\i\.\i\.\i\(\i\.\i\.\i\),.+?"other"\}\])/,
replace: "$self.getReasons($1)"
},
{
match: /useState\(null\)(?=.{0,300}targetUserId:)/,
replace: "useState($self.getDefaultState())"
}]
replacement: [
{
match: /(\[\{name:\i\.\i\.\i\(\i\.\i\.\i\),.+?"other"\}\])/,
replace: "$self.getReasons($1)"
},
{
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());
const reasons: string[] = storedReasons.length
? storedReasons
: [];
getReasons(defaults: { name: string; value: string; }[]) {
const stored = getStoredReasons().filter(r => r.text.trim());
return [
...reasons.map(s => ({ name: s, value: s })),
...defaults
...stored.map(r => ({ name: r.text, value: r.text })),
...defaults,
];
},
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,
});

View file

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

View file

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

View file

@ -1,3 +1,8 @@
[class*="usersList"] [class*="text"] {
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({
name: "BetterCommands",
description: "Enhances the command system with miscellaneous improvements.",
dependencies: ["CommandsAPI"],
tags: ["Appearance", "Commands", "Shortcuts"],
authors: [Devs.thororen],
settings,
patches: [

View file

@ -34,13 +34,14 @@ function lurk(id: string) {
export default definePlugin({
name: "BetterInvites",
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],
patches: [
{
find: "#{intl::xdCLeM::raw}",
replacement: [
{
match: /profile:\i\}\),.{0,15}profile:\i/,
match: /profile:\i\}\),.{0,20}profile:\i(?=.{0,20}profile:\i)/,
replace: "$&,invite:arguments[0].invite"
}
]
@ -85,8 +86,11 @@ export default definePlugin({
);
},
Header(inviter: User | undefined, guildName: string) {
if (!inviter) return null;
const userId = UserStore.getCurrentUser().id;
if (!inviter || userId === inviter.id) return null;
const isSelf = userId === inviter.id;
return (
<div className="vc-bi-header-inner">
<img
@ -98,7 +102,9 @@ export default definePlugin({
: "/assets/1f0bfc0865d324c2587920a7d80c609b.png?size=128"}
/>
<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>
);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -157,6 +157,7 @@ const settings = definePluginSettings({
export default definePlugin({
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.",
tags: ["Activity", "Customisation", "Notifications", "Servers"],
authors: [Devs.Inbestigator],
dependencies: ["AudioPlayerAPI"],
flux: {

View file

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

View file

@ -7,7 +7,7 @@
import { BaseText } from "@components/BaseText";
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 { activeQuestIntervals } from "@equicordplugins/questify"; // sorry murphy!
import { getActiveAutoCompletes } from "@equicordplugins/questify/utils/completion";
import { classNameFactory } from "@utils/css";
import { getGuildAcronym, getIntlMessage, getUniqueUsername } from "@utils/discord";
import { classes } from "@utils/misc";
@ -467,20 +467,19 @@ export default function ChannelTab(props: ChannelTabsProps & { index: number; })
}), []);
drag(drop(ref));
// check if quests running (questify momentLet)
const hasActiveQuests = activeQuestIntervals.size > 0;
const hasActiveQuests = getActiveAutoCompletes().length > 0;
return <div
className={cl("tab", {
"tab-compact": compact,
"tab-selected": isTabSelected(id),
"tab-entering": isEntering,
"tab-closing": isClosing,
"tab-dragging": isDragging,
"tab-drop-target": isDropTarget,
"tab-nitro": channelId === "__nitro__",
"tab-quests-active": channelId === "__quests__" && hasActiveQuests,
wider: settings.store.widerTabsAndBookmarks
})}
"tab-compact": compact,
"tab-selected": isTabSelected(id),
"tab-entering": isEntering,
"tab-closing": isClosing,
"tab-dragging": isDragging,
"tab-drop-target": isDropTarget,
"tab-nitro": channelId === "__nitro__",
"tab-quests-active": channelId === "__quests__" && hasActiveQuests,
wider: settings.store.widerTabsAndBookmarks
})}
key={index}
ref={ref}
onMouseEnter={() => setIsHovered(true)}

View file

@ -33,6 +33,7 @@ export default function ChannelsTabsContainer(props: BasicChannelTabsProps) {
showBookmarkBar,
widerTabsAndBookmarks,
tabWidthScale,
tabHeightScale,
enableNumberKeySwitching,
numberKeySwitchCount,
enableCloseTabShortcut,
@ -65,6 +66,7 @@ export default function ChannelsTabsContainer(props: BasicChannelTabsProps) {
"showBookmarkBar",
"widerTabsAndBookmarks",
"tabWidthScale",
"tabHeightScale",
"enableNumberKeySwitching",
"numberKeySwitchCount",
"enableCloseTabShortcut",
@ -288,6 +290,16 @@ export default function ChannelsTabsContainer(props: BasicChannelTabsProps) {
if (isFullscreen) return null;
const shouldFollowNewTabButton = newTabButtonBehavior && !tabsOverflow;
const newTabButton = (
<button
onClick={() => createTab(props, true)}
className={cl("button", "new-button", "hoverable")}
>
<PlusSmallIcon />
</button>
);
return (
<div
className={classes(
@ -312,7 +324,7 @@ export default function ChannelsTabsContainer(props: BasicChannelTabsProps) {
!compactAutoExpandOnHover && cl("no-compact-hover-expand")
)}
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 />)}
>
{showBookmarkBar && <>
@ -322,20 +334,16 @@ export default function ChannelsTabsContainer(props: BasicChannelTabsProps) {
<div className={cl("tab-container")}>
<HorizontalScroller
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) =>
<ChannelTab {...tab} index={i} key={tab.id} />
)}
{GhostTabs}
{shouldFollowNewTabButton && newTabButton}
</HorizontalScroller>
<button
onClick={() => createTab(props, true)}
className={cl("button", "new-button", "hoverable")}
>
<PlusSmallIcon />
</button>
{!shouldFollowNewTabButton && newTabButton}
</div >
</div>

View file

@ -15,8 +15,8 @@ import { ChannelStore, Menu } from "@webpack/common";
import { JSX } from "react";
import ChannelsTabsContainer from "./components/ChannelTabsContainer";
import { BasicChannelTabsProps, createTab, handleChannelSwitch, settings } from "./util";
import * as ChannelTabsUtils from "./util";
import { BasicChannelTabsProps, createTab, handleChannelSwitch, settings } from "./util";
const contextMenuPatch: NavContextMenuPatchCallback = (children, props: { channel: Channel, messageId?: string; }) => {
const { channel, messageId } = props;
@ -47,7 +47,8 @@ const contextMenuPatch: NavContextMenuPatchCallback = (children, props: { channe
export default definePlugin({
name: "ChannelTabs",
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"],
contextMenus: {
"channel-mention-context": contextMenuPatch,

View file

@ -212,7 +212,8 @@
border-color 0.15s ease,
box-shadow 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;
cursor: grab;
flex-shrink: 0;
@ -1338,4 +1339,4 @@
/* disable transitions when compact expand animation is disabled */
.vc-channeltabs-no-compact-animation .vc-channeltabs-tab-number-badge {
transition: none !important;
}
}

View file

@ -39,11 +39,9 @@ function AnimationSettings(): JSX.Element {
{ label: "Active Quests Gradient", value: "quests-active", selected: settings.store.animationQuestsActive }
];
const [currentValue, setCurrentValue] = useState(animationOptions.filter(option => option.selected));
function updateSettingsTruthy(enabled: DynamicDropdownSettingOption[]) {
const enabledValues = enabled.map(option => option.value);
const [currentValue, setCurrentValue] = useState(animationOptions.filter(option => option.selected).map(option => option.value));
function updateSettingsTruthy(enabledValues: string[]) {
animationOptions.forEach(option => {
option.selected = enabledValues.includes(option.value);
});
@ -64,23 +62,24 @@ function AnimationSettings(): JSX.Element {
settings.store.animationResizeHandle = enabledValues.includes("resize-handle");
settings.store.animationQuestsActive = enabledValues.includes("quests-active");
setCurrentValue(enabled);
setCurrentValue(enabledValues);
}
function handleChange(values: Array<DynamicDropdownSettingOption | string>) {
if (values.length === 0) {
updateSettingsTruthy([]);
const valueStrings = values.map(v => typeof v === "string" ? v : v.value);
const toggled = valueStrings.length > currentValue.length
? valueStrings.find(v => !currentValue.includes(v))
: currentValue.find(v => !valueStrings.includes(v));
if (toggled == null) {
updateSettingsTruthy(valueStrings);
return;
}
const stringlessValues = values.filter(v => typeof v !== "string") as DynamicDropdownSettingOption[];
const selectedOption = values.find(v => typeof v === "string") as string;
const option = animationOptions.find(option => option.value === selectedOption) as DynamicDropdownSettingOption;
if (option.selected) {
updateSettingsTruthy(stringlessValues.filter(v => v.value !== selectedOption));
if (currentValue.includes(toggled)) {
updateSettingsTruthy(currentValue.filter(v => v !== toggled));
} else {
updateSettingsTruthy([...stringlessValues, option]);
updateSettingsTruthy([...currentValue, toggled]);
}
}
@ -179,6 +178,12 @@ export const settings = definePluginSettings({
hidden: true,
restartNeeded: false
},
tabHeightScale: {
type: OptionType.NUMBER,
description: "Tab height scale (percentage).",
default: 100,
restartNeeded: false
},
renderAllTabs: {
type: OptionType.BOOLEAN,
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
*/
import { iconsModule } from "@equicordplugins/_core/concatenatedModules";
import { Icon } from "@vencord/discord-types";
import { findByPropsLazy } from "@webpack";
const iconsModule = findByPropsLazy("AngleBracketsIcon", "StaffBadgeIcon") as Record<string, Icon>;
let iconNames: string[] | undefined;

View file

@ -7,38 +7,75 @@
import { Devs } from "@utils/constants";
import definePlugin from "@utils/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({
name: "CleanChannelName",
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: [
{
find: "loadAllGuildAndPrivateChannelsFromDisk(){",
replacement: {
match: /(?<=getChannel\(\i\)\{if\(null!=\i\)return )\i\(\i\)/,
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) {
if (channel) {
channel.name = channel.name
.normalize("NFKC")
.replace(/[ᴀʙᴄᴅᴇꜰɢʜɪᴊᴋʟᴍɴᴏᴘǫʀꜱᴛᴜᴠᴡxʏᴢ]/g, match => {
const smallCapsToNormal = {
"ᴀ": "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"
};
return smallCapsToNormal[match];
})
.replace(/[^\u0020-\u007E]?\p{Extended_Pictographic}[^\u0020-\u007E]?/ug, "")
.replace(/-?[^\p{Letter}\u0020-\u007E]-?/ug, [2, 4].includes(channel.type) ? " " : "-")
.replace(/(^-|-$)/g, "")
.replace(/-+/g, "-");
}
if (channel == null) return channel;
const c = channel as any;
if (c[ORIGINAL_NAME] !== undefined) return channel;
c[ORIGINAL_NAME] = channel.name;
Object.defineProperty(channel, "name", {
configurable: true,
enumerable: true,
get() {
if (editingChannelId === channel.id) return c[ORIGINAL_NAME];
return computeClean(c[ORIGINAL_NAME], channel.type);
},
set(value: string) {
c[ORIGINAL_NAME] = value;
},
});
return channel;
}
},
});

View file

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

View file

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

View file

@ -142,7 +142,8 @@ function activeNowView(cards) {
export default definePlugin({
name: "ClientSideBlock",
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],
settings,
activeNowView,
@ -224,8 +225,8 @@ export default definePlugin({
{
find: "ACTIVE_NOW_COLUMN)",
replacement: {
match: /(\i\.\i),\{(?=\}\)\])/,
replace: '"div",{children:$self.activeNowView($1())'
match: /(__invalid_consentCard.{0,40}\()(\i),\{/,
replace: '$1"div",{children:$self.activeNowView($2())'
}
},
// mutual friends list in user profile

View file

@ -37,6 +37,12 @@ const settings = definePluginSettings({
default: true,
restartNeeded: true
},
enableSpeakingIndicators: {
type: OptionType.BOOLEAN,
description: "Enable speaking indicators",
default: true,
restartNeeded: true
},
enableAdvancedSignals: {
type: OptionType.BOOLEAN,
description: "Enable advanced clip signals (auto-clip triggers)",
@ -72,6 +78,7 @@ migratePluginSettings("ClipsEnhancements", "TimelessClips");
export default definePlugin({
name: "ClipsEnhancements",
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],
settings,
patches: [
@ -100,8 +107,8 @@ export default definePlugin({
{
find: "2026-03-clips-experiment",
replacement: {
match: /defaultConfig:\{enableClips:!\d,ignorePlatformRestriction:!\d,showClipsHeaderEntrypoint:!\d,enableScreenshotKeybind:!\d,enableVoiceOnlyClips:!\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}"
match: /defaultConfig:\{enableClips:!\d,ignorePlatformRestriction:!\d,enableScreenshotKeybind:!\d,enableVoiceOnlyClips:!\d,enableSpeakingIndicators:!\d,enableAdvancedSignals:!\d\}/,
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 { NotesIcon } from "@components/Icons";
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 { addScheduledMessage, getChannelDisplayInfo, getScheduledMessages, removeScheduledMessage, sendScheduledMessageNow, updateScheduledMessageTime } from "@equicordplugins/scheduledMessages/utils";
import { sleep } from "@utils/misc";
@ -21,17 +19,11 @@ import { DISCORD_INTERNAL_HOSTS, executeCommand, getCommandById } from "../regis
import { makeIconFromUrl } from "../ui/iconFromUrl";
import { sendMessageToChannel, sendMessageToUser } from "./sendMessageAction";
interface ResolvedHolyNote {
notebook: string;
note: Note;
}
interface StagedRescheduledMessage {
id: string;
label: string;
}
let stagedMoveNote: ResolvedHolyNote | null = null;
let stagedRescheduleMessage: StagedRescheduledMessage | 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[] {
const parsed = parseQuickScheduleTarget(target);
if (!parsed) return [];
@ -926,15 +726,11 @@ function buildCancelScheduledMessageCandidates(target: string): QueryActionCandi
export function buildQueryResolution(query: string): QueryResolution {
const parsed = parseQuery(query);
if (!parsed) {
stagedMoveNote = null;
stagedRescheduleMessage = null;
pendingCancelScheduledMessageId = null;
return { type: "none" };
}
if (parsed.intent !== "move_note") {
stagedMoveNote = null;
}
if (parsed.intent !== "reschedule_message") {
stagedRescheduleMessage = null;
}
@ -971,14 +767,6 @@ export function buildQueryResolution(query: string): QueryResolution {
return { type: "candidates", candidates: buildTogglePluginCandidates(parsed.target) };
case "open_url":
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":
return { type: "candidates", candidates: buildQuickScheduleMessageCandidates(parsed.target) };
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
}
];
}

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