Merge branch 'dev' into bun

This commit is contained in:
thororen1234 2025-12-16 23:43:06 -05:00
commit f656dc2612
No known key found for this signature in database
655 changed files with 16217 additions and 13754 deletions

View file

@ -64,7 +64,8 @@ jobs:
git config --global user.email actions@github.com
git clone https://$USERNAME:$GITHUB_TOKEN@github.com/$REPO.git plugins
cd plugins
cp ../dist/release/*plugins.json .
cp ../dist/release/*.json .
git add -A
git commit -m "Plugins for https://github.com/$GITHUB_REPOSITORY/commit/$GITHUB_SHA"
git push --force https://$USERNAME:$GITHUB_TOKEN@github.com/$REPO.git

View file

@ -77,13 +77,22 @@ jobs:
if: steps.check.outputs.release == 'true'
run: pnpm buildWebStandalone
- name: Create sources zip
if: steps.check.outputs.release == 'true'
run: zip -r dist/extension-sources.zip . -x "node_modules/*" -x "dist/*" -x ".git/*"
- name: Publish extension
if: steps.check.outputs.release == 'true'
run: |
cd dist/browser/chromium-unpacked
pnpx chrome-webstore-upload-cli@2.1.0 upload --auto-publish
run: pnpx 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:
EXTENSION_ID: ${{ secrets.CHROME_EXTENSION_ID }}
CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }}
CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }}
REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }}
CHROME_EXTENSION_ID: ${{ secrets.CHROME_EXTENSION_ID }}
CHROME_CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }}
CHROME_CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }}
CHROME_REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }}
FIREFOX_EXTENSION_ID: ${{ secrets.FIREFOX_EXTENSION_ID }}
FIREFOX_JWT_ISSUER: ${{ secrets.FIREFOX_JWT_ISSUER }}
FIREFOX_JWT_SECRET: ${{ secrets.FIREFOX_JWT_SECRET }}
EDGE_PRODUCT_ID: ${{ secrets.EDGE_PRODUCT_ID }}
EDGE_CLIENT_ID: ${{ secrets.EDGE_CLIENT_ID }}
EDGE_API_KEY: ${{ secrets.EDGE_API_KEY }}

View file

@ -25,7 +25,7 @@ MacOS
Linux
- [GUI-X11](https://github.com/Equicord/Equilotl/releases/latest/download/Equilotl-x11)
- [GUI](https://github.com/Equicord/Equilotl/releases/latest/download/Equilotl-x11)
- [CLI](https://github.com/Equicord/Equilotl/releases/latest/download/EquilotlCli-Linux)
- [AUR](https://aur.archlinux.org/packages?O=0&K=equicord)
@ -70,12 +70,22 @@ Build Equicord:
bun run build
```
Inject Equicord into your client:
Inject Equicord into your desktop client:
```shell
bun run inject
```
Build Equicord for web:
```shell
pnpm buildWeb
```
After building Equicord's web extension, locate the appropriate ZIP file in the `dist` directory and follow your browser’s guide for installing custom extensions, if supported.
Note: Firefox extension zip requires Firefox for developers
## Credits
Thank you to [Vendicated](https://github.com/Vendicated) for creating [Vencord](https://github.com/Vendicated/Vencord) & [Suncord](https://github.com/verticalsync/Suncord) by [verticalsync](https://github.com/verticalsync) for helping when needed.

View file

@ -19,13 +19,15 @@
/// <reference path="../src/modules.d.ts" />
/// <reference path="../src/globals.d.ts" />
// Be very careful with imports in this file to avoid circular dependency issues.
// Only import pure modules that don't import other parts of Vencord.
import monacoHtmlLocal from "file://monacoWin.html?minify";
import * as DataStore from "../src/api/DataStore";
import { debounce, localStorage } from "../src/utils";
import { EXTENSION_BASE_URL } from "../src/utils/web-metadata";
import { getTheme, Theme } from "../src/utils/discord";
import { Settings } from "../src/Vencord";
import * as DataStore from "@api/DataStore";
import type { Settings } from "@api/Settings";
import { debounce } from "@shared/debounce";
import { localStorage } from "@utils/localStorage";
import { getStylusWebStoreUrl } from "@utils/web";
import { EXTENSION_BASE_URL } from "@utils/web-metadata";
// listeners for ipc.on
const cssListeners = new Set<(css: string) => void>();
@ -90,6 +92,8 @@ window.VencordNative = {
return;
}
const { getTheme, Theme } = require("@utils/discord");
win.baseUrl = EXTENSION_BASE_URL;
win.setCss = setCssDebounced;
win.getCurrentCss = () => VencordNative.quickCss.get();

View file

@ -49,8 +49,17 @@
],
"browser_specific_settings": {
"gecko": {
"id": "equicord-firefox@equicord.org",
"strict_min_version": "128.0"
"id": "firefox@equicord.org",
"strict_min_version": "128.0",
"data_collection_permissions": {
"required": [
"none"
],
"optional": [
"authenticationInfo",
"locationInfo"
]
}
}
}
}

View file

@ -88,7 +88,7 @@ export default tseslint.config(
"@stylistic/no-extra-semi": "error",
// TS Rules
"@stylistic/func-call-spacing": ["error", "never"],
"@stylistic/function-call-spacing": ["error", "never"],
// ESLint Rules
"yoda": "error",
@ -113,7 +113,7 @@ export default tseslint.config(
"no-useless-escape": [
"error",
{
"extra": "i"
"allowRegexCharacters": ["i"]
}
],
"no-fallthrough": "error",
@ -138,7 +138,7 @@ export default tseslint.config(
// Plugin Rules
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error",
"unused-imports/no-unused-imports": "error",
"unused-imports/no-unused-imports": "error"
}
}
);

View file

@ -1,7 +1,7 @@
{
"name": "equicord",
"private": "true",
"version": "1.13.4",
"version": "1.13.9.1",
"description": "The other cutest Discord client mod",
"homepage": "https://github.com/Equicord/Equicord#readme",
"bugs": {
@ -25,8 +25,8 @@
"watchWeb": "bun run buildWeb --watch",
"generateDevsList": "bun run scripts/generateDevsList.ts",
"generatePluginJson": "bun run scripts/generatePluginList.ts",
"generateEquicordPluginJson": "bun run scripts/generateEquicordPluginList.ts",
"generateVencordPluginJson": "bun run scripts/generateVencordPluginList.ts",
"generateEquicordPluginJson": "bun run scripts/generatePluginList.ts --equicord",
"generateVencordPluginJson": "bun run scripts/generatePluginList.ts --vencord",
"generateTypes": "tspc --emitDeclarationOnly --declaration --outDir packages/vencord-types --allowJs false",
"inject": "bun run scripts/runInstaller.mjs --install",
"uninject": "bun run scripts/runInstaller.mjs --uninstall",
@ -51,45 +51,46 @@
"gifenc": "github:mattdesl/gifenc#64842fca317b112a8590f8fef2bf3825da8f6fe3",
"idb": "8.0.3",
"jsqr": "1.4.0",
"monaco-editor": "^0.52.2",
"nanoid": "^5.1.5",
"monaco-editor": "^0.54.0",
"nanoid": "^5.1.6",
"usercss-meta": "^0.12.0",
"virtual-merge": "^1.0.1"
},
"devDependencies": {
"@electron/asar": "^4.0.1",
"@stylistic/eslint-plugin": "^4.2.0",
"@stylistic/eslint-plugin": "^5.6.0",
"@types/adm-zip": "^0.5.7",
"@types/chrome": "^0.0.312",
"@types/chrome": "^0.1.30",
"@types/diff": "^7.0.2",
"@types/lodash": "^4.17.14",
"@types/node": "^22.13.13",
"@types/lodash": "^4.17.20",
"@types/node": "^24.10.1",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@types/yazl": "^2.4.5",
"@types/yazl": "^3.3.0",
"@vencord/discord-types": "link:@vencord/discord-types",
"adm-zip": "^0.5.16",
"diff": "^7.0.0",
"esbuild": "^0.25.1",
"eslint": "^8.57.0",
"diff": "^8.0.2",
"esbuild": "^0.27.0",
"eslint": "9.39.1",
"eslint-import-resolver-alias": "^1.1.2",
"eslint-plugin-react": "^7.37.3",
"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.1.4",
"eslint-plugin-unused-imports": "^4.3.0",
"highlight.js": "11.11.1",
"html-minifier-terser": "^7.2.0",
"moment": "^2.22.2",
"puppeteer-core": "^24.19.0",
"puppeteer-core": "^24.30.0",
"standalone-electron-types": "^34.2.0",
"stylelint": "^16.17.0",
"stylelint-config-standard": "^37.0.0",
"stylelint": "^16.25.0",
"stylelint-config-standard": "^39.0.1",
"ts-patch": "^3.3.0",
"ts-pattern": "^5.6.0",
"type-fest": "^4.38.0",
"typescript": "^5.8.2",
"typescript-eslint": "^8.28.0",
"typescript-transform-paths": "^3.5.5"
"type-fest": "^5.2.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.47.0",
"typescript-transform-paths": "^3.5.5",
"zip-local": "^0.3.5"
},
"packageManager": "bun@1.1.0",
"trustedDependencies": [

View file

@ -3,8 +3,10 @@ import { ActivityFlags, ActivityStatusDisplayType, ActivityType } from "../../en
export interface ActivityAssets {
large_image?: string;
large_text?: string;
large_url?: string;
small_image?: string;
small_text?: string;
small_url?: string;
}
export interface ActivityButton {
@ -45,3 +47,5 @@ export interface Activity {
size?: [number, number];
};
}
export type OnlineStatus = "online" | "idle" | "dnd" | "invisible" | "offline" | "unknown" | "streaming";

View file

@ -62,4 +62,5 @@ export interface UserJSON {
id: string;
publicFlags: number;
username: string;
globalName: string | undefined;
}

View file

@ -2,7 +2,7 @@ import { CommandOption } from './Commands';
import { User, UserJSON } from '../User';
import { Embed, EmbedJSON } from './Embed';
import { DiscordRecord } from "../Record";
import { MessageFlags, MessageType, StickerFormatType } from "../../../enums";
import { ApplicationIntegrationType, MessageFlags, MessageType, StickerFormatType } from "../../../enums";
/*
* TODO: looks like discord has moved over to Date instead of Moment;
@ -71,6 +71,19 @@ export class Message extends DiscordRecord {
type: number;
version: string;
}[];
interactionMetadata?: {
id: string;
type: number;
name?: string;
command_type?: number;
ephemerality_reason?: number;
user: User;
authorizing_integration_owners: Record<ApplicationIntegrationType, string>;
original_response_message_id?: string;
interacted_message_id?: string;
target_user?: User;
target_message_id?: string;
};
interactionError: unknown[];
isSearchHit: boolean;
loggingName: unknown;

View file

@ -318,14 +318,6 @@ export type Slider = ComponentClass<PropsWithChildren<{
"aria-describedby"?: string;
}>>;
// TODO - type maybe idk probably not that useful other than the constants
export type Flex = ComponentType<PropsWithChildren<any>> & {
Align: Record<"START" | "END" | "CENTER" | "STRETCH" | "BASELINE", string>;
Direction: Record<"VERTICAL" | "HORIZONTAL" | "HORIZONTAL_REVERSE", string>;
Justify: Record<"START" | "END" | "CENTER" | "BETWEEN" | "AROUND", string>;
Wrap: Record<"NO_WRAP" | "WRAP" | "WRAP_REVERSE", string>;
};
declare enum PopoutAnimation {
NONE = "1",
TRANSLATE = "2",

View file

@ -1,4 +1,4 @@
import type { ComponentType, CSSProperties, MouseEvent, PropsWithChildren, ReactNode, UIEvent } from "react";
import type { ComponentType, CSSProperties, ForwardRefRenderFunction, MouseEvent, PropsWithChildren, ReactNode, UIEvent } from "react";
type RC<C> = ComponentType<PropsWithChildren<C & Record<string, any>>>;
@ -46,6 +46,8 @@ export interface Menu {
MenuControlItem: RC<{
id: string;
interactive?: boolean;
label?: string;
control: ForwardRefRenderFunction<any, any>;
}>;
MenuSliderControl: RC<{
minValue?: number,

View file

@ -0,0 +1,26 @@
import { Activity, OnlineStatus } from "../common";
import { FluxStore } from "./FluxStore";
export interface UserAndActivity {
userId: string;
activity: Activity;
}
export type DiscordPlatform = "desktop" | "mobile" | "web" | "embedded";
export class PresenceStore extends FluxStore {
findActivity(userId: string, predicate: (activity: Activity) => boolean, guildId?: string): Activity | undefined;
getActivities(userId: string, guildId?: string): Activity[];
getActivityMetadata(userId: string): any;
getAllApplicationActivities(applicationId: string): UserAndActivity[];
getApplicationActivity(userId: string, applicationId: string, guildId?: string): Activity | null;
getClientStatus(userId: string): Record<DiscordPlatform, OnlineStatus>;
getHiddenActivities(): any;
/** literally just getActivities(...)[0] */
getPrimaryActivity(userId: string, guildId?: string): Activity | null;
getState(): any;
getStatus(userId: string, guildId?: string | null, defaultStatus?: OnlineStatus): OnlineStatus;
getUnfilteredActivities(userId: string, guildId?: string): Activity[];
getUserIds(): string[];
isMobileOnline(userId: string): boolean;
}

View file

@ -0,0 +1,11 @@
import { CloudUpload, DraftType, FluxStore } from "..";
export class UploadAttachmentStore extends FluxStore {
getFirstUpload(channelId: string, draftType: string): CloudUpload;
hasAdditionalUploads(channelId: string, draftType: string): boolean;
getUploads(channelId: string, draftType: DraftType): CloudUpload[];
getUploadCount(channelId: string, draftType: DraftType): number;
getUpload(channelId: string, commandName: string, draftType: DraftType): CloudUpload;
findUpload(channelId: string, draftType: string, predicate: (upload: CloudUpload) => boolean): CloudUpload | undefined;
}

View file

@ -10,6 +10,7 @@ export interface VoiceState extends DiscordRecord {
sessionId: string | null | undefined;
mute: boolean;
deaf: boolean;
stream: boolean;
selfMute: boolean;
selfDeaf: boolean;
selfVideo: boolean;

View file

@ -8,6 +8,7 @@ export * from "./GuildMemberStore";
export * from "./GuildRoleStore";
export * from "./GuildStore";
export * from "./MessageStore";
export * from "./PresenceStore";
export * from "./RelationshipStore";
export * from "./MediaEngineStore";
export * from "./StreamerModeStore";

View file

@ -1,11 +1,11 @@
# Vencord Types
# Equicord Types
Typings for Vencord's api, published to npm
Typings for Equicord's api, published to npm
```sh
npm i @vencord/types
npm i @equicord/types
yarn add @vencord/types
yarn add @equicord/types
bun add @vencord/types
```

View file

@ -1,7 +1,7 @@
{
"name": "@vencord/types",
"private": false,
"version": "1.13.3",
"version": "1.13.8",
"description": "",
"types": "index.d.ts",
"scripts": {

View file

@ -1,49 +0,0 @@
diff --git a/lib/rules/no-useless-escape.js b/lib/rules/no-useless-escape.js
index 0e0f6f09f2c35f3276173c08f832cde9f2cf56a0..7dc22851715f3574d935f513c1b5e35552985711 100644
--- a/lib/rules/no-useless-escape.js
+++ b/lib/rules/no-useless-escape.js
@@ -65,13 +65,31 @@ module.exports = {
escapeBackslash: "Replace the `\\` with `\\\\` to include the actual backslash character."
},
- schema: []
+ schema: [{
+ type: "object",
+ properties: {
+ extra: {
+ type: "string",
+ default: ""
+ },
+ extraCharClass: {
+ type: "string",
+ default: ""
+ },
+ },
+ additionalProperties: false
+ }]
},
create(context) {
+ const options = context.options[0] || {};
+ const { extra, extraCharClass } = options;
const sourceCode = context.sourceCode;
const parser = new RegExpParser();
+ const NON_CHARCLASS_ESCAPES = union(REGEX_NON_CHARCLASS_ESCAPES, new Set(extra));
+ const CHARCLASS_ESCAPES = union(REGEX_GENERAL_ESCAPES, new Set(extraCharClass));
+
/**
* Reports a node
* @param {ASTNode} node The node to report
@@ -200,9 +218,9 @@ module.exports = {
let allowedEscapes;
if (characterClassStack.length) {
- allowedEscapes = unicodeSets ? REGEX_CLASSSET_CHARACTER_ESCAPES : REGEX_GENERAL_ESCAPES;
+ allowedEscapes = unicodeSets ? REGEX_CLASSSET_CHARACTER_ESCAPES : CHARCLASS_ESCAPES;
} else {
- allowedEscapes = REGEX_NON_CHARCLASS_ESCAPES;
+ allowedEscapes = NON_CHARCLASS_ESCAPES;
}
if (allowedEscapes.has(escapedChar)) {
return;

View file

@ -21,7 +21,7 @@
import { createPackage } from "@electron/asar";
import { readdir, writeFile } from "fs/promises";
import { dirname, join } from "path";
import { dirname, join, resolve } from "path";
import { fileURLToPath } from "url";
import { BUILD_TIMESTAMP, commonOpts, exists, globPlugins, IS_DEV, IS_REPORTER, IS_COMPANION_TEST, IS_STANDALONE, IS_UPDATER_DISABLED, resolvePluginName, VERSION, commonRendererPlugins, watch, buildOrWatchAll, stringifyValues, IS_ANTI_CRASH_TEST } from "./common.mjs";
@ -81,6 +81,10 @@ const globNativesPlugin = {
let code = "";
let natives = "\n";
let i = 0;
/**
* @type {string[]}
*/
const watchFiles = [];
for (const dir of pluginDirs) {
const dirPath = join("src", dir);
if (!await exists(dirPath)) continue;
@ -90,6 +94,8 @@ const globNativesPlugin = {
const nativePath = join(dirPath, fileName, "native.ts");
const indexNativePath = join(dirPath, fileName, "native/index.ts");
watchFiles.push(resolve(nativePath), resolve(indexNativePath));
if (!(await exists(nativePath)) && !(await exists(indexNativePath)))
continue;
@ -104,7 +110,9 @@ const globNativesPlugin = {
code += `export default {${natives}};`;
return {
contents: code,
resolveDir: "./src"
resolveDir: "./src",
watchDirs: pluginDirs.map(d => resolve("src", d)),
watchFiles,
};
});
}

View file

@ -26,7 +26,7 @@ import esbuild, { build, context } from "esbuild";
import { constants as FsConstants, readFileSync } from "fs";
import { access, readdir, readFile } from "fs/promises";
import { minify as minifyHtml } from "html-minifier-terser";
import { dirname, join, relative } from "path";
import { dirname, join, relative, resolve } from "path";
import { fileURLToPath } from "url";
import { promisify } from "util";
@ -195,7 +195,8 @@ export const globPlugins = kind => ({
code += `export default {${pluginsCode}};export const PluginMeta={${metaCode}};export const ExcludedPlugins={${excludedCode}};`;
return {
contents: code,
resolveDir: "./src"
resolveDir: "./src",
watchDirs: pluginDirs.map(d => resolve("src", d)),
};
});
}

View file

@ -1,298 +0,0 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Dirent, readdirSync, readFileSync, writeFileSync } from "fs";
import { access, readFile } from "fs/promises";
import { join, sep } from "path";
import { normalize as posixNormalize, sep as posixSep } from "path/posix";
import { BigIntLiteral, createSourceFile, Identifier, isArrayLiteralExpression, isCallExpression, isExportAssignment, isIdentifier, isObjectLiteralExpression, isPropertyAccessExpression, isPropertyAssignment, isSatisfiesExpression, isStringLiteral, isVariableStatement, NamedDeclaration, NodeArray, ObjectLiteralExpression, PropertyAssignment, ScriptTarget, StringLiteral, SyntaxKind } from "typescript";
import { getPluginTarget } from "./utils.mjs";
interface Dev {
name: string;
id: string;
}
interface Command {
name: string;
description: string;
}
interface PluginData {
name: string;
description: string;
tags: string[];
authors: Dev[];
dependencies: string[];
hasPatches: boolean;
hasCommands: boolean;
commands: Command[];
required: boolean;
enabledByDefault: boolean;
target: "discordDesktop" | "vesktop" | "equibop" | "desktop" | "web" | "dev";
filePath: string;
dirName: string;
isModified: boolean;
}
const devs = {} as Record<string, Dev>;
const equicordDevs = {} as Record<string, Dev>;
function getName(node: NamedDeclaration) {
return node.name && isIdentifier(node.name) ? node.name.text : undefined;
}
function hasName(node: NamedDeclaration, name: string) {
return getName(node) === name;
}
function getObjectProp(node: ObjectLiteralExpression, name: string) {
const prop = node.properties.find(p => hasName(p, name));
if (prop && isPropertyAssignment(prop)) return prop.initializer;
return prop;
}
function parseDevs() {
const file = createSourceFile("constants.ts", readFileSync("src/utils/constants.ts", "utf8"), ScriptTarget.Latest);
for (const child of file.getChildAt(0).getChildren()) {
if (!isVariableStatement(child)) continue;
const devsDeclaration = child.declarationList.declarations.find(d => hasName(d, "Devs"));
if (!devsDeclaration?.initializer || !isCallExpression(devsDeclaration.initializer)) continue;
const value = devsDeclaration.initializer.arguments[0];
if (!isSatisfiesExpression(value) || !isObjectLiteralExpression(value.expression)) throw new Error("Failed to parse devs: not an object literal");
for (const prop of value.expression.properties) {
const name = (prop.name as Identifier).text;
const value = isPropertyAssignment(prop) ? prop.initializer : prop;
if (!isObjectLiteralExpression(value)) throw new Error(`Failed to parse devs: ${name} is not an object literal`);
devs[name] = {
name: (getObjectProp(value, "name") as StringLiteral).text,
id: (getObjectProp(value, "id") as BigIntLiteral).text.slice(0, -1)
};
}
return;
}
throw new Error("Could not find Devs constant");
}
function parseEquicordDevs() {
const file = createSourceFile("constants.ts", readFileSync("src/utils/constants.ts", "utf8"), ScriptTarget.Latest);
for (const child of file.getChildAt(0).getChildren()) {
if (!isVariableStatement(child)) continue;
const devsDeclaration = child.declarationList.declarations.find(d => hasName(d, "EquicordDevs"));
if (!devsDeclaration?.initializer || !isCallExpression(devsDeclaration.initializer)) continue;
const value = devsDeclaration.initializer.arguments[0];
if (!isSatisfiesExpression(value) || !isObjectLiteralExpression(value.expression)) throw new Error("Failed to parse EquicordDevs: not an object literal");
for (const prop of value.expression.properties) {
const name = (prop.name as Identifier).text;
const value = isPropertyAssignment(prop) ? prop.initializer : prop;
if (!isObjectLiteralExpression(value)) throw new Error(`Failed to parse EquicordDevs: ${name} is not an object literal`);
equicordDevs[name] = {
name: (getObjectProp(value, "name") as StringLiteral).text,
id: (getObjectProp(value, "id") as BigIntLiteral).text.slice(0, -1)
};
}
return;
}
throw new Error("Could not find EquicordDevs constant");
}
async function parseFile(fileName: string) {
const file = createSourceFile(fileName, await readFile(fileName, "utf8"), ScriptTarget.Latest);
const fail = (reason: string) => {
return new Error(`Invalid plugin ${fileName}, because ${reason}`);
};
for (const node of file.getChildAt(0).getChildren()) {
if (!isExportAssignment(node) || !isCallExpression(node.expression)) continue;
const call = node.expression;
if (!isIdentifier(call.expression) || call.expression.text !== "definePlugin") continue;
const pluginObj = node.expression.arguments[0];
if (!isObjectLiteralExpression(pluginObj)) throw fail("no object literal passed to definePlugin");
const data = {
hasPatches: false,
hasCommands: false,
enabledByDefault: false,
required: false,
isModified: false,
tags: [] as string[]
} as PluginData;
for (const prop of pluginObj.properties) {
const key = getName(prop);
const value = isPropertyAssignment(prop) ? prop.initializer : prop;
switch (key) {
case "name":
case "description":
if (!isStringLiteral(value)) throw fail(`${key} is not a string literal`);
data[key] = value.text;
break;
case "patches":
data.hasPatches = true;
break;
case "commands":
data.hasCommands = true;
if (isArrayLiteralExpression(value)) {
data.commands = value.elements.map((e) => {
if (isObjectLiteralExpression(e)) {
const nameProperty = e.properties.find((p): p is PropertyAssignment =>
isPropertyAssignment(p) && isIdentifier(p.name) && p.name.escapedText === "name"
);
const descriptionProperty = e.properties.find((p): p is PropertyAssignment =>
isPropertyAssignment(p) && isIdentifier(p.name) && p.name.escapedText === "description"
);
if (!nameProperty || !descriptionProperty) throw fail("command missing required properties");
const name = isStringLiteral(nameProperty.initializer) ? nameProperty.initializer.text : "";
const description = isStringLiteral(descriptionProperty.initializer) ? descriptionProperty.initializer.text : "";
return { name, description };
} else if (isCallExpression(e) && isIdentifier(e.expression)) {
const [nameArg] = e.arguments;
if (!isStringLiteral(nameArg)) throw fail("first argument must be a string");
return { name: nameArg.text, description: "" };
} else if (e.kind === SyntaxKind.SpreadElement) {
return undefined;
}
throw fail("commands array contains invalid elements");
}).filter((c): c is { name: string; description: string; } => Boolean(c)) as Command[];
} else if (isIdentifier(value)) {
data.commands = [];
} else {
throw fail("commands is not an array literal or identifier");
}
break;
case "authors":
if (!isArrayLiteralExpression(value)) throw fail("authors is not an array literal");
data.authors = value.elements.map(e => {
if (!isPropertyAccessExpression(e)) throw fail("authors array contains non-property access expressions");
const d = devs[getName(e)!] || equicordDevs[getName(e)!];
if (!d) throw fail(`couldn't look up author ${getName(e)}`);
return d;
});
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");
return e.text;
});
break;
case "dependencies":
if (!isArrayLiteralExpression(value)) throw fail("dependencies is not an array literal");
const { elements } = value;
if (elements.some(e => !isStringLiteral(e))) throw fail("dependencies array contains non-string elements");
data.dependencies = (elements as NodeArray<StringLiteral>).map(e => e.text);
break;
case "required":
case "isModified":
case "enabledByDefault":
data[key] = value.kind === SyntaxKind.TrueKeyword;
break;
}
}
if (!data.name || !data.description || !data.authors) throw fail("name, description or authors are missing");
const target = getPluginTarget(fileName);
if (target) {
if (!["web", "discordDesktop", "vencordDesktop", "equibop", "desktop", "dev"].includes(target)) throw fail(`invalid target ${target}`);
data.target = target as any;
}
data.filePath = posixNormalize(fileName)
.split(sep)
.join(posixSep)
.replace(/\/index\.([jt]sx?)$/, "");
data.dirName = posixNormalize(fileName)
.split(sep)
.join(posixSep)
.replace(/\/index\.([jt]sx?)$/, "")
.replace(/^src\/equicordplugins\//, "");
return [data] as const;
}
throw fail("no default export called 'definePlugin' found");
}
async function getEntryPoint(dir: string, dirent: Dirent) {
const base = join(dir, dirent.name);
if (!dirent.isDirectory()) return base;
for (const name of ["index.ts", "index.tsx"]) {
const full = join(base, name);
try {
await access(full);
return full;
} catch { }
}
throw new Error(`${dirent.name}: Couldn't find entry point`);
}
function isPluginFile({ name }: { name: string; }) {
if (name === "index.ts") return false;
return !name.startsWith("_") && !name.startsWith(".");
}
(async () => {
parseDevs();
parseEquicordDevs();
const plugins = [] as PluginData[];
await Promise.all(["src/equicordplugins"].flatMap(dir =>
readdirSync(dir, { withFileTypes: true })
.filter(isPluginFile)
.map(async dirent => {
const [data] = await parseFile(await getEntryPoint(dir, dirent));
plugins.sort().push(data);
})
));
const data = JSON.stringify(plugins);
if (process.argv.length > 2) {
writeFileSync(process.argv[2], data);
} else {
console.log(data);
}
})();

View file

@ -16,283 +16,47 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Dirent, readdirSync, readFileSync, writeFileSync } from "fs";
import { access, readFile } from "fs/promises";
import { join, sep } from "path";
import { normalize as posixNormalize, sep as posixSep } from "path/posix";
import { BigIntLiteral, createSourceFile, Identifier, isArrayLiteralExpression, isCallExpression, isExportAssignment, isIdentifier, isObjectLiteralExpression, isPropertyAccessExpression, isPropertyAssignment, isSatisfiesExpression, isStringLiteral, isVariableStatement, NamedDeclaration, NodeArray, ObjectLiteralExpression, PropertyAssignment, ScriptTarget, StringLiteral, SyntaxKind } from "typescript";
import { getPluginTarget } from "./utils.mjs";
interface Dev {
name: string;
id: string;
}
interface Command {
name: string;
description: string;
}
interface PluginData {
name: string;
description: string;
tags: string[];
authors: Dev[];
dependencies: string[];
hasPatches: boolean;
hasCommands: boolean;
commands: Command[];
required: boolean;
enabledByDefault: boolean;
target: "discordDesktop" | "vesktop" | "equibop" | "desktop" | "web" | "dev";
filePath: string;
dirName: string;
isModified: boolean;
}
const devs = {} as Record<string, Dev>;
const equicordDevs = {} as Record<string, Dev>;
function getName(node: NamedDeclaration) {
return node.name && isIdentifier(node.name) ? node.name.text : undefined;
}
function hasName(node: NamedDeclaration, name: string) {
return getName(node) === name;
}
function getObjectProp(node: ObjectLiteralExpression, name: string) {
const prop = node.properties.find(p => hasName(p, name));
if (prop && isPropertyAssignment(prop)) return prop.initializer;
return prop;
}
function parseDevs() {
const file = createSourceFile("constants.ts", readFileSync("src/utils/constants.ts", "utf8"), ScriptTarget.Latest);
for (const child of file.getChildAt(0).getChildren()) {
if (!isVariableStatement(child)) continue;
const devsDeclaration = child.declarationList.declarations.find(d => hasName(d, "Devs"));
if (!devsDeclaration?.initializer || !isCallExpression(devsDeclaration.initializer)) continue;
const value = devsDeclaration.initializer.arguments[0];
if (!isSatisfiesExpression(value) || !isObjectLiteralExpression(value.expression)) throw new Error("Failed to parse devs: not an object literal");
for (const prop of value.expression.properties) {
const name = (prop.name as Identifier).text;
const value = isPropertyAssignment(prop) ? prop.initializer : prop;
if (!isObjectLiteralExpression(value)) throw new Error(`Failed to parse devs: ${name} is not an object literal`);
devs[name] = {
name: (getObjectProp(value, "name") as StringLiteral).text,
id: (getObjectProp(value, "id") as BigIntLiteral).text.slice(0, -1)
};
}
return;
}
throw new Error("Could not find Devs constant");
}
function parseEquicordDevs() {
const file = createSourceFile("constants.ts", readFileSync("src/utils/constants.ts", "utf8"), ScriptTarget.Latest);
for (const child of file.getChildAt(0).getChildren()) {
if (!isVariableStatement(child)) continue;
const devsDeclaration = child.declarationList.declarations.find(d => hasName(d, "EquicordDevs"));
if (!devsDeclaration?.initializer || !isCallExpression(devsDeclaration.initializer)) continue;
const value = devsDeclaration.initializer.arguments[0];
if (!isSatisfiesExpression(value) || !isObjectLiteralExpression(value.expression)) throw new Error("Failed to parse EquicordDevs: not an object literal");
for (const prop of value.expression.properties) {
const name = (prop.name as Identifier).text;
const value = isPropertyAssignment(prop) ? prop.initializer : prop;
if (!isObjectLiteralExpression(value)) throw new Error(`Failed to parse EquicordDevs: ${name} is not an object literal`);
equicordDevs[name] = {
name: (getObjectProp(value, "name") as StringLiteral).text,
id: (getObjectProp(value, "id") as BigIntLiteral).text.slice(0, -1)
};
}
return;
}
throw new Error("Could not find EquicordDevs constant");
}
async function parseFile(fileName: string) {
const file = createSourceFile(fileName, await readFile(fileName, "utf8"), ScriptTarget.Latest);
const fail = (reason: string) => {
return new Error(`Invalid plugin ${fileName}, because ${reason}`);
};
for (const node of file.getChildAt(0).getChildren()) {
if (!isExportAssignment(node) || !isCallExpression(node.expression)) continue;
const call = node.expression;
if (!isIdentifier(call.expression) || call.expression.text !== "definePlugin") continue;
const pluginObj = node.expression.arguments[0];
if (!isObjectLiteralExpression(pluginObj)) throw fail("no object literal passed to definePlugin");
const data = {
hasPatches: false,
hasCommands: false,
enabledByDefault: false,
required: false,
isModified: false,
tags: [] as string[]
} as PluginData;
for (const prop of pluginObj.properties) {
const key = getName(prop);
const value = isPropertyAssignment(prop) ? prop.initializer : prop;
switch (key) {
case "name":
case "description":
if (!isStringLiteral(value)) throw fail(`${key} is not a string literal`);
data[key] = value.text;
break;
case "patches":
data.hasPatches = true;
break;
case "commands":
data.hasCommands = true;
if (isArrayLiteralExpression(value)) {
data.commands = value.elements.map((e) => {
if (isObjectLiteralExpression(e)) {
const nameProperty = e.properties.find((p): p is PropertyAssignment =>
isPropertyAssignment(p) && isIdentifier(p.name) && p.name.escapedText === "name"
);
const descriptionProperty = e.properties.find((p): p is PropertyAssignment =>
isPropertyAssignment(p) && isIdentifier(p.name) && p.name.escapedText === "description"
);
if (!nameProperty || !descriptionProperty) throw fail("command missing required properties");
const name = isStringLiteral(nameProperty.initializer) ? nameProperty.initializer.text : "";
const description = isStringLiteral(descriptionProperty.initializer) ? descriptionProperty.initializer.text : "";
return { name, description };
} else if (isCallExpression(e) && isIdentifier(e.expression)) {
const [nameArg] = e.arguments;
if (!isStringLiteral(nameArg)) throw fail("first argument must be a string");
return { name: nameArg.text, description: "" };
} else if (e.kind === SyntaxKind.SpreadElement) {
return undefined;
}
throw fail("commands array contains invalid elements");
}).filter((c): c is { name: string; description: string; } => Boolean(c)) as Command[];
} else if (isIdentifier(value)) {
data.commands = [];
} else {
throw fail("commands is not an array literal or identifier");
}
break;
case "authors":
if (!isArrayLiteralExpression(value)) throw fail("authors is not an array literal");
data.authors = value.elements.map(e => {
if (!isPropertyAccessExpression(e)) throw fail("authors array contains non-property access expressions");
const d = devs[getName(e)!] || equicordDevs[getName(e)!];
if (!d) throw fail(`couldn't look up author ${getName(e)}`);
return d;
});
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");
return e.text;
});
break;
case "dependencies":
if (!isArrayLiteralExpression(value)) throw fail("dependencies is not an array literal");
const { elements } = value;
if (elements.some(e => !isStringLiteral(e))) throw fail("dependencies array contains non-string elements");
data.dependencies = (elements as NodeArray<StringLiteral>).map(e => e.text);
break;
case "required":
case "isModified":
case "enabledByDefault":
data[key] = value.kind === SyntaxKind.TrueKeyword;
break;
}
}
if (!data.name || !data.description || !data.authors) throw fail("name, description or authors are missing");
const target = getPluginTarget(fileName);
if (target) {
if (!["web", "discordDesktop", "vesktop", "equibop", "desktop", "dev"].includes(target)) throw fail(`invalid target ${target}`);
data.target = target as any;
}
data.filePath = posixNormalize(fileName)
.split(sep)
.join(posixSep)
.replace(/\/index\.([jt]sx?)$/, "");
data.dirName = posixNormalize(fileName)
.split(sep)
.join(posixSep)
.replace(/\/index\.([jt]sx?)$/, "")
.replace(/^src\/plugins\//, "")
.replace(/^src\/equicordplugins\//, "");
return [data] as const;
}
throw fail("no default export called 'definePlugin' found");
}
async function getEntryPoint(dir: string, dirent: Dirent) {
const base = join(dir, dirent.name);
if (!dirent.isDirectory()) return base;
for (const name of ["index.ts", "index.tsx"]) {
const full = join(base, name);
try {
await access(full);
return full;
} catch { }
}
throw new Error(`${dirent.name}: Couldn't find entry point`);
}
function isPluginFile({ name }: { name: string; }) {
if (name === "index.ts") return false;
return !name.startsWith("_") && !name.startsWith(".");
}
import { readdirSync, writeFileSync } from "fs";
import { getEntryPoint, isPluginFile, parseDevs, parseEquicordDevs, parseFile, PluginData } from "./utils";
(async () => {
parseDevs();
parseEquicordDevs();
const args = process.argv.slice(2);
const equicordFlag = args.includes("--equicord");
const vencordFlag = args.includes("--vencord");
let dirs: string[];
if (equicordFlag) {
dirs = ["src/equicordplugins"];
} else if (vencordFlag) {
dirs = ["src/plugins", "src/plugins/_core"];
} else {
dirs = ["src/plugins", "src/plugins/_core", "src/equicordplugins"];
}
const outputPath = args.find(a => !a.startsWith("--")) ?? null;
const plugins = [] as PluginData[];
await Promise.all(["src/plugins", "src/plugins/_core", "src/equicordplugins"].flatMap(dir =>
await Promise.all(
dirs.flatMap(dir =>
readdirSync(dir, { withFileTypes: true })
.filter(isPluginFile)
.map(async dirent => {
const [data] = await parseFile(await getEntryPoint(dir, dirent));
plugins.sort().push(data);
})
));
)
);
const data = JSON.stringify(plugins);
if (process.argv.length > 2) {
writeFileSync(process.argv[2], data);
if (outputPath) {
writeFileSync(outputPath, data);
} else {
console.log(data);
}

View file

@ -87,8 +87,14 @@ const IGNORED_DISCORD_ERRORS = [
function toCodeBlock(s: string, indentation = 0, isDiscord = false) {
s = s.replace(/```/g, "`\u200B`\u200B`");
const indentationStr = Array(!isDiscord ? indentation : 0).fill(" ").join("");
return `\`\`\`\n${s.split("\n").map(s => indentationStr + s).join("\n")}\n${indentationStr}\`\`\``;
s = s.replace(/\\x([0-9A-Fa-f]{2})/g, (_, hex) =>
String.fromCharCode(parseInt(hex, 16))
);
const indentationStr = " ".repeat(!isDiscord ? indentation : 0);
const content = s.split("\n").map(s => indentationStr + s).join("\n");
return `\`\`\`\n${content}\n${indentationStr}\`\`\``;
}
async function printReport() {
@ -340,7 +346,7 @@ page.on("console", async e => {
});
page.on("error", e => logStderr("[Error]", e.message));
page.on("pageerror", e => {
page.on("pageerror", (e: any) => {
if (e.message.includes("Sentry successfully disabled")) return;
if (!e.message.startsWith("Object") && !e.message.includes("Cannot find module") && !/^.{1,2}$/.test(e.message)) {

View file

@ -24,17 +24,17 @@ import { BigIntLiteral, createSourceFile, Identifier, isArrayLiteralExpression,
import { getPluginTarget } from "./utils.mjs";
interface Dev {
export interface Dev {
name: string;
id: string;
}
interface Command {
export interface Command {
name: string;
description: string;
}
interface PluginData {
export interface PluginData {
name: string;
description: string;
tags: string[];
@ -50,24 +50,25 @@ interface PluginData {
dirName: string;
isModified: boolean;
}
const devs = {} as Record<string, Dev>;
const equicordDevs = {} as Record<string, Dev>;
function getName(node: NamedDeclaration) {
export const devs = {} as Record<string, Dev>;
export const equicordDevs = {} as Record<string, Dev>;
export function getName(node: NamedDeclaration) {
return node.name && isIdentifier(node.name) ? node.name.text : undefined;
}
function hasName(node: NamedDeclaration, name: string) {
export function hasName(node: NamedDeclaration, name: string) {
return getName(node) === name;
}
function getObjectProp(node: ObjectLiteralExpression, name: string) {
export function getObjectProp(node: ObjectLiteralExpression, name: string) {
const prop = node.properties.find(p => hasName(p, name));
if (prop && isPropertyAssignment(prop)) return prop.initializer;
return prop;
}
function parseDevs() {
export function parseDevs() {
const file = createSourceFile("constants.ts", readFileSync("src/utils/constants.ts", "utf8"), ScriptTarget.Latest);
for (const child of file.getChildAt(0).getChildren()) {
@ -98,7 +99,7 @@ function parseDevs() {
throw new Error("Could not find Devs constant");
}
function parseEquicordDevs() {
export function parseEquicordDevs() {
const file = createSourceFile("constants.ts", readFileSync("src/utils/constants.ts", "utf8"), ScriptTarget.Latest);
for (const child of file.getChildAt(0).getChildren()) {
@ -129,7 +130,7 @@ function parseEquicordDevs() {
throw new Error("Could not find EquicordDevs constant");
}
async function parseFile(fileName: string) {
export async function parseFile(fileName: string) {
const file = createSourceFile(fileName, await readFile(fileName, "utf8"), ScriptTarget.Latest);
const fail = (reason: string) => {
@ -244,7 +245,8 @@ async function parseFile(fileName: string) {
.split(sep)
.join(posixSep)
.replace(/\/index\.([jt]sx?)$/, "")
.replace(/^src\/plugins\//, "");
.replace(/^src\/plugins\//, "")
.replace(/^src\/equicordplugins\//, "");
return [data] as const;
}
@ -252,7 +254,7 @@ async function parseFile(fileName: string) {
throw fail("no default export called 'definePlugin' found");
}
async function getEntryPoint(dir: string, dirent: Dirent) {
export async function getEntryPoint(dir: string, dirent: Dirent) {
const base = join(dir, dirent.name);
if (!dirent.isDirectory()) return base;
@ -267,31 +269,7 @@ async function getEntryPoint(dir: string, dirent: Dirent) {
throw new Error(`${dirent.name}: Couldn't find entry point`);
}
function isPluginFile({ name }: { name: string; }) {
export function isPluginFile({ name }: { name: string; }) {
if (name === "index.ts") return false;
return !name.startsWith("_") && !name.startsWith(".");
}
(async () => {
parseDevs();
parseEquicordDevs();
const plugins = [] as PluginData[];
await Promise.all(["src/plugins", "src/plugins/_core"].flatMap(dir =>
readdirSync(dir, { withFileTypes: true })
.filter(isPluginFile)
.map(async dirent => {
const [data] = await parseFile(await getEntryPoint(dir, dirent));
plugins.sort().push(data);
})
));
const data = JSON.stringify(plugins);
if (process.argv.length > 2) {
writeFileSync(process.argv[2], data);
} else {
console.log(data);
}
})();

View file

@ -20,34 +20,32 @@
import "~plugins";
export * as Api from "./api";
export * as Plugins from "./api/PluginManager";
export * as Components from "./components";
export * as Plugins from "./plugins";
export * as Util from "./utils";
export * as QuickCss from "./utils/quickCss";
export * as Updater from "./utils/updater";
export * as Webpack from "./webpack";
export * as WebpackPatcher from "./webpack/patchWebpack";
export { PlainSettings, Settings };
import "./utils/quickCss";
import "./webpack/patchWebpack";
import { addVencordUiStyles } from "@components/css";
import { openUpdaterModal } from "@components/settings/tabs/updater";
import { openSettingsTabModal, UpdaterTab } from "@components/settings";
import { debounce } from "@shared/debounce";
import { IS_WINDOWS } from "@utils/constants";
import { createAndAppendStyle } from "@utils/css";
import { StartAt } from "@utils/types";
import { get as dsGet } from "./api/DataStore";
import { NotificationData, showNotification } from "./api/Notifications";
import { PlainSettings, Settings } from "./api/Settings";
import { patches, PMLogger, startAllPlugins } from "./plugins";
import { initPluginManager, PMLogger, startAllPlugins } from "./api/PluginManager";
import { PlainSettings, Settings, SettingsStore } from "./api/Settings";
import { getCloudSettings, putCloudSettings } from "./api/SettingsSync/cloudSync";
import { localStorage } from "./utils/localStorage";
import { relaunch } from "./utils/native";
import { getCloudSettings, putCloudSettings } from "./utils/settingsSync";
import { checkForUpdates, update, UpdateLogger } from "./utils/updater";
import { onceReady } from "./webpack";
import { SettingsRouter } from "./webpack/common";
import { openUserSettingsPanel } from "./webpack/common";
import { patches } from "./webpack/patchWebpack";
if (IS_REPORTER) {
require("./debug/runReporter");
@ -63,7 +61,7 @@ async function syncSettings() {
title: "Cloud Settings",
body: "Cloud sync was disabled because this account isn't connected to the cloud App. You can enable it again by connecting this account in Cloud Settings. (note: it will store your preferences separately)",
color: "var(--yellow-360)",
onClick: () => SettingsRouter.open("VencordCloud")
onClick: () => openUserSettingsPanel("equicord_cloud")
});
// Disable cloud sync globally
Settings.cloud.authenticated = false;
@ -82,7 +80,7 @@ async function syncSettings() {
body: "We've noticed you have cloud integrations enabled in another client! Due to limitations, you will " +
"need to re-authenticate to continue using them. Click here to go to the settings page to do so!",
color: "var(--yellow-360)",
onClick: () => SettingsRouter.open("EquicordCloud")
onClick: () => openUserSettingsPanel("equicord_cloud")
});
return;
}
@ -108,11 +106,25 @@ async function syncSettings() {
});
}
}
const saveSettingsOnFrequentAction = debounce(async () => {
if (Settings.cloud.settingsSync && Settings.cloud.authenticated) {
await putCloudSettings();
delete localStorage.Vencord_settingsDirty;
}
}, 60_000);
SettingsStore.addGlobalChangeListener(() => {
localStorage.Vencord_settingsDirty = true;
saveSettingsOnFrequentAction();
});
}
let notifiedForUpdatesThisSession = false;
async function runUpdateCheck() {
if (IS_UPDATER_DISABLED) return;
const notify = (data: NotificationData) => {
if (notifiedForUpdatesThisSession) return;
notifiedForUpdatesThisSession = true;
@ -143,7 +155,7 @@ async function runUpdateCheck() {
notify({
title: "A Equicord update is available!",
body: "Click here to view the update",
onClick: openUpdaterModal!
onClick: () => openSettingsTabModal(UpdaterTab!)
});
} catch (err) {
UpdateLogger.error("Failed to check for updates", err);
@ -179,6 +191,7 @@ async function init() {
}
}
initPluginManager();
startAllPlugins(StartAt.Init);
init();

View file

@ -17,9 +17,12 @@
*/
import ErrorBoundary from "@components/ErrorBoundary";
import BadgeAPIPlugin from "plugins/_api/badges";
import globalBadges from "@equicordplugins/globalBadges";
import BadgeAPIPlugin from "@plugins/_api/badges";
import { ComponentType, HTMLProps } from "react";
import { isPluginEnabled } from "./PluginManager";
export const enum BadgePosition {
START,
END
@ -31,7 +34,7 @@ export interface ProfileBadge {
/** Custom component for the badge (tooltip not included) */
component?: ComponentType<ProfileBadge & BadgeUserArgs>;
/** The custom image to use */
image?: string;
iconSrc?: string;
link?: string;
/** Action to perform when you click the badge */
onClick?(event: React.MouseEvent, props: ProfileBadge & BadgeUserArgs): void;
@ -99,6 +102,18 @@ export function _getBadges(args: BadgeUserArgs) {
const donorBadges = BadgeAPIPlugin.getDonorBadges(args.userId);
const equicordDonorBadges = BadgeAPIPlugin.getEquicordDonorBadges(args.userId);
const GlobalBadges = isPluginEnabled(globalBadges.name) ? globalBadges.getGlobalBadges(args.userId) : false;
// do globalbadges first so it shows before the contrib badges but after donor badges
if (GlobalBadges) {
badges.unshift(
...GlobalBadges.map(badge => ({
...args,
...badge,
}))
);
}
if (donorBadges) {
badges.unshift(
...donorBadges.map(badge => ({

View file

@ -9,11 +9,15 @@ import "./ChatButton.css";
import ErrorBoundary from "@components/ErrorBoundary";
import { Logger } from "@utils/Logger";
import { classes } from "@utils/misc";
import { IconComponent } from "@utils/types";
import { Channel } from "@vencord/discord-types";
import { waitFor } from "@webpack";
import { ButtonWrapperClasses, Clickable, Tooltip } from "@webpack/common";
import { ButtonWrapperClasses, Clickable, Menu, Tooltip } from "@webpack/common";
import { HTMLProps, JSX, MouseEventHandler, ReactNode } from "react";
import { addContextMenuPatch, findGroupChildrenByChildId } from "./ContextMenu";
import { useSettings } from "./Settings";
let ChannelTextAreaClasses: Record<"button" | "buttonContainer", string>;
waitFor(["buttonContainer", "channelTextArea"], m => ChannelTextAreaClasses = m);
@ -75,25 +79,53 @@ export interface ChatBarProps {
};
}
export type ChatBarButtonFactory = (props: ChatBarProps & { isMainChat: boolean; }) => JSX.Element | null;
export type ChatBarButtonFactory = (props: ChatBarProps & { isMainChat: boolean; isAnyChat: boolean; }) => JSX.Element | null;
export type ChatBarButtonData = {
render: ChatBarButtonFactory;
/**
* This icon is used only for Settings UI. Your render function must still render an icon,
* and it can be different from this one.
*/
icon: IconComponent;
};
const buttonFactories = new Map<string, ChatBarButtonFactory>();
/**
* Don't use this directly, use {@link addChatBarButton} and {@link removeChatBarButton} instead.
*/
export const ChatBarButtonMap = new Map<string, ChatBarButtonData>();
const logger = new Logger("ChatButtons");
function VencordChatBarButtons(props: ChatBarProps) {
const { chatBarButtons } = useSettings(["uiElements.chatBarButtons.*"]).uiElements;
const { analyticsName } = props.type;
return (
<>
{Array.from(ChatBarButtonMap)
.filter(([key]) => chatBarButtons[key]?.enabled !== false)
.map(([key, { render: Button }]) => (
<ErrorBoundary noop key={key} onError={e => logger.error(`Failed to render ${key}`, e.error)}>
<Button {...props} isMainChat={analyticsName === "normal"} isAnyChat={["normal", "sidebar"].includes(analyticsName)} />
</ErrorBoundary>
))}
</>
);
}
export function _injectButtons(buttons: ReactNode[], props: ChatBarProps) {
if (props.disabled) return;
for (const [key, Button] of buttonFactories) {
buttons.push(
<ErrorBoundary noop key={key} onError={e => logger.error(`Failed to render ${key}`, e.error)}>
<Button {...props} isMainChat={props.type.analyticsName === "normal"} />
</ErrorBoundary>
);
}
buttons.unshift(<VencordChatBarButtons key="vencord-chat-buttons" {...props} />);
return buttons;
}
export const addChatBarButton = (id: string, button: ChatBarButtonFactory) => buttonFactories.set(id, button);
export const removeChatBarButton = (id: string) => buttonFactories.delete(id);
/**
* The icon argument is used only for Settings UI. Your render function must still render an icon,
* and it can be different from this one.
*/
export const addChatBarButton = (id: string, render: ChatBarButtonFactory, icon: IconComponent) => ChatBarButtonMap.set(id, { render, icon });
export const removeChatBarButton = (id: string) => ChatBarButtonMap.delete(id);
export interface ChatBarButtonProps {
children: ReactNode;
@ -103,6 +135,7 @@ export interface ChatBarButtonProps {
onAuxClick?: MouseEventHandler;
buttonProps?: Omit<HTMLProps<HTMLDivElement>, "size" | "onClick" | "onContextMenu" | "onAuxClick">;
}
export const ChatBarButton = ErrorBoundary.wrap((props: ChatBarButtonProps) => {
return (
<Tooltip text={props.tooltip}>
@ -127,3 +160,35 @@ export const ChatBarButton = ErrorBoundary.wrap((props: ChatBarButtonProps) => {
</Tooltip>
);
}, { noop: true });
addContextMenuPatch("textarea-context", (children, args) => {
const { chatBarButtons } = useSettings(["uiElements.chatBarButtons.*"]).uiElements;
const buttons = Array.from(ChatBarButtonMap.entries());
if (!buttons.length) return;
const group = findGroupChildrenByChildId("submit-button", children);
if (!group) return;
const idx = group.findIndex(c => c?.props?.id === "submit-button");
if (idx === -1) return;
group.splice(idx, 0,
<Menu.MenuItem id="vc-chat-buttons" key="vencord-chat-buttons" label="Equicord Buttons">
{buttons.map(([id]) => (
<Menu.MenuCheckboxItem
label={id}
key={id}
id={`vc-chat-button-${id}`}
checked={chatBarButtons[id]?.enabled !== false}
action={() => {
const wasEnabled = chatBarButtons[id]?.enabled !== false;
chatBarButtons[id] ??= {} as any;
chatBarButtons[id].enabled = !wasEnabled;
}}
/>
))}
</Menu.MenuItem>
);
});

204
src/api/HeaderBar.tsx Normal file
View file

@ -0,0 +1,204 @@
/*
* 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 { findComponentByCodeLazy } from "@webpack";
import { useEffect, useState } from "@webpack/common";
import type { ComponentType, JSX, MouseEventHandler, ReactNode } from "react";
const logger = new Logger("HeaderBarAPI");
const HeaderBarIcon = findComponentByCodeLazy(".HEADER_BAR_BADGE_TOP:", '.iconBadge,"top"') as ComponentType<HeaderBarButtonProps>;
export interface HeaderBarButtonProps {
/** The icon component to render inside the button */
icon: ComponentType<any>;
/** Tooltip text shown on hover. Pass null to disable tooltip */
tooltip: ReactNode;
/** Called when the button is clicked */
onClick?: MouseEventHandler<HTMLDivElement>;
/** Called when the button is right-clicked */
onContextMenu?: MouseEventHandler<HTMLDivElement>;
/** Additional CSS class names */
className?: string;
/** CSS class name for the icon element */
iconClassName?: string;
/** Tooltip position relative to the button */
position?: "top" | "bottom" | "left" | "right";
/** Whether the button appears in a selected/active state */
selected?: boolean;
/** Whether the button is disabled */
disabled?: boolean;
/** Whether to show a notification badge */
showBadge?: boolean;
/** Position of the notification badge */
badgePosition?: "top" | "bottom";
/** Size of the icon in pixels */
iconSize?: number;
/** Ref to the button element */
ref?: React.RefObject<any>;
}
export type HeaderBarButtonFactory = () => JSX.Element | null;
export interface HeaderBarButtonData {
/** Function that renders the button component */
render: HeaderBarButtonFactory;
/** Icon component used for settings UI display */
icon: ComponentType<any>;
/** Higher priority buttons appear further right. Default: 0 */
priority?: number;
/** Where to render the button. Default: "headerbar" */
location?: "headerbar" | "channeltoolbar";
}
interface ButtonEntry {
render: HeaderBarButtonFactory;
priority: number;
}
/**
* Button component for the top header bar (title bar area).
*
* @example
* <HeaderBarButton
* icon={MyIcon}
* tooltip="My Button"
* onClick={() => console.log("clicked")}
* />
*/
export const HeaderBarButton = HeaderBarIcon;
/**
* Button component for the channel toolbar (below the search bar).
* Automatically handles selected state styling.
*
* @example
* <ChannelToolbarButton
* icon={MyIcon}
* tooltip={isOpen ? null : "My Button"}
* onClick={() => setOpen(v => !v)}
* selected={isOpen}
* />
*/
export function ChannelToolbarButton(props: HeaderBarButtonProps) {
return <HeaderBarIcon {...props} />;
}
const headerBarButtons = new Map<string, ButtonEntry>();
const channelToolbarButtons = new Map<string, ButtonEntry>();
const headerBarListeners = new Set<() => void>();
const channelToolbarListeners = new Set<() => void>();
/**
* Adds a button to the header bar (title bar area).
*
* @param id - Unique identifier for the button (e.g., "my-plugin-button")
* @param render - Function that returns the button JSX
* @param priority - Higher values appear further right. Default: 0
*
* @example
* addHeaderBarButton("my-button", () => (
* <HeaderBarButton
* icon={MyIcon}
* tooltip="My Button"
* onClick={handleClick}
* />
* ));
*/
export function addHeaderBarButton(id: string, render: HeaderBarButtonFactory, priority = 0) {
headerBarButtons.set(id, { render, priority });
headerBarListeners.forEach(listener => listener());
}
/**
* Removes a button from the header bar.
*
* @param id - The identifier used when adding the button
*/
export function removeHeaderBarButton(id: string) {
headerBarButtons.delete(id);
headerBarListeners.forEach(listener => listener());
}
/**
* Adds a button to the channel toolbar (below the search bar, next to pins/members).
*
* @param id - Unique identifier for the button (e.g., "my-plugin-toolbar")
* @param render - Function that returns the button JSX
* @param priority - Higher values appear further right. Default: 0
*
* @example
* addChannelToolbarButton("my-toolbar", () => (
* <ChannelToolbarButton
* icon={MyIcon}
* tooltip="My Button"
* onClick={handleClick}
* />
* ));
*/
export function addChannelToolbarButton(id: string, render: HeaderBarButtonFactory, priority = 0) {
channelToolbarButtons.set(id, { render, priority });
channelToolbarListeners.forEach(listener => listener());
}
/**
* Removes a button from the channel toolbar.
*
* @param id - The identifier used when adding the button
*/
export function removeChannelToolbarButton(id: string) {
channelToolbarButtons.delete(id);
channelToolbarListeners.forEach(listener => listener());
}
function HeaderBarButtons() {
const [, forceUpdate] = useState(0);
useEffect(() => {
const listener = () => forceUpdate(n => n + 1);
headerBarListeners.add(listener);
return () => { headerBarListeners.delete(listener); };
}, []);
return Array.from(headerBarButtons)
.sort(([, a], [, b]) => a.priority - b.priority)
.map(([id, { render: Button }]) => (
<ErrorBoundary noop key={id} onError={e => logger.error(`Failed to render header bar button: ${id}`, e.error)}>
<Button />
</ErrorBoundary>
));
}
function ChannelToolbarButtons() {
const [, forceUpdate] = useState(0);
useEffect(() => {
const listener = () => forceUpdate(n => n + 1);
channelToolbarListeners.add(listener);
return () => { channelToolbarListeners.delete(listener); };
}, []);
return Array.from(channelToolbarButtons)
.sort(([, a], [, b]) => a.priority - b.priority)
.map(([id, { render: Button }]) => (
<ErrorBoundary noop key={id} onError={e => logger.error(`Failed to render channel toolbar button: ${id}`, e.error)}>
<Button />
</ErrorBoundary>
));
}
/** @internal Injected by HeaderBarAPI patch (do NOT call directly) */
export function _addHeaderBarButtons() {
return [<HeaderBarButtons key="vc-header-bar-buttons" />];
}
/** @internal Injected by HeaderBarAPI patch (do NOT call directly) */
export function _addChannelToolbarButtons(toolbar: ReactNode[]) {
toolbar.push(<ChannelToolbarButtons key="vc-channel-toolbar-buttons" />);
}

View file

@ -18,9 +18,12 @@
import ErrorBoundary from "@components/ErrorBoundary";
import { Logger } from "@utils/Logger";
import { IconComponent } from "@utils/types";
import { Channel, Message } from "@vencord/discord-types";
import type { ComponentType, MouseEventHandler } from "react";
import { useSettings } from "./Settings";
const logger = new Logger("MessagePopover");
export interface MessagePopoverButtonItem {
@ -34,41 +37,63 @@ export interface MessagePopoverButtonItem {
}
export type MessagePopoverButtonFactory = (message: Message) => MessagePopoverButtonItem | null;
export type MessagePopoverButtonData = {
render: MessagePopoverButtonFactory;
/**
* This icon is used only for Settings UI. Your render function must still return an icon,
* and it can be different from this one.
*/
icon: IconComponent;
};
export const buttons = new Map<string, MessagePopoverButtonFactory>();
export const MessagePopoverButtonMap = new Map<string, MessagePopoverButtonData>();
/**
* The icon argument is used only for Settings UI. Your render function must still return an icon,
* and it can be different from this one.
*/
export function addMessagePopoverButton(
identifier: string,
item: MessagePopoverButtonFactory,
render: MessagePopoverButtonFactory,
icon: IconComponent
) {
buttons.set(identifier, item);
MessagePopoverButtonMap.set(identifier, { render, icon });
}
export function removeMessagePopoverButton(identifier: string) {
buttons.delete(identifier);
MessagePopoverButtonMap.delete(identifier);
}
function VencordPopoverButtons(props: { Component: React.ComponentType<MessagePopoverButtonItem>, message: Message; }) {
const { Component, message } = props;
const { messagePopoverButtons } = useSettings(["uiElements.messagePopoverButtons.*"]).uiElements;
const elements = Array.from(MessagePopoverButtonMap.entries())
.filter(([key]) => messagePopoverButtons[key]?.enabled !== false)
.map(([key, { render }]) => {
try {
// FIXME: this should use proper React to ensure hooks work
const item = render(message);
if (!item) return null;
return (
<ErrorBoundary noop>
<Component key={key} {...item} />
</ErrorBoundary>
);
} catch (err) {
logger.error(`[${key}]`, err);
return null;
}
});
return <>{elements}</>;
}
export function _buildPopoverElements(
Component: React.ComponentType<MessagePopoverButtonItem>,
message: Message
) {
const items: React.ReactNode[] = [];
for (const [identifier, getItem] of buttons.entries()) {
try {
const item = getItem(message);
if (item) {
item.key ??= identifier;
items.push(
<ErrorBoundary noop>
<Component {...item} />
</ErrorBoundary>
);
}
} catch (err) {
logger.error(`[${identifier}]`, err);
}
}
return <>{items}</>;
return <VencordPopoverButtons Component={Component} message={message} />;
}

View file

@ -37,7 +37,7 @@
}
.vc-notification-title {
color: var(--header-primary);
color: var(--text-strong);
font-size: 1rem;
font-weight: 600;
line-height: 1.25rem;
@ -47,13 +47,13 @@
.vc-notification-close-btn {
all: unset;
cursor: pointer;
color: var(--interactive-normal);
color: var(--interactive-icon-default);
opacity: 0.5;
transition: opacity 0.2s ease-in-out, color 0.2s ease-in-out;
}
.vc-notification-close-btn:hover {
color: var(--interactive-hover);
color: var(--interactive-icon-hover);
opacity: 1;
}
@ -137,5 +137,5 @@
.vc-notification-log-danger-btn {
color: var(--white-500);
background-color: var(--button-danger-background);
background-color: var(--redesign-button-danger-background);
}

View file

@ -28,8 +28,8 @@ 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 { makeLazy } from "@utils/lazy";
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 { FluxEvents } from "@vencord/discord-types";
@ -37,53 +37,32 @@ import { FluxDispatcher } from "@webpack/common";
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 { addUserAreaButton, removeUserAreaButton } from "./UserArea";
const logger = new Logger("PluginManager", "#a6d189");
export const PMLogger = logger;
export const plugins = Plugins;
export { patches };
/** Whether we have subscribed to flux events of all the enabled plugins when FluxDispatcher was ready */
let enabledPluginsSubscribedFlux = false;
const subscribedFluxEventsPlugins = new Set<string>();
const pluginsValues = Object.values(Plugins);
const settings = Settings.plugins;
/**
* Whether a plugin is required (or dependency of another enabled plugin)
*/
export function isPluginRequired(name: string) {
const p = Plugins[name];
if (!p) return false;
return p.required || p.isDependency;
}
/**
* A map of plugin names to the plugins that depend on them
*/
export const calculatePluginDependencyMap = makeLazy(() => {
const dependencies: Record<string, string[]> = {};
for (const plugin in Plugins) {
const deps = Plugins[plugin].dependencies;
if (deps) {
for (const dep of deps) {
dependencies[dep] ??= [];
dependencies[dep].push(plugin);
}
}
}
return dependencies;
});
export function isPluginEnabled(p: string) {
return (
isPluginRequired(p) ||
settings[p]?.enabled
Plugins[p]?.required ||
Plugins[p]?.isDependency ||
Settings.plugins[p]?.enabled
) ?? false;
}
export function isPluginRequired(p: string) {
return (
Plugins[p]?.required
) ?? false;
}
@ -122,92 +101,8 @@ function isReporterTestable(p: Plugin, part: ReporterTestable) {
: (p.reporterTestable & part) === part;
}
const pluginKeysToBind: Array<keyof PluginDef & `${"on" | "render"}${string}`> = [
"onBeforeMessageEdit", "onBeforeMessageSend", "onMessageClick",
"renderChatBarButton", "renderMemberListDecorator", "renderNicknameIcon", "renderMessageAccessory", "renderMessageDecoration", "renderMessagePopoverButton"
];
const neededApiPlugins = new Set<string>();
// First round-trip to mark and force enable dependencies
//
// FIXME: might need to revisit this if there's ever nested (dependencies of dependencies) dependencies since this only
// goes for the top level and their children, but for now this works okay with the current API plugins
for (const p of pluginsValues) if (isPluginEnabled(p.name)) {
p.dependencies?.forEach(d => {
const dep = Plugins[d];
if (!dep) {
const error = new Error(`Plugin ${p.name} has unresolved dependency ${d}`);
if (IS_DEV) {
throw error;
}
logger.warn(error);
return;
}
settings[d].enabled = true;
dep.isDependency = true;
});
if (p.userProfileBadge) {
if (!p.userProfileBadges) {
p.userProfileBadges = [p.userProfileBadge];
} else {
p.userProfileBadges = [p.userProfileBadge, ...p.userProfileBadges];
}
}
if (p.commands?.length) neededApiPlugins.add("CommandsAPI");
if (p.onBeforeMessageEdit || p.onBeforeMessageSend || p.onMessageClick) neededApiPlugins.add("MessageEventsAPI");
if (p.renderChatBarButton) neededApiPlugins.add("ChatInputButtonAPI");
if (p.renderMemberListDecorator) neededApiPlugins.add("MemberListDecoratorsAPI");
if (p.renderNicknameIcon) neededApiPlugins.add("NicknameIconsAPI");
if (p.renderMessageAccessory) neededApiPlugins.add("MessageAccessoriesAPI");
if (p.renderMessageDecoration) neededApiPlugins.add("MessageDecorationsAPI");
if (p.renderMessagePopoverButton) neededApiPlugins.add("MessagePopoverAPI");
if (p.userProfileBadges) neededApiPlugins.add("BadgeAPI");
for (const key of pluginKeysToBind) {
p[key] &&= p[key].bind(p) as any;
}
}
for (const p of neededApiPlugins) {
Plugins[p].isDependency = true;
settings[p].enabled = true;
}
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);
}
}
}
if (p.patches && isPluginEnabled(p.name)) {
if (!IS_REPORTER || isReporterTestable(p, ReporterTestable.Patches)) {
for (const patch of p.patches) {
addPatch(patch, p.name);
}
}
}
export function pluginRequiresRestart(p: Plugin) {
return p.requiresRestart !== false && (p.requiresRestart || !!p.patches?.length);
}
export const startAllPlugins = traceFunction("startAllPlugins", function startAllPlugins(target: StartAt) {
@ -225,6 +120,7 @@ export const startAllPlugins = traceFunction("startAllPlugins", function startAl
});
export function startDependenciesRecursive(p: Plugin) {
const settings = Settings.plugins;
let restartNeeded = false;
const failures: string[] = [];
@ -237,7 +133,7 @@ export function startDependenciesRecursive(p: Plugin) {
settings[d].enabled = true;
dep.isDependency = true;
if (dep.patches) {
if (pluginRequiresRestart(dep)) {
logger.warn(`Enabling dependency ${d} requires restart.`);
restartNeeded = true;
return;
@ -298,7 +194,9 @@ export const startPlugin = traceFunction("startPlugin", function startPlugin(p:
const {
name, commands, contextMenus, managedStyle, userProfileBadges,
onBeforeMessageEdit, onBeforeMessageSend, onMessageClick,
renderChatBarButton, renderMemberListDecorator, renderNicknameIcon, renderMessageAccessory, renderMessageDecoration, renderMessagePopoverButton
renderChatBarButton, chatBarButton, renderMemberListDecorator, renderMessageAccessory, renderMessageDecoration, renderMessagePopoverButton, messagePopoverButton,
// Custom
renderNicknameIcon, headerBarButton, audioProcessor, userAreaButton
} = p;
if (p.start) {
@ -348,12 +246,27 @@ export const startPlugin = traceFunction("startPlugin", function startPlugin(p:
if (onBeforeMessageSend) addMessagePreSendListener(onBeforeMessageSend);
if (onMessageClick) addMessageClickListener(onMessageClick);
if (renderChatBarButton) addChatBarButton(name, renderChatBarButton);
if (chatBarButton) addChatBarButton(name, chatBarButton.render, chatBarButton.icon);
// @ts-expect-error: legacy code doesn't have icon
else if (renderChatBarButton) addChatBarButton(name, renderChatBarButton);
if (renderMemberListDecorator) addMemberListDecorator(name, renderMemberListDecorator);
if (renderNicknameIcon) addNicknameIcon(name, renderNicknameIcon);
if (renderMessageDecoration) addMessageDecoration(name, renderMessageDecoration);
if (renderMessageAccessory) addMessageAccessory(name, renderMessageAccessory);
if (renderMessagePopoverButton) addMessagePopoverButton(name, renderMessagePopoverButton);
if (messagePopoverButton) addMessagePopoverButton(name, messagePopoverButton.render, messagePopoverButton.icon);
// @ts-expect-error: legacy code doesn't have icon
else if (renderMessagePopoverButton) addMessagePopoverButton(name, renderMessagePopoverButton);
// Custom
if (renderNicknameIcon) addNicknameIcon(name, renderNicknameIcon);
if (headerBarButton) {
if (headerBarButton.location === "channeltoolbar") {
addChannelToolbarButton(name, headerBarButton.render, headerBarButton.priority);
} else {
addHeaderBarButton(name, headerBarButton.render, headerBarButton.priority);
}
}
if (audioProcessor) addAudioProcessor(name, audioProcessor);
if (userAreaButton) addUserAreaButton(name, userAreaButton.render, userAreaButton.priority);
return true;
}, p => `startPlugin ${p.name}`);
@ -362,7 +275,9 @@ export const stopPlugin = traceFunction("stopPlugin", function stopPlugin(p: Plu
const {
name, commands, contextMenus, managedStyle, userProfileBadges,
onBeforeMessageEdit, onBeforeMessageSend, onMessageClick,
renderChatBarButton, renderMemberListDecorator, renderNicknameIcon, renderMessageAccessory, renderMessageDecoration, renderMessagePopoverButton
renderChatBarButton, chatBarButton, renderMemberListDecorator, renderMessageAccessory, renderMessageDecoration, renderMessagePopoverButton, messagePopoverButton,
// Custom
renderNicknameIcon, headerBarButton, audioProcessor, userAreaButton
} = p;
if (p.stop) {
@ -410,12 +325,115 @@ export const stopPlugin = traceFunction("stopPlugin", function stopPlugin(p: Plu
if (onBeforeMessageSend) removeMessagePreSendListener(onBeforeMessageSend);
if (onMessageClick) removeMessageClickListener(onMessageClick);
if (renderChatBarButton) removeChatBarButton(name);
if (chatBarButton || renderChatBarButton) removeChatBarButton(name);
if (renderMemberListDecorator) removeMemberListDecorator(name);
if (renderNicknameIcon) removeNicknameIcon(name);
if (renderMessageDecoration) removeMessageDecoration(name);
if (renderMessageAccessory) removeMessageAccessory(name);
if (renderMessagePopoverButton) removeMessagePopoverButton(name);
if (messagePopoverButton || renderMessagePopoverButton) removeMessagePopoverButton(name);
// Custom
if (renderNicknameIcon) removeNicknameIcon(name);
if (headerBarButton) {
if (headerBarButton.location === "channeltoolbar") {
removeChannelToolbarButton(name);
} else {
removeHeaderBarButton(name);
}
}
if (audioProcessor) removeAudioProcessor(name);
if (userAreaButton) removeUserAreaButton(name);
return true;
}, p => `stopPlugin ${p.name}`);
export const initPluginManager = onlyOnce(function init() {
const pluginsValues = Object.values(Plugins);
const settings = Settings.plugins;
const pluginKeysToBind: Array<keyof PluginDef & `${"on" | "render"}${string}`> = [
"onBeforeMessageEdit", "onBeforeMessageSend", "onMessageClick",
"renderChatBarButton", "renderMemberListDecorator", "renderMessageAccessory", "renderMessageDecoration", "renderMessagePopoverButton",
// Custom
"renderNicknameIcon"
];
const neededApiPlugins = new Set<string>();
// First round-trip to mark and force enable dependencies
//
// FIXME: might need to revisit this if there's ever nested (dependencies of dependencies) dependencies since this only
// goes for the top level and their children, but for now this works okay with the current API plugins
for (const p of pluginsValues) if (isPluginEnabled(p.name)) {
p.dependencies?.forEach(d => {
const dep = Plugins[d];
if (!dep) {
const error = new Error(`Plugin ${p.name} has unresolved dependency ${d}`);
if (IS_DEV) {
throw error;
}
logger.warn(error);
return;
}
settings[d].enabled = true;
dep.isDependency = true;
});
if (p.commands?.length) neededApiPlugins.add("CommandsAPI");
if (p.onBeforeMessageEdit || p.onBeforeMessageSend || p.onMessageClick) neededApiPlugins.add("MessageEventsAPI");
if (p.chatBarButton || p.renderChatBarButton) neededApiPlugins.add("ChatInputButtonAPI");
if (p.renderMemberListDecorator) neededApiPlugins.add("MemberListDecoratorsAPI");
if (p.renderMessageAccessory) neededApiPlugins.add("MessageAccessoriesAPI");
if (p.renderMessageDecoration) neededApiPlugins.add("MessageDecorationsAPI");
if (p.messagePopoverButton || p.renderMessagePopoverButton) neededApiPlugins.add("MessagePopoverAPI");
if (p.userProfileBadge) neededApiPlugins.add("BadgeAPI");
// Custom
if (p.renderNicknameIcon) neededApiPlugins.add("NicknameIconsAPI");
if (p.headerBarButton) neededApiPlugins.add("HeaderBarAPI");
if (p.audioProcessor) neededApiPlugins.add("AudioPlayerAPI");
if (p.userAreaButton) neededApiPlugins.add("UserAreaAPI");
for (const key of pluginKeysToBind) {
p[key] &&= p[key].bind(p) as any;
}
}
for (const p of neededApiPlugins) {
Plugins[p].isDependency = true;
settings[p].enabled = true;
}
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);
}
}
}
if (p.patches && isPluginEnabled(p.name)) {
if (!IS_REPORTER || isReporterTestable(p, ReporterTestable.Patches)) {
for (const patch of p.patches) {
addPatch(patch, p.name);
}
}
}
}
});

View file

@ -16,18 +16,27 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { debounce } from "@shared/debounce";
import { SettingsStore as SettingsStoreClass } from "@shared/SettingsStore";
import { localStorage } from "@utils/localStorage";
import { Logger } from "@utils/Logger";
import { mergeDefaults } from "@utils/mergeDefaults";
import { putCloudSettings } from "@utils/settingsSync";
import { DefinedSettings, OptionType, SettingsChecks, SettingsDefinition } from "@utils/types";
import { React, useEffect } from "@webpack/common";
import plugins from "~plugins";
const logger = new Logger("Settings");
export interface SettingsPluginUiElement {
enabled: boolean;
// TODO
/** not implemented for now */
order?: number;
}
export type SettingsPluginUiElements = {
/** id will be whatever id the element was registered with. Usually, but not always, the plugin name */
[id: string]: SettingsPluginUiElement;
};
export interface Settings {
autoUpdate: boolean;
autoUpdateNotification: boolean;
@ -35,6 +44,8 @@ export interface Settings {
eagerPatches: boolean;
enabledThemes: string[];
enabledThemeLinks: string[];
enableOnlineThemes: boolean;
pinnedThemes: string[];
themeNames: Record<string, string>;
enableReactDevtools: boolean;
themeLinks: string[];
@ -64,6 +75,11 @@ export interface Settings {
};
};
uiElements: {
messagePopoverButtons: SettingsPluginUiElements;
chatBarButtons: SettingsPluginUiElements;
},
notifications: {
timeout: number;
position: "top-right" | "bottom-right";
@ -96,6 +112,8 @@ const DefaultSettings: Settings = {
eagerPatches: IS_REPORTER,
enabledThemes: [],
enabledThemeLinks: [],
enableOnlineThemes: true,
pinnedThemes: [],
themeNames: {},
enableReactDevtools: false,
frameless: false,
@ -106,6 +124,11 @@ const DefaultSettings: Settings = {
winNativeTitleBar: false,
plugins: {},
uiElements: {
chatBarButtons: {},
messagePopoverButtons: {}
},
notifications: {
timeout: 5000,
position: "bottom-right",
@ -129,14 +152,6 @@ const DefaultSettings: Settings = {
const settings = !IS_REPORTER ? VencordNative.settings.get() : {} as Settings;
mergeDefaults(settings, DefaultSettings);
const saveSettingsOnFrequentAction = debounce(async () => {
if (Settings.cloud.settingsSync && Settings.cloud.authenticated) {
await putCloudSettings();
delete localStorage.Vencord_settingsDirty;
}
}, 60_000);
export const SettingsStore = new SettingsStoreClass(settings, {
readOnly: true,
getDefaultValue({
@ -179,8 +194,6 @@ export const SettingsStore = new SettingsStoreClass(settings, {
if (!IS_REPORTER) {
SettingsStore.addGlobalChangeListener((_, path) => {
SettingsStore.plain.cloud.settingsSyncVersion = Date.now();
localStorage.Vencord_settingsDirty = true;
saveSettingsOnFrequentAction();
VencordNative.settings.set(SettingsStore.plain, path);
});
}
@ -214,8 +227,21 @@ export function useSettings(paths?: UseSettings<Settings>[]) {
useEffect(() => {
if (paths) {
paths.forEach(p => SettingsStore.addChangeListener(p, forceUpdate));
return () => paths.forEach(p => SettingsStore.removeChangeListener(p, forceUpdate));
paths.forEach(p => {
if (p.endsWith(".*")) {
SettingsStore.addPrefixChangeListener(p.slice(0, -2), forceUpdate);
} else {
SettingsStore.addChangeListener(p, forceUpdate);
}
});
return () => paths.forEach(p => {
if (p.endsWith(".*")) {
SettingsStore.removePrefixChangeListener(p.slice(0, -2), forceUpdate);
} else {
SettingsStore.removeChangeListener(p, forceUpdate);
}
});
} else {
SettingsStore.addGlobalChangeListener(forceUpdate);
return () => SettingsStore.removeGlobalChangeListener(forceUpdate);
@ -225,6 +251,21 @@ export function useSettings(paths?: UseSettings<Settings>[]) {
return SettingsStore.store;
}
export function migratePluginToSetting(newName: string, oldName: string, settingName: string) {
const { plugins } = SettingsStore.plain;
const newPlugin = plugins[newName];
const oldPlugin = plugins[oldName];
if (!newPlugin || !oldPlugin) return;
if (oldPlugin?.enabled) {
newPlugin[settingName] = true;
oldPlugin.enabled = false;
if (!newPlugin?.enabled) newPlugin.enabled = true;
SettingsStore.markAsChanged();
}
}
export function migratePluginSettings(name: string, ...oldNames: string[]) {
const { plugins } = SettingsStore.plain;
if (name in plugins) return;
@ -278,9 +319,11 @@ export function definePluginSettings<
if (!definedSettings.pluginName) throw new Error("Cannot access settings before plugin is initialized");
return PlainSettings.plugins[definedSettings.pluginName] as any;
},
use: settings => useSettings(
settings?.map(name => `plugins.${definedSettings.pluginName}.${name}`) as UseSettings<Settings>[]
).plugins[definedSettings.pluginName] as any,
use: settings => useSettings((
settings
? settings.map(name => `plugins.${definedSettings.pluginName}.${name}`)
: [`plugins.${definedSettings.pluginName}.*`]
) as UseSettings<Settings>[]).plugins[definedSettings.pluginName] as any,
def,
checks: checks ?? {} as any,
pluginName: "",
@ -300,7 +343,7 @@ type ResolveUseSettings<T extends object> = {
Key extends string
? T[Key] extends Record<string, unknown>
// @ts-expect-error "Type instantiation is excessively deep and possibly infinite"
? UseSettings<T[Key]> extends string ? `${Key}.${UseSettings<T[Key]>}` : never
? `${Key}.*` | (ResolveUseSettings<T[Key]> extends Record<string, string> ? `${Key}.${ResolveUseSettings<T[Key]>[keyof T[Key]]}` : never)
: Key
: never;
};

View file

@ -1,30 +1,17 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import * as DataStore from "@api/DataStore";
import { showNotification } from "@api/Notifications";
import { Settings } from "@api/Settings";
import { Logger } from "@utils/Logger";
import { openModal } from "@utils/modal";
import { OAuth2AuthorizeModal, UserStore } from "@webpack/common";
import { Logger } from "./Logger";
import { openModal } from "./modal";
export const cloudLogger = new Logger("Cloud", "#39b7e0");
export const logger = new Logger("SettingsSync:CloudSetup", "#39b7e0");
export const getCloudUrl = () => new URL(Settings.cloud.url);
const getCloudUrlOrigin = () => getCloudUrl().origin;
@ -111,7 +98,7 @@ export async function authorizeCloud() {
});
const { secret } = await res.json();
if (secret) {
cloudLogger.info("Authorized with secret");
logger.info("Authorized with secret");
await setAuthorization(secret);
showNotification({
title: "Cloud Integration",
@ -126,7 +113,7 @@ export async function authorizeCloud() {
Settings.cloud.authenticated = false;
}
} catch (e: any) {
cloudLogger.error("Failed to authorize", e);
logger.error("Failed to authorize", e);
showNotification({
title: "Cloud Integration",
body: `Setup failed (${e.toString()}).`

View file

@ -0,0 +1,224 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { showNotification } from "@api/Notifications";
import { PlainSettings, Settings } from "@api/Settings";
import { Logger } from "@utils/Logger";
import { relaunch } from "@utils/native";
import { openUserSettingsPanel } from "@webpack/common";
import { deflateSync, inflateSync } from "fflate";
import { deauthorizeCloud, getCloudAuth, getCloudUrl } from "./cloudSetup";
import { exportSettings, importSettings } from "./offline";
const logger = new Logger("SettingsSync:Cloud", "#39b7e0");
export async function putCloudSettings(manual?: boolean) {
const settings = await exportSettings({ syncDataStore: false, minify: true });
try {
const res = await fetch(new URL("/v1/settings", getCloudUrl()), {
method: "PUT",
headers: {
Authorization: await getCloudAuth(),
"Content-Type": "application/octet-stream"
},
body: deflateSync(new TextEncoder().encode(settings)) as Uint8Array<ArrayBuffer>
});
if (!res.ok) {
logger.error(`Failed to sync up, API returned ${res.status}`);
showNotification({
title: "Cloud Settings",
body: `Could not synchronize settings to cloud (API returned ${res.status}).`,
color: "var(--red-360)"
});
return;
}
const { written } = await res.json();
PlainSettings.cloud.settingsSyncVersion = written;
VencordNative.settings.set(PlainSettings);
logger.info("Settings uploaded to cloud successfully");
if (manual) {
showNotification({
title: "Cloud Settings",
body: "Synchronized settings to the cloud!",
noPersist: true,
});
}
} catch (e: any) {
logger.error("Failed to sync up", e);
showNotification({
title: "Cloud Settings",
body: `Could not synchronize settings to the cloud (${e.toString()}).`,
color: "var(--red-360)"
});
}
}
export async function getCloudSettings(shouldNotify = true, force = false) {
try {
const res = await fetch(new URL("/v1/settings", getCloudUrl()), {
method: "GET",
headers: {
Authorization: await getCloudAuth(),
Accept: "application/octet-stream",
"If-None-Match": Settings.cloud.settingsSyncVersion.toString()
},
});
if (res.status === 401) {
// User switched to an account that isn't connected to cloud
showNotification({
title: "Cloud Settings",
body: "Cloud sync was disabled because this account isn't connected to the cloud App. You can enable it again by connecting this account in Cloud Settings. (note: it will store your preferences separately)",
color: "var(--yellow-360)",
onClick: () => openUserSettingsPanel("equicord_cloud")
});
// Disable cloud sync globally
Settings.cloud.authenticated = false;
return false;
}
if (res.status === 404) {
logger.info("No settings on the cloud");
if (shouldNotify)
showNotification({
title: "Cloud Settings",
body: "There are no settings in the cloud.",
noPersist: true
});
return false;
}
if (res.status === 304) {
logger.info("Settings up to date");
if (shouldNotify)
showNotification({
title: "Cloud Settings",
body: "Your settings are up to date.",
noPersist: true
});
return false;
}
if (!res.ok) {
logger.error(`Failed to sync down, API returned ${res.status}`);
showNotification({
title: "Cloud Settings",
body: `Could not synchronize settings from the cloud (API returned ${res.status}).`,
color: "var(--red-360)"
});
return false;
}
const written = Number(res.headers.get("etag")!);
const localWritten = Settings.cloud.settingsSyncVersion;
// don't need to check for written > localWritten because the server will return 304 due to if-none-match
if (!force && written < localWritten) {
if (shouldNotify)
showNotification({
title: "Cloud Settings",
body: "Your local settings are newer than the cloud ones.",
noPersist: true,
});
return;
}
const data = await res.arrayBuffer();
const settings = new TextDecoder().decode(inflateSync(new Uint8Array(data)));
await importSettings(settings, "all", true);
// sync with server timestamp instead of local one
PlainSettings.cloud.settingsSyncVersion = written;
VencordNative.settings.set(PlainSettings);
logger.info("Settings loaded from cloud successfully");
if (shouldNotify)
showNotification({
title: "Cloud Settings",
body: "Your settings have been updated! Click here to restart to fully apply changes!",
color: "var(--green-360)",
onClick: IS_WEB ? () => location.reload() : relaunch,
noPersist: true
});
return true;
} catch (e: any) {
logger.error("Failed to sync down", e);
showNotification({
title: "Cloud Settings",
body: `Could not synchronize settings from the cloud (${e.toString()}).`,
color: "var(--red-360)"
});
return false;
}
}
export async function deleteCloudSettings() {
try {
const res = await fetch(new URL("/v1/settings", getCloudUrl()), {
method: "DELETE",
headers: { Authorization: await getCloudAuth() },
});
if (!res.ok) {
logger.error(`Failed to delete, API returned ${res.status}`);
showNotification({
title: "Cloud Settings",
body: `Could not delete settings (API returned ${res.status}).`,
color: "var(--red-360)"
});
return;
}
logger.info("Settings deleted from cloud successfully");
showNotification({
title: "Cloud Settings",
body: "Settings deleted from cloud!",
color: "var(--green-360)"
});
} catch (e: any) {
logger.error("Failed to delete", e);
showNotification({
title: "Cloud Settings",
body: `Could not delete settings (${e.toString()}).`,
color: "var(--red-360)"
});
}
}
export async function eraseAllCloudData() {
const res = await fetch(new URL("/v1/", getCloudUrl()), {
method: "DELETE",
headers: { Authorization: await getCloudAuth() }
});
if (!res.ok) {
logger.error(`Failed to erase data, API returned ${res.status}`);
showNotification({
title: "Cloud Integrations",
body: `Could not erase all data (API returned ${res.status}), please contact support.`,
color: "var(--red-360)"
});
return;
}
Settings.cloud.authenticated = false;
await deauthorizeCloud();
showNotification({
title: "Cloud Integrations",
body: "Successfully erased all data.",
color: "var(--green-360)"
});
}

View file

@ -0,0 +1,174 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { PlainSettings } from "@api/Settings";
import { Logger } from "@utils/Logger";
import { chooseFile, saveFile } from "@utils/web";
import { moment, Toasts } from "@webpack/common";
import { DataStore } from "..";
type BackupType = "all" | "plugins" | "css" | "datastore";
const toast = (type: string, message: string) =>
Toasts.show({
type,
message,
id: Toasts.genId()
});
const toastSuccess = () =>
toast(Toasts.Type.SUCCESS, "Settings successfully imported. Restart to apply changes!");
const toastFailure = (err: any) =>
toast(Toasts.Type.FAILURE, `Failed to import settings: ${String(err)}`);
const logger = new Logger("SettingsSync:Offline", "#39b7e0");
function isSafeObject(obj: any) {
if (obj == null || typeof obj !== "object") return true;
for (const key in obj) {
if (["__proto__", "constructor", "prototype"].includes(key)) {
return false;
}
if (!isSafeObject(obj[key])) {
return false;
}
}
return true;
}
export async function importSettings(data: string, type: BackupType = "all", cloud = false) {
try {
var parsed = JSON.parse(data);
} catch (err) {
console.log(data);
throw new Error("Failed to parse JSON: " + String(err));
}
if (!isSafeObject(parsed))
throw new Error("Unsafe Settings");
switch (type) {
case "all": {
if (!cloud && (!("settings" in parsed) || !("quickCss" in parsed) || !("dataStore" in parsed)))
throw new Error("Invalid Settings. Missing one or more required keys.");
if (parsed.settings) {
Object.assign(PlainSettings, parsed.settings);
await VencordNative.settings.set(parsed.settings);
}
if (parsed.quickCss) await VencordNative.quickCss.set(parsed.quickCss);
if (parsed.dataStore) await DataStore.setMany(parsed.dataStore);
break;
}
case "plugins": {
if (!parsed.settings?.settings) throw new Error("Plugin settings missing");
Object.assign(PlainSettings, parsed.settings.settings);
await VencordNative.settings.set(parsed.settings.settings);
break;
}
case "css": {
if (!parsed.quickCss) throw new Error("CSS missing");
await VencordNative.quickCss.set(parsed.quickCss);
break;
}
case "datastore": {
if (!parsed.dataStore) throw new Error("DataStore data missing");
await DataStore.setMany(parsed.dataStore);
break;
}
}
}
export async function exportSettings({ syncDataStore = true, type = "all", minify }: { syncDataStore?: boolean; type?: BackupType; minify?: boolean; }) {
const settings = VencordNative.settings.get();
const quickCss = await VencordNative.quickCss.get();
const dataStore = syncDataStore ? await DataStore.entries() : undefined;
switch (type) {
case "all": {
return JSON.stringify({ settings, quickCss, ...(syncDataStore && { dataStore }) }, null, minify ? undefined : 4);
}
case "plugins": {
return JSON.stringify({ settings: { settings } }, null, minify ? undefined : 4);
}
case "css": {
return JSON.stringify({ quickCss }, null, minify ? undefined : 4);
}
case "datastore": {
return JSON.stringify({ dataStore }, null, minify ? undefined : 4);
}
}
}
export async function exportPlugins({ minify }: { minify?: boolean; } = {}) {
const { plugins } = VencordNative.settings.get();
return JSON.stringify({ settings: { plugins } }, null, minify ? undefined : 4);
}
export async function exportCSS({ minify }: { minify?: boolean; } = {}) {
const quickCss = await VencordNative.quickCss.get();
return JSON.stringify({ quickCss }, null, minify ? undefined : 4);
}
export async function exportDataStores({ minify }: { minify?: boolean; } = {}) {
const dataStore = await DataStore.entries();
return JSON.stringify({ dataStore }, null, minify ? undefined : 4);
}
export async function downloadSettingsBackup(type: BackupType = "all", { minify }: { minify?: boolean; } = {}) {
const backup = await exportSettings({ minify, type });
const filename = `equicord-${type}-backup-${moment().format("YYYY-MM-DD")}.json`;
const data = new TextEncoder().encode(backup);
if (IS_DISCORD_DESKTOP) {
DiscordNative.fileManager.saveWithDialog(data, filename);
} else {
saveFile(new File([data], filename, { type: "application/json" }));
}
}
export async function uploadSettingsBackup(type: BackupType = "all", showToast = true): Promise<void> {
if (IS_DISCORD_DESKTOP) {
const [file] = await DiscordNative.fileManager.openFiles({
filters: [
{ name: "Vencord Settings Backup", extensions: ["json"] },
{ name: "all", extensions: ["*"] }
]
});
if (file) {
try {
await importSettings(new TextDecoder().decode(file.data), type);
if (showToast) toastSuccess();
} catch (err) {
logger.error(err);
if (showToast) toastFailure(err);
}
}
} else {
const file = await chooseFile("application/json");
if (!file) return;
const reader = new FileReader();
reader.onload = async () => {
try {
await importSettings(reader.result as string, type);
if (showToast) toastSuccess();
} catch (err) {
logger.error(err);
if (showToast) toastFailure(err);
}
};
reader.readAsText(file);
}
}

View file

@ -16,9 +16,12 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { MapValue } from "type-fest/source/entry";
export type Style = MapValue<typeof VencordStyles>;
export interface Style {
name: string;
source: string;
classNames: Record<string, string>;
dom: HTMLStyleElement | null;
}
export const styleMap = window.VencordStyles ??= new Map();

View file

@ -17,9 +17,8 @@
*/
import { Settings, SettingsStore } from "@api/Settings";
import { ThemeStore } from "@webpack/common";
import { createAndAppendStyle } from "./css";
import { createAndAppendStyle } from "@utils/css";
import { ThemeStore } from "@vencord/discord-types";
let style: HTMLStyleElement;
let themesStyle: HTMLStyleElement;
@ -54,14 +53,15 @@ async function initThemes() {
const { enabledThemeLinks, enabledThemes } = Settings;
const enabledlinks: string[] = [...enabledThemeLinks];
const { ThemeStore } = require("@webpack/common/stores") as typeof import("@webpack/common/stores");
// "darker" and "midnight" both count as dark
// This function is first called on DOMContentLoaded, so ThemeStore may not have been loaded yet
const activeTheme = ThemeStore == null
? undefined
: ThemeStore.theme === "light" ? "light" : "dark";
const links = enabledlinks
const links = enabledThemeLinks
.map(rawLink => {
const match = /^@(light|dark) (.*)/.exec(rawLink);
if (!match) return rawLink;
@ -103,16 +103,16 @@ document.addEventListener("DOMContentLoaded", () => {
}
}, { once: true });
export function initQuickCssThemeStore() {
export function initQuickCssThemeStore(themeStore: ThemeStore) {
if (IS_USERSCRIPT) return;
initThemes();
let currentTheme = ThemeStore.theme;
ThemeStore.addChangeListener(() => {
if (currentTheme === ThemeStore.theme) return;
let currentTheme = themeStore.theme;
themeStore.addChangeListener(() => {
if (currentTheme === themeStore.theme) return;
currentTheme = ThemeStore.theme;
currentTheme = themeStore.theme;
initThemes();
});
}

78
src/api/UserArea.tsx Normal file
View file

@ -0,0 +1,78 @@
/*
* 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 { findComponentByCodeLazy } from "@webpack";
import type { ComponentType, MouseEventHandler, ReactNode } from "react";
const PanelButton = findComponentByCodeLazy(".NONE,disabled:", ".PANEL_BUTTON") as ComponentType<UserAreaButtonProps>;
export interface UserAreaButtonProps {
icon: ReactNode;
tooltipText?: ReactNode;
onClick?: MouseEventHandler<HTMLDivElement>;
onContextMenu?: MouseEventHandler<HTMLDivElement>;
className?: string;
role?: string;
"aria-label"?: string;
"aria-checked"?: boolean;
disabled?: boolean;
plated?: boolean;
redGlow?: boolean;
orangeGlow?: boolean;
}
export interface UserAreaRenderProps {
nameplate?: any;
iconForeground?: string;
hideTooltips?: boolean;
}
export type UserAreaButtonFactory = (props: UserAreaRenderProps) => ReactNode;
export interface UserAreaButtonData {
render: UserAreaButtonFactory;
icon: ComponentType<{ className?: string; }>;
priority?: number;
}
interface ButtonEntry {
render: UserAreaButtonFactory;
priority: number;
}
export const UserAreaButton = PanelButton;
const logger = new Logger("UserArea");
export const buttons = new Map<string, ButtonEntry>();
export function addUserAreaButton(id: string, render: UserAreaButtonFactory, priority = 0) {
buttons.set(id, { render, priority });
}
export function removeUserAreaButton(id: string) {
buttons.delete(id);
}
function UserAreaButtons({ props }: { props: UserAreaRenderProps; }) {
return (
<>
{Array.from(buttons)
.sort(([, a], [, b]) => a.priority - b.priority)
.map(([id, { render: Button }]) => (
<ErrorBoundary noop key={id} onError={e => logger.error(`Failed to render ${id}`, e.error)}>
<Button {...props} />
</ErrorBoundary>
))}
</>
);
}
export function _renderButtons(props: UserAreaRenderProps) {
return [<UserAreaButtons key="vc-user-area-buttons" props={props} />];
}

View file

@ -20,6 +20,8 @@ import { proxyLazy } from "@utils/lazy";
import { Logger } from "@utils/Logger";
import { findModuleId, proxyLazyWebpack, wreq } from "@webpack";
import { isPluginEnabled } from "./PluginManager";
interface UserSettingDefinition<T> {
/**
* Get the setting value
@ -57,7 +59,7 @@ export const UserSettings: Record<PropertyKey, UserSettingDefinition<any>> | und
* @param name The name of the setting
*/
export function getUserSetting<T = any>(group: string, name: string): UserSettingDefinition<T> | undefined {
if (!Vencord.Plugins.isPluginEnabled("UserSettingsAPI")) throw new Error("Cannot use UserSettingsAPI without setting as dependency.");
if (!isPluginEnabled("UserSettingsAPI")) throw new Error("Cannot use UserSettingsAPI without setting it as a dependency.");
for (const key in UserSettings) {
const userSetting = UserSettings[key];

View file

@ -22,6 +22,7 @@ import * as $ChatButtons from "./ChatButtons";
import * as $Commands from "./Commands";
import * as $ContextMenu from "./ContextMenu";
import * as $DataStore from "./DataStore";
import * as $HeaderBar from "./HeaderBar";
import * as $MemberListDecorators from "./MemberListDecorators";
import * as $MessageAccessories from "./MessageAccessories";
import * as $MessageDecorations from "./MessageDecorations";
@ -31,9 +32,12 @@ import * as $MessageUpdater from "./MessageUpdater";
import * as $NicknameIcons from "./NicknameIcons";
import * as $Notices from "./Notices";
import * as $Notifications from "./Notifications";
import * as $UserArea from "./UserArea";
export * as PluginManager from "./PluginManager";
import * as $ServerList from "./ServerList";
import * as $Settings from "./Settings";
import * as $Styles from "./Styles";
import * as $Themes from "./Themes";
import * as $UserSettings from "./UserSettings";
/**
@ -127,6 +131,11 @@ export const ContextMenu = $ContextMenu;
*/
export const ChatButtons = $ChatButtons;
/**
* An API allowing you to add buttons to the header bar or channel toolbar.
*/
export const HeaderBar = $HeaderBar;
/**
* An API allowing you to update and re-render messages.
*/
@ -137,6 +146,11 @@ export const MessageUpdater = $MessageUpdater;
*/
export const UserSettings = $UserSettings;
/**
* Don't use this
*/
export const Themes = $Themes;
/**
* An API allowing you to add icons to the nickname, in profiles.
*/
@ -147,6 +161,11 @@ export const NicknameIcons = $NicknameIcons;
*/
export const AudioPlayer = $AudioPlayer;
/**
* An API allowing you to add buttons to the user area panel.
*/
export const UserArea = $UserArea;
/**
* Just used to identify if user is on Equicord as Vencord doesn't have this.
*/

59
src/components/Alert.tsx Normal file
View file

@ -0,0 +1,59 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { filters, mapMangledModuleLazy } from "@webpack";
import type { ComponentType, CSSProperties, ReactNode } from "react";
const { HelpMessage, HelpMessageTypes } = mapMangledModuleLazy('POSITIVE="positive', {
HelpMessageTypes: filters.byProps("POSITIVE", "WARNING", "INFO", "ERROR"),
HelpMessage: filters.byCode(".iconDiv")
});
export interface AlertProps {
children: ReactNode;
className?: string;
style?: CSSProperties;
icon?: ComponentType<{ className?: string; color?: string; }>;
}
function Warning({ children, className, style, icon }: AlertProps) {
return (
<HelpMessage messageType={HelpMessageTypes.WARNING} className={className} style={style} icon={icon}>
{children}
</HelpMessage>
);
}
function Info({ children, className, style, icon }: AlertProps) {
return (
<HelpMessage messageType={HelpMessageTypes.INFO} className={className} style={style} icon={icon}>
{children}
</HelpMessage>
);
}
function Error({ children, className, style, icon }: AlertProps) {
return (
<HelpMessage messageType={HelpMessageTypes.ERROR} className={className} style={style} icon={icon}>
{children}
</HelpMessage>
);
}
function Positive({ children, className, style, icon }: AlertProps) {
return (
<HelpMessage messageType={HelpMessageTypes.POSITIVE} className={className} style={style} icon={icon}>
{children}
</HelpMessage>
);
}
export const Alert = {
Warning,
Info,
Error,
Positive
};

View file

@ -13,7 +13,7 @@ import type { ComponentPropsWithoutRef, ReactNode } from "react";
const textCls = classNameFactory("vc-text-");
const Sizes = {
export const TextSizes = {
xxs: "0.625rem",
xs: "0.75rem",
sm: "0.875rem",
@ -23,7 +23,7 @@ const Sizes = {
xxl: "2rem"
} as const;
const Weights = {
export const TextWeights = {
thin: "100",
extralight: "200",
light: "300",
@ -34,27 +34,41 @@ const Weights = {
extrabold: "800",
} as const;
export const TextColors = {
"text-default": "var(--text-default)",
"text-muted": "var(--text-muted)",
"text-link": "var(--text-link)",
"text-danger": "var(--text-feedback-critical)",
"text-brand": "var(--text-brand)",
"text-strong": "var(--text-strong)",
"text-subtle": "var(--text-subtle)",
"control-text-critical": "var(--control-text-critical-secondary-default)",
"control-text-primary": "var(--control-text-primary-default)",
} as const;
export function generateTextCss() {
let css = "";
for (const [size, value] of Object.entries(Sizes)) {
for (const [size, value] of Object.entries(TextSizes)) {
css += `.${textCls(size)}{font-size:${value};}`;
}
for (const [weight, value] of Object.entries(Weights)) {
for (const [weight, value] of Object.entries(TextWeights)) {
css += `.${textCls(weight)}{font-weight:${value};}`;
}
return css;
}
export type TextSize = keyof typeof Sizes;
export type TextWeight = keyof typeof Weights;
export type TextSize = keyof typeof TextSizes;
export type TextWeight = keyof typeof TextWeights;
export type TextColor = keyof typeof TextColors;
export type TextTag = "div" | "span" | "p" | `h${1 | 2 | 3 | 4 | 5 | 6}`;
export type BaseTextProps<Tag extends TextTag = "div"> = ComponentPropsWithoutRef<Tag> & {
size?: TextSize;
weight?: TextWeight;
color?: TextColor;
tag?: Tag;
};
@ -62,6 +76,7 @@ export function BaseText<T extends TextTag = "div">(props: BaseTextProps<T>): Re
const {
size = "md",
weight = "normal",
color,
tag: Tag = "div",
children,
className,
@ -72,7 +87,10 @@ export function BaseText<T extends TextTag = "div">(props: BaseTextProps<T>): Re
return (
<Tag
className={classes(textCls("base", size, weight), className)}
style={style}
style={{
...style,
...(color && { color: TextColors[color] })
}}
{...restProps}
>
{children}
@ -81,7 +99,7 @@ export function BaseText<T extends TextTag = "div">(props: BaseTextProps<T>): Re
}
// #region Old compability
// #region Old compatibility
export const TextCompat: DiscordText = function TextCompat({ color, variant, ...restProps }) {
const newBaseTextProps = restProps as BaseTextProps;

View file

@ -10,7 +10,7 @@
text-align: start;
transition: 50ms ease-in;
transition-property: background-color, color, border-color, opacity;
background: var(--control-background-secondary-default);
background: var(--control-secondary-background-default);
color: var(--text-default);
white-space: nowrap;
@ -26,7 +26,7 @@
&:focus-visible {
/* stylelint-disable-next-line custom-property-pattern */
box-shadow: 0 0 0 4px var(--__adaptive-focus-ring-color, var(--focus-primary, #00b0f4));
box-shadow: 0 0 0 4px var(--__adaptive-focus-ring-color, var(--border-focus, #00b0f4));
}
}
@ -63,85 +63,84 @@
}
.vc-btn-primary {
background-color: var(--control-background-primary-default);
border-color: var(--control-border-primary-default);
color: var(--control-text-primary-default);
background-color: var(--control-primary-background-default);
border-color: var(--control-primary-border-default);
color: var(--control-primary-text-default);
&:hover {
background-color: var(--control-background-primary-hover);
border-color: var(--control-border-primary-hover);
color: var(--control-text-primary-hover);
background-color: var(--control-primary-background-hover);
border-color: var(--control-primary-border-hover);
color: var(--control-primary-text-hover);
}
}
.vc-btn-secondary,
.vc-btn-link {
background-color: var(--control-background-secondary-default);
border-color: var(--control-border-secondary-default);
color: var(--control-text-secondary-default);
background-color: var(--control-secondary-background-default);
border-color: var(--control-secondary-border-default);
color: var(--control-secondary-text-default);
&:hover {
background-color: var(--control-background-secondary-hover);
border-color: var(--control-border-secondary-hover);
color: var(--control-text-secondary-hover);
background-color: var(--control-secondary-background-hover);
border-color: var(--control-secondary-border-hover);
color: var(--control-secondary-text-hover);
}
}
.vc-btn-dangerPrimary {
background-color: var(--control-background-critical-primary-default);
border-color: var(--control-border-critical-primary-default);
color: var(--control-text-critical-primary-default);
background-color: var(--control-critical-primary-background-default);
border-color: var(--control-critical-primary-border-default);
color: var(--control-critical-primary-text-default);
&:hover {
background-color: var(--control-background-critical-primary-hover);
border-color: var(--control-border-critical-primary-hover);
color: var(--control-text-critical-primary-hover);
background-color: var(--control-critical-primary-background-hover);
border-color: var(--control-critical-primary-border-hover);
color: var(--control-critical-primary-text-hover);
}
}
.vc-btn-dangerSecondary {
background-color: var(--control-background-critical-secondary-default);
border-color: var(--control-border-critical-secondary-default);
color: var(--control-text-critical-secondary-default);
background-color: var(--control-critical-secondary-background-default);
border-color: var(--control-critical-secondary-border-default);
color: var(--control-critical-secondary-text-default);
&:hover {
background-color: var(--control-background-critical-secondary-hover);
border-color: var(--control-border-critical-secondary-hover);
color: var(--control-text-critical-secondary-hover);
background-color: var(--control-critical-secondary-background-hover);
border-color: var(--control-critical-secondary-border-hover);
color: var(--control-critical-secondary-text-hover);
}
}
.vc-btn-overlayPrimary {
background-color: var(--control-background-overlay-primary-default);
border-color: var(--control-border-overlay-primary-default);
color: var(--control-text-overlay-primary-default);
background-color: var(--control-overlay-primary-background-default);
border-color: var(--control-overlay-primary-border-default);
color: var(--control-overlay-primary-text-default);
&:hover {
background-color: var(--control-background-overlay-primary-hover);
border-color: var(--control-border-overlay-primary-hover);
color: var(--control-text-overlay-primary-hover);
background-color: var(--control-overlay-primary-background-hover);
border-color: var(--control-overlay-primary-border-hover);
color: var(--control-overlay-primary-text-hover);
}
}
.vc-btn-positive {
background-color: var(--button-positive-background);
border-color: var(--button-positive-border);
background-color: var(--redesign-button-positive-background);
color: var(--white);
&:hover {
background-color: var(--button-positive-background-hover);
background-color: var(--redesign-button-positive-pressed-background);
}
}
.vc-btn-none {
background-color: transparent;
border-color: transparent;
color: var(--control-icon-icon-only-default);
color: var(--control-icon-only-icon-default);
&:hover {
background-color: var(--control-background-icon-only-hover);
border-color: var(--control-border-icon-only-hover);
color: var(--control-icon-icon-only-hover);
background-color: var(--control-icon-only-background-hover);
border-color: var(--control-icon-only-border-hover);
color: var(--control-icon-only-icon-hover);
}
}
@ -178,7 +177,7 @@
&:focus-visible {
/* stylelint-disable-next-line custom-property-pattern */
box-shadow: 0 0 0 4px var(--__adaptive-focus-ring-color, var(--focus-primary, #00b0f4));
box-shadow: 0 0 0 4px var(--__adaptive-focus-ring-color, var(--border-focus, #00b0f4));
}
}
@ -187,11 +186,11 @@
}
.vc-text-btn-secondary {
color: var(--text-primary);
color: var(--text-strong, var(--text-default));
}
.vc-text-btn-danger {
color: var(--text-feedback-critical, var(--text-danger));
color: var(--text-feedback-critical);
}
.vc-text-btn-link {

View file

@ -12,6 +12,7 @@ import type { Button as DiscordButton } from "@vencord/discord-types";
import type { ComponentPropsWithRef } from "react";
import { OpenExternalIcon } from "./Icons";
import { Link } from "./Link";
const btnCls = classNameFactory("vc-btn-");
const textBtnCls = classNameFactory("vc-text-btn-");
@ -25,6 +26,11 @@ export type ButtonProps = ComponentPropsWithRef<"button"> & {
size?: ButtonSize;
};
export type LinkButtonProps = ComponentPropsWithRef<"a"> & {
size?: ButtonSize;
variant?: ButtonVariant;
};
export function Button({ variant = "primary", size = "medium", children, className, ...restProps }: ButtonProps) {
return (
<button data-mana-component="button" className={classes(btnCls("base", variant, size), className)} {...restProps}>
@ -34,6 +40,15 @@ export function Button({ variant = "primary", size = "medium", children, classNa
);
}
export function LinkButton({ variant = "link", size = "medium", className, children, ...restProps }: LinkButtonProps) {
return (
<Link data-mana-component="button" className={classes(btnCls("base", variant, size), className)} {...restProps}>
{children}
<OpenExternalIcon className={btnCls("link-icon")} />
</Link>
);
}
export type TextButtonVariant = "primary" | "secondary" | "danger" | "link";
export type TextButtonProps = ComponentPropsWithRef<"button"> & {

20
src/components/Card.css Normal file
View file

@ -0,0 +1,20 @@
.vc-card-base {
background: var(--card-background-default);
border-radius: var(--radius-sm, 8px)
}
.vc-card-normal {
border: 1px solid var(--border-subtle);
}
.vc-card-warning {
border: 1px solid var(--icon-status-idle);
}
.vc-card-danger {
border: 1px solid var(--icon-status-dnd);
}
.vc-card-defaultPadding {
padding: 1em;
}

31
src/components/Card.tsx Normal file
View file

@ -0,0 +1,31 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./Card.css";
import { classNameFactory } from "@api/Styles";
import { classes } from "@utils/misc";
import { ComponentPropsWithRef } from "react";
const cl = classNameFactory("vc-card-");
export interface CardProps extends ComponentPropsWithRef<"div"> {
variant?: "normal" | "warning" | "danger";
/** Add a default padding of 1em to the card. This is implied if no className prop is passed */
defaultPadding?: boolean;
}
export function Card({ variant = "normal", defaultPadding, children, className, ...restProps }: CardProps) {
const addDefaultPadding = defaultPadding != null
? defaultPadding
: !className;
return (
<div className={classes(cl("base", variant, addDefaultPadding && "defaultPadding"), className)} {...restProps}>
{children}
</div>
);
}

View file

@ -20,6 +20,7 @@ import { LazyComponent, LazyComponentWrapper } from "@utils/lazyReact";
import { Logger } from "@utils/Logger";
import { Margins } from "@utils/margins";
import type { React } from "@webpack/common";
import { ComponentType } from "react";
import { ErrorCard } from "./ErrorCard";
@ -115,13 +116,21 @@ const ErrorBoundary = LazyComponent(() => {
};
}) as
LazyComponentWrapper<React.ComponentType<React.PropsWithChildren<Props>> & {
wrap<T extends object = any>(Component: React.ComponentType<T>, errorBoundaryProps?: Omit<Props<T>, "wrappedProps">): React.FunctionComponent<T>;
wrap<T extends object = any>(Component: React.ComponentType<T>, errorBoundaryProps?: Omit<Props<T>, "wrappedProps"> & { displayName?: string; }): React.FunctionComponent<T>;
}>;
ErrorBoundary.wrap = (Component, errorBoundaryProps) => props => (
ErrorBoundary.wrap = (Component, errorBoundaryProps) => {
const wrapper: ComponentType<any> = props => (
<ErrorBoundary {...errorBoundaryProps} wrappedProps={props}>
<Component {...props} />
</ErrorBoundary>
);
if (errorBoundaryProps?.displayName) {
wrapper.displayName = errorBoundaryProps.displayName;
}
return wrapper;
};
export default ErrorBoundary;

View file

@ -1,11 +1,15 @@
.vc-error-card {
padding: 2em;
background-color: #e7828430;
border: 1px solid #e78284;
border-radius: 5px;
color: var(--text-default, white);
padding: 1em;
background-color: var(--background-feedback-critical, rgb(237 66 69 / 10%));
border: 1px solid var(--text-status-dnd, #ed4245);
border-radius: var(--radius-sm, 8px);
color: var(--text-default);
}
& a:hover {
.vc-error-card a {
color: var(--text-link);
}
.vc-error-card a:hover {
text-decoration: underline;
}
}

View file

@ -16,22 +16,30 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { React } from "@webpack/common";
import type { CSSProperties, HTMLAttributes } from "react";
export interface FlexProps extends HTMLAttributes<HTMLDivElement> {
flexDirection?: CSSProperties["flexDirection"];
gap?: CSSProperties["gap"];
justifyContent?: CSSProperties["justifyContent"];
alignItems?: CSSProperties["alignItems"];
flexWrap?: CSSProperties["flexWrap"];
}
export function Flex({ flexDirection, gap = "1em", justifyContent, alignItems, flexWrap, children, style, ...restProps }: FlexProps) {
style = {
display: "flex",
flexDirection,
gap,
justifyContent,
alignItems,
flexWrap,
...style
};
export function Flex(props: React.PropsWithChildren<{
flexDirection?: React.CSSProperties["flexDirection"];
style?: React.CSSProperties;
className?: string;
} & React.HTMLProps<HTMLDivElement>>) {
props.style ??= {};
props.style.display = "flex";
// TODO(ven): Remove me, what was I thinking??
props.style.gap ??= "1em";
props.style.flexDirection ||= props.flexDirection;
delete props.flexDirection;
return (
<div {...props}>
{props.children}
<div style={style} {...restProps}>
{children}
</div>
);
}

View file

@ -1,30 +1,34 @@
.vc-form-switch-wrapper {
.vc-form-switch-container {
padding-bottom: 20px;
margin-bottom: 20px;
cursor: pointer;
border-bottom: 1px solid var(--border-subtle);
}
.vc-form-switch-no-border {
border-bottom: none;
margin-bottom: 0;
}
.vc-form-switch {
display: flex;
width: 100%;
> :last-child {
margin-left: auto;
}
align-items: flex-start;
justify-content: space-between;
cursor: pointer;
gap: 16px;
}
.vc-form-switch-disabled {
opacity: 0.5;
pointer-events: none;
cursor: not-allowed;
}
.vc-form-switch-text {
display: flex;
flex-direction: column;
justify-content: center;
gap: 8px;
flex: 1;
min-width: 0;
max-width: 80%;
}
.vc-form-switch-border {
margin-top: 20px;
.vc-form-switch-control {
flex-shrink: 0;
padding-top: 2px;
}

View file

@ -9,7 +9,7 @@ import "./FormSwitch.css";
import { classes } from "@utils/misc";
import type { PropsWithChildren, ReactNode } from "react";
import { Divider } from "./Divider";
import { Flex } from "./Flex";
import { Span } from "./Span";
import { Switch } from "./Switch";
@ -26,24 +26,22 @@ export interface FormSwitchProps {
export function FormSwitch({ onChange, title, value, description, disabled, className, hideBorder }: FormSwitchProps) {
return (
<label className="vc-form-switch-wrapper">
<div className={classes("vc-form-switch", className, disabled && "vc-form-switch-disabled")}>
<div className={"vc-form-switch-text"}>
<Span size="md" weight="medium">{title}</Span>
{description && <Span size="sm" weight="normal">{description}</Span>}
<div className={classes("vc-form-switch-container", hideBorder && "vc-form-switch-no-border")}>
<label className={classes("vc-form-switch", className, disabled && "vc-form-switch-disabled")}>
<div className="vc-form-switch-text">
<Flex flexDirection="column" gap="4px">
<Span size="md" weight="medium" color="text-strong">{title}</Span>
{description && <Span size="sm" color="text-subtle">{description}</Span>}
</Flex>
</div>
<div className="vc-form-switch-control">
<Switch checked={value} onChange={onChange} disabled={disabled} />
</div>
{!hideBorder && <Divider className="vc-form-switch-border" />}
</label>
</div>
);
}
// #region Old compatibility
export function FormSwitchCompat({ note, children, ...restProps }: PropsWithChildren<any>) {
return <FormSwitch title={children ?? ""} description={note} {...restProps} />;
}
// #endregion

View file

@ -1,13 +1,13 @@
.vc-h1,
.vc-h2 {
color: var(--header-primary);
color: var(--text-strong);
font-weight: 600;
}
.vc-h3,
.vc-h4,
.vc-h5 {
color: var(--header-secondary);
color: var(--text-strong);
}
.vc-h1 {
@ -41,7 +41,7 @@
}
.vc-h5 {
color: var(--header-primary);
color: var(--text-strong);
font-size: 16px;
font-weight: 500;
margin-bottom: 8px;

View file

@ -397,6 +397,27 @@ export function NotesIcon(props: IconProps) {
);
}
export function IDIcon(props: IconProps) {
return (
<Icon
{...props}
className={classes(props.className, "vc-id-icon")}
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M15.3 14.48c-.46.45-1.08.67-1.86.67h-1.39V9.2h1.39c.78 0 1.4.22 1.86.67.46.45.68 1.22.68 2.31 0 1.1-.22 1.86-.68 2.31Z"
/>
<path
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-3H5Zm1 15h2.04V7.34H6V17Zm4-9.66V17h3.44c1.46 0 2.6-.42 3.38-1.25.8-.83 1.2-2.02 1.2-3.58s-.4-2.75-1.2-3.58c-.79-.83-1.92-1.25-3.38-1.25H10Z"
clipRule="evenodd"
/>
</Icon>
);
}
export function FolderIcon(props: IconProps) {
return (
<Icon
@ -503,3 +524,165 @@ export function WebsiteIcon(props: IconProps) {
</Icon>
);
}
/**
* A question mark inside a square, used as a placeholder icon when no other icon is available
*/
export function PlaceholderIcon(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-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" clipRule="evenodd" />
</Icon>
);
}
export function MainSettingsIcon(props: IconProps) {
return (
<Icon
{...props}
viewBox="0 0 24 24"
>
<path
fill={props.fill || "currentColor"}
d="M10.56 1.1c-.46.05-.7.53-.64.98.18 1.16-.19 2.2-.98 2.53-.8.33-1.79-.15-2.49-1.1-.27-.36-.78-.52-1.14-.24-.77.59-1.45 1.27-2.04 2.04-.28.36-.12.87.24 1.14.96.7 1.43 1.7 1.1 2.49-.33.8-1.37 1.16-2.53.98-.45-.07-.93.18-.99.64a11.1 11.1 0 0 0 0 2.88c.06.46.54.7.99.64 1.16-.18 2.2.19 2.53.98.33.8-.14 1.79-1.1 2.49-.36.27-.52.78-.24 1.14.59.77 1.27 1.45 2.04 2.04.36.28.87.12 1.14-.24.7-.95 1.7-1.43 2.49-1.1.8.33 1.16 1.37.98 2.53-.07.45.18.93.64.99a11.1 11.1 0 0 0 2.88 0c.46-.06.7-.54.64-.99-.18-1.16.19-2.2.98-2.53.8-.33 1.79.14 2.49 1.1.27.36.78.52 1.14.24.77-.59 1.45-1.27 2.04-2.04.28-.36.12-.87-.24-1.14-.96-.7-1.43-1.7-1.1-2.49.33-.8 1.37-1.16 2.53-.98.45.07.93-.18.99-.64a11.1 11.1 0 0 0 0-2.88c-.06-.46-.54-.7-.99-.64-1.16.18-2.2-.19-2.53-.98-.33-.8.14-1.79 1.1-2.49.36-.27.52-.78.24-1.14a11.07 11.07 0 0 0-2.04-2.04c-.36-.28-.87-.12-1.14.24-.7.96-1.7 1.43-2.49 1.1-.8-.33-1.16-1.37-.98-2.53.07-.45-.18-.93-.64-.99a11.1 11.1 0 0 0-2.88 0ZM16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z"
/>
</Icon>
);
}
export function PluginsIcon(props: IconProps) {
return (
<Icon
{...props}
viewBox="0 0 24 24"
>
<path
fill={props.fill || "currentColor"}
d="M18.559 12.8227C17.7884 13.4957 16.6663 13.3616 15.9404 12.641C14.7975 11.5063 11.4931 8.21104 11.4931 8.21104C10.897 7.63087 10.897 6.44662 11.4931 5.85464C12.319 5.03435 13.6053 3.75146 13.6053 3.75146C13.9641 3.39195 14.456 3.18972 14.9653 3.18886L18.3363 3.18425L19.5255 2L22.5 4.96048L21.3108 6.14473L21.3021 9.50878C21.2992 10.0164 21.0967 10.5026 20.735 10.8613C20.735 10.8613 19.5718 11.9384 18.559 12.8227ZM15.2315 13.9548L13.4954 15.8273C14.0972 16.4265 14.0972 16.9113 13.64 17.6997L11.3976 20.2485C11.0359 20.6081 10.5469 20.8103 10.0347 20.8111L6.66378 20.8158L5.47455 22L2.5 19.0395L3.68927 17.8553L3.70082 14.4912C3.70082 13.9836 3.90338 13.4974 4.26507 13.1387L6.37153 11.0404C6.96759 10.4485 8.15685 10.4485 8.73844 11.0404L8.74424 11.0465L10.5295 9.26998L11.7188 10.4542L9.93347 12.2305L12.3119 14.599L14.0972 12.8227L15.2315 13.9548Z"
/>
</Icon>
);
}
export function CloudIcon(props: IconProps) {
return (
<Icon
{...props}
viewBox="0 0 24 24"
>
<path
fill={props.fill || "currentColor"}
d="M16.8333 19H5.16667C3.16667 19 1.5 17.3333 1.5 15.3333C1.5 13.4 2.96667 11.8667 4.83333 11.6667V11.3333C4.83333 7.86667 7.7 5 11.1667 5C14.0333 5 16.5667 6.93333 17.3 9.66667C19.7 9.86667 21.5 11.8667 21.5 14.3333C21.5 16.9333 19.4333 19 16.8333 19Z"
/>
</Icon>
);
}
export function BackupRestoreIcon(props: IconProps) {
return (
<Icon
{...props}
viewBox="0 0 24 24"
>
<path
fill={props.fill || "currentColor"}
d="M21 2.01232C21.2652 2.01232 21.5196 2.11757 21.7071 2.30492C21.8946 2.49226 22 2.74636 22 3.0113V9.00521C22 9.27015 21.8946 9.52425 21.7071 9.7116C21.5196 9.89894 21.2652 10.0042 21 10.0042H15C14.7348 10.0042 14.4804 9.89894 14.2929 9.7116C14.1054 9.52425 14 9.27015 14 9.00521C14 8.74026 14.1054 8.48617 14.2929 8.29882C14.4804 8.11147 14.7348 8.00622 15 8.00622H18.93C18.352 7.00597 17.5638 6.14275 16.6198 5.47602C15.6758 4.80929 14.5983 4.35488 13.4616 4.1441C12.3249 3.93332 11.1559 3.97117 10.0353 4.25505C8.91459 4.53892 7.86883 5.06208 6.97 5.78848C6.76313 5.9554 6.49836 6.03338 6.23393 6.00528C5.96951 5.97718 5.72709 5.84529 5.56 5.63863C5.39291 5.43197 5.31485 5.16747 5.34298 4.90331C5.37111 4.63916 5.50313 4.39698 5.71 4.23006C6.7542 3.38308 7.959 2.7557 9.25204 2.38561C10.5451 2.01552 11.8996 1.91037 13.2344 2.07646C14.5691 2.24255 15.8565 2.67646 17.0191 3.35212C18.1818 4.02778 19.1957 4.93125 20 6.00826V3.0113C20 2.74636 20.1054 2.49226 20.2929 2.30492C20.4804 2.11757 20.7348 2.01232 21 2.01232ZM3 21.992C2.73478 21.992 2.48043 21.8867 2.29289 21.6994C2.10536 21.5121 2 21.258 2 20.993V14.9991C2 14.7342 2.10536 14.4801 2.29289 14.2927C2.48043 14.1054 2.73478 14.0001 3 14.0001H9C9.26522 14.0001 9.51957 14.1054 9.70711 14.2927C9.89464 14.4801 10 14.7342 10 14.9991C10 15.2641 9.89464 15.5182 9.70711 15.7055C9.51957 15.8928 9.26522 15.9981 9 15.9981H5.07C5.64801 16.9983 6.43617 17.8616 7.3802 18.5283C8.32424 19.195 9.40171 19.6494 10.5384 19.8602C11.6751 20.071 12.8441 20.0331 13.9647 19.7493C15.0854 19.4654 16.1312 18.9422 17.03 18.2158C17.1324 18.1332 17.2502 18.0715 17.3764 18.0343C17.5027 17.9971 17.6351 17.9851 17.7661 17.999C17.897 18.013 18.0239 18.0525 18.1395 18.1154C18.2552 18.1783 18.3573 18.2634 18.44 18.3657C18.5227 18.468 18.5845 18.5856 18.6217 18.7118C18.659 18.8379 18.6709 18.9702 18.657 19.101C18.6431 19.2318 18.6035 19.3586 18.5405 19.4741C18.4776 19.5896 18.3924 19.6916 18.29 19.7743C17.2452 20.6199 16.0403 21.2461 14.7475 21.6154C13.4547 21.9847 12.1005 22.0895 10.7662 21.9235C9.43181 21.7574 8.14476 21.324 6.98212 20.6491C5.81947 19.9743 4.80518 19.0719 4 17.9961V20.993C4 21.258 3.89464 21.5121 3.70711 21.6994C3.51957 21.8867 3.26522 21.992 3 21.992Z"
/>
</Icon>
);
}
export function UpdaterIcon(props: IconProps) {
return (
<Icon
{...props}
viewBox="0 0 24 24"
>
<path
fill={props.fill || "currentColor"}
d="M12 2C12.2652 2 12.5196 2.10536 12.7071 2.29289C12.8946 2.48043 13 2.73478 13 3V13.59L16.3 10.29C16.3904 10.186 16.5013 10.1018 16.6258 10.0427C16.7503 9.98362 16.8856 9.95088 17.0234 9.94656C17.1611 9.94224 17.2982 9.96644 17.4261 10.0176C17.5541 10.0688 17.6701 10.1459 17.7668 10.244C17.8635 10.3421 17.939 10.4592 17.9883 10.5878C18.0377 10.7165 18.0599 10.8539 18.0537 10.9916C18.0474 11.1292 18.0127 11.2641 17.9519 11.3877C17.891 11.5114 17.8053 11.6211 17.7 11.71L12.7 16.71C12.5131 16.8932 12.2618 16.9959 12 16.9959C11.7382 16.9959 11.4869 16.8932 11.3 16.71L6.3 11.71C6.19474 11.6211 6.10898 11.5114 6.04812 11.3877C5.98726 11.2641 5.95261 11.1292 5.94634 10.9916C5.94007 10.8539 5.96231 10.7165 6.01167 10.5878C6.06104 10.4592 6.13646 10.3421 6.2332 10.244C6.32994 10.1459 6.44592 10.0688 6.57385 10.0176C6.70179 9.96644 6.83892 9.94224 6.97665 9.94656C7.11438 9.95088 7.24972 9.98362 7.3742 10.0427C7.49868 10.1018 7.6096 10.186 7.7 10.29L11 13.59V3C11 2.73478 11.1054 2.48043 11.2929 2.29289C11.4804 2.10536 11.7348 2 12 2ZM3 20C2.73478 20 2.48043 20.1054 2.29289 20.2929C2.10536 20.4804 2 20.7348 2 21C2 21.2652 2.10536 21.5196 2.29289 21.7071C2.48043 21.8946 2.73478 22 3 22H21C21.2652 22 21.5196 21.8946 21.7071 21.7071C21.8946 21.5196 22 21.2652 22 21C22 20.7348 21.8946 20.4804 21.7071 20.2929C21.5196 20.1054 21.2652 20 21 20H3Z"
/>
</Icon>
);
}
export function PatchHelperIcon(props: IconProps) {
return (
<Icon
{...props}
viewBox="0 0 24 24"
>
<path
fill={props.fill || "currentColor"}
d="M7.79997 15.7699C8.49996 16.1999 8.99997 16.9099 8.99997 17.7299V20.9999C8.99997 21.2651 9.10533 21.5195 9.29286 21.707C9.48039 21.8945 9.73476 21.9999 9.99996 21.9999H14C14.2652 21.9999 14.5196 21.8945 14.7071 21.707C14.8946 21.5195 15 21.2651 15 20.9999V17.7299C15 16.9099 15.5 16.1999 16.2 15.7699C17.357 15.0536 18.3137 14.056 18.9812 12.8701C19.6486 11.6842 20.0048 10.3487 20.0168 8.98795C20.0288 7.62724 19.6961 6.28564 19.0497 5.08819C18.4032 3.89074 17.4642 2.87647 16.32 2.13989C15.72 1.74989 15 2.22989 15 2.93989V8.91988C15 9.18511 14.8946 9.43945 14.7071 9.62701C14.5196 9.81454 14.2652 9.9199 14 9.9199H9.99996C9.73476 9.9199 9.48039 9.81454 9.29286 9.62701C9.10533 9.43945 8.99997 9.18511 8.99997 8.91988V2.93989C8.99997 2.22989 8.27997 1.74989 7.67997 2.13989C6.53577 2.87647 5.59671 3.89074 4.9503 5.08819C4.30386 6.28564 3.97113 7.62724 3.9831 8.98795C3.9951 10.3487 4.35138 11.6842 5.01879 12.8701C5.6862 14.056 6.64299 15.0536 7.79997 15.7699Z"
/>
</Icon>
);
}
export function VesktopSettingsIcon(props: IconProps) {
return (
<Icon
{...props}
viewBox="0 0 24 24"
>
<path
fill={props.fill || "currentColor"}
d="M18.157.056a1.224 1.224 0 0 0-.709.628c-.105.229-.114.305-.115 1.081v.836l-.351.176a5.545 5.545 0 0 0-.586.342l-.233.167-.543-.311c-.899-.515-.944-.535-1.293-.539-.562-.004-1.018.352-1.17.917-.07.262-.021.567.138.858.151.28.21.325 1.02.794l.606.35v1.38l-.606.35c-.81.469-.869.514-1.02.793-.16.291-.208.597-.137.859.151.564.607.92 1.17.916.347-.004.393-.023 1.289-.537l.54-.308.313.21c.172.116.438.262.588.325l.275.114v.85c.001.793.009.87.115 1.098a1.19 1.19 0 0 0 1.969.282c.269-.307.292-.412.292-1.37v-.869l.315-.148c.173-.081.435-.228.582-.325l.266-.177.706.4c.796.45 1.029.523 1.408.438.882-.198 1.235-1.287.635-1.96-.085-.097-.457-.348-.827-.56l-.67-.385V5.359l.67-.386c.37-.212.742-.463.827-.56.6-.672.247-1.762-.635-1.96-.38-.084-.612-.012-1.405.437l-.7.397-.235-.18a3.792 3.792 0 0 0-.586-.344l-.35-.163v-.848c0-.935-.023-1.044-.292-1.35a1.2 1.2 0 0 0-1.26-.346M4.007 1.25a4.15 4.15 0 0 0-1.21.44c-.354.207-1.102.955-1.309 1.309-.199.34-.374.837-.441 1.252-.07.434-.07 10.426 0 10.86.127.792.42 1.343 1.039 1.963.477.478.81.697 1.317.874.59.204.818.216 4.125.217h3.163v2.42l-1.975.014c-2.202.015-2.148.008-2.5.362-.255.253-.346.474-.346.84s.09.587.345.84c.376.376-.09.348 5.688.348 5.75 0 5.309.025 5.67-.327a1.1 1.1 0 0 0 .362-.86c.002-.367-.09-.586-.344-.84-.353-.355-.3-.348-2.5-.363l-1.976-.014v-2.42h3.164c3.306-.001 3.535-.013 4.124-.217.508-.177.84-.396 1.318-.874.882-.882 1.113-1.57 1.082-3.213-.018-.956-.047-1.068-.364-1.384-.253-.255-.474-.346-.84-.346s-.586.091-.84.346c-.317.316-.343.42-.372 1.47-.023.846-.036.966-.13 1.14a1.22 1.22 0 0 1-.597.553c-.217.098-.276.098-7.757.098-7.48 0-7.54 0-7.757-.098a1.153 1.153 0 0 1-.612-.602l-.114-.242V9.68c.001-5.041.003-5.119.1-5.333.122-.27.3-.462.553-.599.19-.103.242-.104 2.958-.128 3.08-.027 2.927-.01 3.288-.372.255-.254.345-.474.345-.84s-.09-.587-.345-.84c-.364-.365-.204-.347-3.288-.355-1.52-.004-2.881.012-3.024.036m15.047 3.693c.207.108.452.361.57.59.143.278.143.755 0 1.028-.285.54-1.08.81-1.636.556a1.357 1.357 0 0 1-.59-.625c-.107-.255-.092-.7.032-.956.121-.251.46-.568.69-.645.213-.073.755-.043.934.052"
/>
</Icon>
);
}
export function EyeIcon(props: IconProps) {
return (
<Icon
{...props}
viewBox="0 0 24 24"
>
<path
fill={props.fill || "currentColor"}
d="M15.56 11.77c.2-.1.44.02.44.23a4 4 0 1 1-4-4c.21 0 .33.25.23.44a2.5 2.5 0 0 0 3.32 3.32Z"
/>
<path
clipRule="evenodd"
fillRule="evenodd"
fill={props.fill || "currentColor"}
d="M22.89 11.7c.07.2.07.4 0 .6C22.27 13.9 19.1 21 12 21c-7.11 0-10.27-7.11-10.89-8.7a.83.83 0 0 1 0-.6C1.73 10.1 4.9 3 12 3c7.11 0 10.27 7.11 10.89 8.7Zm-4.5-3.62A15.11 15.11 0 0 1 20.85 12c-.38.88-1.18 2.47-2.46 3.92C16.87 17.62 14.8 19 12 19c-2.8 0-4.87-1.38-6.39-3.08A15.11 15.11 0 0 1 3.15 12c.38-.88 1.18-2.47 2.46-3.92C7.13 6.38 9.2 5 12 5c2.8 0 4.87 1.38 6.39 3.08Z"
/>
</Icon>
);
}
export function ColorPaletteIcon(props: IconProps) {
return (
<Icon
{...props}
viewBox="0 0 24 24"
>
<path
fill={props.fill || "currentColor"}
d="M12.37 9.04c.25-.26.73-.2 1.06.13L15 10.7c.33.33.39.8.13 1.06L4.92 21.84c-.27.26-.74.2-1.07-.13l-1.56-1.54c-.33-.32-.39-.8-.13-1.05l10.2-10.08ZM16.09 5.16c.25-.26.73-.2 1.06.13l1.56 1.54c.33.32.39.8.13 1.05l-2.1 2.08c-.26.25-.74.2-1.07-.13l-1.56-1.54c-.33-.33-.38-.8-.13-1.05l2.1-2.08ZM17.48 14.36a.56.56 0 0 1 1.04 0l.85 2.27 2.27.85c.48.18.48.86 0 1.04l-2.27.85-.85 2.27a.56.56 0 0 1-1.04 0l-.85-2.27-2.27-.85a.56.56 0 0 1 0-1.04l2.27-.85.85-2.27ZM7.6 2.32a.5.5 0 0 1 .94 0L9.17 4l1.66.62a.5.5 0 0 1 0 .93l-1.66.63-.63 1.66a.5.5 0 0 1-.93 0l-.63-1.66-1.66-.63a.5.5 0 0 1 0-.93l1.66-.62.63-1.67Z"
/>
</Icon>
);
}
export function MagnifyingGlassIcon(props: IconProps) {
return (
<Icon
{...props}
viewBox="0 0 24 24"
>
<path
clipRule="evenodd"
fillRule="evenodd"
fill={props.fill || "currentColor"}
d="M15.62 17.03a9 9 0 1 1 1.41-1.41l4.68 4.67a1 1 0 0 1-1.42 1.42l-4.67-4.68ZM17 10a7 7 0 1 1-14 0 7 7 0 0 1 14 0Z"
/>
</Icon>
);
}

14
src/components/Link.css Normal file
View file

@ -0,0 +1,14 @@
.vc-link {
color: var(--text-link);
text-decoration: none;
cursor: pointer;
}
.vc-link:hover {
text-decoration: underline;
}
.vc-link-disabled {
pointer-events: none;
opacity: 0.5;
}

View file

@ -1,39 +1,29 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2022 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { React } from "@webpack/common";
import "./Link.css";
interface Props extends React.DetailedHTMLProps<React.AnchorHTMLAttributes<HTMLAnchorElement>, HTMLAnchorElement> {
import { classes } from "@utils/misc";
import type { AnchorHTMLAttributes, PropsWithChildren } from "react";
export interface LinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
disabled?: boolean;
}
export function Link(props: React.PropsWithChildren<Props>) {
if (props.disabled) {
props.style ??= {};
props.style.pointerEvents = "none";
props["aria-disabled"] = true;
}
props.rel ??= "noreferrer";
export function Link({ disabled, className, children, ...restProps }: PropsWithChildren<LinkProps>) {
return (
<a role="link" target="_blank" {...props}>
{props.children}
<a
role="link"
target="_blank"
rel="noreferrer noopener"
className={classes("vc-link", disabled && "vc-link-disabled", className)}
aria-disabled={disabled}
{...restProps}
>
{children}
</a>
);
}

View file

@ -8,9 +8,9 @@ import { BaseText, type BaseTextProps } from "./BaseText";
export type SpanProps = BaseTextProps<"span">;
export function Span({ children, ...restProps }: SpanProps) {
export function Span({ children, size = "sm", weight = "normal", ...restProps }: SpanProps) {
return (
<BaseText tag="span" size="sm" weight="normal" {...restProps}>
<BaseText tag="span" size={size} weight={weight} {...restProps}>
{children}
</BaseText>
);

View file

@ -15,7 +15,7 @@
.vc-switch-checked {
background: var(--brand-500);
border-color: var(--control-border-primary-default);
border-color: var(--control-primary-border-default);
}
.vc-switch-disabled {
@ -25,7 +25,7 @@
.vc-switch-focusVisible {
/* stylelint-disable-next-line custom-property-pattern */
box-shadow: 0 0 0 4px var(--__adaptive-focus-ring-color, var(--focus-primary, #00b0f4));
box-shadow: 0 0 0 4px var(--__adaptive-focus-ring-color, var(--border-focus, #00b0f4));
}
.vc-switch-slider {

View file

@ -18,11 +18,15 @@
import "./themesStyles.css";
import { isPluginEnabled } from "@api/PluginManager";
import { Settings, useSettings } from "@api/Settings";
import { classNameFactory } from "@api/Styles";
import { Alert } from "@components/Alert";
import { Button } from "@components/Button";
import { Divider } from "@components/Divider";
import { ErrorCard } from "@components/ErrorCard";
import { Flex } from "@components/Flex";
import { FormSwitch } from "@components/FormSwitch";
import { Heading } from "@components/Heading";
import { CogWheel, DeleteIcon, FolderIcon, PaintbrushIcon, PencilIcon, PluginIcon, PlusIcon, RestartIcon } from "@components/Icons";
import { Link } from "@components/Link";
@ -30,7 +34,7 @@ import { Paragraph } from "@components/Paragraph";
import { AddonCard, openPluginModal, QuickAction, QuickActionCard, SettingsTab, wrapTab } from "@components/settings";
import { OnlineThemeCard } from "@components/settings/OnlineThemeCard";
import { CspBlockedUrls, useCspErrors } from "@utils/cspViolations";
import { openInviteModal } from "@utils/discord";
import { copyWithToast, openInviteModal } from "@utils/discord";
import { Margins } from "@utils/margins";
import { classes } from "@utils/misc";
import { openModal } from "@utils/modal";
@ -40,13 +44,39 @@ import type { ThemeHeader } from "@utils/themes";
import { getThemeInfo, stripBOM, type UserThemeHeader } from "@utils/themes/bd";
import { usercssParse } from "@utils/themes/usercss";
import { getStylusWebStoreUrl } from "@utils/web";
import { findLazy } from "@webpack";
import { Alerts, Button, Card, React, showToast, TabBar, TextInput, Tooltip, useEffect, useMemo, useRef, useState } from "@webpack/common";
import { findComponentByCodeLazy, findLazy } from "@webpack";
import { Alerts, Menu, React, Select, showToast, TextInput, Toasts, Tooltip, useEffect, useMemo, useState } from "@webpack/common";
import { ContextMenuApi } from "@webpack/common/menu";
import type { ComponentType, Ref, SyntheticEvent } from "react";
import type { UserstyleHeader } from "usercss-meta";
import Plugins from "~plugins";
const PinIcon = findComponentByCodeLazy("1-.06-.63L6.16");
const HomeIcon = findComponentByCodeLazy("m2.4 8.4 8.38-6.46a2");
const RefreshIcon = findComponentByCodeLazy("M21 2a1 1 0 0 1 1 1v6");
const LinkIcon = findComponentByCodeLazy("M16.32 14.72a1 1 0 0 1 0-1.41l2.51-2.51");
const DiscordIcon = findComponentByCodeLazy("1.6 5.64-2.87");
const DownloadIcon = findComponentByCodeLazy("1.42l3.3 3.3V3a1");
function LocalThemeIcon({ size }: { size?: string; }) {
const sizeVal = size === "sm" ? 16 : 24;
return (
<svg viewBox="0 0 24 24" width={sizeVal} height={sizeVal} fill="currentColor">
<path d="M20 6h-8l-2-2H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2z" />
</svg>
);
}
function OnlineThemeIcon({ size }: { size?: string; }) {
const sizeVal = size === "sm" ? 16 : 24;
return (
<svg viewBox="0 0 24 24" width={sizeVal} height={sizeVal} fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" />
</svg>
);
}
import { UserCSSSettingsModal } from "./UserCSSModal";
type FileInput = ComponentType<{
@ -59,6 +89,22 @@ const FileInput: FileInput = findLazy(m => m.prototype?.activateUploadDialogue &
const cl = classNameFactory("vc-settings-theme-");
enum ThemeFilter {
All = "all",
Online = "online",
Local = "local",
Enabled = "enabled",
Disabled = "disabled"
}
const filterOptions = [
{ label: "Show All", value: ThemeFilter.All },
{ label: "Online Themes", value: ThemeFilter.Online },
{ label: "Local Themes", value: ThemeFilter.Local },
{ label: "Enabled", value: ThemeFilter.Enabled },
{ label: "Disabled", value: ThemeFilter.Disabled }
];
function Validator({ link, onValidate }: { link: string; onValidate: (valid: boolean) => void; }) {
const [res, err, pending] = useAwaiter(() => fetch(link).then(res => {
if (res.status > 300) throw `${res.status} ${res.statusText}`;
@ -79,7 +125,7 @@ function Validator({ link, onValidate }: { link: string; onValidate: (valid: boo
: "Valid!";
return <Paragraph style={{
color: pending ? "var(--text-muted)" : err ? "var(--text-danger)" : "var(--status-positive)"
color: pending ? "var(--text-muted)" : err ? "var(--text-feedback-critical)" : "var(--status-positive)"
}}>{text}</Paragraph>;
}
@ -90,6 +136,15 @@ interface OtherThemeCardProps {
onDelete: () => void;
showDeleteButton?: boolean;
onEditName?: (newName: string) => void;
disabled?: boolean;
onPin?: () => void;
isPinned?: boolean;
onRefresh?: () => void;
onOpenFolder?: () => void;
onCopyUrl?: () => void;
onDownload?: () => void;
themeLink?: string;
isLocal?: boolean;
}
interface UserCSSCardProps {
@ -102,7 +157,7 @@ interface UserCSSCardProps {
function UserCSSThemeCard({ theme, enabled, onChange, onDelete, onSettingsReset }: UserCSSCardProps) {
const missingPlugins = useMemo(() =>
theme.requiredPlugins?.filter(p => !Vencord.Plugins.isPluginEnabled(p)), [theme]);
theme.requiredPlugins?.filter(p => !isPluginEnabled(p)), [theme]);
return (
<AddonCard
@ -143,9 +198,11 @@ function UserCSSThemeCard({ theme, enabled, onChange, onDelete, onSettingsReset
</>
}
footer={
<Flex flexDirection="row" style={{ gap: "0.2em" }}>
<Flex flexDirection="row" gap="0.4em" style={{ alignItems: "center" }}>
{!!theme.homepageURL && <Link href={theme.homepageURL}>Homepage</Link>}
{!!(theme.homepageURL && theme.supportURL) && " • "}
{!!(theme.homepageURL && theme.supportURL) && (
<span style={{ color: "var(--text-muted)" }}>•</span>
)}
{!!theme.supportURL && <Link href={theme.supportURL}>Support</Link>}
</Flex>
}
@ -153,7 +210,86 @@ function UserCSSThemeCard({ theme, enabled, onChange, onDelete, onSettingsReset
);
}
function OtherThemeCard({ theme, enabled, onChange, onDelete, showDeleteButton, onEditName }: OtherThemeCardProps) {
function OtherThemeCard({ theme, enabled, onChange, onDelete, showDeleteButton, onEditName, disabled, onPin, isPinned, onRefresh, onOpenFolder, onCopyUrl, onDownload, themeLink, isLocal }: OtherThemeCardProps) {
const openThemeMenu = (e: React.MouseEvent) => {
ContextMenuApi.openContextMenu(e, () => (
<Menu.Menu navId="theme-card-menu" onClose={ContextMenuApi.closeContextMenu}>
{onPin && (
<Menu.MenuItem
id="pin-theme"
label={isPinned ? "Unpin" : "Pin"}
icon={PinIcon}
action={onPin}
/>
)}
{theme.website && (
<Menu.MenuItem
id="open-website"
label="Open Website"
icon={HomeIcon}
action={() => window.open(theme.website, "_blank")}
/>
)}
{theme.invite && (
<Menu.MenuItem
id="join-discord"
label="Join Discord"
icon={DiscordIcon}
action={() => {
openInviteModal(theme.invite!).catch(() =>
showToast("Invalid or expired invite")
);
}}
/>
)}
{onCopyUrl && themeLink && (
<Menu.MenuItem
id="copy-url"
label="Copy URL"
icon={LinkIcon}
action={onCopyUrl}
/>
)}
{onDownload && (
<Menu.MenuItem
id="download-theme"
label="Download"
icon={DownloadIcon}
action={onDownload}
/>
)}
{onOpenFolder && (
<Menu.MenuItem
id="open-folder"
label="Open in Folder"
icon={FolderIcon}
action={onOpenFolder}
/>
)}
{onRefresh && (
<Menu.MenuItem
id="refresh-theme"
label="Refresh"
icon={RefreshIcon}
action={onRefresh}
/>
)}
{(IS_WEB || showDeleteButton) && onDelete && (
<>
<Menu.MenuSeparator />
<Menu.MenuItem
id="delete-theme"
label="Delete"
color="danger"
icon={DeleteIcon}
action={() => onDelete()}
/>
</>
)}
</Menu.Menu>
));
};
return (
<OnlineThemeCard
customName={theme.customName}
@ -162,17 +298,47 @@ function OtherThemeCard({ theme, enabled, onChange, onDelete, showDeleteButton,
author={theme.author}
enabled={enabled}
setEnabled={onChange}
disabled={disabled}
infoButton={
(IS_WEB || showDeleteButton) && (
<div style={{ cursor: "pointer", color: "var(--status-danger" }} onClick={onDelete}>
<DeleteIcon />
(IS_WEB || showDeleteButton || onPin) && (
<div
className={cl("menu-button")}
onClick={openThemeMenu}
>
<CogWheel />
</div>
)
}
footer={
<Flex flexDirection="row" style={{ gap: "0.2em" }}>
<Flex flexDirection="row" gap="0.4em" alignItems="center">
<Tooltip text={isLocal ? "Local Theme" : "Online Theme"}>
{({ onMouseLeave, onMouseEnter }) => (
<div
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
style={{ color: "var(--text-muted)", display: "flex" }}
>
{isLocal ? <LocalThemeIcon size="sm" /> : <OnlineThemeIcon size="sm" />}
</div>
)}
</Tooltip>
{isPinned && (
<Tooltip text="Pinned">
{({ onMouseLeave, onMouseEnter }) => (
<div
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className={cl("footer-pin-icon")}
>
<PinIcon size="xs" />
</div>
)}
</Tooltip>
)}
{!!theme.website && <Link href={theme.website}>Website</Link>}
{!!(theme.website && theme.invite) && " • "}
{!!(theme.website && theme.invite) && (
<span style={{ color: "var(--text-muted)" }}>•</span>
)}
{!!theme.invite && (
<Link
href={`https://discord.gg/${theme.invite}`}
@ -195,16 +361,19 @@ function OtherThemeCard({ theme, enabled, onChange, onDelete, showDeleteButton,
);
}
enum ThemeTab {
LOCAL,
ONLINE
interface UnifiedTheme {
type: "local" | "online";
themeType: "usercss" | "other";
name: string;
enabled: boolean;
header: UserThemeHeader | UserstyleHeader;
link?: string;
}
function ThemesTab() {
const settings = useSettings(["themeLinks", "enabledThemeLinks", "enabledThemes"]);
const settings = useSettings(["themeLinks", "enabledThemeLinks", "enabledThemes", "enableOnlineThemes", "pinnedThemes"]);
const fileInputRef = useRef<HTMLInputElement>(null);
const [currentTab, setCurrentTab] = useState(ThemeTab.LOCAL);
const fileInputRef = useState<HTMLInputElement | null>(null)[1];
const [currentThemeLink, setCurrentThemeLink] = useState("");
const [themeLinkValid, setThemeLinkValid] = useState(false);
const [userThemes, setUserThemes] = useState<ThemeHeader[] | null>(null);
@ -212,7 +381,9 @@ function ThemesTab() {
const [themeNames, setThemeNames] = useState<Record<string, string>>(() => {
return settings.themeNames ?? {};
});
const [themeDir, , themeDirPending] = useAwaiter(VencordNative.themes.getThemesDir);
const [themeDir] = useAwaiter(VencordNative.themes.getThemesDir);
const [searchQuery, setSearchQuery] = useState("");
const [filter, setFilter] = useState(ThemeFilter.All);
useEffect(() => {
updateThemes();
@ -241,7 +412,6 @@ function ThemesTab() {
if (!fileName.endsWith(".css")) continue;
if ((!IS_WEB || "legcord" in window) && fileName.endsWith(".user.css")) {
// handle it as usercss
const header = await usercssParse(content, fileName);
themeInfo.push({
@ -274,7 +444,6 @@ function ThemesTab() {
Settings.userCssVars[header.id][name] ??= normalizedValue;
}
} else {
// presumably BD but could also be plain css
themeInfo.push({
type: "other",
header: getThemeInfo(stripBOM(content), fileName)
@ -285,7 +454,6 @@ function ThemesTab() {
setUserThemes(themeInfo);
}
// When a local theme is enabled/disabled, update the settings
function onLocalThemeChange(fileName: string, value: boolean) {
if (value) {
if (settings.enabledThemes.includes(fileName)) return;
@ -320,103 +488,6 @@ function ThemesTab() {
refreshLocalThemes();
}
function LocalThemes() {
return (
<>
<Card className="vc-settings-card">
<Heading>Find Themes:</Heading>
<div style={{ marginBottom: ".5em", display: "flex", flexDirection: "column" }}>
<Link style={{ marginRight: ".5em" }} href="https://betterdiscord.app/themes">
BetterDiscord Themes
</Link>
<Link href="https://github.com/search?q=discord+theme">GitHub</Link>
</div>
<Paragraph>If using the BD site, click on "Download" and place the downloaded .theme.css file into your themes folder.</Paragraph>
</Card>
<section>
<Heading>Local Themes</Heading>
<QuickActionCard>
<>
{IS_WEB ?
(
<QuickAction
text={
<span style={{ position: "relative" }}>
Upload Theme
<FileInput
ref={fileInputRef}
onChange={onFileUpload}
multiple={true}
filters={[{ extensions: ["css"] }]}
/>
</span>
}
Icon={PlusIcon}
/>
) : (
<QuickAction
text="Open Themes Folder"
action={() => showItemInFolder(themeDir!)}
disabled={themeDirPending}
Icon={FolderIcon}
/>
)}
<QuickAction
text="Load missing Themes"
action={refreshLocalThemes}
Icon={RestartIcon}
/>
<QuickAction
text="Edit QuickCSS"
action={() => VencordNative.quickCss.openEditor()}
Icon={PaintbrushIcon}
/>
{Settings.plugins.ClientTheme.enabled && (
<QuickAction
text="Edit ClientTheme"
action={() => openPluginModal(Plugins.ClientTheme)}
Icon={PencilIcon}
/>
)}
</>
</QuickActionCard>
<div className={cl("grid")}>
{userThemes?.map(({ type, header: theme }: ThemeHeader) => (
type === "other" ? (
<OtherThemeCard
key={theme.fileName}
enabled={settings.enabledThemes.includes(theme.fileName)}
onChange={enabled => onLocalThemeChange(theme.fileName, enabled)}
onDelete={async () => {
onLocalThemeChange(theme.fileName, false);
await VencordNative.themes.deleteTheme(theme.fileName);
refreshLocalThemes();
}}
theme={theme as UserThemeHeader}
/>
) : (
<UserCSSThemeCard
key={theme.fileName}
enabled={settings.enabledThemes.includes(theme.fileName)}
onChange={enabled => onLocalThemeChange(theme.fileName, enabled)}
onDelete={async () => {
onLocalThemeChange(theme.fileName, false);
await VencordNative.themes.deleteTheme(theme.fileName);
refreshLocalThemes();
}}
onSettingsReset={refreshLocalThemes}
theme={theme as UserstyleHeader}
/>
)))}
</div>
</section>
</>
);
}
function addThemeLink(link: string) {
if (!themeLinkValid) return;
if (settings.themeLinks.includes(link)) return;
@ -454,94 +525,342 @@ function ThemesTab() {
function deleteThemeLink(link: string) {
settings.themeLinks = settings.themeLinks.filter(f => f !== link);
settings.pinnedThemes = settings.pinnedThemes.filter(f => f !== link);
refreshOnlineThemes();
}
function OnlineThemes() {
function togglePinTheme(themeId: string) {
if (settings.pinnedThemes.includes(themeId)) {
settings.pinnedThemes = settings.pinnedThemes.filter(f => f !== themeId);
} else {
settings.pinnedThemes = [...settings.pinnedThemes, themeId];
}
}
const themes = (onlineThemes ?? []).map(theme => ({
...theme,
customName: themeNames[theme.link] ?? null,
}));
async function refreshOnlineTheme(link: string) {
try {
const res = await fetch(link);
if (!res.ok) throw new Error(`Failed to fetch ${link}`);
const css = await res.text();
const updatedTheme = { ...getThemeInfo(css, link), link };
setOnlineThemes(prev =>
prev?.map(t => t.link === link ? updatedTheme : t) ?? null
);
showToast("Theme refreshed!", Toasts.Type.SUCCESS);
} catch {
showToast("Failed to refresh theme", Toasts.Type.FAILURE);
}
}
async function downloadTheme(link: string, name: string) {
try {
const res = await fetch(link);
if (!res.ok) throw new Error(`Failed to fetch ${link}`);
const css = await res.text();
const fileName = name.replace(/[^a-z0-9]/gi, "-") + ".css";
if (IS_DISCORD_DESKTOP) {
DiscordNative.fileManager.saveWithDialog(new TextEncoder().encode(css), fileName);
} else {
const blob = new Blob([css], { type: "text/css" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
}
} catch {
showToast("Failed to download theme", Toasts.Type.FAILURE);
}
}
const allThemes = useMemo((): UnifiedTheme[] => {
const themes: UnifiedTheme[] = [];
for (const theme of onlineThemes ?? []) {
const customName = themeNames[theme.link] ?? null;
themes.push({
type: "online",
themeType: "other",
name: customName ?? theme.name ?? theme.fileName,
enabled: settings.enabledThemeLinks.includes(theme.link),
header: { ...theme, customName },
link: theme.link
});
}
for (const { type, header } of userThemes ?? []) {
const name = type === "usercss"
? (header as UserstyleHeader).name ?? "Unknown"
: (header as UserThemeHeader).name ?? (header as UserThemeHeader).fileName;
themes.push({
type: "local",
themeType: type,
name,
enabled: settings.enabledThemes.includes(header.fileName),
header
});
}
return themes;
}, [onlineThemes, userThemes, themeNames, settings.enabledThemeLinks, settings.enabledThemes]);
const filteredThemes = useMemo(() => {
let themes = allThemes;
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
themes = themes.filter(t => t.name.toLowerCase().includes(query));
}
switch (filter) {
case ThemeFilter.Online:
themes = themes.filter(t => t.type === "online");
break;
case ThemeFilter.Local:
themes = themes.filter(t => t.type === "local");
break;
case ThemeFilter.Enabled:
themes = themes.filter(t => t.enabled);
break;
case ThemeFilter.Disabled:
themes = themes.filter(t => !t.enabled);
break;
}
const getThemeId = (t: UnifiedTheme) => t.type === "online" ? t.link! : (t.header as UserThemeHeader).fileName;
themes.sort((a, b) => {
const aId = getThemeId(a);
const bId = getThemeId(b);
const aPinIndex = settings.pinnedThemes.indexOf(aId);
const bPinIndex = settings.pinnedThemes.indexOf(bId);
const aIsPinned = aPinIndex !== -1;
const bIsPinned = bPinIndex !== -1;
if (aIsPinned && !bIsPinned) return -1;
if (!aIsPinned && bIsPinned) return 1;
if (aIsPinned && bIsPinned) return aPinIndex - bPinIndex;
return 0;
});
return themes;
}, [allThemes, searchQuery, filter, settings.pinnedThemes]);
const localCount = allThemes.filter(t => t.type === "local").length;
const onlineCount = allThemes.filter(t => t.type === "online").length;
const enabledCount = allThemes.filter(t => t.enabled).length;
return (
<>;
<section>
<Heading>Online Themes</Heading>
<Card className="vc-settings-theme-add-card">
<Paragraph>Make sure to use direct links to files (raw or github.io)!</Paragraph>
<Flex flexDirection="row">
<TextInput placeholder="Theme Link" className="vc-settings-theme-link-input" value={currentThemeLink} onChange={setCurrentThemeLink} />
<Button onClick={() => addThemeLink(currentThemeLink)} disabled={!themeLinkValid}>Add</Button>
</Flex>
{currentThemeLink && <Validator link={currentThemeLink} onValidate={setThemeLinkValid} />}
</Card>
<SettingsTab>
<CspErrorCard />
<div className={cl("grid")}>
{themes.map(theme => {
const { label, link } = (() => {
const match = /^@(light|dark) (.*)/.exec(theme.link);
if (!match) return { label: theme, link: theme };
<Heading className={Margins.top16}>Theme Management</Heading>
<Paragraph className={Margins.bottom16}>
Customize Discord's appearance with themes. Add local .css files or load themes directly from URLs. Themes with a cog wheel icon have customizable settings you can modify.
</Paragraph>
const [, mode, link] = match;
return { label: `[${mode} mode only] ${link}`, link };
})();
<Heading>Quick Actions</Heading>
<Paragraph className={Margins.bottom16}>
Shortcuts for managing your themes. Open your themes folder to add new themes, use QuickCSS for quick style tweaks, or reload themes after making changes.
</Paragraph>
<QuickActionCard>
{IS_WEB ? (
<QuickAction
text={
<span style={{ position: "relative" }}>
Upload Theme
<FileInput
ref={fileInputRef}
onChange={onFileUpload}
multiple={true}
filters={[{ extensions: ["css"] }]}
/>
</span>
}
Icon={PlusIcon}
/>
) : (
<QuickAction
text="Open Themes Folder"
action={() => VencordNative.themes.openFolder()}
Icon={FolderIcon}
/>
)}
<QuickAction
text="Load missing Themes"
action={refreshLocalThemes}
Icon={RestartIcon}
/>
<QuickAction
text="Edit QuickCSS"
action={() => VencordNative.quickCss.openEditor()}
Icon={PaintbrushIcon}
/>
{Settings.plugins.ClientTheme.enabled && (
<QuickAction
text="Edit ClientTheme"
action={() => openPluginModal(Plugins.ClientTheme)}
Icon={PencilIcon}
/>
)}
</QuickActionCard>
<Divider className={Margins.top20} />
<Heading className={Margins.top20}>Online Themes</Heading>
<Paragraph className={Margins.bottom16}>
Load themes directly from URLs instead of local files. Online themes auto-update when the source changes, so you always have the latest version without manual downloads.
</Paragraph>
<FormSwitch
title="Enable Online Themes"
description="Toggle online theme loading. When disabled, all online themes will be turned off and you won't be able to add new ones."
value={settings.enableOnlineThemes ?? true}
onChange={value => {
settings.enableOnlineThemes = value;
if (!value) {
settings.enabledThemeLinks = [];
}
}}
/>
<Alert.Info className={Margins.bottom16} style={{ width: "100%" }}>
Looking for themes? Check out <Link href="https://betterdiscord.app/themes">BetterDiscord Themes</Link> or search on <Link href="https://github.com/search?q=discord+theme">GitHub</Link>. When downloading from BetterDiscord, click "Download" and place the .theme.css file into your themes folder.
</Alert.Info>
<div className={cl("link-row")}>
<TextInput
placeholder="https://example.com/theme.css"
value={currentThemeLink}
onChange={setCurrentThemeLink}
disabled={!(settings.enableOnlineThemes ?? true)}
/>
<Button onClick={() => addThemeLink(currentThemeLink)} disabled={!themeLinkValid || !(settings.enableOnlineThemes ?? true)}>
Add
</Button>
</div>
{currentThemeLink && (
<div className={Margins.top8}>
<Validator link={currentThemeLink} onValidate={setThemeLinkValid} />
</div>
)}
<Divider className={Margins.top20} />
<Heading className={Margins.top20}>Installed Themes</Heading>
<Paragraph className={Margins.bottom8}>
Manage your themes here. Local themes load from your themes folder, online themes from URLs. Themes with a cog wheel icon have customizable settings.
</Paragraph>
<Paragraph color="text-subtle" className={Margins.bottom16}>
{allThemes.length} theme{allThemes.length !== 1 ? "s" : ""} installed ({localCount} local, {onlineCount} online) · {enabledCount} enabled
</Paragraph>
<div className={cl("filter-row")}>
<TextInput
placeholder="Search for a theme..."
value={searchQuery}
onChange={setSearchQuery}
/>
<div>
<Select
options={filterOptions}
select={setFilter}
isSelected={v => v === filter}
serialize={v => v}
/>
</div>
</div>
{userThemes === null ? (
<Paragraph color="text-muted" className={Margins.top16}>Loading themes...</Paragraph>
) : filteredThemes.length === 0 ? (
<Paragraph color="text-muted" className={Margins.top16}>
{allThemes.length === 0
? "No themes installed yet. Add theme files to your themes folder or add an online theme above to get started."
: "No themes match your search or filter criteria."
}
</Paragraph>
) : (
<div className={classes(cl("grid"), Margins.top16)}>
{filteredThemes.map(theme => {
if (theme.type === "online") {
const onlineTheme = theme.header as UserThemeHeader & { link: string; };
const onlineThemesDisabled = !(settings.enableOnlineThemes ?? true);
return (
<OtherThemeCard
key={theme.fileName}
theme={theme}
enabled={settings.enabledThemeLinks.includes(theme.link)}
onChange={enabled => onThemeLinkEnabledChange(theme.link, enabled)}
onDelete={async () => {
onThemeLinkEnabledChange(theme.link, false);
deleteThemeLink(theme.link);
key={onlineTheme.link}
theme={onlineTheme}
enabled={theme.enabled}
onChange={enabled => onThemeLinkEnabledChange(onlineTheme.link, enabled)}
onDelete={() => {
onThemeLinkEnabledChange(onlineTheme.link, false);
deleteThemeLink(onlineTheme.link);
}}
showDeleteButton
disabled={onlineThemesDisabled}
onPin={() => togglePinTheme(onlineTheme.link)}
isPinned={settings.pinnedThemes.includes(onlineTheme.link)}
themeLink={onlineTheme.link}
onCopyUrl={() => copyWithToast(onlineTheme.link, "Theme URL copied!")}
onRefresh={() => refreshOnlineTheme(onlineTheme.link)}
onDownload={() => downloadTheme(onlineTheme.link, onlineTheme.name ?? "theme")}
isLocal={false}
onEditName={newName => {
const updatedNames = { ...themeNames, [theme.link]: newName };
const updatedNames = { ...themeNames, [onlineTheme.link]: newName };
setThemeNames(updatedNames);
settings.themeNames = {
...settings.themeNames,
[theme.link]: newName,
[onlineTheme.link]: newName,
};
}}
/>
);
})}
</div>
</section>
</>
}
if (theme.themeType === "usercss") {
const usercssTheme = theme.header as UserstyleHeader;
return (
<UserCSSThemeCard
key={usercssTheme.fileName}
enabled={theme.enabled}
onChange={enabled => onLocalThemeChange(usercssTheme.fileName, enabled)}
onDelete={async () => {
onLocalThemeChange(usercssTheme.fileName, false);
await VencordNative.themes.deleteTheme(usercssTheme.fileName);
refreshLocalThemes();
}}
onSettingsReset={refreshLocalThemes}
theme={usercssTheme}
/>
);
}
const localTheme = theme.header as UserThemeHeader;
return (
<SettingsTab title="Themes">
<TabBar
type="top"
look="brand"
className="vc-settings-tab-bar"
selectedItem={currentTab}
onItemSelect={setCurrentTab}
>
<TabBar.Item
className="vc-settings-tab-bar-item"
id={ThemeTab.LOCAL}
>
Local Themes
</TabBar.Item>
<TabBar.Item
className="vc-settings-tab-bar-item"
id={ThemeTab.ONLINE}
>
Online Themes
</TabBar.Item>
</TabBar>
<CspErrorCard />
{currentTab === ThemeTab.LOCAL && <LocalThemes />}
{currentTab === ThemeTab.ONLINE && <OnlineThemes />}
<OtherThemeCard
key={localTheme.fileName}
enabled={theme.enabled}
onChange={enabled => onLocalThemeChange(localTheme.fileName, enabled)}
onDelete={async () => {
onLocalThemeChange(localTheme.fileName, false);
await VencordNative.themes.deleteTheme(localTheme.fileName);
refreshLocalThemes();
}}
showDeleteButton
onPin={() => togglePinTheme(localTheme.fileName)}
isPinned={settings.pinnedThemes.includes(localTheme.fileName)}
onOpenFolder={!IS_WEB ? () => showItemInFolder(themeDir + "/" + localTheme.fileName) : undefined}
onRefresh={refreshLocalThemes}
isLocal
theme={localTheme}
/>
);
})}
</div>
)}
</SettingsTab>
);
}
@ -559,7 +878,7 @@ export function CspErrorCard() {
const allowUrl = async (url: string) => {
const { origin: baseUrl, host } = new URL(url);
const result = await VencordNative.csp.requestAddOverride(baseUrl, ["connect-src", "img-src", "style-src", "font-src"], "Vencord Themes");
const result = await VencordNative.csp.requestAddOverride(baseUrl, ["connect-src", "img-src", "style-src", "font-src"], "Equicord Themes");
if (result !== "ok") return;
CspBlockedUrls.forEach(url => {
@ -582,37 +901,25 @@ export function CspErrorCard() {
const hasImgurHtmlDomain = errors.some(isImgurHtmlDomain);
return (
<ErrorCard className="vc-settings-card">
<Heading>Blocked Resources</Heading>
<Paragraph>Some images, styles, or fonts were blocked because they come from disallowed domains.</Paragraph>
<Paragraph>It is highly recommended to move them to GitHub or Imgur. But you may also allow domains if you fully trust them.</Paragraph>
<Paragraph>
After allowing a domain, you have to fully close (from tray / task manager) and restart {IS_DISCORD_DESKTOP ? "Discord" : IS_EQUIBOP ? "Equibop" : "Vesktop"} to apply the change.
<ErrorCard className={classes(cl("error-card"), Margins.top16)}>
<Heading className={Margins.bottom8}>Blocked Resources</Heading>
<Paragraph className={Margins.bottom8}>
Some resources were blocked from disallowed domains. Move them to GitHub or Imgur, or allow trusted domains below.
</Paragraph>
<Heading className={classes(Margins.top16, Margins.bottom8)}>Blocked URLs</Heading>
<div className="vc-settings-csp-list">
{errors.map((url, i) => (
<div key={url}>
{i !== 0 && <Divider className={Margins.bottom8} />}
<div className="vc-settings-csp-row">
{errors.map(url => (
<div key={url} className={cl("csp-row")}>
<Link href={url}>{url}</Link>
<Button color={Button.Colors.PRIMARY} onClick={() => allowUrl(url)} disabled={isImgurHtmlDomain(url)}>
<Button size="small" variant="secondary" onClick={() => allowUrl(url)} disabled={isImgurHtmlDomain(url)}>
Allow
</Button>
</div>
</div>
))}
</div>
{hasImgurHtmlDomain && (
<>
<Divider className={classes(Margins.top8, Margins.bottom16)} />
<Paragraph>
Imgur links should be direct links in the form of <code>https://i.imgur.com/...</code>
<Paragraph color="text-subtle" className={Margins.top8}>
Imgur links should be direct links like <code>https://i.imgur.com/...</code>
</Paragraph>
<Paragraph>To obtain a direct link, right-click the image and select "Copy image address".</Paragraph>
</>
)}
</ErrorCard>
);
@ -620,14 +927,14 @@ export function CspErrorCard() {
function UserscriptThemesTab() {
return (
<SettingsTab title="Themes">
<Card className="vc-settings-card">
<Heading>Themes are not supported on the Userscript!</Heading>
<Paragraph>
You can instead install themes with the <Link href={getStylusWebStoreUrl()}>Stylus extension</Link>!
<SettingsTab>
<Heading className={Margins.top16}>Themes Not Supported</Heading>
<Paragraph className={Margins.bottom8}>
Themes are not available on the Userscript version.
</Paragraph>
<Paragraph color="text-subtle">
You can install themes using the <Link href={getStylusWebStoreUrl()}>Stylus extension</Link> instead.
</Paragraph>
</Card>
</SettingsTab>
);
}

View file

@ -8,7 +8,7 @@ import { Settings, useSettings } from "@api/Settings";
import { BaseText } from "@components/BaseText";
import { Flex } from "@components/Flex";
import { CopyIcon, PasteIcon, ResetIcon } from "@components/Icons";
import { copyWithToast } from "@utils/misc";
import { copyWithToast } from "@utils/discord";
import { ModalCloseButton, ModalContent, ModalHeader, ModalProps, ModalRoot } from "@utils/modal";
import { showToast, Toasts, Tooltip } from "@webpack/common";
import { type ReactNode } from "react";
@ -90,8 +90,8 @@ function ResetButton({ themeSettings, themeId, close, onReset }: ResetButtonProp
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onClick={async () => {
await close(); // close the modal first to stop rendering
onClick={() => {
close(); // close the modal first to stop rendering
delete themeSettings[themeId];
onReset();
}}>

View file

@ -11,9 +11,9 @@
overflow: hidden;
margin-top: 0;
margin-bottom: 0;
color: var(--header-primary);
color: var(--text-strong);
line-height: 24px;
font-size: 16px;
font-weight: 500;
word-wrap: break-word;
overflow-wrap: break-word;
}

View file

@ -1,49 +1,82 @@
.vc-settings-theme-grid {
display: grid;
grid-gap: 16px;
gap: 8px;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
}
.vc-settings-theme-card {
.vc-settings-theme-link-row {
display: grid;
height: 40px;
gap: 8px;
grid-template-columns: 1fr auto;
}
.vc-settings-theme-filter-row {
display: grid;
height: 40px;
gap: 8px;
grid-template-columns: 1fr 150px;
}
.vc-settings-theme-error-card {
padding: 16px;
}
.vc-settings-csp-list {
display: flex;
flex-direction: column;
background-color: var(--background-base-lower-alt);
color: var(--interactive-active);
border-radius: 8px;
padding: 1em;
width: 100%;
transition: 0.1s ease-out;
transition-property: box-shadow, transform, background, opacity;
}
.vc-settings-theme-card-text {
text-overflow: ellipsis;
height: 1.2em;
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
}
.vc-settings-theme-author::before {
content: "by ";
}
.vc-settings-theme-link-input {
width: 100%;
}
.vc-settings-theme-add-card {
padding: 1em;
margin-bottom: 16px;
gap: 8px;
}
.vc-settings-usercss-ie-buttons>div {
color: var(--interactive-normal);
opacity: .5;
color: var(--interactive-icon-default, var(--interactive-normal));
opacity: 0.5;
padding: 4px;
cursor: pointer;
}
.vc-settings-usercss-ie-buttons>div:hover {
color: var(--interactive-hover);
color: var(--interactive-icon-hover, var(--interactive-hover));
opacity: 1;
}
.vc-settings-csp-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
padding: 8px 0;
}
.vc-settings-csp-row a {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.vc-settings-theme-menu-button {
cursor: pointer;
color: var(--interactive-icon-default, var(--interactive-normal));
display: flex;
align-items: center;
height: 24px;
}
.vc-settings-theme-menu-button:hover {
color: var(--interactive-icon-hover, var(--interactive-hover));
}
.vc-settings-theme-footer-pin-icon {
display: flex;
}
.vc-settings-theme-footer-pin-icon svg {
width: 16px;
height: 16px;
}
.vc-settings-theme-footer-pin-icon svg path {
fill: var(--text-muted);
}

View file

@ -4,7 +4,10 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
export * from "./Alert";
export * from "./BaseText";
export * from "./Button";
export * from "./Card";
export * from "./CheckedTextInput";
export * from "./CodeBlock";
export * from "./Divider";

View file

@ -1,6 +1,6 @@
.vc-addon-card {
background-color: var(--card-primary-bg);
color: var(--interactive-active);
background-color: var(--card-background-default);
color: var(--interactive-icon-active);
border: 1px solid var(--border-subtle);
border-radius: 8px;
display: block;
@ -41,6 +41,7 @@
-webkit-box-orient: vertical;
/* stylelint-disable-next-line property-no-unknown */
box-orient: vertical;
color: var(--text-default);
}
.vc-addon-name-author {
@ -101,3 +102,7 @@
color: inherit;
font: inherit;
}
.vc-addon-footer {
font-size: small;
}

View file

@ -25,8 +25,6 @@ import { Switch } from "@components/Switch";
import { Tooltip, useRef } from "@webpack/common";
import type { MouseEventHandler, ReactNode } from "react";
import { useTruncatedText } from "./tabs/plugins/components/truncateText";
const cl = classNameFactory("vc-addon-");
interface Props {
@ -49,7 +47,6 @@ interface Props {
export function AddonCard({ disabled, isNew, sourceBadge, tooltip, name, infoButton, footer, author, enabled, setEnabled, description, onMouseEnter, onMouseLeave }: Props) {
const titleRef = useRef<HTMLDivElement>(null);
const titleContainerRef = useRef<HTMLDivElement>(null);
const { truncated, containerRef } = useTruncatedText(description ? description.toString() : "");
return (
<div
@ -79,7 +76,7 @@ export function AddonCard({ disabled, isNew, sourceBadge, tooltip, name, infoBut
</BaseText>
{!!author && (
<BaseText size="md" className={cl("author")}>
<BaseText size="md" color="text-subtle" className={cl("author")}>
{author}
</BaseText>
)}
@ -107,15 +104,14 @@ export function AddonCard({ disabled, isNew, sourceBadge, tooltip, name, infoBut
</div>
<div
ref={containerRef}
className={cl("note")}
style={{ lineHeight: "1.25em", fontSize: "small" }}
title={description ? description.toString() : ""}
>
{truncated}
{description}
</div>
{footer}
{footer && <div className={cl("footer")}>{footer}</div>}
</div>
);
}

View file

@ -16,42 +16,27 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Button } from "@components/Button";
import { Heart } from "@components/Heart";
import { OpenExternalIcon } from "@components/Icons";
import { openInviteModal } from "@utils/discord";
import { ButtonProps } from "@vencord/discord-types";
import { Button, showToast } from "@webpack/common";
export function VCDonateButton({
look = Button.Looks.LINK,
color = Button.Colors.TRANSPARENT,
...props
}: Partial<ButtonProps>) {
return (
<Button
{...props}
look={look}
color={color}
onClick={() => VencordNative.native.openExternal("https://github.com/sponsors/Vendicated")}
className="vc-donate-button"
>
<Heart />
Donate
</Button>
);
}
import { showToast } from "@webpack/common";
export function DonateButton({
look = Button.Looks.LINK,
color = Button.Colors.TRANSPARENT,
equicord = false,
className,
...props
}: Partial<ButtonProps>) {
}: Partial<ButtonProps> & { equicord?: boolean; }) {
const link = equicord ? "https://github.com/sponsors/thororen1234" : "https://github.com/sponsors/Vendicated";
return (
<Button
{...props}
look={look}
color={color}
onClick={() => VencordNative.native.openExternal("https://github.com/sponsors/thororen1234")}
className="vc-donate-button"
variant="none"
size="medium"
type="button"
onClick={() => VencordNative.native.openExternal(link)}
className={className || "vc-donate-button"}
>
<Heart />
Donate
@ -60,24 +45,25 @@ export function DonateButton({
}
export function InviteButton({
look = Button.Looks.LINK,
color = Button.Colors.TRANSPARENT,
className,
...props
}: Partial<ButtonProps>) {
return (
<Button
{...props}
look={look}
color={color}
variant="none"
size="medium"
type="button"
onClick={async e => {
e.preventDefault();
openInviteModal("equicord-1173279886065029291").catch(() =>
showToast("Invalid or expired invite"),
);
}}
className="vc-donate-button"
className={className || "vc-donate-button"}
>
Invite
<OpenExternalIcon className="vc-invite-link" />
</Button>
);
}

View file

@ -105,7 +105,7 @@ export function OnlineThemeCard({
</BaseText>
{!!author && (
<BaseText size="md" className={cl("author")}>
<BaseText size="md" color="text-subtle" className={cl("author")}>
{author}
</BaseText>
)}
@ -129,7 +129,7 @@ export function OnlineThemeCard({
{truncated}
</div>
{footer}
{footer && <div className={cl("footer")}>{footer}</div>}
</div>
);
}

View file

@ -14,29 +14,33 @@
.vc-settings-quickActions-pill {
all: unset;
background: var(--button-secondary-background);
color: var(--header-secondary);
background: var(--redesign-button-secondary-background);
color: var(--text-default);
display: flex;
align-items: center;
justify-content: center;
gap: 0.5em;
padding: 8px 9px;
padding: 8px 12px 8px 8px;
border-radius: 8px;
transition: 0.1s ease-out;
box-sizing: border-box;
line-height: 1;
cursor: pointer;
}
.vc-settings-quickActions-pill:hover {
background: var(--button-secondary-background-hover);
background: var(--redesign-button-secondary-pressed-background);
transform: translateY(-1px);
box-shadow: var(--elevation-high);
}
.vc-settings-quickActions-pill:focus-visible {
outline: 2px solid var(--focus-primary);
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
.vc-settings-quickActions-img {
width: 24px;
height: 24px;
flex-shrink: 0;
}

View file

@ -7,7 +7,7 @@
import "./QuickAction.css";
import { classNameFactory } from "@api/Styles";
import { Card } from "@webpack/common";
import { Card } from "@components/Card";
import type { ComponentType, PropsWithChildren, ReactNode } from "react";
const cl = classNameFactory("vc-settings-quickActions-");
@ -30,9 +30,9 @@ export function QuickAction(props: QuickActionProps) {
);
}
export function QuickActionCard(props: PropsWithChildren) {
export function QuickActionCard(props: PropsWithChildren<{ columns?: number; }>) {
return (
<Card className={cl("card")}>
<Card className={cl("card")} style={props.columns ? { gridTemplateColumns: `repeat(${props.columns}, 1fr)` } : undefined}>
{props.children}
</Card>
);

View file

@ -1,13 +1,71 @@
.vc-invite-button,
.vc-donate-button {
overflow: visible !important;
color: black;
background-color: color-mix(in srgb, #ED87A9 25%, pink);
}
.vc-donate-button .vc-heart-icon {
.vc-invite-button:hover,
.vc-donate-button:hover {
overflow: visible !important;
color: black;
background-color: color-mix(in srgb, #ED87A9 50%, pink);
}
.vc-invite-support-button,
.vc-donate-support-button {
overflow: visible !important;
color: black;
background-color: color-mix(in srgb, #c3a3ce 25%, rgb(240 178 240));
}
.vc-invite-support-button:hover,
.vc-donate-support-button:hover {
overflow: visible !important;
color: black;
background-color: color-mix(in srgb, #c3a3ce 50%, rgb(240 178 240));
}
.vc-invite-link {
width: 16px;
height: 16px;
margin-left: 8px;
flex-shrink: 0;
}
.vc-contrib-button {
overflow: visible !important;
margin-top: 1em;
color: black;
background-color: color-mix(in srgb, #EDCC87 60%, lightyellow);
}
.vc-contrib-button:hover {
color: black;
background-color: color-mix(in srgb, #EDCC87 75%, lightyellow);
}
.vc-contrib-github {
width: 24px;
height: 24px;
flex-shrink: 0;
transition: transform 0.3s;
}
.vc-invite-button .vc-invite-link,
.vc-contrib-button .vc-contrib-github,
.vc-donate-button .vc-heart-icon,
.vc-invite-support-button .vc-invite-link,
.vc-donate-support-button .vc-heart-icon {
transition: transform 0.3s;
margin-right: 0.5em;
}
.vc-donate-button:hover .vc-heart-icon {
.vc-invite-button:hover .vc-invite-link,
.vc-contrib-button:hover .vc-contrib-github,
.vc-donate-button:hover .vc-heart-icon,
.vc-invite-support-button:hover .vc-invite-link,
.vc-donate-support-button:hover .vc-heart-icon {
transform: scale(1.1);
z-index: 10;
position: relative;

View file

@ -19,10 +19,11 @@
import "./SpecialCard.css";
import { classNameFactory } from "@api/Styles";
import { Card } from "@components/Card";
import { Divider } from "@components/Divider";
import { Heading } from "@components/Heading";
import { Paragraph } from "@components/Paragraph";
import { Card, Clickable, React } from "@webpack/common";
import { Clickable } from "@webpack/common";
import type { PropsWithChildren } from "react";
const cl = classNameFactory("vc-special-");

View file

@ -1,49 +0,0 @@
:root{
--vc-donatebutton: #ffc1cbc2;
--vc-donatebutton-secondary: #f7536fe5;
}
.vc-donatebutton{
background-color: var(--background-color) !important;
border-color: var(--vc-donatebutton) !important;
filter: brightness(1);
border-width: .1rem !important;
border-style: solid !important;
}
.vc-donatebutton:hover{
background-color: var(--vc-donatebutton) !important;
border-color: transparent !important;
}
.vc-joindiscordbutton{
background-color: var(--button-secondary-background) !important;
}
.vc-joindiscordbutton:hover{
background-color: var(--button-secondary-background-hover) !important;
}
.vc-settings-card-buttons{
display: flex;
gap: 1em !important;
margin-left: .2rem;
margin-bottom: .2rem;
}
.vc-settingbuttons{
border-radius: 5px !important;
}
.form-switch-warning {
font-size: 16px;
background-color: rgb(240 71 71 / 10%);
color: rgb(240 71 71);
border: 1px solid rgb(240 71 71 / 60%);
border-radius: 4px;
font-weight: 500;
padding: 6px 10px;
text-align: center;
margin-top: 10px;
}

View file

@ -16,27 +16,41 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { BaseText } from "@components/BaseText";
import ErrorBoundary from "@components/ErrorBoundary";
import { handleComponentFailed } from "@components/handleComponentFailed";
import { Margins } from "@utils/margins";
import { ModalCloseButton, ModalContent, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { onlyOnce } from "@utils/onlyOnce";
import type { ComponentType, PropsWithChildren } from "react";
export function SettingsTab({ title, children }: PropsWithChildren<{ title: string; }>) {
export function SettingsTab({ children }: PropsWithChildren) {
return (
<section>
<BaseText tag="h2" size="xl" weight="semibold" className={Margins.bottom16}>{title}</BaseText>
{children}
</section>
<section className="vc-settings-tab">{children}</section>
);
}
export const handleSettingsTabError = onlyOnce(handleComponentFailed);
export function wrapTab(component: ComponentType<any>, tab: string) {
return ErrorBoundary.wrap(component, {
const wrapped = ErrorBoundary.wrap(component, {
displayName: `${tab}SettingsTab`,
message: `Failed to render the ${tab} tab. If this issue persists, try using the installer to reinstall!`,
onError: handleSettingsTabError,
});
return wrapped;
}
export function openSettingsTabModal(Tab: ComponentType<any>) {
try {
openModal(wrapTab((modalProps: ModalProps) => (
<ModalRoot {...modalProps} size={ModalSize.MEDIUM}>
<ModalContent className="vc-settings-modal">
<ModalCloseButton onClick={modalProps.onClose} className="vc-settings-modal-close" />
<Tab />
</ModalContent>
</ModalRoot>
), Tab.displayName || "Settings Tab"));
} catch {
handleSettingsTabError();
}
}

View file

@ -6,13 +6,15 @@
import { useSettings } from "@api/Settings";
import { classNameFactory } from "@api/Styles";
import { Button } from "@components/Button";
import { Card } from "@components/Card";
import { Heading } from "@components/Heading";
import { Paragraph } from "@components/Paragraph";
import { PluginCard } from "@components/settings/tabs/plugins/PluginCard";
import { ChangeList } from "@utils/ChangeList";
import { Margins } from "@utils/margins";
import { useForceUpdater } from "@utils/react";
import { Button, Card, React, Tooltip } from "@webpack/common";
import { React, Tooltip } from "@webpack/common";
import Plugins from "~plugins";
@ -164,8 +166,8 @@ export function NewPluginsSection({
{tooltipProps => (
<Button
{...tooltipProps}
color={Button.Colors.LINK}
size={Button.Sizes.SMALL}
variant="link"
size="small"
onClick={() => location.reload()}
className={Margins.top16}
>

View file

@ -6,6 +6,8 @@
import "./styles.css";
import { Button } from "@components/Button";
import { Card } from "@components/Card";
import { Divider } from "@components/Divider";
import { ErrorCard } from "@components/ErrorCard";
import { Heading } from "@components/Heading";
@ -13,15 +15,14 @@ import { DeleteIcon } from "@components/Icons";
import { Link } from "@components/Link";
import { Paragraph } from "@components/Paragraph";
import { SettingsTab, wrapTab } from "@components/settings/tabs/BaseTab";
import { gitHashShort } from "@shared/vencordUserAgent";
import { HashLink } from "@components/settings/tabs/updater/Components";
import { Margins } from "@utils/margins";
import { useAwaiter } from "@utils/react";
import { getRepo, UpdateLogger } from "@utils/updater";
import { Alerts, Button, Card, React, Toasts } from "@webpack/common";
import { Alerts, React, Toasts } from "@webpack/common";
import gitHash from "~git-hash";
import { HashLink } from "../updater/Components";
import {
ChangelogEntry,
ChangelogHistory,
@ -115,9 +116,8 @@ function UpdateLogCard({
: `Update: ${log.fromHash.slice(0, 7)} → ${log.toHash.slice(0, 7)}`}
</span>
<Button
size={Button.Sizes.NONE}
color={Button.Colors.TRANSPARENT}
look={Button.Looks.FILLED}
size="min"
variant="secondary"
className="vc-changelog-delete-button"
style={{
padding: "4px",
@ -477,39 +477,17 @@ function ChangelogContent() {
return (
<>
<Heading className={Margins.top16}>Fetch Changes</Heading>
<Paragraph className={Margins.bottom16}>
View the most recent changes to Equicord. This fetches commits
directly from the repository to show you what's new.
</Paragraph>
<Heading>Repository</Heading>
<Paragraph className={Margins.bottom16}>
{repoPending ? (
repo
) : repoErr ? (
"Failed to retrieve - check console"
) : (
<Link href={repo}>
{repo.split("/").slice(-2).join("/")}
</Link>
)}{" "}
(Current:{" "}
<span className="vc-changelog-current-hash">
{gitHashShort}
</span>
)
Check the repository for new commits, plugin updates, and code changes. This will compare your current version with the latest available and show you what's new.
</Paragraph>
<div className="vc-changelog-controls">
<Button
size={Button.Sizes.SMALL}
size="small"
disabled={isLoading || repoPending || !!repoErr}
onClick={fetchChangelog}
color={
recentlyChecked
? Button.Colors.GREEN
: Button.Colors.BRAND
}
variant={recentlyChecked ? "positive" : "primary"}
>
{isLoading
? "Loading..."
@ -521,20 +499,16 @@ function ChangelogContent() {
{changelogHistory.length > 0 && (
<>
<Button
size={Button.Sizes.SMALL}
color={
showHistory
? Button.Colors.PRIMARY
: Button.Colors.BRAND
}
size="small"
variant={showHistory ? "primary" : "secondary"}
onClick={() => setShowHistory(!showHistory)}
style={{ marginLeft: "8px" }}
>
{showHistory ? "Hide Logs" : "Show Logs"}
</Button>
<Button
size={Button.Sizes.SMALL}
color={Button.Colors.RED}
size="small"
variant="dangerPrimary"
onClick={() => {
Alerts.show({
title: "Clear All Logs",
@ -547,13 +521,11 @@ function ChangelogContent() {
await loadChangelogHistory();
setShowHistory(false);
Toasts.show({
message:
"All logs have been cleared",
message: "All logs have been cleared",
id: Toasts.genId(),
type: Toasts.Type.SUCCESS,
options: {
position:
Toasts.Position.BOTTOM,
position: Toasts.Position.BOTTOM,
},
});
},
@ -568,41 +540,51 @@ function ChangelogContent() {
</div>
{error && (
<ErrorCard style={{ padding: "1em", marginBottom: "1em" }}>
<p>{error}</p>
<Paragraph
style={{
marginTop: "0.5em",
color: "var(--text-muted)",
}}
>
<ErrorCard style={{ padding: "1em", marginTop: "1em" }}>
<Paragraph>{error}</Paragraph>
<Paragraph color="text-subtle" style={{ marginTop: "0.5em" }}>
Make sure you have an internet connection and try again.
</Paragraph>
</ErrorCard>
)}
<Divider className={Margins.bottom16} />
<Divider className={Margins.top20} />
{/* Current Changes Section */}
{hasCurrentChanges ? (
<div className="vc-changelog-current">
<Heading className={Margins.bottom8}>
Recent Changes
</Heading>
<Heading className={Margins.top20}>Repository</Heading>
<Paragraph className={Margins.bottom8}>
This is the GitHub repository where Equicord fetches updates from.
</Paragraph>
<Paragraph color="text-subtle">
{repoPending ? (
repo
) : repoErr ? (
"Failed to retrieve - check console"
) : (
<Link href={repo}>
{repo.split("/").slice(-2).join("/")}
</Link>
)}
{" "}(<HashLink repo={repo} hash={gitHash} disabled={repoPending} />)
</Paragraph>
{hasCurrentChanges && (
<>
<Divider className={Margins.top20} />
<Heading className={Margins.top20}>Recent Changes</Heading>
<Paragraph className={Margins.bottom16}>
These are the new commits and plugin updates since your last version. You can see what features were added, bugs were fixed, and which plugins received updates.
</Paragraph>
{/* New Plugins Section */}
{newPlugins.length > 0 && (
<div className={Margins.bottom16}>
<NewPluginsSection
newPlugins={newPlugins}
onPluginToggle={(pluginName, enabled) => {
// Handle plugin toggle if needed
}}
onPluginToggle={() => { }}
/>
</div>
)}
{/* Updated Plugins Section */}
{updatedPlugins.length > 0 && (
<div className={Margins.bottom16}>
<Heading className={Margins.bottom8}>
@ -612,12 +594,10 @@ function ChangelogContent() {
</div>
)}
{/* Code Changes */}
{changelog.length > 0 && (
<div>
<Heading className={Margins.bottom8}>
Code Changes ({changelog.length}{" "}
{changelog.length === 1 ? "commit" : "commits"})
Code Changes ({changelog.length} {changelog.length === 1 ? "commit" : "commits"})
</Heading>
<div className="vc-changelog-commits-list">
{changelog.map(entry => (
@ -631,34 +611,28 @@ function ChangelogContent() {
</div>
</div>
)}
</div>
) : (
!isLoading &&
!error && (
<Card className="vc-changelog-empty">
<Paragraph>
No commits available ahead of your current version.
Click "Fetch from Repository" to check for new
changes.
</Paragraph>
</Card>
)
</>
)}
{!hasCurrentChanges && !isLoading && !error && (
<>
<Divider className={Margins.top20} />
<Heading className={Margins.top20}>Recent Changes</Heading>
<Paragraph color="text-subtle">
No commits available ahead of your current version. Click "Fetch from Repository" to check for new changes.
</Paragraph>
</>
)}
{/* History Section */}
{showHistory && changelogHistory.length > 0 && (
<div className="vc-changelog-history">
<Divider
className={Margins.top16}
style={{ marginBottom: "1em" }}
/>
<Heading className={Margins.bottom8}>
Update Logs ({changelogHistory.length}{" "}
{changelogHistory.length === 1 ? "log" : "logs"})
<>
<Divider className={Margins.top20} />
<Heading className={Margins.top20}>
Update Logs ({changelogHistory.length} {changelogHistory.length === 1 ? "log" : "logs"})
</Heading>
<Paragraph className={Margins.bottom16}>
Previous update sessions with their commit history and
plugin changes.
A history of your previous update sessions with their commit history and plugin changes. Click on a log to expand it and see the details.
</Paragraph>
<div className="vc-changelog-history-list">
@ -682,11 +656,7 @@ function ChangelogContent() {
await loadChangelogHistory();
setExpandedLogs(
new Set(
Array.from(
expandedLogs,
).filter(
id => id !== logId,
),
Array.from(expandedLogs).filter(id => id !== logId),
),
);
Toasts.show({
@ -694,8 +664,7 @@ function ChangelogContent() {
id: Toasts.genId(),
type: Toasts.Type.SUCCESS,
options: {
position:
Toasts.Position.BOTTOM,
position: Toasts.Position.BOTTOM,
},
});
},
@ -704,7 +673,7 @@ function ChangelogContent() {
/>
))}
</div>
</div>
</>
)}
</>
);
@ -712,7 +681,7 @@ function ChangelogContent() {
function ChangelogTab() {
return (
<SettingsTab title="Changelog">
<SettingsTab>
<ChangelogContent />
</SettingsTab>
);

View file

@ -15,7 +15,7 @@
}
.vc-changelog-entry:hover {
background-color: var(--background-tertiary);
background-color: var(--background-base-low);
transform: translateY(-1px);
box-shadow: var(--elevation-high);
}
@ -27,8 +27,8 @@
}
.vc-changelog-entry.required:hover {
background-color: var(--background-tertiary);
border-left-color: var(--status-warning-foreground);
background-color: var(--background-base-low);
border-left-color: var(--status-warning-background);
}
.vc-changelog-entry-header {
@ -41,12 +41,12 @@
.vc-changelog-entry-hash {
font-size: 0.875em;
font-family: var(--font-code);
color: var(--text-muted);
color: var(--text-subtle);
}
.vc-changelog-entry-author {
font-size: 0.875em;
color: var(--text-muted);
color: var(--text-subtle);
font-weight: 500;
}
@ -57,14 +57,14 @@
}
.vc-changelog-entry.required .vc-changelog-entry-hash {
color: var(--text-muted);
color: var(--text-subtle);
font-weight: 600;
}
.vc-changelog-loading {
text-align: center;
padding: 2em;
color: var(--text-muted);
color: var(--text-subtle);
}
.vc-changelog-empty {
@ -72,11 +72,10 @@
padding: 2em;
background-color: var(--background-base-lower-alt);
border-radius: 8px;
color: var(--text-muted);
color: var(--text-subtle);
}
.vc-changelog-controls {
margin-bottom: 1em;
display: flex;
align-items: center;
flex-wrap: wrap;
@ -114,7 +113,7 @@
}
.vc-changelog-new-plugin-card {
background-color: var(--background-tertiary);
background-color: var(--background-base-low);
border-radius: 6px;
padding: 0.5em;
transition: all 0.1s ease-out;
@ -132,8 +131,8 @@
}
.vc-changelog-new-plugin-card.required:hover {
background-color: var(--background-tertiary);
border-left-color: var(--status-warning-foreground);
background-color: var(--background-base-low);
border-left-color: var(--status-warning-background);
}
.vc-changelog-new-plugins-compact {
@ -169,23 +168,23 @@
.vc-changelog-new-plugin-tag.more {
background-color: var(--background-base-lower-alt);
color: var(--text-muted);
color: var(--text-subtle);
}
.vc-changelog-new-plugin-tag.required {
background-color: var(--status-warning);
color: var(--white);
border: 1px solid var(--status-warning-foreground);
border: 1px solid var(--status-warning-background);
}
.vc-changelog-new-plugin-tag.required:hover {
background-color: var(--status-warning-foreground);
background-color: var(--status-warning-background);
}
.vc-changelog-restart-notice {
margin-top: 1em;
padding: 0.75em;
background-color: var(--info-warning-background);
background-color: var(--background-feedback-warning);
border-radius: 6px;
border-left: 4px solid var(--status-warning);
}
@ -229,7 +228,7 @@
}
.vc-changelog-log-header:hover {
background-color: var(--background-tertiary);
background-color: var(--background-base-low);
}
.vc-changelog-log-info {
@ -250,13 +249,13 @@
.vc-changelog-log-meta {
font-size: 0.875em;
color: var(--text-muted);
color: var(--text-subtle);
line-height: 1.3;
}
.vc-changelog-log-toggle {
transition: transform 0.2s ease;
color: var(--text-muted);
color: var(--text-subtle);
font-size: 0.75em;
flex-shrink: 0;
}
@ -310,7 +309,7 @@
/* Visual refresh support */
.visual-refresh .vc-changelog-entry {
background-color: var(--card-primary-bg);
background-color: var(--background-surface-high);
border: 1px solid var(--border-subtle);
}
@ -328,7 +327,7 @@
.visual-refresh .vc-changelog-entry.required {
border-left: 4px solid var(--status-warning);
background-color: var(--card-primary-bg);
background-color: var(--background-surface-high);
border: 1px solid var(--border-subtle);
padding-left: calc(0.75em - 4px);
}
@ -357,7 +356,7 @@
}
.vc-changelog-log-commits-list::-webkit-scrollbar-thumb {
background: var(--text-muted);
background: var(--text-subtle);
border-radius: 3px;
}
@ -390,7 +389,7 @@
.vc-changelog-history-list::-webkit-scrollbar-thumb,
.vc-changelog-log-content::-webkit-scrollbar-thumb {
background: var(--text-muted);
background: var(--text-subtle);
border-radius: 3px;
}
@ -400,12 +399,12 @@
}
.visual-refresh .vc-changelog-entry:hover {
background-color: var(--card-primary-bg);
background-color: var(--background-surface-high);
box-shadow: 0 2px 8px rgb(0 0 0 / 10%);
}
.visual-refresh .vc-changelog-empty {
background-color: var(--card-primary-bg);
background-color: var(--background-surface-high);
border: 1px solid var(--border-subtle);
}
@ -415,7 +414,7 @@
}
.visual-refresh .vc-changelog-new-plugins-section {
background-color: var(--card-primary-bg);
background-color: var(--background-surface-high);
border: 1px solid var(--border-subtle);
border-left: 4px solid var(--brand);
}
@ -426,7 +425,7 @@
}
.visual-refresh .vc-changelog-log {
background-color: var(--card-primary-bg);
background-color: var(--background-surface-high);
border: 1px solid var(--border-subtle);
}
@ -439,7 +438,7 @@
}
.visual-refresh .vc-changelog-restart-notice {
background-color: var(--card-primary-bg);
background-color: var(--background-surface-high);
border: 1px solid var(--status-warning);
border-left: 4px solid var(--status-warning);
}
@ -514,7 +513,7 @@
/* Required plugin styling */
.vc-changelog-dep-text {
font-size: 0.875em;
color: var(--text-muted);
color: var(--text-subtle);
margin-left: 1em;
font-style: italic;
}
@ -522,7 +521,7 @@
/* Visual refresh support for required plugins */
.visual-refresh .vc-changelog-new-plugin-tag.required {
background-color: var(--status-warning);
border: 1px solid var(--status-warning-foreground);
border: 1px solid var(--status-warning-background);
}
.visual-refresh .vc-changelog-new-plugin-card.required {
@ -564,7 +563,7 @@
.vc-changelog-delete-button:hover {
opacity: 1 !important;
color: var(--status-danger) !important;
background-color: var(--background-modifier-hover);
background-color: var(--background-mod-subtle);
}
.vc-changelog-log-header:hover .vc-changelog-delete-button {

View file

@ -15,5 +15,5 @@ export { openContributorModal } from "./plugins/ContributorModal";
export { openPluginModal } from "./plugins/PluginModal";
export { default as BackupAndRestoreTab } from "./sync/BackupAndRestoreTab";
export { default as CloudTab } from "./sync/CloudTab";
export { openUpdaterModal, default as UpdaterTab } from "./updater";
export { default as UpdaterTab } from "./updater";
export { default as VencordTab } from "./vencord";

View file

@ -5,7 +5,6 @@
*/
import { Paragraph } from "@components/Paragraph";
import { Margins } from "@utils/margins";
import { Patch, ReplaceFn } from "@utils/types";
import { TextArea, useEffect, useRef, useState } from "@webpack/common";
@ -69,16 +68,13 @@ export function FullPatchInput({ setFind, setParsedFind, setMatch, setReplacemen
return (
<>
<Paragraph className={Margins.bottom8}>
Paste your full JSON patch here to fill out the fields
</Paragraph>
<TextArea
inputRef={textAreaRef}
value={patch}
onChange={setPatch}
onBlur={update}
/>
{error !== "" && <Paragraph style={{ color: "var(--text-danger)" }}>{error}</Paragraph>}
{error !== "" && <Paragraph style={{ color: "var(--text-feedback-critical)" }}>{error}</Paragraph>}
</>
);
}

View file

@ -142,7 +142,7 @@ export function PatchPreview({ module, match, replacement, setReplacementError }
)}
{compileResult && (
<Paragraph style={{ color: compileResult[0] ? "var(--status-positive)" : "var(--text-danger)" }}>
<Paragraph style={{ color: compileResult[0] ? "var(--status-positive)" : "var(--text-feedback-critical)" }}>
{compileResult[1]}
</Paragraph>
)}

View file

@ -16,18 +16,21 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Button } from "@components/Button";
import { CodeBlock } from "@components/CodeBlock";
import { Divider } from "@components/Divider";
import { Flex } from "@components/Flex";
import { HeadingTertiary } from "@components/Heading";
import { Heading } from "@components/Heading";
import { Paragraph } from "@components/Paragraph";
import { SettingsTab, wrapTab } from "@components/settings/tabs/BaseTab";
import { Span } from "@components/Span";
import { debounce } from "@shared/debounce";
import { copyWithToast } from "@utils/discord";
import { Margins } from "@utils/margins";
import { copyWithToast } from "@utils/misc";
import { stripIndent } from "@utils/text";
import { ReplaceFn } from "@utils/types";
import { search } from "@webpack";
import { Button, React, TextInput, useMemo, useState } from "@webpack/common";
import { React, TextInput, useMemo, useState } from "@webpack/common";
import { FullPatchInput } from "./FullPatchInput";
import { PatchPreview } from "./PatchPreview";
@ -104,8 +107,14 @@ function PatchHelper() {
}
return (
<SettingsTab title="Patch Helper">
<HeadingTertiary>Full patch</HeadingTertiary>
<SettingsTab>
<Heading className={Margins.top16}>Patch Helper</Heading>
<Paragraph className={Margins.bottom16}>
A developer tool to help you create patches for Equicord plugins.
</Paragraph>
<Heading className="">Full Patch</Heading>
<Paragraph className={Margins.bottom8}>Paste your full JSON patch here to fill out the fields</Paragraph>
<FullPatchInput
setFind={onFindChange}
setParsedFind={setParsedFind}
@ -113,48 +122,58 @@ function PatchHelper() {
setReplacement={setReplacement}
/>
<HeadingTertiary className={Margins.top8}>Find</HeadingTertiary>
<div className={Margins.top20}>
<Heading className="">Find</Heading>
<TextInput
type="text"
value={find}
onChange={onFindChange}
error={findError}
/>
<HeadingTertiary className={Margins.top8}>Match</HeadingTertiary>
</div>
<div className={Margins.top20}>
<Heading className="">Match</Heading>
<TextInput
type="text"
value={match}
onChange={onMatchChange}
error={matchError}
/>
</div>
<div className={Margins.top8} />
<div className={Margins.top20}>
<ReplacementInput
replacement={replacement}
setReplacement={setReplacement}
replacementError={replacementError}
/>
</div>
<Divider />
{module && (
<>
<Divider className={Margins.top16 + " " + Margins.bottom16} />
<Span size="md" weight="medium" color="text-strong">Preview</Span>
<PatchPreview
module={module}
match={match}
replacement={replacement}
setReplacementError={setReplacementError}
/>
</>
)}
{!!(find && match && replacement) && (
<>
<HeadingTertiary className={Margins.top20}>Code</HeadingTertiary>
<Divider className={Margins.top16 + " " + Margins.bottom16} />
<Span size="md" weight="medium" color="text-strong">Generated Code</Span>
<div style={{ width: "100%", marginTop: 8 }}>
<CodeBlock lang="js" content={code} />
<Flex className={Margins.top16}>
<Button onClick={() => copyWithToast(code)}>
</div>
<Flex className={Margins.top8} gap="8px">
<Button size="small" onClick={() => copyWithToast(code)}>
Copy to Clipboard
</Button>
<Button onClick={() => copyWithToast("```ts\n" + code + "\n```")}>
<Button size="small" onClick={() => copyWithToast("```ts\n" + code + "\n```")}>
Copy as Codeblock
</Button>
</Flex>

View file

@ -19,7 +19,7 @@ import { ModalContent, ModalRoot, openModal } from "@utils/modal";
import { User } from "@vencord/discord-types";
import { showToast, useEffect, useMemo, UserProfileStore, useStateFromStores } from "@webpack/common";
import Plugins from "~plugins";
import Plugins, { PluginMeta } from "~plugins";
import { GithubButton, WebsiteButton } from "./LinkIconButton";
import { PluginCard } from "./PluginCard";
@ -53,9 +53,12 @@ function ContributorModal({ user }: { user: User; }) {
const plugins = useMemo(() => {
const allPlugins = Object.values(Plugins);
const pluginsByAuthor = VencordDevsById[user.id] || EquicordDevsById[user.id]
const pluginsByAuthor = (VencordDevsById[user.id] || EquicordDevsById[user.id])
? allPlugins.filter(p => p.authors.includes(VencordDevsById[user.id] || EquicordDevsById[user.id]))
: allPlugins.filter(p => p.authors.some(a => a.name === user.username));
: allPlugins.filter(p =>
PluginMeta[p.name]?.userPlugin && p.authors.some(a => a.id.toString() === user.id)
|| p.authors.some(a => a.name === user.username)
);
return pluginsByAuthor
.filter(p => !p.name.endsWith("API"))

View file

@ -1,12 +0,0 @@
.vc-settings-modal-link-icon {
height: 32px;
width: 32px;
border-radius: 50%;
border: 4px solid var(--background-base-lowest);
box-sizing: border-box
}
.vc-settings-modal-links {
display: flex;
gap: 0.2em;
}

View file

@ -4,38 +4,28 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./LinkIconButton.css";
import { Button } from "@components/Button";
import { GithubIcon, WebsiteIcon } from "@components/Icons";
import { getTheme, Theme } from "@utils/discord";
import { MaskedLink, Tooltip } from "@webpack/common";
export function GithubLinkIcon() {
const theme = getTheme() === Theme.Light ? "#000000" : "#FFFFFF";
return <GithubIcon aria-hidden fill={theme} className={"vc-settings-modal-link-icon"} />;
}
export function WebsiteLinkIcon() {
const theme = getTheme() === Theme.Light ? "#000000" : "#FFFFFF";
return <WebsiteIcon aria-hidden fill={theme} className={"vc-settings-modal-link-icon"} />;
}
interface Props {
text: string;
href: string;
}
function LinkIcon({ text, href, Icon }: Props & { Icon: React.ComponentType; }) {
export function WebsiteButton({ text, href }: Props) {
return (
<Tooltip text={text}>
{props => (
<MaskedLink {...props} href={href}>
<Icon />
</MaskedLink>
)}
</Tooltip>
<Button variant="secondary" size="small" style={{ gap: 4 }} onClick={() => VencordNative.native.openExternal(href)}>
<WebsiteIcon width={16} height={16} />
{text}
</Button>
);
}
export const WebsiteButton = (props: Props) => <LinkIcon {...props} Icon={WebsiteLinkIcon} />;
export const GithubButton = (props: Props) => <LinkIcon {...props} Icon={GithubLinkIcon} />;
export function GithubButton({ text, href }: Props) {
return (
<Button variant="secondary" size="small" style={{ gap: 4 }} onClick={() => VencordNative.native.openExternal(href)}>
<GithubIcon width={16} height={16} />
{text}
</Button>
);
}

View file

@ -5,10 +5,10 @@
*/
import { showNotice } from "@api/Notices";
import { isPluginEnabled, pluginRequiresRestart, startDependenciesRecursive, startPlugin, stopPlugin } from "@api/PluginManager";
import { classNameFactory } from "@api/Styles";
import { CogWheel, InfoIcon } from "@components/Icons";
import { AddonCard } from "@components/settings/AddonCard";
import { proxyLazy } from "@utils/lazy";
import { Logger } from "@utils/Logger";
import { isObjectEmpty } from "@utils/misc";
import { Plugin } from "@utils/types";
@ -21,10 +21,6 @@ import { openPluginModal } from "./PluginModal";
const logger = new Logger("PluginCard");
const cl = classNameFactory("vc-plugins-");
// Avoid circular dependency
const { startDependenciesRecursive, startPlugin, stopPlugin, isPluginEnabled } = proxyLazy(() => require("plugins") as typeof import("plugins"));
interface PluginCardProps extends React.HTMLProps<HTMLDivElement> {
plugin: Plugin;
disabled?: boolean;
@ -39,8 +35,8 @@ export function PluginCard({ plugin, disabled, onRestartNeeded, onMouseEnter, on
const pluginMeta = PluginMeta[plugin.name];
const isEquicordPlugin = pluginMeta.folderName.startsWith("src/equicordplugins/") ?? false;
const isVencordPlugin = pluginMeta.folderName.startsWith("src/plugins/") ?? false;
const isUserPlugin = pluginMeta.userPlugin ?? false;
const isModifiedPlugin = plugin?.isModified ?? false;
const isUserPlugin = pluginMeta?.userPlugin ?? false;
const isModifiedPlugin = plugin.isModified ?? false;
const isEnabled = () => isPluginEnabled(plugin.name);
@ -65,8 +61,8 @@ export function PluginCard({ plugin, disabled, onRestartNeeded, onMouseEnter, on
}
}
// if the plugin has patches, dont use stopPlugin/startPlugin. Wait for restart to apply changes.
if (plugin.patches?.length) {
// if the plugin requires a restart, don't use stopPlugin/startPlugin. Wait for restart to apply changes.
if (pluginRequiresRestart(plugin)) {
settings.enabled = !wasEnabled;
onRestartNeeded(plugin.name, "enabled");
return;
@ -151,7 +147,7 @@ export function PluginCard({ plugin, disabled, onRestartNeeded, onMouseEnter, on
onClick={() => openPluginModal(plugin, onRestartNeeded)}
className={cl("info-button")}
>
{plugin.options && !isObjectEmpty(plugin.options)
{plugin.settings?.def && !isObjectEmpty(plugin.settings.def)
? <CogWheel className={cl("info-icon")} />
: <InfoIcon className={cl("info-icon")} />
}

View file

@ -1,7 +1,56 @@
.vc-plugin-modal-info {
align-items: center;
.vc-plugin-modal-header {
align-items: flex-start;
position: relative;
padding: 16px 16px 0 24px;
}
.vc-plugin-modal-header-content {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
}
.vc-plugin-modal-header-trailing {
position: absolute;
top: 8px;
right: 8px;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.vc-plugin-modal-title {
color: var(--text-strong);
padding-right: 32px;
}
.vc-plugin-modal-description {
flex-grow: 1;
color: var(--text-subtle);
padding-right: 60px;
}
.vc-plugin-modal-links {
display: flex;
gap: 4px;
margin-left: auto;
}
.vc-plugin-modal-confirm [class*="footer"] {
flex-direction: row-reverse;
gap: var(--space-8, 8px);
}
.vc-plugin-modal-confirm [class*="footer"]>button {
flex: 1;
margin: 0;
width: 100%;
}
.vc-plugin-modal-warning {
display: flex;
align-items: center;
gap: 4px;
color: var(--text-feedback-critical);
}

View file

@ -19,9 +19,10 @@
import "./PluginModal.css";
import { generateId } from "@api/Commands";
import { Settings, useSettings } from "@api/Settings";
import { useSettings } from "@api/Settings";
import { classNameFactory } from "@api/Styles";
import { BaseText } from "@components/BaseText";
import { Button } from "@components/Button";
import ErrorBoundary from "@components/ErrorBoundary";
import { Flex } from "@components/Flex";
import { Paragraph } from "@components/Paragraph";
@ -30,11 +31,11 @@ import { gitRemote } from "@shared/vencordUserAgent";
import { proxyLazy } from "@utils/lazy";
import { Margins } from "@utils/margins";
import { classes, isObjectEmpty } from "@utils/misc";
import { ModalCloseButton, 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 { User } from "@vencord/discord-types";
import { findByPropsLazy } from "@webpack";
import { Button, Clickable, FluxDispatcher, React, Toasts, Tooltip, useEffect, UserStore, UserSummaryItem, UserUtils, useState } from "@webpack/common";
import { findByPropsLazy, findComponentByCodeLazy } from "@webpack";
import { Clickable, FluxDispatcher, React, Toasts, Tooltip, useEffect, UserStore, UserSummaryItem, UserUtils, useState } from "@webpack/common";
import { Constructor } from "type-fest";
import { PluginMeta } from "~plugins";
@ -46,6 +47,9 @@ import { GithubButton, WebsiteButton } from "./LinkIconButton";
const cl = classNameFactory("vc-plugin-modal-");
const AvatarStyles = findByPropsLazy("moreUsers", "emptyUser", "avatarContainer", "clickableAvatar");
const CloseButton = findComponentByCodeLazy("CLOSE_BUTTON_LABEL");
const ConfirmModal = findComponentByCodeLazy('parentComponent:"ConfirmModal"');
const WarningIcon = findComponentByCodeLazy("3.15H3.29c-1.74");
const UserRecord: Constructor<Partial<User>> = proxyLazy(() => UserStore.getCurrentUser().constructor) as any;
interface PluginModalProps extends ModalProps {
@ -71,7 +75,7 @@ export function makeDummyUser(user: { username: string; id?: string; avatar?: st
}
export default function PluginModal({ plugin, onRestartNeeded, onClose, transitionState }: PluginModalProps) {
const pluginSettings = useSettings().plugins[plugin.name];
const pluginSettings = useSettings([`plugins.${plugin.name}.*`]).plugins[plugin.name];
const hasSettings = Boolean(pluginSettings && plugin.options && !isObjectEmpty(plugin.options));
const [authors, setAuthors] = useState<Partial<User>[]>([]);
@ -94,7 +98,7 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti
}, [plugin.authors]);
function handleResetClick() {
openWarningModal(plugin, { onClose, transitionState }, onRestartNeeded);
openWarningModal(plugin, onRestartNeeded);
}
function renderSettings() {
@ -157,29 +161,26 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti
return (
<ModalRoot transitionState={transitionState} size={ModalSize.MEDIUM}>
<ModalHeader separator={false} className={Margins.bottom8}>
<BaseText size="xl" weight="bold" style={{ flexGrow: 1 }}>{plugin.name}</BaseText>
<ModalCloseButton onClick={onClose} />
<ModalHeader separator={false} className={cl("header")}>
<div className={cl("header-content")}>
<BaseText size="lg" weight="semibold" className={cl("title")}>{plugin.name}</BaseText>
<BaseText size="sm" className={cl("description")}>{plugin.description}</BaseText>
{!!plugin.settingsAboutComponent && (
<div className={Margins.top8}>
<ErrorBoundary message="An error occurred while rendering this plugin's custom Info Component">
<plugin.settingsAboutComponent />
</ErrorBoundary>
</div>
)}
</div>
<div className={cl("header-trailing")}>
<CloseButton onClick={onClose} />
</div>
</ModalHeader>
<ModalContent className={Margins.bottom16}>
<section>
<Flex className={cl("info")}>
<Paragraph className={cl("description")}>{plugin.description}</Paragraph>
{!pluginMeta.userPlugin && (
<div className="vc-settings-modal-links">
<WebsiteButton
text="View more info"
href={isEquicordPlugin ? `https://equicord.org/plugins/${plugin.name}` : `https://vencord.dev/plugins/${plugin.name}`}
/>
<GithubButton
text="View source code"
href={`https://github.com/${gitRemote}/tree/main/${pluginMeta.folderName}`}
/>
</div>
)}
</Flex>
<BaseText size="lg" weight="semibold" className={classes(Margins.top8, Margins.bottom8)}>Authors</BaseText>
<BaseText size="lg" weight="semibold" color="text-strong" className={Margins.bottom8}>Authors</BaseText>
<div style={{ width: "fit-content" }}>
<ErrorBoundary noop>
<UserSummaryItem
@ -191,7 +192,7 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti
renderUser={(user: User) => (
<Clickable
className={AvatarStyles.clickableAvatar}
onClick={() => openContributorModal(user)}
onClick={() => isEquicordPlugin ? openContributorModal(user) : openContributorModal(user)}
>
<img
className={AvatarStyles.avatar}
@ -206,31 +207,21 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti
</div>
</section>
{!!plugin.settingsAboutComponent && (
<div className={Margins.top16}>
<section>
<ErrorBoundary message="An error occurred while rendering this plugin's custom Info Component">
<plugin.settingsAboutComponent />
</ErrorBoundary>
</section>
</div>
)}
<section>
<BaseText size="lg" weight="semibold" className={classes(Margins.top16, Margins.bottom8)}>Settings</BaseText>
<BaseText size="lg" weight="semibold" color="text-strong" className={classes(Margins.top16, Margins.bottom8)}>Settings</BaseText>
{renderSettings()}
</section>
</ModalContent>
{
hasSettings && <ModalFooter>
<ModalFooter>
<Flex flexDirection="column" style={{ width: "100%" }}>
<Flex style={{ justifyContent: "space-between" }}>
<Flex style={{ justifyContent: "space-between", alignItems: "center" }}>
{hasSettings ? (
<Tooltip text="Reset to default settings" shouldShow={!isObjectEmpty(pluginSettings)}>
{({ onMouseEnter, onMouseLeave }) => (
<Button
className={cl("disable-warning")}
size={Button.Sizes.SMALL}
color={Button.Colors.BRAND}
size="small"
variant="primary"
onClick={handleResetClick}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
@ -239,10 +230,22 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti
</Button>
)}
</Tooltip>
) : <div />}
{!pluginMeta.userPlugin && (
<div className={cl("links")}>
<WebsiteButton
text="Website"
href={isEquicordPlugin ? `https://equicord.org/plugins/${plugin.name}` : `https://vencord.dev/plugins/${plugin.name}`}
/>
<GithubButton
text="Source Code"
href={`https://github.com/${gitRemote}/tree/main/${pluginMeta.folderName}`}
/>
</div>
)}
</Flex>
</Flex>
</ModalFooter>
}
</ModalRoot >
);
}
@ -257,14 +260,11 @@ export function openPluginModal(plugin: Plugin, onRestartNeeded?: (pluginName: s
));
}
function resetSettings(plugin: Plugin, warningModalProps?: ModalProps, pluginModalProps?: ModalProps, onRestartNeeded?: (pluginName: string) => void) {
function resetSettings(plugin: Plugin, onRestartNeeded?: (pluginName: string) => void) {
const defaultSettings = plugin.settings?.def;
const pluginName = plugin.name;
if (!defaultSettings) {
console.error(`No default settings found for ${pluginName}`);
return;
}
if (!defaultSettings) return;
const newSettings: Record<string, any> = {};
let restartNeeded = false;
@ -286,7 +286,6 @@ function resetSettings(plugin: Plugin, warningModalProps?: ModalProps, pluginMod
}
}
const currentSettings = plugin.settings?.store;
if (currentSettings) {
Object.assign(currentSettings, newSettings);
@ -304,88 +303,35 @@ function resetSettings(plugin: Plugin, warningModalProps?: ModalProps, pluginMod
position: Toasts.Position.TOP
}
});
warningModalProps?.onClose();
pluginModalProps?.onClose();
}
export function openWarningModal(plugin?: Plugin | null, pluginModalProps?: ModalProps | null, onRestartNeeded?: (pluginName: string) => void, isPlugin = true, enabledPlugins?: number | null, reset?: any) {
if (Settings.ignoreResetWarning && isPlugin) {
if (plugin && pluginModalProps) return resetSettings(plugin, pluginModalProps, pluginModalProps, onRestartNeeded);
return;
} else if (Settings.ignoreResetWarning && !isPlugin) {
return reset();
}
const text = isPlugin
? `You are about to reset all settings for ${plugin?.name} to their default values.`
: `You are about to disable ${enabledPlugins} plugins!`;
openModal(warningModalProps => (
<ModalRoot
{...warningModalProps}
size={ModalSize.SMALL}
className="vc-text-selectable"
transitionState={warningModalProps.transitionState}
>
<ModalHeader separator={false}>
<BaseText className="text-danger">Dangerous Action</BaseText>
<ModalCloseButton onClick={warningModalProps.onClose} className="vc-modal-close-button" />
</ModalHeader>
<ModalContent>
<section>
<Flex className="vc-warning-info">
<BaseText className="text-normal">
{text}
</BaseText>
<BaseText className="warning-text">
THIS ACTION IS IRREVERSIBLE!
</BaseText>
<BaseText className="text-normal margin-bottom">
If you are certain you want to proceed, click <strong>Confirm Reset</strong>. Otherwise, click <strong>Cancel</strong>.
</BaseText>
</Flex>
</section>
</ModalContent>
<ModalFooter className="vc-modal-footer">
<Flex className="vc-button-container">
<Flex className="button-group">
<Button
size={Button.Sizes.SMALL}
color={Button.Colors.PRIMARY}
onClick={warningModalProps.onClose}
look={Button.Looks.FILLED}
>
Cancel
</Button>
{!Settings.ignoreResetWarning && (
<Button
size={Button.Sizes.SMALL}
className={cl("disable-warning")}
onClick={() => {
Settings.ignoreResetWarning = true;
}}
>
Disable Warning Forever
</Button>
)}
<Button
size={Button.Sizes.SMALL}
onClick={() => {
if (isPlugin) {
if (plugin && pluginModalProps)
resetSettings(plugin, pluginModalProps, pluginModalProps, onRestartNeeded);
export function openWarningModal(plugin?: Plugin | null, onRestartNeeded?: (pluginName: string) => void, isPlugin = true, enabledPlugins?: number | null, reset?: () => void) {
openModal(props => (
<ConfirmModal
{...props}
className={cl("confirm")}
header={isPlugin ? "Reset Settings" : "Disable Plugins"}
confirmText={isPlugin ? "Reset" : "Disable All"}
cancelText="Cancel"
onConfirm={() => {
if (isPlugin && plugin) {
resetSettings(plugin, onRestartNeeded);
} else {
reset();
reset?.();
}
}}
className={cl("confirm-reset")}
onCancel={props.onClose}
>
Confirm Reset
</Button>
</Flex>
</Flex>
</ModalFooter>
</ModalRoot>
<Paragraph>
{isPlugin
? <>Are you sure you want to reset all settings for <strong>{plugin?.name}</strong> to their default values?</>
: `Are you sure you want to disable ${enabledPlugins} plugins?`
}
</Paragraph>
<div className={classes(Margins.top16, cl("warning"))}>
<WarningIcon color="var(--text-feedback-critical)" />
<span>This action cannot be undone.</span>
</div>
</ConfirmModal>
));
}

View file

@ -0,0 +1,42 @@
.vc-plugin-ui-elements-button {
display: flex;
align-items: center;
color: var(--interactive-icon-default);
margin-top: 0.5em;
cursor: pointer;
&:hover {
background-color: var(--background-mod-subtle);
color: var(--interactive-icon-hover);
}
}
.vc-plugin-ui-elements-button-arrow {
margin-left: auto;
width: 24px;
height: 24px;
}
.vc-plugin-ui-elements-modal-content {
padding: 1em;
display: flex;
flex-direction: column;
gap: 1.5em;
}
.vc-plugin-ui-elements-switches {
display: flex;
flex-direction: column;
gap: 1em;
}
.vc-plugin-ui-elements-switches-row {
display: flex;
gap: 1em;
width: 100%;
align-items: center;
:last-child {
margin-left: auto;
}
}

View file

@ -0,0 +1,107 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./UIElements.css";
import { ChatBarButtonMap } from "@api/ChatButtons";
import { MessagePopoverButtonMap } from "@api/MessagePopover";
import { SettingsPluginUiElements, useSettings } from "@api/Settings";
import { classNameFactory } from "@api/Styles";
import { BaseText } from "@components/BaseText";
import { Card } from "@components/Card";
import { PlaceholderIcon } from "@components/Icons";
import { Paragraph } from "@components/Paragraph";
import { Switch } from "@components/Switch";
import { Margins } from "@utils/margins";
import { classes } from "@utils/misc";
import { ModalContent, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { IconComponent } from "@utils/types";
import { Clickable } from "@webpack/common";
const cl = classNameFactory("vc-plugin-ui-elements-");
export function UIElementsButton() {
return (
<Clickable onClick={() => openModal(modalProps => <UIElementsModal {...modalProps} />)}>
<Card className={cl("button")} defaultPadding>
<div className={cl("button-description")}>
<Paragraph size="md" weight="semibold">
Manage plugin UI elements
</Paragraph>
<Paragraph size="xs">
Allows you to hide buttons you don't like
</Paragraph>
</div>
<svg
className={cl("button-arrow")}
aria-hidden="true"
viewBox="0 0 24 24"
>
<path fill="currentColor" d="M9.3 5.3a1 1 0 0 0 0 1.4l5.29 5.3-5.3 5.3a1 1 0 1 0 1.42 1.4l6-6a1 1 0 0 0 0-1.4l-6-6a1 1 0 0 0-1.42 0Z" />
</svg>
</Card>
</Clickable >
);
}
function Section(props: {
title: string;
description: string;
settings: SettingsPluginUiElements;
buttonMap: Map<string, { icon: IconComponent; }>;
}) {
const { buttonMap, description, title, settings } = props;
return (
<section>
<BaseText tag="h3" size="xl" weight="bold">{title}</BaseText>
<Paragraph size="sm" className={classes(Margins.top8, Margins.bottom20)}>{description}</Paragraph>
<div className={cl("switches")}>
{Array.from(buttonMap, ([name, { icon }]) => {
const Icon = icon ?? PlaceholderIcon;
return (
<Paragraph size="md" weight="semibold" key={name} className={cl("switches-row")}>
<Icon height={20} width={20} />
{name}
<Switch
checked={settings[name]?.enabled ?? true}
onChange={v => {
settings[name] ??= {} as any;
settings[name].enabled = v;
}}
/>
</Paragraph>
);
})}
</div>
</section>
);
}
function UIElementsModal(props: ModalProps) {
const { uiElements } = useSettings(["uiElements.*"]);
return (
<ModalRoot {...props} size={ModalSize.MEDIUM}>
<ModalContent className={cl("modal-content")}>
<Section
title="Chatbar Buttons"
description="These are the buttons on the right side of the chat input bar"
buttonMap={ChatBarButtonMap}
settings={uiElements.chatBarButtons}
/>
<Section
title="Message Popover Buttons"
description="These are the floating buttons on the right when you hover over a message"
buttonMap={MessagePopoverButtonMap}
settings={uiElements.messagePopoverButtons}
/>
</ModalContent>
</ModalRoot>
);
}

View file

@ -23,13 +23,13 @@
}
.vc-plugins-setting-title {
color: var(--header-primary);
color: var(--text-strong);
}
.vc-plugins-setting-description {
color: var(--header-secondary);
.vc-plugins-setting-inline .vc-plugins-setting-description {
color: var(--text-subtle);
}
.vc-plugins-setting-error {
color: var(--text-danger);
color: var(--text-feedback-critical, #FF5C5C);
}

View file

@ -19,8 +19,11 @@
import "./styles.css";
import * as DataStore from "@api/DataStore";
import { isPluginEnabled, stopPlugin } from "@api/PluginManager";
import { useSettings } from "@api/Settings";
import { classNameFactory } from "@api/Styles";
import { Button } from "@components/Button";
import { Card } from "@components/Card";
import { Divider } from "@components/Divider";
import ErrorBoundary from "@components/ErrorBoundary";
import { HeadingTertiary } from "@components/Heading";
@ -28,12 +31,12 @@ import { Paragraph } from "@components/Paragraph";
import { SettingsTab } from "@components/settings";
import { debounce } from "@shared/debounce";
import { ChangeList } from "@utils/ChangeList";
import { proxyLazy } from "@utils/lazy";
import { isTruthy } from "@utils/guards";
import { Logger } from "@utils/Logger";
import { Margins } from "@utils/margins";
import { classes } from "@utils/misc";
import { useAwaiter, useIntersection } from "@utils/react";
import { Alerts, Button, Card, lodash, Parser, React, Select, TextInput, Toasts, Tooltip, useMemo } from "@webpack/common";
import { Alerts, lodash, Parser, React, Select, TextInput, Toasts, Tooltip, useCallback, useMemo, useState } from "@webpack/common";
import { JSX } from "react";
import Plugins, { ExcludedPlugins, PluginMeta } from "~plugins";
@ -41,9 +44,7 @@ import Plugins, { ExcludedPlugins, PluginMeta } from "~plugins";
import { PluginCard } from "./PluginCard";
import { openWarningModal } from "./PluginModal";
import { StockPluginsCard, UserPluginsCard } from "./PluginStatCards";
// Avoid circular dependency
const { startDependenciesRecursive, startPlugin, stopPlugin } = proxyLazy(() => require("plugins"));
import { UIElementsButton } from "./UIElements";
export const cl = classNameFactory("vc-plugins-");
export const logger = new Logger("PluginSettings", "#a6d189");
@ -68,7 +69,7 @@ function ReloadRequiredCard({ required, enabledPlugins, openWarningModal, resetC
<Paragraph className={cl("dep-text")}>
Restart now to apply new plugins and their settings
</Paragraph>
<Button className={cl("restart-button")} onClick={() => location.reload()}>
<Button variant="primary" className={cl("restart-button")} onClick={() => location.reload()}>
Restart
</Button>
</>
@ -81,10 +82,11 @@ function ReloadRequiredCard({ required, enabledPlugins, openWarningModal, resetC
)}
{enabledPlugins.length > 0 && !required && (
<Button
size={Button.Sizes.SMALL}
variant="secondary"
size="small"
className={"vc-plugins-disable-warning vc-modal-align-reset"}
onClick={() => {
return openWarningModal(null, null, null, false, enabledPlugins.length, resetCheckAndDo);
return openWarningModal(null, undefined, false, enabledPlugins.length, resetCheckAndDo);
}}
>
Disable All Plugins
@ -100,22 +102,25 @@ const enum SearchStatus {
DISABLED,
EQUICORD,
VENCORD,
CUSTOM,
NEW,
USER_PLUGINS,
API_PLUGINS
}
export const ExcludedReasons: Record<"web" | "discordDesktop" | "vesktop" | "equibop" | "desktop" | "dev", string> = {
desktop: "Discord Desktop app or Vesktop",
desktop: "Discord Desktop app or Vesktop/Equibop",
discordDesktop: "Discord Desktop app",
vesktop: "Vesktop & Equibop apps",
equibop: "Vesktop & Equibop apps",
web: "Vesktop & Equibop apps as well as the Web version of Discord",
vesktop: "Vesktop/Equibop apps",
equibop: "Vesktop/Equibop apps",
web: "Vesktop/Equibop apps & Discord web",
dev: "Developer version of Equicord"
};
function ExcludedPluginsList({ search }: { search: string; }) {
const matchingExcludedPlugins = Object.entries(ExcludedPlugins)
.filter(([name]) => name.toLowerCase().includes(search));
const matchingExcludedPlugins = search
? Object.entries(ExcludedPlugins)
.filter(([name]) => name.toLowerCase().includes(search))
: [];
return (
<Paragraph className={Margins.top16}>
@ -169,34 +174,70 @@ export default function PluginSettings() {
};
}, []);
const depMap = Vencord.Plugins.calculatePluginDependencyMap();
const depMap = useMemo(() => {
const o = {} as Record<string, string[]>;
for (const plugin in Plugins) {
const deps = Plugins[plugin].dependencies;
if (deps) {
for (const dep of deps) {
o[dep] ??= [];
o[dep].push(plugin);
}
}
}
return o;
}, []);
const sortedPlugins = useMemo(() => Object.values(Plugins)
.sort((a, b) => a.name.localeCompare(b.name)), []);
const [searchValue, setSearchValue] = React.useState({ value: "", status: SearchStatus.ALL });
const hasUserPlugins = useMemo(() => !IS_STANDALONE && Object.values(PluginMeta).some(m => m.userPlugin), []);
const [searchValue, setSearchValue] = useState({ value: "", status: SearchStatus.ALL });
const [searchInput, setSearchInput] = useState("");
const debouncedSetSearch = useMemo(
() => debounce((query: string) => setSearchValue(prev => ({ ...prev, value: query })), 150),
[]
);
const search = searchValue.value.toLowerCase();
const onSearch = (query: string) => {
setSearchValue(prev => ({ ...prev, value: query }));
};
const onStatusChange = (status: SearchStatus) => {
const onSearch = useCallback((query: string) => {
setSearchInput(query);
debouncedSetSearch(query);
}, [debouncedSetSearch]);
const onStatusChange = useCallback((status: SearchStatus) => {
setSearchValue(prev => ({ ...prev, status }));
};
}, []);
const pluginFilter = (plugin: typeof Plugins[keyof typeof Plugins]) => {
const pluginFilter = useCallback((plugin: typeof Plugins[keyof typeof Plugins], newPluginsSet: Set<string> | null) => {
const { status } = searchValue;
const enabled = Vencord.Plugins.isPluginEnabled(plugin.name);
const pluginMeta = PluginMeta[plugin.name];
const isEquicordPlugin = pluginMeta.folderName.startsWith("src/equicordplugins/") ?? false;
const isUserplugin = pluginMeta.userPlugin ?? false;
const enabled = isPluginEnabled(plugin.name);
switch (status) {
case SearchStatus.DISABLED:
if (enabled) return false;
break;
case SearchStatus.ENABLED:
if (!enabled) return false;
break;
case SearchStatus.EQUICORD:
if (!PluginMeta[plugin.name].folderName.startsWith("src/equicordplugins/")) return false;
break;
case SearchStatus.VENCORD:
if (!PluginMeta[plugin.name].folderName.startsWith("src/plugins/")) return false;
break;
case SearchStatus.NEW:
if (!newPluginsSet?.has(plugin.name)) return false;
break;
case SearchStatus.USER_PLUGINS:
if (!PluginMeta[plugin.name]?.userPlugin) return false;
break;
case SearchStatus.API_PLUGINS:
if (!plugin.name.endsWith("API")) return false;
break;
}
if (enabled && status === SearchStatus.DISABLED) return false;
if (!enabled && status === SearchStatus.ENABLED) return false;
if (status === SearchStatus.NEW && !newPlugins?.includes(plugin.name)) return false;
if (status === SearchStatus.EQUICORD && !isEquicordPlugin) return false;
if (status === SearchStatus.VENCORD && isEquicordPlugin) return false;
if (status === SearchStatus.CUSTOM && !isUserplugin) return false;
if (!search.length) return true;
return (
@ -204,9 +245,9 @@ export default function PluginSettings() {
plugin.description.toLowerCase().includes(search) ||
plugin.tags?.some(t => t.toLowerCase().includes(search))
);
};
}, [searchValue, search]);
const [newPlugins] = useAwaiter(() => DataStore.get("Vencord_existingPlugins").then((cachedPlugins: Record<string, number> | undefined) => {
const [newPluginsSet] = useAwaiter(() => DataStore.get("Vencord_existingPlugins").then((cachedPlugins: Record<string, number> | undefined) => {
const now = Date.now() / 1000;
const existingTimestamps: Record<string, number> = {};
const sortedPluginNames = Object.values(sortedPlugins).map(plugin => plugin.name);
@ -220,18 +261,21 @@ export default function PluginSettings() {
}
DataStore.set("Vencord_existingPlugins", existingTimestamps);
return lodash.isEqual(newPlugins, sortedPluginNames) ? [] : newPlugins;
return lodash.isEqual(newPlugins, sortedPluginNames) ? null : new Set(newPlugins);
}));
const handleRestartNeeded = useCallback((name: string) => changes.handleChange(name), [changes]);
const { plugins, requiredPlugins } = useMemo(() => {
const plugins = [] as JSX.Element[];
const requiredPlugins = [] as JSX.Element[];
const showApi = searchValue.value.includes("API");
const showApi = searchValue.status === SearchStatus.API_PLUGINS;
for (const p of sortedPlugins) {
if (p.hidden || (!p.options && p.name.endsWith("API") && !showApi))
if (p.hidden || (!p.settings?.def && p.name.endsWith("API") && !showApi))
continue;
if (!pluginFilter(p)) continue;
if (!pluginFilter(p, newPluginsSet)) continue;
const isRequired = p.required || p.isDependency || depMap[p.name]?.some(d => settings.plugins[d].enabled);
@ -246,10 +290,9 @@ export default function PluginSettings() {
<PluginCard
onMouseLeave={onMouseLeave}
onMouseEnter={onMouseEnter}
onRestartNeeded={name => changes.handleChange(name)}
onRestartNeeded={handleRestartNeeded}
disabled={true}
plugin={p}
key={p.name}
/>
)}
</Tooltip>
@ -257,15 +300,17 @@ export default function PluginSettings() {
} else {
plugins.push(
<PluginCard
onRestartNeeded={name => changes.handleChange(name)}
onRestartNeeded={handleRestartNeeded}
disabled={false}
plugin={p}
isNew={newPlugins?.includes(p.name)}
isNew={newPluginsSet?.has(p.name)}
key={p.name}
/>
);
}
}
return { plugins, requiredPlugins };
}, [sortedPlugins, searchValue, newPluginsSet, depMap, settings.plugins, pluginFilter, handleRestartNeeded]);
function resetCheckAndDo() {
let restartNeeded = false;
@ -309,6 +354,7 @@ export default function PluginSettings() {
// Code directly taken from supportHelper.tsx
const { totalStockPlugins, totalUserPlugins, enabledStockPlugins, enabledUserPlugins, enabledPlugins } = useMemo(() => {
const isApiPlugin = (plugin: string) => plugin.endsWith("API") || Plugins[plugin].required;
const totalPlugins = Object.keys(Plugins).filter(p => !isApiPlugin(p));
@ -318,6 +364,8 @@ export default function PluginSettings() {
const totalUserPlugins = totalPlugins.filter(p => PluginMeta[p].userPlugin).length;
const enabledStockPlugins = enabledPlugins.filter(p => !PluginMeta[p].userPlugin).length;
const enabledUserPlugins = enabledPlugins.filter(p => PluginMeta[p].userPlugin).length;
return { totalStockPlugins, totalUserPlugins, enabledStockPlugins, enabledUserPlugins, enabledPlugins };
}, [settings.plugins]);
const pluginsToLoad = Math.min(36, plugins.length);
const [visibleCount, setVisibleCount] = React.useState(pluginsToLoad);
const loadMore = React.useCallback(() => {
@ -336,17 +384,10 @@ export default function PluginSettings() {
const visiblePlugins = plugins.slice(0, visibleCount);
return (
<SettingsTab title="Plugins">
<SettingsTab>
<ReloadRequiredCard required={changes.hasChanges} enabledPlugins={enabledPlugins} openWarningModal={openWarningModal} resetCheckAndDo={resetCheckAndDo} />
<div className={cl("stats-container")} style={{
marginTop: "16px",
gap: "16px",
display: "flex",
flexDirection: "row",
width: "100%"
}}>
<div className={cl("stats-container")}>
<StockPluginsCard
totalStockPlugins={totalStockPlugins}
enabledStockPlugins={enabledStockPlugins}
@ -357,13 +398,17 @@ export default function PluginSettings() {
/>
</div>
<div className={cl("ui-elements")}>
<UIElementsButton />
</div>
<HeadingTertiary className={classes(Margins.top20, Margins.bottom8)}>
Filters
</HeadingTertiary>
<div className={classes(Margins.bottom20, cl("filter-controls"))}>
<ErrorBoundary noop>
<TextInput autoFocus value={searchValue.value} placeholder="Search for a plugin..." onChange={onSearch} />
<TextInput autoFocus value={searchInput} placeholder="Search for a plugin..." onChange={onSearch} />
</ErrorBoundary>
<div>
<ErrorBoundary noop>
@ -374,9 +419,10 @@ export default function PluginSettings() {
{ label: "Show Disabled", value: SearchStatus.DISABLED },
{ label: "Show Equicord", value: SearchStatus.EQUICORD },
{ label: "Show Vencord", value: SearchStatus.VENCORD },
...(totalUserPlugins > 0 ? [{ label: "Show Custom", value: SearchStatus.CUSTOM }] : []),
{ label: "Show New", value: SearchStatus.NEW },
]}
hasUserPlugins && { label: "Show UserPlugins", value: SearchStatus.USER_PLUGINS },
{ label: "Show API Plugins", value: SearchStatus.API_PLUGINS },
].filter(isTruthy)}
serialize={String}
select={onStatusChange}
isSelected={v => v === searchValue.status}

View file

@ -19,7 +19,7 @@
.vc-plugins-grid {
margin-top: 16px;
display: grid;
grid-gap: 16px;
gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
}
@ -29,16 +29,16 @@
padding: 0;
background: transparent;
margin-right: 8px;
color: var(--icon-secondary);
color: var(--icon-muted);
transition: background-color .2s ease;
&:hover {
color: var(--interactive-hover)
color: var(--interactive-icon-hover)
}
}
.vc-plugins-settings-button:hover {
color: var(--interactive-hover);
color: var(--interactive-icon-hover);
}
.vc-plugins-filter-controls {
@ -78,23 +78,23 @@
.vc-plugins-restart-card {
padding: 1em;
background: var(--info-warning-background);
border: 1px solid var(--info-warning-foreground);
color: var(--info-warning-foreground);
background: var(--icon-status-idle) !important;
border: 1px solid var(--icon-status-idle) !important;
color: var(--text-status-idle);
}
.vc-plugins-info-icon:not(:hover, :focus) {
color: var(--text-muted);
color: var(--text-subtle);
}
.vc-plugins-restart-button {
background-color: var(--background-color) !important;
border: 1px solid var(--info-warning-foreground) !important;
background-color: var(--background-base-low) !important;
border: 1px solid var(--text-status-idle) !important;
margin-top: 0.5em;
}
.vc-plugins-restart-button:hover {
background-color: var(--info-warning-foreground) !important;
background-color: var(--text-status-idle) !important;
}
.vc-plugin-stats {
@ -112,7 +112,7 @@
.vc-plugins-info-card:hover,
.vc-plugin-stats:hover {
background-color: var(--background-tertiary) !important;
background-color: var(--background-base-low) !important;
transform: translateY(-1px);
box-shadow: var(--elevation-high);
}
@ -159,7 +159,7 @@
}
.text-danger {
color: var(--text-danger) !important;
color: var(--text-feedback-critical) !important;
font-size: 1.2rem;
font-weight: bold;
}
@ -175,29 +175,29 @@
.vc-plugins-disable-warning,
.vc-plugin-modal-disable-warning {
background-color: color-mix(in srgb, var(--button-danger-background) 10%, transparent) !important;
background-color: color-mix(in srgb, var(--redesign-button-danger-background) 10%, transparent) !important;
border-style: solid !important;
border-width: .1rem !important;
border-color: color-mix(in srgb, var(--button-danger-background) 80%, transparent) !important;
border-color: color-mix(in srgb, var(--redesign-button-danger-background) 80%, transparent) !important;
border-radius: 5px !important;
color: color-mix(in srgb, var(--button-danger-background) 100%, transparent) !important;
color: color-mix(in srgb, var(--redesign-button-danger-background) 100%, transparent) !important;
}
.vc-plugins-disable-warning:hover,
.vc-plugin-modal-disable-warning:hover {
background-color: color-mix(in srgb, var(--button-danger-background-hover) 40%, transparent) !important;
background-color: color-mix(in srgb, var(--redesign-button-danger-background) 40%, transparent) !important;
}
.vc-plugins-confirm-reset,
.vc-plugin-modal-confirm-reset {
background-color: var(--button-danger-background) !important;
color: var(--header-primary);
background-color: var(--redesign-button-danger-background) !important;
color: var(--redesign-button-danger-pressed-text);
}
.vc-plugins-confirm-reset:hover,
.vc-plugin-modal-confirm-reset:hover {
background-color: var(--button-danger-background-hover) !important;
filter: saturate(80%);
background-color: var(--redesign-button-danger-pressed-background) !important;
color: var(--redesign-button-danger-pressed-text);
}
.vc-modal-close-button {
@ -224,7 +224,7 @@
/* Specific to disable all modal */
.warning-text {
color: var(--text-danger) !important;
color: var(--text-feedback-critical) !important;
font-size: 1.2rem !important;
}
@ -250,20 +250,20 @@
}
.visual-refresh .vc-plugins-info-card {
background-color: var(--card-primary-bg) !important;
background-color: var(--background-surface-high) !important;
border: 1px solid var(--border-subtle) !important;
&:hover {
background-color: var(--card-primary-bg) !important;
background-color: var(--background-surface-high) !important;
}
}
.visual-refresh .vc-plugin-stats {
background-color: var(--card-primary-bg) !important;
background-color: var(--background-surface-high) !important;
border: 1px solid var(--border-subtle) !important;
&:hover {
background-color: var(--card-primary-bg) !important;
background-color: var(--background-surface-high) !important;
}
}
@ -287,3 +287,15 @@
margin-top: 2px;
margin-right: 8px;
}
.vc-plugins-stats-container {
margin-top: 16px;
gap: 16px;
display: flex;
flex-direction: row;
width: 100%;
}
.vc-plugins-ui-elements {
margin-top: 16px;
}

View file

@ -33,15 +33,15 @@
.vc-warning-card {
padding: 1em;
background: var(--info-warning-background);
border: 1px solid var(--info-warning-foreground);
color: var(--info-warning-foreground);
background: var(--background-feedback-warning);
border: 1px solid var(--text-status-idle);
color: var(--text-status-idle);
}
.vc-backup-restore-card {
background-color: var(--info-warning-background);
border-color: var(--info-warning-foreground);
color: var(--info-warning-foreground);
background-color: var(--background-feedback-warning);
border-color: var(--text-status-idle);
color: var(--text-status-idle);
}
.vc-settings-theme-links {
@ -49,7 +49,7 @@
display: inline-block !important;
color: var(--text-default) !important;
padding: 0.5em 1em;
border: 1px solid var(--input-border);
border: 1px solid var(--input-border-default);
max-height: unset;
background-color: transparent;
box-sizing: border-box;
@ -61,7 +61,7 @@
}
.vc-settings-theme-links::placeholder {
color: var(--text-muted) !important;
color: var(--text-subtle) !important;
}
.vc-settings-theme-links:focus {
@ -71,18 +71,18 @@
.vc-cloud-settings-sync-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-gap: 1em;
gap: 1em;
}
.vc-cloud-erase-data-danger-btn {
color: var(--white-500);
background-color: var(--button-danger-background);
background-color: var(--redesign-button-danger-background);
}
.vc-updater-modal {
.vc-settings-modal {
padding: 1.5em !important;
}
.vc-updater-modal-close-button {
.vc-settings-modal-close {
float: right;
}

View file

@ -16,67 +16,103 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { BaseText } from "@components/BaseText";
import { downloadSettingsBackup, uploadSettingsBackup } from "@api/SettingsSync/offline";
import { Alert } from "@components/Alert";
import { Button } from "@components/Button";
import { Divider } from "@components/Divider";
import { Flex } from "@components/Flex";
import { Heading } from "@components/Heading";
import { Paragraph } from "@components/Paragraph";
import { SettingsTab, wrapTab } from "@components/settings/tabs/BaseTab";
import { Margins } from "@utils/margins";
import { classes } from "@utils/misc";
import { downloadSettingsBackup, uploadSettingsBackup } from "@utils/settingsSync";
import { Button, Card } from "@webpack/common";
function BackupAndRestoreTab() {
return (
<SettingsTab title="Backup & Restore">
<Card className={classes("vc-settings-card", "vc-backup-restore-card")}>
<Flex flexDirection="column">
<strong>Warning</strong>
<span>Importing a settings file will overwrite your current settings.</span>
</Flex>
</Card>
<BaseText size="md" className={Margins.bottom8}>
You can import and export your Equicord settings as a JSON file.
This allows you to easily transfer your settings to another device,
or recover your settings after reinstalling Equicord or Discord.
</BaseText>
<BaseText size="md" className={Margins.bottom8}>
Settings Export contains:
<ul>
<li>&mdash; Custom QuickCSS</li>
<li>&mdash; Theme Links</li>
<li>&mdash; Plugin Settings</li>
<li>&mdash; Plugin DataStores (e.g. Timezones or IRememberYou)</li>
</ul>
</BaseText>
<Flex>
<SettingsTab>
<Heading className={Margins.top16}>Backup & Restore</Heading>
<Paragraph className={Margins.bottom20}>
Import and export your Equicord settings as a JSON file. This allows you to easily transfer your settings to another device, or recover them after reinstalling Equicord or Discord.
</Paragraph>
<Alert.Warning className={Margins.bottom20}>
Importing a settings file will overwrite your current settings. Make sure to export a backup first if you want to keep your current configuration.
</Alert.Warning>
<Heading>What's included in a backup</Heading>
<Paragraph className={Margins.bottom20}>
• Custom QuickCSS<br />
• Theme Links<br />
• Plugin Settings<br />
• DataStore Data
</Paragraph>
<Divider className={Margins.bottom20} />
<Heading>Import Settings</Heading>
<Paragraph className={Margins.bottom16}>
Select a previously exported settings file to restore your configuration. This will replace all your current settings with the ones from the backup.
</Paragraph>
<Flex gap="8px" className={Margins.bottom20} style={{ flexWrap: "wrap" }}>
<Button
onClick={() => uploadSettingsBackup()}
size={Button.Sizes.SMALL}
onClick={() => uploadSettingsBackup("all")}
size="small"
variant="secondary"
>
Import Settings
Import All Settings
</Button>
<Button
onClick={() => downloadSettingsBackup("settings")}
size={Button.Sizes.SMALL}
onClick={() => uploadSettingsBackup("plugins")}
size="small"
>
Export Settings
Import Plugins
</Button>
<Button
onClick={() => uploadSettingsBackup("css")}
size="small"
>
Import QuickCSS
</Button>
<Button
onClick={() => uploadSettingsBackup("datastore")}
size="small"
>
Import DataStore
</Button>
</Flex>
<Divider className={Margins.bottom20} />
<Heading>Export Settings</Heading>
<Paragraph className={Margins.bottom16}>
Download your current settings as a backup file. You can export everything at once, or choose to export only specific parts of your configuration.
</Paragraph>
<Flex gap="8px" style={{ flexWrap: "wrap" }}>
<Button
onClick={() => downloadSettingsBackup("all")}
size="small"
variant="secondary"
>
Export All Settings
</Button>
<Button
onClick={() => downloadSettingsBackup("plugins")}
size={Button.Sizes.SMALL}
size="small"
>
Export Plugins
</Button>
<Button
onClick={() => downloadSettingsBackup("css")}
size={Button.Sizes.SMALL}
size="small"
>
Export CSS
Export QuickCSS
</Button>
<Button
onClick={() => downloadSettingsBackup("datastore")}
size={Button.Sizes.SMALL}
size="small"
>
Export DataStores
Export DataStore
</Button>
</Flex>
</SettingsTab>

View file

@ -16,20 +16,28 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { showNotification } from "@api/Notifications";
import { Settings, useSettings } from "@api/Settings";
import { useSettings } from "@api/Settings";
import { authorizeCloud, deauthorizeCloud } from "@api/SettingsSync/cloudSetup";
import { deleteCloudSettings, eraseAllCloudData, getCloudSettings, putCloudSettings } from "@api/SettingsSync/cloudSync";
import { Alert } from "@components/Alert";
import { Button } from "@components/Button";
import { CheckedTextInput } from "@components/CheckedTextInput";
import { Divider } from "@components/Divider";
import { Flex } from "@components/Flex";
import { FormSwitch } from "@components/FormSwitch";
import { Grid } from "@components/Grid";
import { Heading } from "@components/Heading";
import { Link } from "@components/Link";
import { Paragraph } from "@components/Paragraph";
import { QuickAction, QuickActionCard } from "@components/settings/QuickAction";
import { SettingsTab, wrapTab } from "@components/settings/tabs/BaseTab";
import { authorizeCloud, cloudLogger, deauthorizeCloud, getCloudAuth, getCloudUrl } from "@utils/cloud";
import { Margins } from "@utils/margins";
import { deleteCloudSettings, getCloudSettings, putCloudSettings } from "@utils/settingsSync";
import { Alerts, Button, Tooltip, useState } from "@webpack/common";
import { findComponentByCodeLazy } from "@webpack";
import { Alerts, useState } from "@webpack/common";
const UploadIcon = findComponentByCodeLazy("M12.7 3.3a1 1 0 0 0-1.4 0l-5 5a1 1 0 0 0 1.4 1.4L11 6.42V20");
const DownloadIcon = findComponentByCodeLazy("M12.7 20.7a1 1 0 0 1-1.4 0l-5-5a1 1 0 1 1 1.4-1.4l3.3 3.29V4");
const TrashIcon = findComponentByCodeLazy("2.81h8.36a3");
const SkullIcon = findComponentByCodeLazy("m13.47 1 .07.04c.45.06");
function validateUrl(url: string) {
try {
@ -40,93 +48,17 @@ function validateUrl(url: string) {
}
}
async function eraseAllData() {
const res = await fetch(new URL("/v1/", getCloudUrl()), {
method: "DELETE",
headers: { Authorization: await getCloudAuth() }
});
if (!res.ok) {
cloudLogger.error(`Failed to erase data, API returned ${res.status}`);
showNotification({
title: "Cloud Integrations",
body: `Could not erase all data (API returned ${res.status}), please contact support.`,
color: "var(--red-360)"
});
return;
}
Settings.cloud.authenticated = false;
await deauthorizeCloud();
showNotification({
title: "Cloud Integrations",
body: "Successfully erased all data.",
color: "var(--green-360)"
});
}
function SettingsSyncSection() {
const { cloud } = useSettings(["cloud.authenticated", "cloud.settingsSync"]);
const sectionEnabled = cloud.authenticated && cloud.settingsSync;
return (
<section className={Margins.top16}>
<Heading>Settings Sync</Heading>
<Paragraph size="md" className={Margins.bottom20}>
Synchronize your settings to the cloud. This allows easy synchronization across multiple devices with
minimal effort.
</Paragraph>
<FormSwitch
key="cloud-sync"
title="Settings Sync"
value={cloud.settingsSync}
onChange={v => { cloud.settingsSync = v; }}
disabled={!cloud.authenticated}
/>
<div className="vc-cloud-settings-sync-grid">
<Button
size={Button.Sizes.SMALL}
disabled={!sectionEnabled}
onClick={() => putCloudSettings(true)}
>
Sync to Cloud
</Button>
<Tooltip text="This will overwrite your local settings with the ones on the cloud. Use wisely!">
{({ onMouseLeave, onMouseEnter }) => (
<Button
onMouseLeave={onMouseLeave}
onMouseEnter={onMouseEnter}
size={Button.Sizes.SMALL}
color={Button.Colors.RED}
disabled={!sectionEnabled}
onClick={() => getCloudSettings(true, true)}
>
Sync from Cloud
</Button>
)}
</Tooltip>
<Button
size={Button.Sizes.SMALL}
color={Button.Colors.RED}
disabled={!sectionEnabled}
onClick={() => deleteCloudSettings()}
>
Delete Cloud Settings
</Button>
</div>
</section>
);
}
function CloudTab() {
const settings = useSettings(["cloud.authenticated", "cloud.url"]);
const settings = useSettings(["cloud.authenticated", "cloud.url", "cloud.settingsSync"]);
const [inputKey, setInputKey] = useState(0);
const { cloud } = settings;
const isAuthenticated = cloud.authenticated;
const syncEnabled = isAuthenticated && cloud.settingsSync;
async function changeUrl(url: string) {
settings.cloud.url = url;
settings.cloud.authenticated = false;
cloud.url = url;
cloud.authenticated = false;
await deauthorizeCloud();
await authorizeCloud();
@ -135,54 +67,58 @@ function CloudTab() {
}
return (
<SettingsTab title="Equicord Cloud">
<section className={Margins.top16}>
<Heading>Cloud Settings</Heading>
<Paragraph size="md" className={Margins.bottom20}>
Equicord comes with a cloud integration allowing settings to be synced across apps and devices.
<br />
We use our own <Link href="https://github.com/Equicord/Equicloud">Equicloud backend</Link> to provide our cloud instance with enhanced features.
<br />
Our <Link href="https://equicord.org/cloud/policy">privacy policy</Link> allows you to see what information we store, how we use it, and data retention.
<br />
Equicloud is BSD 3.0 licensed so you can host it yourself if you would like.
<br />
You can swap between Equicord and Vencord's different cloud instances below if needed.
<SettingsTab>
<Heading className={Margins.top16}>Cloud Integration</Heading>
<Paragraph className={Margins.bottom16}>
Equicord's cloud integration allows you to sync your settings across multiple devices and Discord installations. Your data is securely stored and can be easily restored at any time.
</Paragraph>
<Alert.Info className={Margins.bottom16} style={{ width: "100%" }}>
We use our own <Link href="https://github.com/Equicord/Equicloud">Equicloud backend</Link> with enhanced features.
View our <Link href="https://equicord.org/cloud/policy">privacy policy</Link> to see what we store and how we use your data.
Equicloud is BSD 3.0 licensed, so you can self-host if preferred.
</Alert.Info>
<FormSwitch
key="backend"
title="Enable Cloud Integrations"
description="This will request authorization if you have not yet set up cloud integrations."
value={settings.cloud.authenticated}
title="Enable Cloud Integration"
description="Connect to the cloud backend for settings synchronization. This will request authorization if you haven't set up cloud integration yet."
value={isAuthenticated}
onChange={v => {
if (v)
authorizeCloud();
else
settings.cloud.authenticated = v;
cloud.authenticated = v;
}}
hideBorder
/>
<Heading>Backend URL</Heading>
<Divider className={Margins.top20} />
<Heading className={Margins.top20}>Cloud Backend</Heading>
<Paragraph className={Margins.bottom8}>
Which backend to use when using cloud integrations.
Choose which cloud backend to use for storing your settings. You can switch between Equicord's and Vencord's cloud services, or use a self-hosted instance.
</Paragraph>
<Paragraph color="text-subtle" className={Margins.bottom16}>
Current: <Link href={cloud.url}>{cloud.url}</Link>
</Paragraph>
<CheckedTextInput
key={`backendUrl-${inputKey}`}
value={settings.cloud.url}
value={cloud.url}
onChange={async v => {
settings.cloud.url = v;
settings.cloud.authenticated = false;
cloud.url = v;
cloud.authenticated = false;
await deauthorizeCloud();
}}
validate={validateUrl}
/>
<Grid columns={2} gap="1em" className={Margins.top8}>
<Flex gap="8px" className={Margins.top16} flexWrap="wrap">
<Button
size={Button.Sizes.MEDIUM}
disabled={!settings.cloud.authenticated}
size="small"
disabled={!isAuthenticated}
onClick={async () => {
settings.cloud.authenticated = false;
cloud.authenticated = false;
await deauthorizeCloud();
await authorizeCloud();
}}
@ -190,31 +126,94 @@ function CloudTab() {
Reauthorize
</Button>
<Button
size={Button.Sizes.MEDIUM}
color={Button.Colors.RED}
disabled={!settings.cloud.authenticated}
onClick={() => Alerts.show({
title: "Are you sure?",
body: "Once your data is erased, we cannot recover it. There's no going back!",
onConfirm: eraseAllData,
confirmText: "Erase it!",
confirmColor: "vc-cloud-erase-data-danger-btn",
cancelText: "Nevermind"
})}
size="small"
variant="secondary"
onClick={() => changeUrl("https://cloud.equicord.org/")}
>
Erase All Data
Use Equicord Cloud
</Button>
<Button size={Button.Sizes.MEDIUM} onClick={() => changeUrl("https://cloud.equicord.org/")}>
Use Equicord's Cloud
<Button
size="small"
variant="secondary"
onClick={() => changeUrl("https://api.vencord.dev/")}
>
Use Vencord Cloud
</Button>
<Button size={Button.Sizes.MEDIUM} onClick={() => changeUrl("https://api.vencord.dev/")}>
Use Vencord's Cloud
</Button>
</Grid>
</Flex>
<Divider className={Margins.top16} />
</section >
<SettingsSyncSection />
<Divider className={Margins.top20} />
<Heading className={Margins.top20}>Settings Sync</Heading>
<Paragraph className={Margins.bottom16}>
Synchronize your Equicord settings to the cloud. This makes it easy to keep your configuration consistent across multiple devices without manual import/export.
</Paragraph>
<FormSwitch
title="Enable Settings Sync"
description="When enabled, your settings can be synced to and from the cloud. Use the actions below to manually sync."
value={cloud.settingsSync}
onChange={v => { cloud.settingsSync = v; }}
disabled={!isAuthenticated}
hideBorder
/>
{isAuthenticated && (
<QuickActionCard columns={2}>
<QuickAction
Icon={UploadIcon}
text="Sync to Cloud"
action={() => putCloudSettings(true)}
disabled={!syncEnabled}
/>
<QuickAction
Icon={DownloadIcon}
text="Sync from Cloud"
action={() => getCloudSettings(true, true)}
disabled={!syncEnabled}
/>
</QuickActionCard>
)}
{!isAuthenticated && (
<Alert.Warning className={Margins.top8} style={{ width: "100%" }}>
Enable cloud integration above to use settings sync features.
</Alert.Warning>
)}
<Divider className={Margins.top20} />
<Heading className={Margins.top20}>Danger Zone</Heading>
<Paragraph className={Margins.bottom16}>
Permanently delete all your data from the cloud. This action cannot be undone and will remove all synced settings and any other data stored on the cloud backend.
</Paragraph>
<Flex gap="8px" flexWrap="wrap">
<Button
variant="dangerPrimary"
disabled={!syncEnabled}
onClick={() => deleteCloudSettings()}
style={{ display: "flex", alignItems: "center" }}
>
<TrashIcon color="currentColor" style={{ marginRight: "8px" }} />
Delete Cloud Settings
</Button>
<Button
variant="dangerPrimary"
disabled={!isAuthenticated}
onClick={() => Alerts.show({
title: "Erase All Cloud Data",
body: "Are you sure you want to permanently delete all your cloud data? This action cannot be undone.",
onConfirm: eraseAllCloudData,
confirmText: "Erase All Data",
confirmColor: "vc-cloud-erase-data-danger-btn",
cancelText: "Cancel"
})}
style={{ display: "flex", alignItems: "center" }}
>
<SkullIcon color="currentColor" style={{ marginRight: "8px" }} />
Erase All Cloud Data
</Button>
</Flex>
</SettingsTab>
);
}

View file

@ -4,15 +4,17 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Button } from "@components/Button";
import { Card } from "@components/Card";
import { ErrorCard } from "@components/ErrorCard";
import { Flex } from "@components/Flex";
import { Link } from "@components/Link";
import { Paragraph } from "@components/Paragraph";
import { Span } from "@components/Span";
import { Margins } from "@utils/margins";
import { classes } from "@utils/misc";
import { relaunch } from "@utils/native";
import { changes, checkForUpdates, update, updateError } from "@utils/updater";
import { Alerts, Button, Card, React, Toasts, useState } from "@webpack/common";
import { Alerts, React, Toasts, useState } from "@webpack/common";
import { runWithDispatch } from "./runWithDispatch";
@ -31,25 +33,26 @@ export function HashLink({ repo, hash, disabled = false }: { repo: string, hash:
export function Changes({ updates, repo, repoPending }: CommonProps & { updates: typeof changes; }) {
return (
<Card style={{ padding: "0 0.5em" }}>
{updates.map(({ hash, author, message }) => (
<Card className={Margins.top16} style={{ padding: 0 }} defaultPadding={false}>
{updates.map(({ hash, author, message }, i) => (
<div
key={hash}
style={{
marginTop: "0.5em",
marginBottom: "0.5em"
padding: "12px 16px",
borderBottom: i < updates.length - 1 ? "1px solid var(--border-subtle)" : undefined
}}
>
<code>
<Flex style={{ alignItems: "center", gap: 8 }}>
<code style={{ color: "var(--text-link)" }}>
<HashLink {...{ repo, hash }} disabled={repoPending} />
</code>
<span style={{
marginLeft: "0.5em",
color: "var(--text-default)"
}}>
{message} - {author}
</span>
<Span size="sm" color="text-default">
{message}
</Span>
<Span size="sm" color="text-subtle">
— {author}
</Span>
</Flex>
</div>
))}
</Card>
@ -59,8 +62,8 @@ export function Changes({ updates, repo, repoPending }: CommonProps & { updates:
export function Newer(props: CommonProps) {
return (
<>
<Paragraph className={Margins.bottom8}>
Your local copy has more recent commits. Please stash or reset them.
<Paragraph>
Your local copy has more recent commits than the remote repository. This usually happens when you've made local changes. Please stash or reset them before updating.
</Paragraph>
<Changes {...props} updates={changes} />
</>
@ -76,25 +79,35 @@ export function Updatable(props: CommonProps) {
return (
<>
{!updates && updateError ? (
<>
<Paragraph>Failed to check updates. Check the console for more info</Paragraph>
<ErrorCard style={{ padding: "1em" }}>
<p>{updateError.stderr || updateError.stdout || "An unknown error occurred"}</p>
</ErrorCard>
</>
) : (
<Paragraph className={Margins.bottom8}>
{isOutdated ? (updates.length === 1 ? "There is 1 Update" : `There are ${updates.length} Updates`) : "Up to Date!"}
</Paragraph>
)}
<Flex className={Margins.bottom8} gap="8px">
<Button
size="small"
disabled={isUpdating || isChecking}
onClick={runWithDispatch(setIsChecking, async () => {
const outdated = await checkForUpdates();
{isOutdated && <Changes updates={updates} {...props} />}
if (outdated) {
setUpdates(changes);
} else {
setUpdates([]);
<Flex className={classes(Margins.bottom8, Margins.top8)}>
Toasts.show({
message: "No updates found!",
id: Toasts.genId(),
type: Toasts.Type.MESSAGE,
options: {
position: Toasts.Position.BOTTOM
}
});
}
})}
>
Check for Updates
</Button>
{isOutdated && (
<Button
size={Button.Sizes.SMALL}
size="small"
variant="primary"
disabled={isUpdating || isChecking}
onClick={runWithDispatch(setIsUpdating, async () => {
if (await update()) {
@ -119,31 +132,26 @@ export function Updatable(props: CommonProps) {
Update Now
</Button>
)}
<Button
size={Button.Sizes.SMALL}
disabled={isUpdating || isChecking}
onClick={runWithDispatch(setIsChecking, async () => {
const outdated = await checkForUpdates();
if (outdated) {
setUpdates(changes);
} else {
setUpdates([]);
Toasts.show({
message: "No updates found!",
id: Toasts.genId(),
type: Toasts.Type.MESSAGE,
options: {
position: Toasts.Position.BOTTOM
}
});
}
})}
>
Check for Updates
</Button>
</Flex>
{!updates && updateError ? (
<>
<Span size="md" weight="medium" color="text-strong">Error checking for updates</Span>
<ErrorCard className={Margins.top8} style={{ padding: "1em" }}>
<p>{updateError.stderr || updateError.stdout || "An unknown error occurred"}</p>
</ErrorCard>
</>
) : isOutdated ? (
<>
<Paragraph>
There {updates.length === 1 ? "is 1 update" : `are ${updates.length} updates`} available. Click the button below to download and install.
</Paragraph>
<Changes updates={updates} {...props} />
</>
) : (
<Paragraph>
You're running the latest version of Equicord.
</Paragraph>
)}
</>
);
}

View file

@ -18,155 +18,25 @@
import { useSettings } from "@api/Settings";
import { Divider } from "@components/Divider";
import { ErrorCard } from "@components/ErrorCard";
import { Flex } from "@components/Flex";
import { FormSwitch } from "@components/FormSwitch";
import { Heading } from "@components/Heading";
import { Link } from "@components/Link";
import { Paragraph } from "@components/Paragraph";
import { handleSettingsTabError, SettingsTab, wrapTab } from "@components/settings";
import { SettingsTab, wrapTab } from "@components/settings";
import { Margins } from "@utils/margins";
import { classes } from "@utils/misc";
import { ModalCloseButton, ModalContent, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { relaunch } from "@utils/native";
import { useAwaiter } from "@utils/react";
import { changes, checkForUpdates, getRepo, isNewer, update, updateError, UpdateLogger } from "@utils/updater";
import { Alerts, Button, Parser, React, Toasts } from "@webpack/common";
import { getRepo, isNewer, UpdateLogger } from "@utils/updater";
import { React } from "@webpack/common";
import gitHash from "~git-hash";
import { Changes, HashLink } from "./Components";
function withDispatcher(dispatcher: React.Dispatch<React.SetStateAction<boolean>>, action: () => any) {
return async () => {
dispatcher(true);
try {
await action();
} catch (e: any) {
UpdateLogger.error("Failed to update", e);
let err: string;
if (!e) {
err = "An unknown error occurred (error is undefined).\nPlease try again.";
} else if (e.code && e.cmd) {
const { code, path, cmd, stderr } = e;
if (code === "ENOENT")
err = `Command \`${path}\` not found.\nPlease install it and try again`;
else {
err = `An error occurred while running \`${cmd}\`:\n`;
err += stderr || `Code \`${code}\`. See the console for more info`;
}
} else {
err = "An unknown error occurred. See the console for more info.";
}
Alerts.show({
title: "Oops!",
body: (
<ErrorCard>
{err.split("\n").map((line, idx) => <div key={idx}>{Parser.parse(line)}</div>)}
</ErrorCard>
)
});
}
finally {
dispatcher(false);
}
};
}
import { HashLink, Newer, Updatable } from "./Components";
interface CommonProps {
repo: string;
repoPending: boolean;
}
function Updatable(props: CommonProps) {
const [updates, setUpdates] = React.useState(changes);
const [isChecking, setIsChecking] = React.useState(false);
const [isUpdating, setIsUpdating] = React.useState(false);
const isOutdated = (updates?.length ?? 0) > 0;
return (
<>
<Flex className={classes(Margins.bottom8, Margins.top8)}>
{isOutdated && <Button
size={Button.Sizes.SMALL}
disabled={isUpdating || isChecking}
onClick={withDispatcher(setIsUpdating, async () => {
if (await update()) {
setUpdates([]);
return await new Promise<void>(r => {
Alerts.show({
title: "Update Success!",
body: "Successfully updated. Restart now to apply the changes?",
confirmText: "Restart",
cancelText: "Not now!",
onConfirm() {
relaunch();
r();
},
onCancel: r
});
});
}
})}
>
Update Now
</Button>}
<Button
size={Button.Sizes.SMALL}
disabled={isUpdating || isChecking}
onClick={withDispatcher(setIsChecking, async () => {
const outdated = await checkForUpdates();
if (outdated) {
setUpdates(changes);
} else {
setUpdates([]);
Toasts.show({
message: "No updates found!",
id: Toasts.genId(),
type: Toasts.Type.MESSAGE,
options: {
position: Toasts.Position.BOTTOM
}
});
}
})}
>
Check for Updates
</Button>
</Flex>
{!updates && updateError ? (
<>
<Paragraph>Failed to check updates. Check the console for more info</Paragraph>
<ErrorCard style={{ padding: "1em" }}>
<p>{updateError.stderr || updateError.stdout || "An unknown error occurred"}</p>
</ErrorCard>
</>
) : (
<Paragraph className={Margins.bottom8}>
{isOutdated ? (updates.length === 1 ? "There is 1 Update" : `There are ${updates.length} Updates`) : "Up to Date!"}
</Paragraph>
)}
{isOutdated && <Changes updates={updates} {...props} />}
</>
);
}
function Newer(props: CommonProps) {
return (
<>
<Paragraph className={Margins.bottom8}>
Your local copy has more recent commits. Please stash or reset them.
</Paragraph>
<Changes {...props} updates={changes} />
</>
);
}
function Updater() {
const settings = useSettings(["autoUpdate", "autoUpdateNotification"]);
@ -183,25 +53,35 @@ function Updater() {
};
return (
<SettingsTab title="Equicord Updater">
<Heading>Updater Settings</Heading>
<SettingsTab>
<Heading className={Margins.top16}>Update Preferences</Heading>
<Paragraph className={Margins.bottom20}>
Control how Equicord keeps itself up to date. You can choose to update automatically in the background or be notified when new updates are available.
</Paragraph>
<FormSwitch
title="Automatically update"
description="Automatically update Equicord without confirmation prompt"
description="When enabled, Equicord will automatically download and install updates in the background without asking for confirmation. You'll need to restart Discord to apply the changes."
value={settings.autoUpdate}
onChange={(v: boolean) => settings.autoUpdate = v}
hideBorder
/>
<FormSwitch
value={settings.autoUpdateNotification}
onChange={(v: boolean) => settings.autoUpdateNotification = v}
title="Get notified when an automatic update completes"
description="Shows a notification when Equicord automatically updates"
description="Receive a notification when Equicord finishes downloading an update in the background, so you know when to restart Discord."
disabled={!settings.autoUpdate}
hideBorder
/>
<Heading>Repo</Heading>
<Divider className={Margins.top20} />
<Paragraph>
<Heading className={Margins.top20}>Repository</Heading>
<Paragraph className={Margins.bottom8}>
This is the GitHub repository where Equicord fetches updates from.
</Paragraph>
<Paragraph color="text-subtle">
{repoPending
? repo
: err
@ -215,30 +95,14 @@ function Updater() {
{" "}(<HashLink hash={gitHash} repo={repo} disabled={repoPending} />)
</Paragraph>
<Divider className={Margins.top8 + " " + Margins.bottom8} />
<Heading>Updates</Heading>
<Divider className={Margins.top20} />
<Heading className={Margins.top20}>Updates</Heading>
{isNewer ? <Newer {...commonProps} /> : <Updatable {...commonProps} />}
</SettingsTab>
);
}
export default IS_UPDATER_DISABLED ? null : wrapTab(Updater, "Updater");
export const openUpdaterModal = IS_UPDATER_DISABLED ? null : function () {
const UpdaterTab = wrapTab(Updater, "Updater");
try {
openModal(wrapTab((modalProps: ModalProps) => (
<ModalRoot {...modalProps} size={ModalSize.MEDIUM}>
<ModalContent className="vc-updater-modal">
<ModalCloseButton onClick={modalProps.onClose} className="vc-updater-modal-close-button" />
<UpdaterTab />
</ModalContent>
</ModalRoot>
), "UpdaterModal"));
} catch {
handleSettingsTabError();
}
};
export default IS_UPDATER_DISABLED
? null
: wrapTab(Updater, "Updater");

View file

@ -4,22 +4,27 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { DonateButton } from "@components/settings";
import { Flex } from "@components/Flex";
import { DonateButton, InviteButton } from "@components/settings/DonateButton";
import BadgeAPI from "@plugins/_api/badges";
import { DONOR_ROLE_ID, VC_GUILD_ID } from "@utils/constants";
import { Button, GuildMemberStore } from "@webpack/common";
import BadgeAPI from "plugins/_api/badges";
import { GuildMemberStore } from "@webpack/common";
export const isDonor = (userId: string) => !!(
BadgeAPI.getDonorBadges(userId)?.length > 0
|| GuildMemberStore?.getMember(VC_GUILD_ID, userId)?.roles.includes(DONOR_ROLE_ID)
);
export function DonateButtonComponent() {
export function DonateButtonComponent({ donated = false }) {
return (
<Flex>
<DonateButton
look={Button.Looks.FILLED}
color={Button.Colors.WHITE}
style={{ marginTop: "1em" }}
/>
equicord={true}
className={!donated ? "vc-donate-support-button" : ""}
style={{ marginTop: "1em" }} />
<InviteButton
className={!donated ? "vc-invite-support-button" : ""}
style={{ marginTop: "1em" }} />
</Flex>
);
}

View file

@ -7,6 +7,7 @@
import { openNotificationLogModal } from "@api/Notifications/notificationLog";
import { useSettings } from "@api/Settings";
import { BaseText } from "@components/BaseText";
import { Button } from "@components/Button";
import { ErrorCard } from "@components/ErrorCard";
import { Flex } from "@components/Flex";
import { FormSwitch } from "@components/FormSwitch";
@ -15,7 +16,7 @@ import { Paragraph } from "@components/Paragraph";
import { Margins } from "@utils/margins";
import { identity } from "@utils/misc";
import { ModalCloseButton, ModalContent, ModalHeader, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { Button, Select, Slider } from "@webpack/common";
import { Select, Slider } from "@webpack/common";
export function NotificationSection() {
return (
@ -53,7 +54,7 @@ export function openNotificationSettingsModal() {
}
function NotificationSettings() {
const settings = useSettings().notifications;
const settings = useSettings(["notifications.*"]).notifications;
return (
<div style={{ padding: "1em 0" }}>

View file

@ -4,7 +4,7 @@
}
.vc-donatebutton {
background-color: var(--background-color) !important;
background-color: var(--background-base-low) !important;
border-color: var(--vc-donatebutton) !important;
filter: brightness(1);
border-width: .1rem !important;
@ -18,11 +18,11 @@
.vc-joindiscordbutton {
background-color: var(--button-secondary-background) !important;
background-color: var(--redesign-button-secondary-background) !important;
}
.vc-joindiscordbutton:hover {
background-color: var(--button-secondary-background-hover) !important;
background-color: var(--redesign-button-secondary-pressed-background) !important;
}
.vc-settings-card-buttons {
@ -47,3 +47,8 @@
text-align: center;
margin-top: 10px;
}
.vc-vencord-tab-settings-link {
color: var(--text-link);
display: inline-block;
}

View file

@ -8,23 +8,27 @@ import "./VencordTab.css";
import { openNotificationLogModal } from "@api/Notifications/notificationLog";
import { useSettings } from "@api/Settings";
import { classNameFactory } from "@api/Styles";
import { Alert } from "@components/Alert";
import { Button } from "@components/Button";
import { Divider } from "@components/Divider";
import { Flex } from "@components/Flex";
import { FormSwitch } from "@components/FormSwitch";
import { Heading } from "@components/Heading";
import { FolderIcon, GithubIcon, LogIcon, PaintbrushIcon, RestartIcon } from "@components/Icons";
import { Paragraph } from "@components/Paragraph";
import { openContributorModal, openPluginModal, SettingsTab, wrapTab } from "@components/settings";
import { DonateButton, InviteButton } from "@components/settings/DonateButton";
import { QuickAction, QuickActionCard } from "@components/settings/QuickAction";
import { SpecialCard } from "@components/settings/SpecialCard";
import { gitRemote } from "@shared/vencordUserAgent";
import { DONOR_ROLE_ID, GUILD_ID, 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 { Margins } from "@utils/margins";
import { identity, isAnyPluginDev } from "@utils/misc";
import { relaunch } from "@utils/native";
import { Button, Flex, GuildMemberStore, React, Select, UserStore } from "@webpack/common";
import { GuildMemberStore, React, Select, UserStore } from "@webpack/common";
import BadgeAPI from "plugins/_api/badges";
import { DonateButtonComponent } from "./DonateButton";
import { openNotificationSettingsModal } from "./NotificationSettings";
const DEFAULT_DONATE_IMAGE = "https://cdn.discordapp.com/emojis/1026533090627174460.png";
@ -36,6 +40,8 @@ const COZY_CONTRIB_IMAGE = "https://cdn.discordapp.com/emojis/102653307095587233
const DONOR_BACKGROUND_IMAGE = "https://media.discordapp.net/stickers/1311070116305436712.png?size=2048";
const CONTRIB_BACKGROUND_IMAGE = "https://media.discordapp.net/stickers/1311070166481895484.png?size=2048";
const cl = classNameFactory("vc-vencord-tab-");
type KeysOfType<Object, Type> = {
[K in keyof Object]: Object[K] extends Type ? K : never;
}[keyof Object];
@ -48,9 +54,7 @@ function EquicordSettings() {
[],
);
const isWindows = navigator.platform.toLowerCase().startsWith("win");
const isMac = navigator.platform.toLowerCase().startsWith("mac");
const needsVibrancySettings = IS_DISCORD_DESKTOP && isMac;
const needsVibrancySettings = IS_DISCORD_DESKTOP && IS_MAC;
const user = UserStore?.getCurrentUser();
@ -65,58 +69,64 @@ function EquicordSettings() {
{
key: "useQuickCss",
title: "Enable Custom CSS",
description: "Load custom CSS from the QuickCSS editor. This allows you to customize Discord's appearance with your own styles.",
restartRequired: true,
warning: { enabled: false },
},
!IS_WEB && {
key: "enableReactDevtools",
title: "Enable React Developer Tools",
description: "Enable the React Developer Tools extension for debugging Discord's React components. Useful for plugin development.",
restartRequired: true,
warning: { enabled: false },
},
!IS_WEB &&
(!IS_DISCORD_DESKTOP || !isWindows
(!IS_DISCORD_DESKTOP || !IS_WINDOWS
? {
key: "frameless",
title: "Disable the Window Frame",
description: "Remove the native window frame for a cleaner look. You can still move the window by dragging the title bar area.",
restartRequired: true,
warning: { enabled: false },
}
: {
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.",
restartRequired: true,
warning: { enabled: false },
}),
!IS_WEB && {
key: "transparent",
title: "Enable Window Transparency",
description: "A theme that supports transparency is required or this will do nothing. Stops the window from being resizable as a side effect",
description: "Make the Discord window transparent. A theme that supports transparency is required or this will do nothing.",
restartRequired: true,
warning: {
enabled: isWindows,
message: "Enabling this will prevent you from snapping this window.",
enabled: true,
message: IS_WINDOWS
? "This will stop the window from being resizable and prevents you from snapping the window to screen edges."
: "This will stop the window from being resizable.",
},
},
IS_DISCORD_DESKTOP && {
key: "disableMinSize",
title: "Disable Minimum Window Size",
description: "Allow the Discord window to be resized smaller than its default minimum size. Useful for tiling window managers or small screens.",
restartRequired: true,
warning: { enabled: false },
},
!IS_WEB &&
isWindows && {
IS_WINDOWS && {
key: "winCtrlQ",
title:
"Register Ctrl+Q as shortcut to close Discord (Alternative to Alt+F4)",
title: "Register Ctrl+Q as shortcut to close Discord",
description: "Add Ctrl+Q as a keyboard shortcut to close Discord. This provides an alternative to Alt+F4 for quickly closing the application.",
restartRequired: true,
warning: { enabled: false },
},
];
return (
<SettingsTab title="Equicord Settings">
<SettingsTab>
{(isEquicordDonor(user?.id) || isVencordDonor(user?.id)) ? (
<SpecialCard
title="Donations"
@ -132,7 +142,7 @@ function EquicordSettings() {
backgroundImage={DONOR_BACKGROUND_IMAGE}
backgroundColor="#ED87A9"
>
<DonateButtonComponent />
<DonateButtonComponent donated={true} />
</SpecialCard>
) : (
<SpecialCard
@ -153,13 +163,24 @@ function EquicordSettings() {
cardImage={COZY_CONTRIB_IMAGE}
backgroundImage={CONTRIB_BACKGROUND_IMAGE}
backgroundColor="#EDCC87"
buttonTitle="See what you've contributed to"
buttonOnClick={() => openContributorModal(user)}
/>
>
<Button
variant="none"
size="medium"
type="button"
onClick={() => openContributorModal(user)}
className="vc-contrib-button"
>
<GithubIcon aria-hidden fill={"#000000"} className={"vc-contrib-github"} />
See what you've contributed to
</Button>
</SpecialCard>
)}
<section>
<Heading>Quick Actions</Heading>
<Heading className={Margins.top16}>Quick Actions</Heading>
<Paragraph className={Margins.bottom16}>
Common actions you might want to perform. These shortcuts give you quick access to frequently used features without navigating through menus.
</Paragraph>
<QuickActionCard>
<QuickAction
@ -196,27 +217,26 @@ function EquicordSettings() {
}
/>
</QuickActionCard>
</section>
<Divider />
<Divider className={Margins.top20} />
<section className={Margins.top16}>
<Heading>Settings</Heading>
<Paragraph className={Margins.bottom20} style={{ color: "var(--text-muted)" }}>
Hint: You can change the position of this settings section in the{" "}
<Button
look={Button.Looks.LINK}
style={{ color: "var(--text-link)", display: "inline-block" }}
onClick={() => openPluginModal(Vencord.Plugins.plugins.Settings)}
>
settings of the Settings plugin
</Button>
!
<Heading className={Margins.top20}>Client Settings</Heading>
<Paragraph className={Margins.bottom16}>
Configure how Equicord behaves and integrates with Discord. These settings affect the Discord client's appearance and behavior.
</Paragraph>
<Alert.Info className={Margins.bottom20} style={{ width: "100%" }}>
You can customize where this settings section appears in Discord's settings menu by configuring the{" "}
<a
role="button"
onClick={() => openPluginModal(Vencord.Plugins.plugins.Settings)}
style={{ cursor: "pointer", color: "var(--text-link)" }}
>
Settings Plugin
</a>.
</Alert.Info>
{Switches.map(
s =>
s && (
{Switches.filter((s): s is Exclude<typeof s, false> => !!s).map(
s => (
<FormSwitch
key={s.key}
value={settings[s.key]}
@ -226,24 +246,27 @@ function EquicordSettings() {
s.warning.enabled ? (
<>
{s.description}
<div className="form-switch-warning">
<Alert.Warning className={Margins.top8} style={{ width: "100%" }}>
{s.warning.message}
</div>
</Alert.Warning>
</>
) : (
s.description
)
}
hideBorder
/>
),
)}
</section>
{needsVibrancySettings && (
<>
<Heading>
Window vibrancy style (requires restart)
</Heading>
<Divider className={Margins.top20} />
<Heading className={Margins.top20}>Window Vibrancy</Heading>
<Paragraph className={Margins.bottom16}>
Customize the macOS window vibrancy effect. This controls the blur and transparency style of the Discord window. Changes require a restart to take effect.
</Paragraph>
<Select
className={Margins.bottom20}
placeholder="Window vibrancy style"
@ -309,38 +332,25 @@ function EquicordSettings() {
</>
)}
<section
className={Margins.top16}
title="Equicord Notifications"
>
<Flex>
<Divider className={Margins.top20} />
<Heading className={Margins.top20}>Notifications</Heading>
<Paragraph className={Margins.bottom16}>
Configure how Equicord handles notifications. You can customize when and how you receive alerts, or view a history of past notifications.
</Paragraph>
<Flex gap="16px">
<Button onClick={openNotificationSettingsModal}>
Notification Settings
</Button>
<Button onClick={openNotificationLogModal} style={{ marginLeft: 16 }}>
<Button variant="secondary" onClick={openNotificationLogModal}>
View Notification Log
</Button>
</Flex>
</section>
</SettingsTab>
);
}
function DonateButtonComponent() {
return (
<Flex>
<DonateButton
look={Button.Looks.FILLED}
color={Button.Colors.TRANSPARENT}
style={{ marginTop: "1em" }} />
<InviteButton
look={Button.Looks.FILLED}
color={Button.Colors.TRANSPARENT}
style={{ marginTop: "1em" }} />
</Flex>
);
}
export default wrapTab(EquicordSettings, "Equicord Settings");
export function isEquicordDonor(userId: string): boolean {

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