Merge branch 'dev' into tray-stuff

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

View file

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

View file

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

View file

@ -20,10 +20,10 @@ jobs:
- uses: pnpm/action-setup@v3 - uses: pnpm/action-setup@v3
- name: Use Node.js 20 - name: Use Node.js 24
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 24
cache: "pnpm" cache: "pnpm"
- name: Install dependencies - name: Install dependencies
@ -50,7 +50,7 @@ jobs:
cp browser/browser.* release cp browser/browser.* release
cp Equicord.user.{js,js.LEGAL.txt} release cp Equicord.user.{js,js.LEGAL.txt} release
# copy the plugin data jsons, the extension zips and the desktop/vesktop asars # copy the plugin data jsons, the extension zips and the desktop/equibop asars
cp *.{json,zip,asar} release cp *.{json,zip,asar} release
# legacy un-asared files # legacy un-asared files
@ -69,6 +69,7 @@ jobs:
run: echo "::set-output name=date::$(date +'%Y-%m-%d')" run: echo "::set-output name=date::$(date +'%Y-%m-%d')"
- name: Upload Equicord Stable - name: Upload Equicord Stable
if: ${{ !vars.ACT }}
run: | run: |
gh release create ${{ steps.date.outputs.date }} --latest=false gh release create ${{ steps.date.outputs.date }} --latest=false
gh release upload ${{ steps.date.outputs.date }} --clobber dist/release/* gh release upload ${{ steps.date.outputs.date }} --clobber dist/release/*

View file

@ -62,11 +62,11 @@ jobs:
if: steps.check.outputs.release == 'true' if: steps.check.outputs.release == 'true'
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
- name: Use Node.js 20 - name: Use Node.js 24
if: steps.check.outputs.release == 'true' if: steps.check.outputs.release == 'true'
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 24
cache: "pnpm" cache: "pnpm"
- name: Install dependencies - name: Install dependencies
@ -79,12 +79,16 @@ jobs:
- name: Create sources zip - name: Create sources zip
if: steps.check.outputs.release == 'true' if: steps.check.outputs.release == 'true'
run: zip -r dist/extension-sources.zip . -x "node_modules/*" -x "dist/*" -x ".git/*" run: zip -r dist/extension-sources.zip . -x "node_modules/*" -x "dist/*" -x
".git/*"
- name: Publish extension - name: Publish extension
if: steps.check.outputs.release == 'true' if: steps.check.outputs.release == 'true' && !vars.ACT
run: pnpx @equicord/publish-browser-extension@latest --chrome-zip dist/extension-chrome.zip --firefox-zip dist/extension-firefox.zip --firefox-sources-zip dist/extension-sources.zip --edge-zip dist/extension-chrome.zip run: pnpx @equicord/publish-browser-extension@latest --chrome-zip
dist/extension-chrome.zip --firefox-zip
dist/extension-firefox.zip --firefox-sources-zip
dist/extension-sources.zip --edge-zip
dist/extension-chrome.zip
env: env:
CHROME_EXTENSION_ID: ${{ secrets.CHROME_EXTENSION_ID }} CHROME_EXTENSION_ID: ${{ secrets.CHROME_EXTENSION_ID }}
CHROME_PUBLISHER_ID: ${{ secrets.CHROME_PUBLISHER_ID }} CHROME_PUBLISHER_ID: ${{ secrets.CHROME_PUBLISHER_ID }}

View file

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

View file

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

3
.gitignore vendored
View file

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

View file

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

View file

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

View file

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

1857
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -60,5 +60,25 @@
} }
.vc-plugin-modal-info { .vc-plugin-modal-info {
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; align-items: center;
} border: 1px solid var(--border-normal);
border-radius: 9999px;
display: flex;
justify-content: center;
padding: 6px 12px;
color: var(--text-default);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -212,7 +212,8 @@
border-color 0.15s ease, border-color 0.15s ease,
box-shadow 0.15s ease, box-shadow 0.15s ease,
opacity 0.15s ease; opacity 0.15s ease;
padding: 6px 8px; min-height: calc(38px * var(--tab-height-scale, 1));
padding: calc(6px * var(--tab-height-scale, 1)) 8px;
position: relative; position: relative;
cursor: grab; cursor: grab;
flex-shrink: 0; flex-shrink: 0;
@ -1338,4 +1339,4 @@
/* disable transitions when compact expand animation is disabled */ /* disable transitions when compact expand animation is disabled */
.vc-channeltabs-no-compact-animation .vc-channeltabs-tab-number-badge { .vc-channeltabs-no-compact-animation .vc-channeltabs-tab-number-badge {
transition: none !important; transition: none !important;
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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