Merge branch 'dev' into bun

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

View file

@ -55,17 +55,20 @@ jobs:
rm release/package.json
rm release/*.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"
git push --force https://$USERNAME:$GITHUB_TOKEN@github.com/$REPO.git

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

@ -41,7 +41,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
cp desktop/* release
@ -56,6 +56,7 @@ jobs:
id: date
run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
- 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

@ -74,12 +74,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: bunx @equicord/publish-browser-extension@latest --chrome-zip dist/extension-chrome.zip --firefox-zip dist/extension-firefox.zip --firefox-sources-zip dist/extension-sources.zip --edge-zip dist/extension-chrome.zip
if: steps.check.outputs.release == 'true' && !vars.ACT
run: bunx @equicord/publish-browser-extension@latest --chrome-zip
dist/extension-chrome.zip --firefox-zip
dist/extension-firefox.zip --firefox-sources-zip
dist/extension-sources.zip --edge-zip
dist/extension-chrome.zip
env:
CHROME_EXTENSION_ID: ${{ secrets.CHROME_EXTENSION_ID }}
CHROME_PUBLISHER_ID: ${{ secrets.CHROME_PUBLISHER_ID }}

View file

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

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": {
@ -59,47 +59,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",
"@electron/asar": "^4.2.0",
"@stylistic/eslint-plugin": "^5.10.0",
"@types/bun": "^1.3.9",
"@types/chrome": "^0.1.30",
"@types/diff": "^7.0.2",
"@types/lodash": "^4.17.20",
"@types/node": "^24.10.1",
"@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": "workspace:*",
"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",
"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",
"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": "bun@1.3.9",

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

View file

@ -78,7 +78,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

@ -138,7 +138,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

@ -80,7 +80,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

@ -37,6 +37,7 @@ export interface PluginData {
name: string;
description: string;
tags: string[];
searchTerms: string[];
authors: Dev[];
dependencies: string[];
hasPatches: boolean;
@ -151,7 +152,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) {
@ -207,9 +209,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 { popNotice, 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, isOutdated as getIsOutdated, 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
}
];
}

View file

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

View file

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

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