Merge branch 'dev' into bun
This commit is contained in:
commit
f656dc2612
655 changed files with 16217 additions and 13754 deletions
3
.github/workflows/build.yml
vendored
3
.github/workflows/build.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
23
.github/workflows/publish.yml
vendored
23
.github/workflows/publish.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
14
README.md
14
README.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
|
|
|||
45
package.json
45
package.json
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
1
packages/discord-types/src/common/User.d.ts
vendored
1
packages/discord-types/src/common/User.d.ts
vendored
|
|
@ -62,4 +62,5 @@ export interface UserJSON {
|
|||
id: string;
|
||||
publicFlags: number;
|
||||
username: string;
|
||||
globalName: string | undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
8
packages/discord-types/src/components.d.ts
vendored
8
packages/discord-types/src/components.d.ts
vendored
|
|
@ -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",
|
||||
|
|
|
|||
4
packages/discord-types/src/menu.d.ts
vendored
4
packages/discord-types/src/menu.d.ts
vendored
|
|
@ -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,
|
||||
|
|
|
|||
26
packages/discord-types/src/stores/PresenceStore.d.ts
vendored
Normal file
26
packages/discord-types/src/stores/PresenceStore.d.ts
vendored
Normal 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;
|
||||
}
|
||||
11
packages/discord-types/src/stores/UploadAttachmentStore.d.ts
vendored
Normal file
11
packages/discord-types/src/stores/UploadAttachmentStore.d.ts
vendored
Normal 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;
|
||||
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
1
packages/discord-types/src/stores/index.d.ts
vendored
1
packages/discord-types/src/stores/index.d.ts
vendored
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@vencord/types",
|
||||
"private": false,
|
||||
"version": "1.13.3",
|
||||
"version": "1.13.8",
|
||||
"description": "",
|
||||
"types": "index.d.ts",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
})();
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
})();
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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 => ({
|
||||
|
|
|
|||
|
|
@ -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
204
src/api/HeaderBar.tsx
Normal 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" />);
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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()}).`
|
||||
224
src/api/SettingsSync/cloudSync.ts
Normal file
224
src/api/SettingsSync/cloudSync.ts
Normal 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)"
|
||||
});
|
||||
}
|
||||
174
src/api/SettingsSync/offline.ts
Normal file
174
src/api/SettingsSync/offline.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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
78
src/api/UserArea.tsx
Normal 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} />];
|
||||
}
|
||||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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
59
src/components/Alert.tsx
Normal 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
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
20
src/components/Card.css
Normal 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
31
src/components/Card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
14
src/components/Link.css
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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-");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
));
|
||||
}
|
||||
|
|
|
|||
42
src/components/settings/tabs/plugins/UIElements.css
Normal file
42
src/components/settings/tabs/plugins/UIElements.css
Normal 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;
|
||||
}
|
||||
}
|
||||
107
src/components/settings/tabs/plugins/UIElements.tsx
Normal file
107
src/components/settings/tabs/plugins/UIElements.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>— Custom QuickCSS</li>
|
||||
<li>— Theme Links</li>
|
||||
<li>— Plugin Settings</li>
|
||||
<li>— 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Reference in a new issue