diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0f4c12f74..23bfac60c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -55,17 +55,20 @@ jobs: rm release/package.json rm release/*.map - name: Upload Equicord Stable + if: ${{ !vars.ACT }} run: | gh release upload latest --clobber dist/release/* - name: Upload Plugin JSONs to Equibored repo + if: github.repository == 'Equicord/Equicord' && !vars.ACT run: | git config --global user.name "GitHub-Actions" git config --global user.email actions@github.com git clone https://$USERNAME:$GITHUB_TOKEN@github.com/$REPO.git plugins cd plugins - cp ../dist/release/*.json . + cp ../dist/release/*plugins.json . + cp ../dist/release/devs.json . git add -A git commit -m "Plugins for https://github.com/$GITHUB_REPOSITORY/commit/$GITHUB_SHA" git push --force https://$USERNAME:$GITHUB_TOKEN@github.com/$REPO.git diff --git a/.github/workflows/buildDev.yml b/.github/workflows/buildBranches.yml similarity index 94% rename from .github/workflows/buildDev.yml rename to .github/workflows/buildBranches.yml index 6ae23fef9..f321da1f0 100644 --- a/.github/workflows/buildDev.yml +++ b/.github/workflows/buildBranches.yml @@ -1,9 +1,10 @@ -name: Build +name: Build Branches on: push: branches: - "**" + - "!pr-**" workflow_dispatch: inputs: branch: @@ -28,6 +29,7 @@ jobs: Build: name: Build Equicord runs-on: ubuntu-latest + if: github.repository == 'Equicord/Equicord' steps: - uses: actions/checkout@v4 @@ -37,11 +39,11 @@ jobs: - uses: pnpm/action-setup@v3 if: github.ref_name != 'bun' - - name: Use Node.js 20 + - name: Use Node.js 24 if: github.ref_name != 'bun' uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 cache: "pnpm" - name: Install dependencies @@ -64,9 +66,8 @@ jobs: pnpm generateVencordPluginJson dist/vencordplugins.json pnpm generateDevsList dist/devs.json - - uses: actions/checkout@v4 - if: github.ref_name == 'bun' - uses: oven-sh/setup-bun@v2 + if: github.ref_name == 'bun' with: bun-version: latest - name: Install dependencies @@ -90,6 +91,7 @@ jobs: bun run generateDevsList dist/devs.json - name: Upload to Equibuilds repo + if: ${{ !vars.ACT }} run: | BRANCH="${{ github.event.inputs.branch || github.ref_name }}" COMMIT_SHA=$(git rev-parse --short HEAD) diff --git a/.github/workflows/nixosBuild.yml b/.github/workflows/nixosBuild.yml index 92f0b74cd..a0a19cb4a 100644 --- a/.github/workflows/nixosBuild.yml +++ b/.github/workflows/nixosBuild.yml @@ -41,7 +41,7 @@ jobs: cp browser/browser.* release cp Equicord.user.{js,js.LEGAL.txt} release - # copy the plugin data jsons, the extension zips and the desktop/vesktop asars + # copy the plugin data jsons, the extension zips and the desktop/equibop asars cp *.{json,zip,asar} release # legacy un-asared files cp desktop/* release @@ -56,6 +56,7 @@ jobs: id: date run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT - name: Upload Equicord Stable + if: ${{ !vars.ACT }} run: | gh release create ${{ steps.date.outputs.date }} --latest=false gh release upload ${{ steps.date.outputs.date }} --clobber dist/release/* diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bed63bea2..89f2f38bb 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -74,12 +74,16 @@ jobs: - name: Create sources zip if: steps.check.outputs.release == 'true' - run: zip -r dist/extension-sources.zip . -x "node_modules/*" -x "dist/*" -x ".git/*" + run: zip -r dist/extension-sources.zip . -x "node_modules/*" -x "dist/*" -x + ".git/*" - name: Publish extension - if: steps.check.outputs.release == 'true' - run: bunx @equicord/publish-browser-extension@latest --chrome-zip dist/extension-chrome.zip --firefox-zip dist/extension-firefox.zip --firefox-sources-zip dist/extension-sources.zip --edge-zip dist/extension-chrome.zip - + if: steps.check.outputs.release == 'true' && !vars.ACT + run: bunx @equicord/publish-browser-extension@latest --chrome-zip + dist/extension-chrome.zip --firefox-zip + dist/extension-firefox.zip --firefox-sources-zip + dist/extension-sources.zip --edge-zip + dist/extension-chrome.zip env: CHROME_EXTENSION_ID: ${{ secrets.CHROME_EXTENSION_ID }} CHROME_PUBLISHER_ID: ${{ secrets.CHROME_PUBLISHER_ID }} diff --git a/.github/workflows/reportBrokenPlugins.yml b/.github/workflows/reportBrokenPlugins.yml index 727d0de0e..6c07dc10d 100644 --- a/.github/workflows/reportBrokenPlugins.yml +++ b/.github/workflows/reportBrokenPlugins.yml @@ -12,7 +12,9 @@ on: default: both webhook_url: type: string - description: "Webhook URL that the report will be posted to. This will be visible for everyone, so DO NOT pass sensitive webhooks like discord webhook. This is meant to be used by Venbot." + description: "Webhook URL that the report will be posted to. This will be + visible for everyone, so DO NOT pass sensitive webhooks like + discord webhook. This is meant to be used by Venbot." required: false schedule: # # Every day at midnight diff --git a/.gitignore b/.gitignore index 183c02028..6a3d49d7b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ dist node_modules .VSCodeCounter +.vars +.secrets +.input *.exe vencord_installer diff --git a/eslint.config.mjs b/eslint.config.mjs index c0602e84e..7fcd15014 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -136,6 +136,8 @@ export default defineConfig( "use-isnan": "error", "prefer-const": ["error", { destructuring: "all" }], "prefer-spread": "error", + // These are old deprecated browser globals which may be used by mistake, e.g. `addEventListener(e => console.log(event))` + "no-restricted-globals": ["error", "event", "name"], // Plugin Rules "simple-import-sort/imports": "error", diff --git a/package.json b/package.json index 049231b8e..0368cc857 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "equicord", "private": "true", - "version": "1.14.6.2", + "version": "1.14.12.0", "description": "The other cutest Discord client mod", "homepage": "https://github.com/Equicord/Equicord#readme", "bugs": { @@ -59,47 +59,47 @@ "gifenc": "github:mattdesl/gifenc#64842fca317b112a8590f8fef2bf3825da8f6fe3", "idb": "8.0.3", "jsqr": "1.4.0", - "monaco-editor": "^0.54.0", + "monaco-editor": "^0.55.1", "nanoid": "^5.1.6", "native-file-system-adapter": "^3.0.1", "usercss-meta": "^0.12.0", - "virtual-merge": "^1.0.1" + "virtual-merge": "^1.0.1", + "yaml-js": "^0.3.1" }, "devDependencies": { - "@electron/asar": "^4.0.1", - "@stylistic/eslint-plugin": "^5.6.0", - "@types/adm-zip": "^0.5.7", + "@electron/asar": "^4.2.0", + "@stylistic/eslint-plugin": "^5.10.0", "@types/bun": "^1.3.9", - "@types/chrome": "^0.1.30", - "@types/diff": "^7.0.2", - "@types/lodash": "^4.17.20", - "@types/node": "^24.10.1", + "@types/chrome": "^0.1.40", + "@types/lodash": "^4.17.24", + "@types/node": "^25.6.0", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", - "@types/yazl": "^3.3.0", + "@types/yazl": "^3.3.1", "@vencord/discord-types": "workspace:*", - "diff": "^8.0.2", - "esbuild": "^0.27.0", - "eslint": "9.39.1", + "diff": "^9.0.0", + "esbuild": "^0.28.0", + "eslint": "10.2.0", "eslint-import-resolver-alias": "^1.1.2", "eslint-plugin-react": "^7.37.5", "eslint-plugin-simple-header": "^1.2.1", - "eslint-plugin-simple-import-sort": "^12.1.1", - "eslint-plugin-unused-imports": "^4.3.0", + "eslint-plugin-simple-import-sort": "^13.0.0", + "eslint-plugin-unused-imports": "^4.4.1", "highlight.js": "11.11.1", "html-minifier-terser": "^7.2.0", "moment": "^2.22.2", "p-limit": "^7.3.0", - "puppeteer-core": "^24.30.0", - "standalone-electron-types": "^34.2.0", - "stylelint": "^16.25.0", - "stylelint-config-standard": "^39.0.1", + "puppeteer-core": "^24.41.0", + "standalone-electron-types": "^41.2.1", + "stylelint": "^17.8.0", + "stylelint-config-standard": "^40.0.0", + "svgo": "^4.0.1", "ts-patch": "^3.3.0", - "ts-pattern": "^5.6.0", - "type-fest": "^5.2.0", - "typescript": "^5.9.3", - "typescript-eslint": "^8.47.0", - "typescript-transform-paths": "^3.5.5", + "ts-pattern": "^5.9.0", + "type-fest": "^5.5.0", + "typescript": "^6.0.2", + "typescript-eslint": "^8.58.2", + "typescript-transform-paths": "^3.5.6", "zip-local": "^0.3.5" }, "packageManager": "bun@1.3.9", diff --git a/packages/discord-types/src/components.d.ts b/packages/discord-types/src/components.d.ts index 8d0aa0ed4..c7d01e481 100644 --- a/packages/discord-types/src/components.d.ts +++ b/packages/discord-types/src/components.d.ts @@ -331,6 +331,15 @@ declare enum PopoutAnimation { type PopoutPosition = "top" | "bottom" | "left" | "right" | "center" | "window_center"; +export interface PopoutProps { + position: PopoutPosition; + nudge: number; + isPositioned: boolean; + setPopoutRef(ref: any): void; + closePopout(): void; + updatePosition(): void; +} + export type Popout = ComponentType<{ children( thing: { @@ -347,14 +356,8 @@ export type Popout = ComponentType<{ ): ReactNode; shouldShow?: boolean; targetElementRef: RefObject; - renderPopout(args: { - closePopout(): void; - isPositioned: boolean; - nudge: number; - position: PopoutPosition; - setPopoutRef(ref: any): void; - updatePosition(): void; - }): ReactNode; + renderPopout(props: PopoutProps): ReactNode; + preload?(): Promise; onRequestOpen?(): void; onRequestClose?(): void; @@ -536,4 +539,5 @@ export type ColorPicker = ComponentType<{ suggestedColors?: string[]; label?: ReactNode; onChange(value: number | null): void; + disabled?: boolean; }>; diff --git a/scripts/build/build.mjs b/scripts/build/build.mjs index d48e322ed..a75b0a55f 100644 --- a/scripts/build/build.mjs +++ b/scripts/build/build.mjs @@ -78,7 +78,7 @@ const globNativesPlugin = { }); build.onLoad({ filter, namespace: "import-natives" }, async () => { - const pluginDirs = ["plugins", "userplugins", "equicordplugins"]; + const pluginDirs = ["plugins", "equicordplugins", "userplugins"]; let code = ""; let natives = "\n"; let i = 0; diff --git a/scripts/build/common.mjs b/scripts/build/common.mjs index 7ca75a2f3..d262ab6cd 100644 --- a/scripts/build/common.mjs +++ b/scripts/build/common.mjs @@ -138,7 +138,7 @@ export const globPlugins = kind => ({ }); build.onLoad({ filter, namespace: "import-plugins" }, async () => { - const pluginDirs = ["plugins/_api", "plugins/_core", "plugins", "userplugins", "equicordplugins", "equicordplugins/_api"]; + const pluginDirs = ["plugins/_api", "plugins/_core", "plugins", "equicordplugins/_api", "equicordplugins/_core", "equicordplugins", "userplugins"]; let code = ""; let pluginsCode = "\n"; let metaCode = "\n"; diff --git a/scripts/generatePluginList.ts b/scripts/generatePluginList.ts index 2a052f13a..2f1c35210 100644 --- a/scripts/generatePluginList.ts +++ b/scripts/generatePluginList.ts @@ -31,11 +31,11 @@ import { getEntryPoint, isPluginFile, parseDevs, parseEquicordDevs, parseFile, P let dirs: string[]; if (equicordFlag) { - dirs = ["src/equicordplugins"]; + dirs = ["src/equicordplugins/_core", "src/equicordplugins"]; } else if (vencordFlag) { dirs = ["src/plugins", "src/plugins/_core"]; } else { - dirs = ["src/plugins", "src/plugins/_core", "src/equicordplugins"]; + dirs = ["src/plugins", "src/plugins/_core", "src/equicordplugins/_core", "src/equicordplugins"]; } const outputPath = args.find(a => !a.startsWith("--")) ?? null; diff --git a/scripts/generateReport.ts b/scripts/generateReport.ts index e5ddbc427..520d70777 100644 --- a/scripts/generateReport.ts +++ b/scripts/generateReport.ts @@ -80,7 +80,9 @@ const IGNORED_DISCORD_ERRORS = [ "Downloading the full bad domains file", /\[GatewaySocket\].{0,110}Cannot access '/, "search for 'name' in undefined", - "Attempting to set fast connect zstd when unsupported" + "Attempting to set fast connect zstd when unsupported", + /Cannot read properties of undefined \(reading 'call'\)/, + "Could not complete Remote Auth login, trying to restart with a new Remote Auth session." ] as Array; function toCodeBlock(s: string, indentation = 0, isDiscord = false) { diff --git a/scripts/utils.ts b/scripts/utils.ts index 4e4cc54e1..870cfc00f 100644 --- a/scripts/utils.ts +++ b/scripts/utils.ts @@ -37,6 +37,7 @@ export interface PluginData { name: string; description: string; tags: string[]; + searchTerms: string[]; authors: Dev[]; dependencies: string[]; hasPatches: boolean; @@ -151,7 +152,8 @@ export async function parseFile(fileName: string) { enabledByDefault: false, required: false, isModified: false, - tags: [] as string[] + tags: [] as string[], + searchTerms: [] as string[], } as PluginData; for (const prop of pluginObj.properties) { @@ -207,9 +209,10 @@ export async function parseFile(fileName: string) { }); break; case "tags": - if (!isArrayLiteralExpression(value)) throw fail("tags is not an array literal"); - data.tags = value.elements.map(e => { - if (!isStringLiteral(e)) throw fail("tags array contains non-string literals"); + case "searchTerms": + if (!isArrayLiteralExpression(value)) throw fail(`${key} is not an array literal`); + data[key] = value.elements.map(e => { + if (!isStringLiteral(e)) throw fail(`${key} array contains non-string literals`); return e.text; }); break; diff --git a/src/Vencord.ts b/src/Vencord.ts index 45c9326f1..f39f41a7c 100644 --- a/src/Vencord.ts +++ b/src/Vencord.ts @@ -41,8 +41,7 @@ import { popNotice, showNotice } from "./api/Notices"; import { NotificationData, showNotification } from "./api/Notifications"; import { initPluginManager, PMLogger, startAllPlugins } from "./api/PluginManager"; import { PlainSettings, Settings, SettingsStore } from "./api/Settings"; -import { getCloudSettings, putCloudSettings, shouldCloudSync } from "./api/SettingsSync/cloudSync"; -import { localStorage } from "./utils/localStorage"; +import { areLocalSettingsDirty, getCloudSettings, getCloudSyncDirection, markLocalSettingsDirty, putCloudSettings, shouldCloudSync } from "./api/SettingsSync/cloudSync"; import { relaunch } from "./utils/native"; import { checkForUpdates, isOutdated as getIsOutdated, update, UpdateLogger } from "./utils/updater"; import { onceReady } from "./webpack"; @@ -53,11 +52,6 @@ if (IS_REPORTER) { } async function syncSettings() { - // Check if cloud auth exists for current user before attempting sync - if (localStorage.Vencord_cloudSyncDirection === undefined) { - // by default, sync bi-directionally - localStorage.Vencord_cloudSyncDirection = "both"; - } const hasCloudAuth = await dsGet("Vencord_cloudSecret"); if (!hasCloudAuth) { if (Settings.cloud.authenticated) { @@ -93,9 +87,9 @@ async function syncSettings() { if ( Settings.cloud.settingsSync && // if it's enabled Settings.cloud.authenticated && // if cloud integrations are enabled - localStorage.Vencord_cloudSyncDirection !== "manual" // if we're not in manual mode + getCloudSyncDirection() !== "manual" // if we're not in manual mode ) { - if (localStorage.Vencord_settingsDirty && shouldCloudSync("push")) { + if (areLocalSettingsDirty() && shouldCloudSync("push")) { await putCloudSettings(); } else if (shouldCloudSync("pull") && await getCloudSettings(false)) { // if we synchronized something (false means no sync) // we show a notification here instead of allowing getCloudSettings() to show one to declutter the amount of @@ -118,7 +112,7 @@ async function syncSettings() { }, 60_000); SettingsStore.addGlobalChangeListener(() => { - localStorage.Vencord_settingsDirty = true; + markLocalSettingsDirty(); saveSettingsOnFrequentAction(); }); } diff --git a/src/api/AudioPlayer.ts b/src/api/AudioPlayer.ts index 53d6c0614..d106d26b8 100644 --- a/src/api/AudioPlayer.ts +++ b/src/api/AudioPlayer.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -import { findByCodeLazy, findLazy } from "webpack"; +import { findByCodeLazy, findLazy } from "@webpack"; let defaultSounds: null | string[] = null; const findDefaultSounds = findLazy(module => module.resolve && module.id && module.keys().some(key => key.endsWith(".mp3")), false); diff --git a/src/api/Badges.ts b/src/api/Badges.ts index e343a9930..88e3c9bd9 100644 --- a/src/api/Badges.ts +++ b/src/api/Badges.ts @@ -29,6 +29,10 @@ export const enum BadgePosition { } export interface ProfileBadge { + /** + * Badge id, unused by vencord, required by discord + */ + id: string, /** The tooltip to show on hover. Required for image badges */ description?: string; /** Custom component for the badge (tooltip not included) */ diff --git a/src/api/ChatButtons.tsx b/src/api/ChatButtons.tsx index 4288c6f8b..a04725386 100644 --- a/src/api/ChatButtons.tsx +++ b/src/api/ChatButtons.tsx @@ -190,3 +190,22 @@ addContextMenuPatch("textarea-context", (children, args) => { ); }); + +// 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(); + +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; +} diff --git a/src/api/ContextMenu.ts b/src/api/ContextMenu.ts index a632aea6f..cee43ae60 100644 --- a/src/api/ContextMenu.ts +++ b/src/api/ContextMenu.ts @@ -131,6 +131,8 @@ interface ContextMenuProps { } export function _usePatchContextMenu(props: ContextMenuProps) { + if (!Menu.MenuItem) return props; // Prevent crashes in case we fail to acquire menu items for some reason + props = { ...props, children: cloneMenuChildren(props.children), diff --git a/src/api/DataStore/index.ts b/src/api/DataStore/index.ts index 3cdb50c7e..80ebb065f 100644 --- a/src/api/DataStore/index.ts +++ b/src/api/DataStore/index.ts @@ -18,97 +18,31 @@ * limitations under the License. */ -import { Logger } from "@utils/Logger"; - -const logger = new Logger("DataStore"); -let loggedFailure = false; -const memoryDatabases = new Map>>(); - -interface MemoryStore { - __memoryStore: true; - get(key: IDBValidKey): T | undefined; - put(value: unknown, key: IDBValidKey): void; - delete(key: IDBValidKey): void; - clear(): void; - getAllKeys(): KeyType[]; - getAll(): T[]; - entries(): [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(promise: Promise, fallback: T): Promise { - 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(); - database.set(storeName, data); - } - - return { - __memoryStore: true, - get: (key: IDBValidKey) => data.get(key) as T | undefined, - put: (value, key) => { data.set(key, value); }, - delete: key => { data.delete(key); }, - clear: () => { data.clear(); }, - getAllKeys: () => Array.from(data.keys()) as KeyType[], - getAll: () => Array.from(data.values()) as T[], - entries: () => Array.from(data.entries()) as [KeyType, ValueType][] - }; -} - -function isMemoryStore(store: StoreLike): store is MemoryStore { - return "__memoryStore" in store; -} - export function promisifyRequest( request: IDBRequest | IDBTransaction, ): Promise { return new Promise((resolve, reject) => { - const target = request as IDBRequest & IDBTransaction; - target.oncomplete = target.onsuccess = () => resolve(target.result); - target.onabort = target.onerror = () => reject(target.error); + // @ts-expect-error - file size hacks + request.oncomplete = request.onsuccess = () => resolve(request.result); + // @ts-expect-error - file size hacks + request.onabort = request.onerror = () => reject(request.error); }); } export function createStore(dbName: string, storeName: string): UseStore { const request = indexedDB.open(dbName); request.onupgradeneeded = () => request.result.createObjectStore(storeName); - const dbp = promisifyRequest(request).catch(error => { - handleFailure(error); - return null; - }); - const memoryStore = getMemoryStore(dbName, storeName); + const dbp = promisifyRequest(request); - return async (txMode, callback) => { - const db = await dbp; - if (!db) return callback(memoryStore); - - return callback(db.transaction(storeName, txMode).objectStore(storeName)); - }; + return (txMode, callback) => + dbp.then(db => + callback(db.transaction(storeName, txMode).objectStore(storeName)), + ); } export type UseStore = ( txMode: IDBTransactionMode, - callback: (store: StoreLike) => T | PromiseLike, + callback: (store: IDBObjectStore) => T | PromiseLike, ) => Promise; let defaultGetStoreFunc: UseStore | undefined; @@ -120,68 +54,88 @@ function defaultGetStore() { return defaultGetStoreFunc; } +/** + * Get a value by its key. + * + * @param key + * @param customStore Method to get a custom store. Use with caution (see the docs). + */ export function get( key: IDBValidKey, customStore = defaultGetStore(), ): Promise { - return withFailureFallback(customStore("readonly", store => { - if (isMemoryStore(store)) return store.get(key) as T | undefined; - return promisifyRequest(store.get(key)); - }), undefined); + return customStore("readonly", store => promisifyRequest(store.get(key))); } +/** + * Set a value with a key. + * + * @param key + * @param value + * @param customStore Method to get a custom store. Use with caution (see the docs). + */ export function set( key: IDBValidKey, value: any, customStore = defaultGetStore(), ): Promise { - return withFailureFallback(customStore("readwrite", store => { + return customStore("readwrite", store => { store.put(value, key); - if (isMemoryStore(store)) return; - return promisifyRequest(store.transaction); - }), undefined); + }); } +/** + * Set multiple values at once. This is faster than calling set() multiple times. + * It's also atomic – if one of the pairs can't be added, none will be added. + * + * @param entries Array of entries, where each entry is an array of `[key, value]`. + * @param customStore Method to get a custom store. Use with caution (see the docs). + */ export function setMany( entries: [IDBValidKey, any][], customStore = defaultGetStore(), ): Promise { - return withFailureFallback(customStore("readwrite", store => { + return customStore("readwrite", store => { entries.forEach(entry => store.put(entry[1], entry[0])); - if (isMemoryStore(store)) return; - return promisifyRequest(store.transaction); - }), undefined); + }); } +/** + * Get multiple values by their keys + * + * @param keys + * @param customStore Method to get a custom store. Use with caution (see the docs). + */ export function getMany( keys: IDBValidKey[], customStore = defaultGetStore(), ): Promise { - return withFailureFallback(customStore("readonly", store => { - if (isMemoryStore(store)) { - return keys.map(key => store.get(key) as T | undefined); - } - - return Promise.all(keys.map(key => promisifyRequest(store.get(key)))); - }), []); + return customStore("readonly", store => + Promise.all(keys.map(key => promisifyRequest(store.get(key)))), + ); } +/** + * Update a value. This lets you see the old value and update it as an atomic operation. + * + * @param key + * @param updater A callback that takes the old value and returns a new value. + * @param customStore Method to get a custom store. Use with caution (see the docs). + */ export function update( key: IDBValidKey, updater: (oldValue: T | undefined) => T, customStore = defaultGetStore(), ): Promise { - return withFailureFallback(customStore( + return customStore( "readwrite", - store => { - if (isMemoryStore(store)) { - store.put(updater(store.get(key) as T | undefined), key); - return; - } - - return new Promise((resolve, reject) => { + store => + // Need to create the promise manually. + // If I try to chain promises, the transaction closes in browsers + // that use a promise polyfill (IE10/11). + new Promise((resolve, reject) => { store.get(key).onsuccess = function () { try { store.put(updater(this.result), key); @@ -190,42 +144,52 @@ export function update( reject(err); } }; - }); - }, - ), undefined); + }), + ); } +/** + * Delete a particular key from the store. + * + * @param key + * @param customStore Method to get a custom store. Use with caution (see the docs). + */ export function del( key: IDBValidKey, customStore = defaultGetStore(), ): Promise { - return withFailureFallback(customStore("readwrite", store => { + return customStore("readwrite", store => { store.delete(key); - if (isMemoryStore(store)) return; - return promisifyRequest(store.transaction); - }), undefined); + }); } +/** + * Delete multiple keys at once. + * + * @param keys List of keys to delete. + * @param customStore Method to get a custom store. Use with caution (see the docs). + */ export function delMany( keys: IDBValidKey[], customStore = defaultGetStore(), ): Promise { - return withFailureFallback(customStore("readwrite", store => { - keys.forEach(key => store.delete(key)); - if (isMemoryStore(store)) return; - + return customStore("readwrite", (store: IDBObjectStore) => { + keys.forEach((key: IDBValidKey) => store.delete(key)); return promisifyRequest(store.transaction); - }), undefined); + }); } +/** + * Clear all values in the store. + * + * @param customStore Method to get a custom store. Use with caution (see the docs). + */ export function clear(customStore = defaultGetStore()): Promise { - return withFailureFallback(customStore("readwrite", store => { + return customStore("readwrite", store => { store.clear(); - if (isMemoryStore(store)) return; - return promisifyRequest(store.transaction); - }), undefined); + }); } function eachCursor( @@ -240,12 +204,16 @@ function eachCursor( return promisifyRequest(store.transaction); } +/** + * Get all keys in the store. + * + * @param customStore Method to get a custom store. Use with caution (see the docs). + */ export function keys( customStore = defaultGetStore(), ): Promise { - return withFailureFallback(customStore("readonly", store => { - if (isMemoryStore(store)) return store.getAllKeys(); - + return customStore("readonly", store => { + // Fast path for modern browsers if (store.getAllKeys) { return promisifyRequest( store.getAllKeys() as unknown as IDBRequest, @@ -253,45 +221,59 @@ export function keys( } const items: KeyType[] = []; + return eachCursor(store, cursor => items.push(cursor.key as KeyType), ).then(() => items); - }), [] as KeyType[]); + }); } +/** + * Get all values in the store. + * + * @param customStore Method to get a custom store. Use with caution (see the docs). + */ export function values(customStore = defaultGetStore()): Promise { - return withFailureFallback(customStore("readonly", store => { - if (isMemoryStore(store)) return store.getAll(); - + return customStore("readonly", store => { + // Fast path for modern browsers if (store.getAll) { return promisifyRequest(store.getAll() as IDBRequest); } const items: T[] = []; + return eachCursor(store, cursor => items.push(cursor.value as T)).then( () => items, ); - }), [] as T[]); + }); } +/** + * Get all entries in the store. Each entry is an array of `[key, value]`. + * + * @param customStore Method to get a custom store. Use with caution (see the docs). + */ export function entries( customStore = defaultGetStore(), ): Promise<[KeyType, ValueType][]> { - return withFailureFallback(customStore("readonly", store => { - if (isMemoryStore(store)) return store.entries(); - + return customStore("readonly", store => { + // Fast path for modern browsers + // (although, hopefully we'll get a simpler path some day) if (store.getAll && store.getAllKeys) { return Promise.all([ promisifyRequest( store.getAllKeys() as unknown as IDBRequest, ), promisifyRequest(store.getAll() as IDBRequest), - ]).then(([keys, values]) => keys.map((key, i) => [key, values[i]] as [KeyType, ValueType])); + ]).then(([keys, values]) => keys.map((key, i) => [key, values[i]])); } const items: [KeyType, ValueType][] = []; - return eachCursor(store, cursor => - items.push([cursor.key as KeyType, cursor.value]), - ).then(() => items); - }), [] as [KeyType, ValueType][]); + + return customStore("readonly", store => + eachCursor(store, cursor => + items.push([cursor.key as KeyType, cursor.value]), + ).then(() => items), + ); + }); } diff --git a/src/api/MessageAccessories.tsx b/src/api/MessageAccessories.tsx index d2bc081e7..5ac7c9531 100644 --- a/src/api/MessageAccessories.tsx +++ b/src/api/MessageAccessories.tsx @@ -17,6 +17,7 @@ */ import ErrorBoundary from "@components/ErrorBoundary"; +import { Logger } from "@utils/Logger"; import { JSX, ReactNode } from "react"; export type MessageAccessoryFactory = (props: Record) => ReactNode; @@ -46,23 +47,28 @@ export function _modifyAccessories( elements: JSX.Element[], props: Record ) { - for (const [key, accessory] of accessories.entries()) { - const res = ( - - - - ); + try { + for (const [key, accessory] of accessories.entries()) { + const res = ( + + + + ); - elements.splice( - accessory.position != null - ? accessory.position < 0 - ? elements.length + accessory.position - : accessory.position - : elements.length, - 0, - res - ); + elements.splice( + accessory.position != null + ? accessory.position < 0 + ? elements.length + accessory.position + : accessory.position + : elements.length, + 0, + res + ); + } + + return elements; + } catch (e) { + new Logger("MessageAccessories").error("Failed to modify message accessories", e); + return elements; } - - return elements; } diff --git a/src/api/Notifications/styles.css b/src/api/Notifications/styles.css index 400fb7d4a..e15e01a7d 100644 --- a/src/api/Notifications/styles.css +++ b/src/api/Notifications/styles.css @@ -15,7 +15,7 @@ position: absolute; z-index: 2147483647; right: 1rem; - width: 25vw; + width: min(25vw, 400px); min-height: 10vh; } diff --git a/src/api/PluginManager.ts b/src/api/PluginManager.ts index 0721b4d20..b15b6848f 100644 --- a/src/api/PluginManager.ts +++ b/src/api/PluginManager.ts @@ -17,7 +17,7 @@ */ import { addProfileBadge, removeProfileBadge } from "@api/Badges"; -import { addChatBarButton, removeChatBarButton } from "@api/ChatButtons"; +import { addChatBarButton, addChatBarButtonWrapper, removeChatBarButton, removeChatBarButtonWrapper } from "@api/ChatButtons"; import { registerCommand, unregisterCommand } from "@api/Commands"; import { addContextMenuPatch, removeContextMenuPatch } from "@api/ContextMenu"; import { addMemberListDecorator, removeMemberListDecorator } from "@api/MemberListDecorators"; @@ -28,10 +28,11 @@ import { addMessagePopoverButton, removeMessagePopoverButton } from "@api/Messag import { addNicknameIcon, removeNicknameIcon } from "@api/NicknameIcons"; import { Settings, SettingsStore } from "@api/Settings"; import { disableStyle, enableStyle } from "@api/Styles"; +import { traceFunction } from "@debug/Tracer"; import { Logger } from "@utils/Logger"; import { onlyOnce } from "@utils/onlyOnce"; import { canonicalizeFind, canonicalizeReplacement } from "@utils/patches"; -import { Patch, Plugin, PluginDef, ReporterTestable, StartAt } from "@utils/types"; +import { DefinedSettings, Patch, Plugin, PluginDef, PluginSettingDef, ReporterTestable, StartAt } from "@utils/types"; import { FluxEvents } from "@vencord/discord-types"; import { FluxDispatcher } from "@webpack/common"; import { patches } from "@webpack/patcher"; @@ -39,10 +40,10 @@ import { patches } from "@webpack/patcher"; import Plugins from "~plugins"; export { Plugins as plugins }; -import { traceFunction } from "../debug/Tracer"; import { addAudioProcessor, removeAudioProcessor } from "./AudioPlayer"; import { addChannelToolbarButton, addHeaderBarButton, removeChannelToolbarButton, removeHeaderBarButton } from "./HeaderBar"; import { addProfileCollection, removeProfileCollection } from "./ProfileCollections"; +import { addProfileSection, removeProfileSection } from "./ProfileSections"; import { addUserAreaButton, removeUserAreaButton } from "./UserArea"; const logger = new Logger("PluginManager", "#a6d189"); @@ -60,14 +61,37 @@ export function isPluginEnabled(p: string) { Settings.plugins[p]?.enabled ) ?? false; } - export function isPluginRequired(p: string) { return ( Plugins[p]?.required ) ?? false; } +export function isSettingHidden(settings: DefinedSettings, setting: PluginSettingDef) { + if (!("hidden" in setting)) return false; + + return typeof setting.hidden === "function" + ? setting.hidden.call(settings) + : Boolean(setting.hidden); +} + +export function isSettingDisabled(settings: DefinedSettings, setting: PluginSettingDef) { + if (!("disabled" in setting)) return false; + + return typeof setting.disabled === "function" + ? setting.disabled.call(settings) + : Boolean(setting.disabled); +} + +export function hasAnyVisibleSettings({ settings }: Plugin) { + return !!settings && Object.values(settings.def).some(s => !isSettingHidden(settings, s)); +} + export function addPatch(newPatch: Omit, pluginName: string, pluginPath = `Vencord.Plugins.plugins[${JSON.stringify(pluginName)}]`) { + // TODO: this causes crashes + if (pluginName === "Vesktop" && newPatch.find === ".STREAMING_AUTO_STREAMER_MODE,") return; + if (pluginName === "Equibop" && newPatch.find === ".STREAMING_AUTO_STREAMER_MODE,") return; + const patch = newPatch as Patch; patch.plugin = pluginName; @@ -197,7 +221,8 @@ export const startPlugin = traceFunction("startPlugin", function startPlugin(p: onBeforeMessageEdit, onBeforeMessageSend, onMessageClick, chatBarButton, renderMemberListDecorator, renderMessageAccessory, renderMessageDecoration, messagePopoverButton, // Custom - renderNicknameIcon, headerBarButton, audioProcessor, userAreaButton, renderProfileCollection + renderNicknameIcon, headerBarButton, audioProcessor, userAreaButton, renderProfileCollection, chatBarButtonWrapper, + renderProfileSection } = p; if (p.start) { @@ -265,6 +290,8 @@ export const startPlugin = traceFunction("startPlugin", function startPlugin(p: if (audioProcessor) addAudioProcessor(name, audioProcessor); if (userAreaButton) addUserAreaButton(name, userAreaButton.render, userAreaButton.priority); if (renderProfileCollection) addProfileCollection(name, renderProfileCollection); + if (chatBarButtonWrapper) addChatBarButtonWrapper(name, chatBarButtonWrapper); + if (renderProfileSection) addProfileSection(name, renderProfileSection); return true; }, p => `startPlugin ${p.name}`); @@ -275,7 +302,8 @@ export const stopPlugin = traceFunction("stopPlugin", function stopPlugin(p: Plu onBeforeMessageEdit, onBeforeMessageSend, onMessageClick, chatBarButton, renderMemberListDecorator, renderMessageAccessory, renderMessageDecoration, messagePopoverButton, // Custom - renderNicknameIcon, headerBarButton, audioProcessor, userAreaButton, renderProfileCollection + renderNicknameIcon, headerBarButton, audioProcessor, userAreaButton, renderProfileCollection, chatBarButtonWrapper, + renderProfileSection } = p; if (p.stop) { @@ -341,6 +369,8 @@ export const stopPlugin = traceFunction("stopPlugin", function stopPlugin(p: Plu if (audioProcessor) removeAudioProcessor(name); if (userAreaButton) removeUserAreaButton(name); if (renderProfileCollection) removeProfileCollection(name); + if (chatBarButtonWrapper) removeChatBarButtonWrapper(name); + if (renderProfileSection) removeProfileSection(name); return true; }, p => `stopPlugin ${p.name}`); @@ -396,6 +426,8 @@ export const initPluginManager = onlyOnce(function init() { if (p.audioProcessor) neededApiPlugins.add("AudioPlayerAPI"); if (p.userAreaButton) neededApiPlugins.add("UserAreaAPI"); if (p.renderProfileCollection) neededApiPlugins.add("ProfileCollectionsAPI"); + if (p.chatBarButtonWrapper) neededApiPlugins.add("ChatInputButtonAPI"); + if (p.renderProfileSection) neededApiPlugins.add("ProfileSectionsAPI"); for (const key of pluginKeysToBind) { p[key] &&= p[key].bind(p) as any; @@ -409,22 +441,11 @@ export const initPluginManager = onlyOnce(function init() { for (const p of pluginsValues) { if (p.settings) { - p.options ??= {}; - p.settings.pluginName = p.name; - for (const name in p.settings.def) { - const def = p.settings.def[name]; - const checks = p.settings.checks?.[name]; - p.options[name] = { ...def, ...checks }; - } - } - if (p.options) { - for (const name in p.options) { - const opt = p.options[name]; - if (opt.onChange != null) { - SettingsStore.addChangeListener(`plugins.${p.name}.${name}`, opt.onChange); - } + for (const [key, def] of Object.entries(p.settings.def)) { + if (def.onChange) + SettingsStore.addChangeListener(`plugins.${p.name}.${key}`, def.onChange); } } diff --git a/src/api/ProfileCollections.tsx b/src/api/ProfileCollections.tsx index 2a8a16783..07afef0cb 100644 --- a/src/api/ProfileCollections.tsx +++ b/src/api/ProfileCollections.tsx @@ -6,12 +6,13 @@ import ErrorBoundary from "@components/ErrorBoundary"; import { Logger } from "@utils/Logger"; +import { User } from "@vencord/discord-types"; import { useEffect, useState } from "@webpack/common"; import type { ReactNode } from "react"; const logger = new Logger("ProfileCollectionAPI"); -export type ProfileCollectionFactory = (props: any) => ReactNode | null; +export type ProfileCollectionFactory = (props: { user: User; isSideBar: boolean; }) => ReactNode | Promise | null; interface CollectionEntry { render: ProfileCollectionFactory; diff --git a/src/api/ProfileSections.tsx b/src/api/ProfileSections.tsx new file mode 100644 index 000000000..ecf52ae9c --- /dev/null +++ b/src/api/ProfileSections.tsx @@ -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 | null; + +interface SectionEntry { + render: ProfileSectionFactory; + priority: number; +} + +const profileSections = new Map(); +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 }) => ( + *
Custom content for {userId}
+ * )); + */ +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 }]) => ( + logger.error(`Failed to render profile section: ${id}`, e.error)}> +
+ + )); +} + +/** @internal Injected by ProfileSectionsAPI patch (do NOT call directly) */ +export function renderProfileSections(props: { userId: string; isSideBar: boolean; }) { + return ; +} diff --git a/src/api/ServerList.tsx b/src/api/ServerList.tsx index 39d8b5da4..0bf5f6155 100644 --- a/src/api/ServerList.tsx +++ b/src/api/ServerList.tsx @@ -25,11 +25,11 @@ export const enum ServerListRenderPosition { Below, } -const componentsIn = new Set(); -const componentsAbove = new Set(); -const componentsBelow = new Set(); +const componentsIn = new Map(); +const componentsAbove = new Map(); +const componentsBelow = new Map(); -function getRenderFunctions(position: ServerListRenderPosition) { +function getRenderMap(position: ServerListRenderPosition) { switch (position) { case ServerListRenderPosition.Above: return componentsAbove; @@ -40,21 +40,20 @@ function getRenderFunctions(position: ServerListRenderPosition) { } } -export function addServerListElement(position: ServerListRenderPosition, renderFunction: ComponentType) { - getRenderFunctions(position).add(renderFunction); +export function addServerListElement(position: ServerListRenderPosition, renderFunction: ComponentType, priority = 0) { + getRenderMap(position).set(renderFunction, priority); } export function removeServerListElement(position: ServerListRenderPosition, renderFunction: ComponentType) { - getRenderFunctions(position).delete(renderFunction); + getRenderMap(position).delete(renderFunction); } export const renderAll = (position: ServerListRenderPosition) => { - return Array.from( - getRenderFunctions(position), - (Component, i) => ( + return Array.from(getRenderMap(position).entries()) + .sort((a, b) => b[1] - a[1]) + .map(([Component], i) => ( - ) - ); + )); }; diff --git a/src/api/Settings.ts b/src/api/Settings.ts index 1951a2a3e..ea9e23088 100644 --- a/src/api/Settings.ts +++ b/src/api/Settings.ts @@ -113,7 +113,7 @@ const DefaultSettings: Settings = { autoUpdateNotification: true, useQuickCss: true, themeLinks: [], - eagerPatches: IS_REPORTER, + eagerPatches: false, // Eagerly patching no longer works due to module factories with the same id being able to have different sources now. enabledThemes: [], enabledThemeLinks: [], enableOnlineThemes: true, @@ -178,7 +178,7 @@ export const SettingsStore = new SettingsStoreClass(settings, { if (path.startsWith("plugins.")) { const plugin = path.slice("plugins.".length); if (plugin in plugins) { - const setting = plugins[plugin].options?.[key]; + const setting = plugins[plugin].settings?.def[key]; if (!setting) return v; if ("default" in setting) @@ -208,7 +208,7 @@ if (!IS_REPORTER) { * Same as {@link Settings} but unproxied. You should treat this as readonly, * as modifying properties on this will not save to disk or call settings * listeners. - * WARNING: default values specified in plugin.options will not be ensured here. In other words, + * WARNING: default values specified in plugin.settings will not be ensured here. In other words, * settings for which you specified a default value may be uninitialised. If you need proper * handling for default values, use {@link Settings} */ @@ -353,7 +353,13 @@ export function definePluginSettings< Checks extends SettingsChecks, PrivateSettings extends object = {} >(def: Def, checks?: Checks) { - const definedSettings: DefinedSettings = { + if (checks) { + for (const [name, check] of Object.entries(checks)) { + Object.assign(def[name], check); + } + } + + const definedSettings: DefinedSettings = { get store() { if (!definedSettings.pluginName) throw new Error("Cannot access settings before plugin is initialized"); return Settings.plugins[definedSettings.pluginName] as any; @@ -368,11 +374,10 @@ export function definePluginSettings< : [`plugins.${definedSettings.pluginName}.*`] ) as UseSettings[]).plugins[definedSettings.pluginName] as any, def, - checks: checks ?? {} as any, pluginName: "", withPrivateSettings() { - return this as DefinedSettings; + return this as DefinedSettings; } }; diff --git a/src/api/SettingsSync/cloudSync.ts b/src/api/SettingsSync/cloudSync.ts index 4d3ef986e..b1f0f5785 100644 --- a/src/api/SettingsSync/cloudSync.ts +++ b/src/api/SettingsSync/cloudSync.ts @@ -20,17 +20,33 @@ import { ManifestEntry, SyncRequest, SyncResponse } from "./types"; const logger = new Logger("SettingsSync:Cloud", "#39b7e0"); const MANIFEST_STORE_KEY = "Vencord_cloudManifest"; +const API_VERSION_STORE_KEY = "Vencord_cloudApiVersions"; type ApiVersion = "v2" | "v1"; -let apiVersion: ApiVersion = "v2"; +const SYNC_DIRECTION_KEY = "Vencord_cloudSyncDirection"; +const SETTINGS_DIRTY_KEY = "Vencord_settingsDirty"; +export const getCloudSyncDirection = () => localStorage.getItem(SYNC_DIRECTION_KEY) || "both"; +export const setCloudSyncDirection = (direction: "push" | "pull" | "both" | "manual") => localStorage.setItem(SYNC_DIRECTION_KEY, direction); +export const areLocalSettingsDirty = () => localStorage.getItem(SETTINGS_DIRTY_KEY) === "true"; +export const markLocalSettingsDirty = () => localStorage.setItem(SETTINGS_DIRTY_KEY, "true"); +export const markLocalSettingsClean = () => localStorage.removeItem(SETTINGS_DIRTY_KEY); + +async function loadApiVersionMap(): Promise> { + return await DataStore.get>(API_VERSION_STORE_KEY) ?? {}; +} async function getApiVersion(): Promise { - return apiVersion; + const map = await loadApiVersionMap(); + return map[getCloudUrl().origin] ?? "v2"; } async function setApiVersion(version: ApiVersion) { - apiVersion = version; + await DataStore.update>(API_VERSION_STORE_KEY, map => { + map ??= {}; + map[getCloudUrl().origin] = version; + return map; + }); } function toBase64(data: Uint8Array): string { diff --git a/src/api/index.ts b/src/api/index.ts index eb1d32335..585cb3dbb 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -35,6 +35,7 @@ import * as $Notifications from "./Notifications"; import * as $UserArea from "./UserArea"; export * as PluginManager from "./PluginManager"; import * as $ProfileCollections from "./ProfileCollections"; +import * as $ProfileSections from "./ProfileSections"; import * as $ServerList from "./ServerList"; import * as $Settings from "./Settings"; import * as $Styles from "./Styles"; @@ -176,3 +177,8 @@ export const isEquicord = true; * An API allowing you to add other collections where discords game collection is. */ export const ProfileCollections = $ProfileCollections; + +/** + * An API allowing you to add sections near the 'Member Since' area of user profile panels. + */ +export const ProfileSections = $ProfileSections; diff --git a/src/components/CheckedTextInput.tsx b/src/components/CheckedTextInput.tsx index 027dd51fa..cb2015f94 100644 --- a/src/components/CheckedTextInput.tsx +++ b/src/components/CheckedTextInput.tsx @@ -16,13 +16,13 @@ * along with this program. If not, see . */ -import { React, TextInput } from "@webpack/common"; +import { React, TextInput, useState } from "@webpack/common"; interface TextInputProps { /** * WARNING: Changing this between renders will have no effect! */ - value: string; + initialValue: string; /** * This will only be called if the new value passed validate() */ @@ -41,13 +41,15 @@ interface TextInputProps { * A very simple wrapper around Discord's TextInput that validates input and shows * the user an error message and only calls your onChange when the input is valid */ -export function CheckedTextInput({ value: initialValue, onChange, validate, placeholder }: TextInputProps) { - const [value, setValue] = React.useState(initialValue); - const [error, setError] = React.useState(); +export function CheckedTextInput({ initialValue, onChange, validate, placeholder }: TextInputProps) { + const [value, setValue] = useState(initialValue); + const [error, setError] = useState(); function handleChange(v: string) { setValue(v); + const res = validate(v); + if (res === true) { setError(void 0); onChange(v); diff --git a/src/components/Icons.tsx b/src/components/Icons.tsx index 16f1e2bcf..afee7b18a 100644 --- a/src/components/Icons.tsx +++ b/src/components/Icons.tsx @@ -730,6 +730,34 @@ export function ClockIcon(props: IconProps) { ); } +export function ChevronSmallDownIcon(props: IconProps) { + return ( + + + + ); +} + +export function ChevronSmallUpIcon(props: IconProps) { + return ( + + + + ); +} + export function DownArrow(props: IconProps) { return ( { ); }; + +export const LogsIcon = (props?: any) => { + return ( + + + + ); +}; + +export const BookmarkIcon = (props?: any) => { + return ( + + + + ); +}; + +export function RobotIcon(props: IconProps) { + return ( + + + + + ); +} + +export function HeadphonesIcon(props: IconProps) { + return ( + + + + ); +} + +export function ShieldIcon(props: IconProps) { + return ( + + + + + ); +} + +export function CircleQuestionIcon(props: IconProps) { + return ( + + + + + ); +} + +export function AttachmentIcon(props: IconProps) { + return ( + + + + ); +} + +export function StickerIcon(props: IconProps) { + return ( + + + + + ); +} + +export function GifIcon(props: IconProps) { + return ( + + + + ); +} + +export function VideoIcon(props: IconProps) { + return ( + + + + ); +} diff --git a/src/components/ThemeSettings/components/SettingColorComponent.tsx b/src/components/ThemeSettings/components/SettingColorComponent.tsx index e01228c5f..9f3e00fd2 100644 --- a/src/components/ThemeSettings/components/SettingColorComponent.tsx +++ b/src/components/ThemeSettings/components/SettingColorComponent.tsx @@ -9,6 +9,7 @@ import "./colorStyles.css"; import { classNameFactory } from "@utils/css"; import { findByCodeLazy } from "@webpack"; import { ColorPicker, useMemo } from "@webpack/common"; +import type tinycolor from "tinycolor2"; // TinyColor is completely unmangled and it's duplicated in two modules! Fun! const TinyColor: tinycolor.Constructor = findByCodeLazy("this._gradientType="); diff --git a/src/components/settings/tabs/BaseTab.tsx b/src/components/settings/tabs/BaseTab.tsx index b4c0ebb53..17a2e8025 100644 --- a/src/components/settings/tabs/BaseTab.tsx +++ b/src/components/settings/tabs/BaseTab.tsx @@ -16,9 +16,10 @@ * along with this program. If not, see . */ +import { BaseText } from "@components/BaseText"; import ErrorBoundary from "@components/ErrorBoundary"; import { handleComponentFailed } from "@components/handleComponentFailed"; -import { ModalCloseButton, ModalContent, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal"; +import { ModalCloseButton, ModalContent, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal"; import { onlyOnce } from "@utils/onlyOnce"; import type { ComponentType, PropsWithChildren } from "react"; @@ -43,13 +44,17 @@ export function wrapTab(component: ComponentType, tab: string) { export function openSettingsTabModal(Tab: ComponentType) { try { openModal(wrapTab((modalProps: ModalProps) => ( - + + + {Tab.displayName?.replace("SettingsTab", "") || "Settings"} + + + - - ), Tab.displayName || "Settings Tab")); + ), Tab.displayName || "SettingsTab")); } catch { handleSettingsTabError(); } diff --git a/src/components/settings/tabs/plugins/PluginCard.tsx b/src/components/settings/tabs/plugins/PluginCard.tsx index 140498249..72507ab1e 100644 --- a/src/components/settings/tabs/plugins/PluginCard.tsx +++ b/src/components/settings/tabs/plugins/PluginCard.tsx @@ -5,14 +5,14 @@ */ import { showNotice } from "@api/Notices"; -import { isPluginEnabled, pluginRequiresRestart, startDependenciesRecursive, startPlugin, stopPlugin } from "@api/PluginManager"; +import { hasAnyVisibleSettings, isPluginEnabled, pluginRequiresRestart, startDependenciesRecursive, startPlugin, stopPlugin } from "@api/PluginManager"; +import { Settings } from "@api/Settings"; import { CogWheel, InfoIcon } from "@components/Icons"; import { AddonCard } from "@components/settings/AddonCard"; import { classNameFactory } from "@utils/css"; import { Logger } from "@utils/Logger"; -import { OptionType, Plugin } from "@utils/types"; +import { Plugin } from "@utils/types"; import { React, showToast, Toasts } from "@webpack/common"; -import { Settings } from "Vencord"; import { PluginMeta } from "~plugins"; @@ -146,7 +146,7 @@ export function PluginCard({ plugin, disabled, onRestartNeeded, onMouseEnter, on onClick={() => openPluginModal(plugin, onRestartNeeded)} className={cl("info-button")} > - {plugin.settings?.def && Object.values(plugin.settings.def).some(s => s.type !== OptionType.CUSTOM && !s.hidden) + {hasAnyVisibleSettings(plugin) ? : } diff --git a/src/components/settings/tabs/plugins/PluginModal.css b/src/components/settings/tabs/plugins/PluginModal.css index 90620c665..bdd35c80c 100644 --- a/src/components/settings/tabs/plugins/PluginModal.css +++ b/src/components/settings/tabs/plugins/PluginModal.css @@ -60,5 +60,25 @@ } .vc-plugin-modal-info { + display: flex; + gap: 1em; + justify-content: space-between; + align-items: start; +} + +.vc-plugin-modal-tags { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-block: 8px 16px; +} + +.vc-plugin-modal-tag { align-items: center; -} \ No newline at end of file + border: 1px solid var(--border-normal); + border-radius: 9999px; + display: flex; + justify-content: center; + padding: 6px 12px; + color: var(--text-default); +} diff --git a/src/components/settings/tabs/plugins/PluginModal.tsx b/src/components/settings/tabs/plugins/PluginModal.tsx index cf3379ee9..186f2be1f 100644 --- a/src/components/settings/tabs/plugins/PluginModal.tsx +++ b/src/components/settings/tabs/plugins/PluginModal.tsx @@ -19,6 +19,7 @@ import "./PluginModal.css"; import { generateId } from "@api/Commands"; +import { hasAnyVisibleSettings, isSettingHidden } from "@api/PluginManager"; import { useSettings } from "@api/Settings"; import { BaseText } from "@components/BaseText"; import { Button } from "@components/Button"; @@ -32,7 +33,7 @@ import { proxyLazy } from "@utils/lazy"; import { Margins } from "@utils/margins"; import { classes, isObjectEmpty } from "@utils/misc"; import { ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal"; -import { OptionType, Plugin } from "@utils/types"; +import { OptionType, Plugin, PluginTag } from "@utils/types"; import { User } from "@vencord/discord-types"; import { findComponentByCodeLazy, findCssClassesLazy } from "@webpack"; import { Clickable, FluxDispatcher, React, Toasts, Tooltip, useEffect, useMemo, UserStore, UserSummaryItem, UserUtils, useState } from "@webpack/common"; @@ -74,9 +75,19 @@ export function makeDummyUser(user: { username: string; id?: string; avatar?: st return newUser; } +function PluginTags({ tags }: { tags: PluginTag[]; }) { + return ( +
+ {tags.map(tag => ( +
{tag}
+ ))} +
+ ); +} + export default function PluginModal({ plugin, onRestartNeeded, onClose, transitionState }: PluginModalProps) { const pluginSettings = useSettings([`plugins.${plugin.name}.*`]).plugins[plugin.name]; - const hasSettings = Boolean(pluginSettings && plugin.options && !isObjectEmpty(plugin.options)); + const hasSettings = hasAnyVisibleSettings(plugin); // avoid layout shift by showing dummy users while loading users const fallbackAuthors = useMemo(() => [makeDummyUser({ username: "Loading...", id: "-1465912127305809920" })], []); @@ -104,14 +115,17 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti } function renderSettings() { - if (!hasSettings || !plugin.options) + const { settings } = plugin; + if (!hasSettings || !settings) return There are no settings for this plugin.; - const options = Object.entries(plugin.options).map(([key, setting]) => { - if (setting.type === OptionType.CUSTOM || setting.hidden) return null; + const options = Object.entries(settings.def).map(([key, setting]) => { + if (setting.type === OptionType.CUSTOM) return null; + + if (isSettingHidden(settings, setting)) return null; function onChange(newValue: any) { - const option = plugin.options?.[key]; + const option = plugin.settings!.def[key]; if (!option || option.type === OptionType.CUSTOM) return; pluginSettings[key] = newValue; @@ -124,10 +138,10 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti ); @@ -167,6 +181,7 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti
{plugin.name} {plugin.description} + {!!plugin.tags?.length && } {!!plugin.settingsAboutComponent && (
diff --git a/src/components/settings/tabs/plugins/components/BooleanSetting.tsx b/src/components/settings/tabs/plugins/components/BooleanSetting.tsx index 51cef49d3..22596c3ba 100644 --- a/src/components/settings/tabs/plugins/components/BooleanSetting.tsx +++ b/src/components/settings/tabs/plugins/components/BooleanSetting.tsx @@ -16,20 +16,21 @@ * along with this program. If not, see . */ +import { isSettingDisabled } from "@api/PluginManager"; import { Switch } from "@components/Switch"; -import { PluginOptionBoolean } from "@utils/types"; +import { PluginSettingBooleanDef } from "@utils/types"; import { React, useState } from "@webpack/common"; import { resolveError, SettingProps, SettingsSection } from "./Common"; -export function BooleanSetting({ option, pluginSettings, definedSettings, id, onChange }: SettingProps) { - const def = pluginSettings[id] ?? option.default; +export function BooleanSetting({ setting, pluginSettings, definedSettings, id, onChange }: SettingProps) { + const def = pluginSettings[id] ?? setting.default; const [state, setState] = useState(def ?? false); const [error, setError] = useState(null); function handleChange(newValue: boolean): void { - const isValid = option.isValid?.call(definedSettings, newValue) ?? true; + const isValid = setting.isValid?.call(definedSettings, newValue) ?? true; setState(newValue); setError(resolveError(isValid)); @@ -40,11 +41,11 @@ export function BooleanSetting({ option, pluginSettings, definedSettings, id, on } return ( - + ); diff --git a/src/components/settings/tabs/plugins/components/Common.tsx b/src/components/settings/tabs/plugins/components/Common.tsx index 56bb24f10..d5f0ac69c 100644 --- a/src/components/settings/tabs/plugins/components/Common.tsx +++ b/src/components/settings/tabs/plugins/components/Common.tsx @@ -8,24 +8,24 @@ import { BaseText } from "@components/BaseText"; import { classNameFactory } from "@utils/css"; import { classes } from "@utils/misc"; import { wordsFromCamel, wordsToTitle } from "@utils/text"; -import { DefinedSettings, PluginOptionBase } from "@utils/types"; +import { DefinedSettings, PluginSettingDefCommon } from "@utils/types"; import { PropsWithChildren } from "react"; export const cl = classNameFactory("vc-plugins-setting-"); interface SettingBaseProps { - option: T; + setting: T; onChange(newValue: any): void; pluginSettings: { [setting: string]: any; enabled: boolean; }; id: string; - definedSettings?: DefinedSettings; + definedSettings: DefinedSettings; } -export type SettingProps = SettingBaseProps; -export type ComponentSettingProps> = SettingBaseProps; +export type SettingProps = SettingBaseProps; +export type ComponentSettingProps> = SettingBaseProps; export function resolveError(isValidResult: boolean | string) { if (typeof isValidResult === "string") return isValidResult; diff --git a/src/components/settings/tabs/plugins/components/ComponentSetting.tsx b/src/components/settings/tabs/plugins/components/ComponentSetting.tsx index c43b72768..e1521f02a 100644 --- a/src/components/settings/tabs/plugins/components/ComponentSetting.tsx +++ b/src/components/settings/tabs/plugins/components/ComponentSetting.tsx @@ -16,10 +16,10 @@ * along with this program. If not, see . */ -import { PluginOptionComponent } from "@utils/types"; +import { PluginSettingComponentDef } from "@utils/types"; import { ComponentSettingProps } from "./Common"; -export function ComponentSetting({ option, onChange }: ComponentSettingProps) { - return option.component({ setValue: onChange, option }); +export function ComponentSetting({ setting, onChange }: ComponentSettingProps) { + return setting.component({ setValue: onChange, option: setting }); } diff --git a/src/components/settings/tabs/plugins/components/NumberSetting.tsx b/src/components/settings/tabs/plugins/components/NumberSetting.tsx index 108ddc3e4..fa348a108 100644 --- a/src/components/settings/tabs/plugins/components/NumberSetting.tsx +++ b/src/components/settings/tabs/plugins/components/NumberSetting.tsx @@ -16,24 +16,25 @@ * along with this program. If not, see . */ -import { OptionType, PluginOptionNumber } from "@utils/types"; +import { isSettingDisabled } from "@api/PluginManager"; +import { OptionType, PluginSettingBigIntDef, PluginSettingNumberDef } from "@utils/types"; import { React, TextInput, useState } from "@webpack/common"; import { resolveError, SettingProps, SettingsSection } from "./Common"; const MAX_SAFE_NUMBER = BigInt(Number.MAX_SAFE_INTEGER); -export function NumberSetting({ option, pluginSettings, definedSettings, id, onChange }: SettingProps) { +export function NumberSetting({ setting, pluginSettings, definedSettings, id, onChange }: SettingProps) { function serialize(value: any) { - if (option.type === OptionType.BIGINT) return BigInt(value); + if (setting.type === OptionType.BIGINT) return BigInt(value); return Number(value); } - const [state, setState] = useState(`${pluginSettings[id] ?? option.default ?? 0}`); + const [state, setState] = useState(`${pluginSettings[id] ?? setting.default ?? 0}`); const [error, setError] = useState(null); function handleChange(newValue: any) { - const isValid = option.isValid?.call(definedSettings, newValue) ?? true; + const isValid = setting.isValid?.call(definedSettings, newValue) ?? true; setError(resolveError(isValid)); @@ -41,7 +42,7 @@ export function NumberSetting({ option, pluginSettings, definedSettings, id, onC onChange(serialize(newValue)); } - if (option.type === OptionType.NUMBER && BigInt(newValue) >= MAX_SAFE_NUMBER) { + if (setting.type === OptionType.NUMBER && BigInt(newValue) >= MAX_SAFE_NUMBER) { setState(`${Number.MAX_SAFE_INTEGER}`); } else { setState(newValue); @@ -49,15 +50,15 @@ export function NumberSetting({ option, pluginSettings, definedSettings, id, onC } return ( - + ); diff --git a/src/components/settings/tabs/plugins/components/SelectSetting.tsx b/src/components/settings/tabs/plugins/components/SelectSetting.tsx index 4c912c515..89f652e7a 100644 --- a/src/components/settings/tabs/plugins/components/SelectSetting.tsx +++ b/src/components/settings/tabs/plugins/components/SelectSetting.tsx @@ -16,19 +16,20 @@ * along with this program. If not, see . */ -import { PluginOptionSelect } from "@utils/types"; +import { isSettingDisabled } from "@api/PluginManager"; +import { PluginSettingSelectDef } from "@utils/types"; import { React, Select, useState } from "@webpack/common"; import { resolveError, SettingProps, SettingsSection } from "./Common"; -export function SelectSetting({ option, pluginSettings, definedSettings, onChange, id }: SettingProps) { - const def = pluginSettings[id] ?? option.options?.find(o => o.default)?.value; +export function SelectSetting({ setting, pluginSettings, definedSettings, onChange, id }: SettingProps) { + const def = pluginSettings[id] ?? setting.options?.find(o => o.default)?.value; const [state, setState] = useState(def ?? null); const [error, setError] = useState(null); function handleChange(newValue: any) { - const isValid = option.isValid?.call(definedSettings, newValue) ?? true; + const isValid = setting.isValid?.call(definedSettings, newValue) ?? true; setState(newValue); setError(resolveError(isValid)); @@ -39,17 +40,17 @@ export function SelectSetting({ option, pluginSettings, definedSettings, onChang } return ( - +