Merge branch 'dev' into page

This commit is contained in:
thororen1234 2025-12-16 23:38:58 -05:00
commit 09cc428770
No known key found for this signature in database
982 changed files with 43396 additions and 21851 deletions

View file

@ -5,9 +5,9 @@ body:
- type: markdown
attributes:
value: |
![Are you a developer? No? This form is not for you!](https://github.com/Vendicated/Vencord/blob/main/.github/ISSUE_TEMPLATE/developer-banner.png?raw=true)
![Are you a developer? No? This form is not for you!](https://github.com/Equicord/Equicord/blob/main/.github/ISSUE_TEMPLATE/developer-banner.png?raw=true)
GitHub Issues are for development, not support! Please use our [support server](https://vencord.dev/discord) unless you are a Vencord Developer.
GitHub Issues are for development, not support! Please use our [support server](https://equicord.org/discord) unless you are a Vencord Developer.
- type: textarea
id: content

View file

@ -3,13 +3,15 @@ on:
push:
branches:
- main
env:
FORCE_COLOR: true
GITHUB_TOKEN: ${{ secrets.ETOKEN }}
REPO: Equicord/Equibored
USERNAME: GitHub-Actions
permissions: write-all
permissions:
contents: write
jobs:
Build:
@ -38,9 +40,10 @@ jobs:
- name: Generate plugin lists
run: |
pnpm generatePluginJson dist/plugins.json
pnpm generateEquicordPluginJson dist/equicordplugins.json
pnpm generateVencordPluginJson dist/vencordplugins.json
pnpm generatePluginJson dist/plugins.json
pnpm generateEquicordPluginJson dist/equicordplugins.json
pnpm generateVencordPluginJson dist/vencordplugins.json
pnpm generateDevsList dist/devs.json
- name: Collect files to be released
run: |
@ -48,7 +51,7 @@ jobs:
mkdir release
cp browser/browser.* release
cp Vencord.user.{js,js.LEGAL.txt} release
cp Equicord.user.{js,js.LEGAL.txt} release
# copy the plugin data jsons, the extension zips and the desktop/vesktop asars
cp *.{json,zip,asar} release
@ -65,7 +68,6 @@ jobs:
rm release/*.map
- name: Upload Equicord Stable
if: ${{ github.ref_name == 'main' }}
run: |
gh release upload latest --clobber dist/release/*
@ -76,7 +78,7 @@ jobs:
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"

View file

@ -7,7 +7,8 @@ env:
FORCE_COLOR: true
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
permissions: write-all
permissions:
contents: write
jobs:
Build:
@ -36,9 +37,10 @@ jobs:
- name: Generate plugin lists
run: |
pnpm generatePluginJson dist/plugins.json
pnpm generateEquicordPluginJson dist/equicordplugins.json
pnpm generateVencordPluginJson dist/vencordplugins.json
pnpm generatePluginJson dist/plugins.json
pnpm generateEquicordPluginJson dist/equicordplugins.json
pnpm generateVencordPluginJson dist/vencordplugins.json
pnpm generateDevsList dist/devs.json
- name: Collect files to be released
run: |
@ -46,7 +48,7 @@ jobs:
mkdir release
cp browser/browser.* release
cp Vencord.user.{js,js.LEGAL.txt} release
cp Equicord.user.{js,js.LEGAL.txt} release
# copy the plugin data jsons, the extension zips and the desktop/vesktop asars
cp *.{json,zip,asar} release

View file

@ -1,44 +1,98 @@
name: Release Browser Extension
on:
push:
tags:
- v*
branches:
- main
env:
FORCE_COLOR: true
permissions:
contents: write
jobs:
Publish:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: check that tag matches package.json version
- name: Get package.json version
id: pkg
run: echo "version=v$(jq -r .version < package.json)" >> $GITHUB_OUTPUT
- name: Get latest Git tag
id: tag
run: |
pkg_version="v$(jq -r .version < package.json)"
if [[ "${{ github.ref_name }}" != "$pkg_version" ]]; then
echo "Tag ${{ github.ref_name }} does not match package.json version $pkg_version" >&2
exit 1
latest_tag=$(git tag --list 'v*' --sort=-v:refname | head -n 1 || echo "v0.0.0")
echo "latest=$latest_tag" >> $GITHUB_OUTPUT
- name: Check if package.json version is newer
id: check
run: |
pkg="${{ steps.pkg.outputs.version }}"
latest="${{ steps.tag.outputs.latest }}"
echo "Latest tag: $latest"
echo "Package version: $pkg"
if [ "$pkg" != "$latest" ] && [ "$(sort -V <<<"$latest"$'\n'"$pkg" | tail -n1)" = "$pkg" ]; then
echo "release=true" >> $GITHUB_OUTPUT
else
echo "release=false" >> $GITHUB_OUTPUT
fi
- uses: pnpm/action-setup@v3
- name: Stop if not newer
if: steps.check.outputs.release == 'false'
run: |
echo "No new version to release (package.json ${{ steps.pkg.outputs.version }} <= latest tag ${{ steps.tag.outputs.latest }})"
exit 0
- name: Create Tag
if: steps.check.outputs.release == 'true'
uses: butlerlogic/action-autotag@1.1.2
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
with:
tag_prefix: "v"
- name: Setup PNPM
if: steps.check.outputs.release == 'true'
uses: pnpm/action-setup@v3
- name: Use Node.js 20
if: steps.check.outputs.release == 'true'
uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- name: Install dependencies
if: steps.check.outputs.release == 'true'
run: pnpm install --frozen-lockfile
- name: Build web
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
run: |
cd dist/browser/chromium-unpacked
pnpx chrome-webstore-upload-cli@2.1.0 upload --auto-publish
if: steps.check.outputs.release == 'true'
run: pnpx publish-browser-extension@latest --chrome-zip dist/extension-chrome.zip --firefox-zip dist/extension-firefox.zip --firefox-sources-zip dist/extension-sources.zip --edge-zip dist/extension-chrome.zip
env:
EXTENSION_ID: ${{ secrets.CHROME_EXTENSION_ID }}
CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }}
CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }}
REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }}
CHROME_EXTENSION_ID: ${{ secrets.CHROME_EXTENSION_ID }}
CHROME_CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }}
CHROME_CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }}
CHROME_REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }}
FIREFOX_EXTENSION_ID: ${{ secrets.FIREFOX_EXTENSION_ID }}
FIREFOX_JWT_ISSUER: ${{ secrets.FIREFOX_JWT_ISSUER }}
FIREFOX_JWT_SECRET: ${{ secrets.FIREFOX_JWT_SECRET }}
EDGE_PRODUCT_ID: ${{ secrets.EDGE_PRODUCT_ID }}
EDGE_CLIENT_ID: ${{ secrets.EDGE_CLIENT_ID }}
EDGE_API_KEY: ${{ secrets.EDGE_API_KEY }}

View file

@ -20,9 +20,6 @@ on:
env:
FORCE_COLOR: true
GITHUB_TOKEN: ${{ secrets.ETOKEN }}
REPO: Equicord/Equibored
USERNAME: GitHub-Actions
jobs:
TestPlugins:
@ -95,27 +92,6 @@ jobs:
done
cat "$stable_output_file" "$canary_output_file" >> $GITHUB_STEP_SUMMARY
git config --global user.name "GitHub-Actions"
git config --global user.email actions@github.com
git clone https://$USERNAME:$GITHUB_TOKEN@github.com/$REPO.git report
cd report
cp ../dist/reporter*.json .
git add -A
if git diff --cached --quiet; then
echo "Nothing to commit"
if [[ $exit_code -eq 0 ]]; then
exit 0
else
exit 1
fi
else
git commit -m "Reporter Results"
git push --force https://$USERNAME:$GITHUB_TOKEN@github.com/$REPO.git
fi
exit $exit_code
env:
WEBHOOK_URL: ${{ inputs.webhook_url || secrets.WEBHOOK_URL }}

View file

@ -9,8 +9,6 @@ on:
- main
- dev
permissions: write-all
jobs:
Test:
name: Test
@ -37,9 +35,3 @@ jobs:
- name: Test if plugin structure is valid
run: pnpm generatePluginJson
- name: Create Tag
if: ${{ github.ref_name == 'main' }}
uses: butlerlogic/action-autotag@1.1.2
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"

3
.gitignore vendored
View file

@ -29,3 +29,6 @@ ExtensionCache/
# vencord companion module cache
.modules
# wakatime related stuff
.wakatime-project

View file

@ -4,6 +4,6 @@
"EditorConfig.EditorConfig",
"GregorBiswanger.json2ts",
"stylelint.vscode-stylelint",
"thororen.equicord-companion"
"sadan.vencord-user-companion"
]
}

View file

@ -1,6 +1,6 @@
# Contributing to Vencord
# Contributing to Equicord
Vencord is a community project and welcomes any kind of contribution from anyone!
Equicord is a community project and welcomes any kind of contribution from anyone!
We have development documentation for new contributors, which can be found at <https://docs.equicord.org>.
@ -20,8 +20,8 @@ Before starting your plugin:
- Be in our discord
- Check existing pull requests to see if someone is already working on a similar plugin
- Check our [plugin requests tracker](https://discord.com/channels/1173279886065029291/1173334591302553631) to see if there is an existing request, or if the same idea has been rejected
- If there isn't an existing request, [open one](https://discord.com/channels/1173279886065029291/1173334591302553631) yourself.
- Check our [plugin requests tracker](https://discord.com/channels/1173279886065029291/1419347113745059961) to see if there is an existing request, or if the same idea has been rejected
- If there isn't an existing request, [open one](https://discord.com/channels/1173279886065029291/1419347113745059961) yourself.
and include that you'd like to work on this yourself. Then wait for feedback to see if the idea even has any chance of being accepted. Or maybe others have some ideas to improve it!
- Familarise yourself with our plugin rules below to ensure your plugin is not banned

View file

@ -6,11 +6,11 @@
Equicord is a fork of [Vencord](https://github.com/Vendicated/Vencord), with over 300+ plugins.
You can join our [discord server](https://equicord.org/discord) for commits, changes, chat or even support.
You can join our [Discord server](https://equicord.org/discord) for commits, changes, chatting, or even support.
### Included Plugins
Our included plugins can be found [here](https://equicord.org/plugins)
Our included plugins can be found [here](https://equicord.org/plugins).
## Installing / Uninstalling
@ -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)
@ -68,12 +68,22 @@ Build Equicord:
pnpm build
```
Inject Equicord into your client:
Inject Equicord into your desktop client:
```shell
pnpm 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.
@ -90,7 +100,7 @@ Thank you to [Vendicated](https://github.com/Vendicated) for creating [Vencord](
## Disclaimer
Discord is trademark of Discord Inc. and solely mentioned for the sake of descriptivity.
Discord is trademark of Discord Inc., and solely mentioned for the sake of descriptivity.
Mentioning it does not imply any affiliation with or endorsement by Discord Inc.
Vencord is not connected to Equicord and as such, all donation links go to Vendicated's donation link.
@ -101,8 +111,8 @@ Client modifications are against Discord’s Terms of Service.
However, Discord is pretty indifferent about them and there are no known cases of users getting banned for using client mods! So you should generally be fine if you don’t use plugins that implement abusive behaviour. But no worries, all inbuilt plugins are safe to use!
Regardless, if your account is essential to you and getting disabled would be a disaster for you, you should probably not use any client mods (not exclusive to Equicord), just to be safe
Regardless, if your account is essential to you and getting disabled would be a disaster for you, you should probably not use any client mods (not exclusive to Equicord), just to be safe.
Additionally, make sure not to post screenshots with Equicord in a server where you might get banned for it
Additionally, make sure not to post screenshots with Equicord in a server where you might get banned for it.
</details>

View file

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

View file

@ -5,12 +5,12 @@ if (typeof browser === "undefined") {
const style = document.createElement("link");
style.type = "text/css";
style.rel = "stylesheet";
style.href = browser.runtime.getURL("dist/Vencord.css");
style.href = browser.runtime.getURL("dist/Equicord.css");
document.addEventListener(
"DOMContentLoaded",
() => {
document.documentElement.append(style);
document.body.insertAdjacentElement("afterend", style);
window.postMessage({
type: "vencord:meta",
meta: {

View file

@ -3,7 +3,7 @@
"minimum_chrome_version": "111",
"name": "Equicord Web",
"description": "The other cutest Discord mod now in your browser",
"author": "Thororen",
"author": "Equicord",
"homepage_url": "https://github.com/Equicord/Equicord",
"icons": {
"128": "icon.png"
@ -33,7 +33,7 @@
"*://*.discord.com/*"
],
"js": [
"dist/Vencord.js"
"dist/Equicord.js"
],
"all_frames": true,
"world": "MAIN"

View file

@ -3,7 +3,7 @@
"minimum_chrome_version": "91",
"name": "Equicord Web",
"description": "The other cutest Discord mod now in your browser",
"author": "Thororen",
"author": "Equicord",
"homepage_url": "https://github.com/Equicord/Equicord",
"icons": {
"128": "icon.png"
@ -32,7 +32,7 @@
"*://*.discord.com/*"
],
"js": [
"dist/Vencord.js"
"dist/Equicord.js"
],
"all_frames": true,
"world": "MAIN"
@ -44,13 +44,22 @@
]
},
"web_accessible_resources": [
"dist/Vencord.js",
"dist/Vencord.css"
"dist/Equicord.js",
"dist/Equicord.css"
],
"browser_specific_settings": {
"gecko": {
"id": "vencord-firefox@vendicated.dev",
"strict_min_version": "128.0"
"id": "firefox@equicord.org",
"strict_min_version": "128.0",
"data_collection_permissions": {
"required": [
"none"
],
"optional": [
"authenticationInfo",
"locationInfo"
]
}
}
}
}

View file

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

View file

@ -1,7 +1,7 @@
{
"name": "equicord",
"private": "true",
"version": "1.12.9",
"version": "1.13.9.1",
"description": "The other cutest Discord client mod",
"homepage": "https://github.com/Equicord/Equicord#readme",
"bugs": {
@ -23,12 +23,14 @@
"watch": "pnpm build --watch",
"dev": "pnpm watch",
"watchWeb": "pnpm buildWeb --watch",
"generateDevsList": "tsx scripts/generateDevsList.ts",
"generatePluginJson": "tsx scripts/generatePluginList.ts",
"generateEquicordPluginJson": "tsx scripts/generateEquicordPluginList.ts",
"generateVencordPluginJson": "tsx scripts/generateVencordPluginList.ts",
"generateEquicordPluginJson": "tsx scripts/generatePluginList.ts --equicord",
"generateVencordPluginJson": "tsx scripts/generatePluginList.ts --vencord",
"generateTypes": "tspc --emitDeclarationOnly --declaration --outDir packages/vencord-types --allowJs false",
"inject": "node scripts/runInstaller.mjs -- --install",
"uninject": "node scripts/runInstaller.mjs -- --uninstall",
"repair": "node scripts/runInstaller.mjs -- --repair",
"lint": "eslint",
"lint-styles": "stylelint \"src/**/*.css\" --ignore-pattern src/userplugins",
"lint:fix": "pnpm lint --fix",
@ -40,7 +42,7 @@
"@ffmpeg/ffmpeg": "^0.12.10",
"@ffmpeg/util": "^0.12.1",
"@intrnl/xxhash64": "^0.1.2",
"@types/less": "^3.0.6",
"@types/less": "^3.0.8",
"@types/stylus": "^0.48.42",
"@types/tinycolor2": "^1.4.6",
"@vap/core": "0.0.12",
@ -49,51 +51,52 @@
"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": "^3.2.10",
"@stylistic/eslint-plugin": "^4.2.0",
"@types/chrome": "^0.0.312",
"@electron/asar": "^4.0.1",
"@stylistic/eslint-plugin": "^5.6.0",
"@types/adm-zip": "^0.5.7",
"@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:packages/discord-types",
"diff": "^7.0.0",
"esbuild": "^0.25.1",
"eslint": "9.20.1",
"adm-zip": "^0.5.16",
"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.4.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",
"tsx": "^4.19.3",
"type-fest": "^4.38.0",
"typescript": "^5.8.2",
"typescript-eslint": "^8.28.0",
"tsx": "^4.20.6",
"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": "pnpm@10.14.0",
"packageManager": "pnpm@10.22.0",
"pnpm": {
"patchedDependencies": {
"eslint@9.20.1": "patches/eslint@9.20.1.patch",
"@types/less@3.0.6": "patches/@types__less@3.0.6.patch"
"@types/less@3.0.8": "patches/@types__less@3.0.8.patch"
},
"peerDependencyRules": {
"ignoreMissing": [
@ -110,7 +113,10 @@
},
"onlyBuiltDependencies": [
"esbuild"
]
],
"overrides": {
"@eslint/plugin-kit@<0.3.4": ">=0.3.4"
}
},
"engines": {
"node": ">=18"

View file

@ -0,0 +1 @@
node_modules

View file

@ -0,0 +1 @@
Hint: https://docs.discord.food is an incredible resource and allows you to copy paste complete enums and interfaces

View file

@ -0,0 +1,30 @@
export const enum ActivityType {
PLAYING = 0,
STREAMING = 1,
LISTENING = 2,
WATCHING = 3,
CUSTOM_STATUS = 4,
COMPETING = 5,
HANG_STATUS = 6
}
export const enum ActivityFlags {
INSTANCE = 1 << 0,
JOIN = 1 << 1,
/** @deprecated */
SPECTATE = 1 << 2,
/** @deprecated */
JOIN_REQUEST = 1 << 3,
SYNC = 1 << 4,
PLAY = 1 << 5,
PARTY_PRIVACY_FRIENDS = 1 << 6,
PARTY_PRIVACY_VOICE_CHANNEL = 1 << 7,
EMBEDDED = 1 << 8,
CONTEXTLESS = 1 << 9
}
export const enum ActivityStatusDisplayType {
NAME = 0,
STATE = 1,
DETAILS = 2
}

View file

@ -0,0 +1,15 @@
export const enum ChannelType {
GUILD_TEXT = 0,
DM = 1,
GUILD_VOICE = 2,
GROUP_DM = 3,
GUILD_CATEGORY = 4,
GUILD_ANNOUNCEMENT = 5,
ANNOUNCEMENT_THREAD = 10,
PUBLIC_THREAD = 11,
PRIVATE_THREAD = 12,
GUILD_STAGE_VOICE = 13,
GUILD_DIRECTORY = 14,
GUILD_FORUM = 15,
GUILD_MEDIA = 16
}

View file

@ -1,2 +1,5 @@
export * from "./activity";
export * from "./channel";
export * from "./commands";
export * from "./messages";
export * from "./misc";

View file

@ -11,3 +11,586 @@ export const enum StickerFormatType {
LOTTIE = 3,
GIF = 4
}
export const enum MessageType {
/**
* A default message (see below)
*
* Value: 0
* Name: DEFAULT
* Rendered Content: "{content}"
* Deletable: true
*/
DEFAULT = 0,
/**
* A message sent when a user is added to a group DM or thread
*
* Value: 1
* Name: RECIPIENT_ADD
* Rendered Content: "{author} added {mentions [0] } to the {group/thread}."
* Deletable: false
*/
RECIPIENT_ADD = 1,
/**
* A message sent when a user is removed from a group DM or thread
*
* Value: 2
* Name: RECIPIENT_REMOVE
* Rendered Content: "{author} removed {mentions [0] } from the {group/thread}."
* Deletable: false
*/
RECIPIENT_REMOVE = 2,
/**
* A message sent when a user creates a call in a private channel
*
* Value: 3
* Name: CALL
* Rendered Content: participated ? "{author} started a call{ended ? " that lasted {duration}" : " — Join the call"}." : "You missed a call from {author} that lasted {duration}."
* Deletable: false
*/
CALL = 3,
/**
* A message sent when a group DM or thread's name is changed
*
* Value: 4
* Name: CHANNEL_NAME_CHANGE
* Rendered Content: "{author} changed the {is_forum ? "post title" : "channel name"}: {content} "
* Deletable: false
*/
CHANNEL_NAME_CHANGE = 4,
/**
* A message sent when a group DM's icon is changed
*
* Value: 5
* Name: CHANNEL_ICON_CHANGE
* Rendered Content: "{author} changed the channel icon."
* Deletable: false
*/
CHANNEL_ICON_CHANGE = 5,
/**
* A message sent when a message is pinned in a channel
*
* Value: 6
* Name: CHANNEL_PINNED_MESSAGE
* Rendered Content: "{author} pinned a message to this channel."
* Deletable: true
*/
CHANNEL_PINNED_MESSAGE = 6,
/**
* A message sent when a user joins a guild
*
* Value: 7
* Name: USER_JOIN
* Rendered Content: See user join message type , obtained via the formula timestamp_ms % 13
* Deletable: true
*/
USER_JOIN = 7,
/**
* A message sent when a user subscribes to (boosts) a guild
*
* Value: 8
* Name: PREMIUM_GUILD_SUBSCRIPTION
* Rendered Content: "{author} just boosted the server{content ? " {content} times"}!"
* Deletable: true
*/
PREMIUM_GUILD_SUBSCRIPTION = 8,
/**
* A message sent when a user subscribes to (boosts) a guild to tier 1
*
* Value: 9
* Name: PREMIUM_GUILD_SUBSCRIPTION_TIER_1
* Rendered Content: "{author} just boosted the server{content ? " {content} times"}! {guild} has achieved Level 1! "
* Deletable: true
*/
PREMIUM_GUILD_SUBSCRIPTION_TIER_1 = 9,
/**
* A message sent when a user subscribes to (boosts) a guild to tier 2
*
* Value: 10
* Name: PREMIUM_GUILD_SUBSCRIPTION_TIER_2
* Rendered Content: "{author} just boosted the server{content ? " {content} times"}! {guild} has achieved Level 2! "
* Deletable: true
*/
PREMIUM_GUILD_SUBSCRIPTION_TIER_2 = 10,
/**
* A message sent when a user subscribes to (boosts) a guild to tier 3
*
* Value: 11
* Name: PREMIUM_GUILD_SUBSCRIPTION_TIER_3
* Rendered Content: "{author} just boosted the server{content ? " {content} times"}! {guild} has achieved Level 3! "
* Deletable: true
*/
PREMIUM_GUILD_SUBSCRIPTION_TIER_3 = 11,
/**
* A message sent when a news channel is followed
*
* Value: 12
* Name: CHANNEL_FOLLOW_ADD
* Rendered Content: "{author} has added {content} to this channel. Its most important updates will show up here."
* Deletable: true
*/
CHANNEL_FOLLOW_ADD = 12,
/**
* A message sent when a guild is disqualified from discovery
*
* Value: 14
* Name: GUILD_DISCOVERY_DISQUALIFIED
* Rendered Content: "This server has been removed from Server Discovery because it no longer passes all the requirements. Check Server Settings for more details."
* Deletable: true
*/
GUILD_DISCOVERY_DISQUALIFIED = 14,
/**
* A message sent when a guild requalifies for discovery
*
* Value: 15
* Name: GUILD_DISCOVERY_REQUALIFIED
* Rendered Content: "This server is eligible for Server Discovery again and has been automatically relisted!"
* Deletable: true
*/
GUILD_DISCOVERY_REQUALIFIED = 15,
/**
* A message sent when a guild has failed discovery requirements for a week
*
* Value: 16
* Name: GUILD_DISCOVERY_GRACE_PERIOD_INITIAL_WARNING
* Rendered Content: "This server has failed Discovery activity requirements for 1 week. If this server fails for 4 weeks in a row, it will be automatically removed from Discovery."
* Deletable: true
*/
GUILD_DISCOVERY_GRACE_PERIOD_INITIAL_WARNING = 16,
/**
* A message sent when a guild has failed discovery requirements for 3 weeks
*
* Value: 17
* Name: GUILD_DISCOVERY_GRACE_PERIOD_FINAL_WARNING
* Rendered Content: "This server has failed Discovery activity requirements for 3 weeks in a row. If this server fails for 1 more week, it will be removed from Discovery."
* Deletable: true
*/
GUILD_DISCOVERY_GRACE_PERIOD_FINAL_WARNING = 17,
/**
* A message sent when a thread is created
*
* Value: 18
* Name: THREAD_CREATED
* Rendered Content: "{author} started a thread: {content} . See all threads."
* Deletable: true
*/
THREAD_CREATED = 18,
/**
* A message sent when a user replies to a message
*
* Value: 19
* Name: REPLY
* Rendered Content: "{content}"
* Deletable: true
*/
REPLY = 19,
/**
* A message sent when a user uses a slash command
*
* Value: 20
* Name: CHAT_INPUT_COMMAND
* Rendered Content: "{content}"
* Deletable: true
*/
CHAT_INPUT_COMMAND = 20,
/**
* A message sent when a thread starter message is added to a thread
*
* Value: 21
* Name: THREAD_STARTER_MESSAGE
* Rendered Content: "{referenced_message?.content}" ?? "Sorry, we couldn't load the first message in this thread"
* Deletable: false
*/
THREAD_STARTER_MESSAGE = 21,
/**
* A message sent to remind users to invite friends to a guild
*
* Value: 22
* Name: GUILD_INVITE_REMINDER
* Rendered Content: "Wondering who to invite?\nStart by inviting anyone who can help you build the server!"
* Deletable: true
*/
GUILD_INVITE_REMINDER = 22,
/**
* A message sent when a user uses a context menu command
*
* Value: 23
* Name: CONTEXT_MENU_COMMAND
* Rendered Content: "{content}"
* Deletable: true
*/
CONTEXT_MENU_COMMAND = 23,
/**
* A message sent when auto moderation takes an action
*
* Value: 24
* Name: AUTO_MODERATION_ACTION
* Rendered Content: Special embed rendered from embeds[0]
* Deletable: true 1
*/
AUTO_MODERATION_ACTION = 24,
/**
* A message sent when a user purchases or renews a role subscription
*
* Value: 25
* Name: ROLE_SUBSCRIPTION_PURCHASE
* Rendered Content: "{author} {is_renewal ? "renewed" : "joined"} {role_subscription.tier_name} and has been a subscriber of {guild} for {role_subscription.total_months_subscribed} month(?s)!"
* Deletable: true
*/
ROLE_SUBSCRIPTION_PURCHASE = 25,
/**
* A message sent when a user is upsold to a premium interaction
*
* Value: 26
* Name: INTERACTION_PREMIUM_UPSELL
* Rendered Content: "{content}"
* Deletable: true
*/
INTERACTION_PREMIUM_UPSELL = 26,
/**
* A message sent when a stage channel starts
*
* Value: 27
* Name: STAGE_START
* Rendered Content: "{author} started {content} "
* Deletable: true
*/
STAGE_START = 27,
/**
* A message sent when a stage channel ends
*
* Value: 28
* Name: STAGE_END
* Rendered Content: "{author} ended {content} "
* Deletable: true
*/
STAGE_END = 28,
/**
* A message sent when a user starts speaking in a stage channel
*
* Value: 29
* Name: STAGE_SPEAKER
* Rendered Content: "{author} is now a speaker."
* Deletable: true
*/
STAGE_SPEAKER = 29,
/**
* A message sent when a user raises their hand in a stage channel
*
* Value: 30
* Name: STAGE_RAISE_HAND
* Rendered Content: "{author} requested to speak."
* Deletable: true
*/
STAGE_RAISE_HAND = 30,
/**
* A message sent when a stage channel's topic is changed
*
* Value: 31
* Name: STAGE_TOPIC
* Rendered Content: "{author} changed the Stage topic: {content} "
* Deletable: true
*/
STAGE_TOPIC = 31,
/**
* A message sent when a user purchases an application premium subscription
*
* Value: 32
* Name: GUILD_APPLICATION_PREMIUM_SUBSCRIPTION
* Rendered Content: "{author} upgraded {application ?? "a deleted application"} to premium for this server!"
* Deletable: true
*/
GUILD_APPLICATION_PREMIUM_SUBSCRIPTION = 32,
/**
* A message sent when a user gifts a premium (Nitro) referral
*
* Value: 35
* Name: PREMIUM_REFERRAL
* Rendered Content: "{content}"
* Deletable: true
*/
PREMIUM_REFERRAL = 35,
/**
* A message sent when a user enabled lockdown for the guild
*
* Value: 36
* Name: GUILD_INCIDENT_ALERT_MODE_ENABLED
* Rendered Content: "{author} enabled security actions until {content}."
* Deletable: true
*/
GUILD_INCIDENT_ALERT_MODE_ENABLED = 36,
/**
* A message sent when a user disables lockdown for the guild
*
* Value: 37
* Name: GUILD_INCIDENT_ALERT_MODE_DISABLED
* Rendered Content: "{author} disabled security actions."
* Deletable: true
*/
GUILD_INCIDENT_ALERT_MODE_DISABLED = 37,
/**
* A message sent when a user reports a raid for the guild
*
* Value: 38
* Name: GUILD_INCIDENT_REPORT_RAID
* Rendered Content: "{author} reported a raid in {guild}."
* Deletable: true
*/
GUILD_INCIDENT_REPORT_RAID = 38,
/**
* A message sent when a user reports a false alarm for the guild
*
* Value: 39
* Name: GUILD_INCIDENT_REPORT_FALSE_ALARM
* Rendered Content: "{author} reported a false alarm in {guild}."
* Deletable: true
*/
GUILD_INCIDENT_REPORT_FALSE_ALARM = 39,
/**
* A message sent when no one sends a message in the current channel for 1 hour
*
* Value: 40
* Name: GUILD_DEADCHAT_REVIVE_PROMPT
* Rendered Content: "{content}"
* Deletable: true
*/
GUILD_DEADCHAT_REVIVE_PROMPT = 40,
/**
* A message sent when a user buys another user a gift
*
* Value: 41
* Name: CUSTOM_GIFT
* Rendered Content: Special embed rendered from embeds[0].url and gift_info
* Deletable: true
*/
CUSTOM_GIFT = 41,
/**
* Value: 42
* Name: GUILD_GAMING_STATS_PROMPT
* Rendered Content: "{content}"
* Deletable: true
*/
GUILD_GAMING_STATS_PROMPT = 42,
/**
* A message sent when a user purchases a guild product
*
* Value: 44
* Name: PURCHASE_NOTIFICATION
* Rendered Content: "{author} has purchased {purchase_notification.guild_product_purchase.product_name}!"
* Deletable: true
*/
PURCHASE_NOTIFICATION = 44,
/**
* A message sent when a poll is finalized
*
* Value: 46
* Name: POLL_RESULT
* Rendered Content: Special embed rendered from embeds[0]
* Deletable: true
*/
POLL_RESULT = 46,
/**
* A message sent by the Discord Updates account when a new changelog is posted
*
* Value: 47
* Name: CHANGELOG
* Rendered Content: "{content}"
* Deletable: true
*/
CHANGELOG = 47,
/**
* A message sent when a Nitro promotion is triggered
*
* Value: 48
* Name: NITRO_NOTIFICATION
* Rendered Content: Special embed rendered from content
* Deletable: true
*/
NITRO_NOTIFICATION = 48,
/**
* A message sent when a voice channel is linked to a lobby
*
* Value: 49
* Name: CHANNEL_LINKED_TO_LOBBY
* Rendered Content: "{content}"
* Deletable: true
*/
CHANNEL_LINKED_TO_LOBBY = 49,
/**
* A local-only ephemeral message sent when a user is prompted to gift Nitro to a friend on their friendship anniversary
*
* Value: 50
* Name: GIFTING_PROMPT
* Rendered Content: Special embed
* Deletable: true
*/
GIFTING_PROMPT = 50,
/**
* A local-only message sent when a user receives an in-game message NUX
*
* Value: 51
* Name: IN_GAME_MESSAGE_NUX
* Rendered Content: "{author} messaged you from {application.name}. In-game chat may not include rich messaging features such as images, polls, or apps. Learn More "
* Deletable: true
*/
IN_GAME_MESSAGE_NUX = 51,
/**
* A message sent when a user accepts a guild join request
*
* Value: 52
* Name: GUILD_JOIN_REQUEST_ACCEPT_NOTIFICATION 2
* Rendered Content: "{join_request.user}'s application to {content} was approved! Welcome!"
* Deletable: true
*/
GUILD_JOIN_REQUEST_ACCEPT_NOTIFICATION = 52,
/**
* A message sent when a user rejects a guild join request
*
* Value: 53
* Name: GUILD_JOIN_REQUEST_REJECT_NOTIFICATION 2
* Rendered Content: "{join_request.user}'s application to {content} was rejected."
* Deletable: true
*/
GUILD_JOIN_REQUEST_REJECT_NOTIFICATION = 53,
/**
* A message sent when a user withdraws a guild join request
*
* Value: 54
* Name: GUILD_JOIN_REQUEST_WITHDRAWN_NOTIFICATION 2
* Rendered Content: "{join_request.user}'s application to {content} has been withdrawn."
* Deletable: true
*/
GUILD_JOIN_REQUEST_WITHDRAWN_NOTIFICATION = 54,
/**
* A message sent when a user upgrades to HD streaming
*
* Value: 55
* Name: HD_STREAMING_UPGRADED
* Rendered Content: "{author} activated HD Splash Potion "
* Deletable: true
*/
HD_STREAMING_UPGRADED = 55,
/**
* A message sent when a user resolves a moderation report by deleting the offending message
*
* Value: 58
* Name: REPORT_TO_MOD_DELETED_MESSAGE
* Rendered Content: "{author} deleted the message"
* Deletable: true
*/
REPORT_TO_MOD_DELETED_MESSAGE = 58,
/**
* A message sent when a user resolves a moderation report by timing out the offending user
*
* Value: 59
* Name: REPORT_TO_MOD_TIMEOUT_USER
* Rendered Content: "{author} timed out {mentions [0] }"
* Deletable: true
*/
REPORT_TO_MOD_TIMEOUT_USER = 59,
/**
* A message sent when a user resolves a moderation report by kicking the offending user
*
* Value: 60
* Name: REPORT_TO_MOD_KICK_USER
* Rendered Content: "{author} kicked {mentions [0] }"
* Deletable: true
*/
REPORT_TO_MOD_KICK_USER = 60,
/**
* A message sent when a user resolves a moderation report by banning the offending user
*
* Value: 61
* Name: REPORT_TO_MOD_BAN_USER
* Rendered Content: "{author} banned {mentions [0] }"
* Deletable: true
*/
REPORT_TO_MOD_BAN_USER = 61,
/**
* A message sent when a user resolves a moderation report
*
* Value: 62
* Name: REPORT_TO_MOD_CLOSED_REPORT
* Rendered Content: "{author} resolved this flag"
* Deletable: true
*/
REPORT_TO_MOD_CLOSED_REPORT = 62,
/**
* A message sent when a user adds a new emoji to a guild
*
* Value: 63
* Name: EMOJI_ADDED
* Rendered Content: "{author} added a new emoji, {content} :{emoji.name}: "
* Deletable: true
*/
EMOJI_ADDED = 63,
}
export const enum MessageFlags {
/**
* Message has been published to subscribed channels (via Channel Following)
*
* Value: 1 << 0
*/
CROSSPOSTED = 1 << 0,
/**
* Message originated from a message in another channel (via Channel Following)
*/
IS_CROSSPOST = 1 << 1,
/**
* Embeds will not be included when serializing this message
*/
SUPPRESS_EMBEDS = 1 << 2,
/**
* Source message for this crosspost has been deleted (via Channel Following)
*/
SOURCE_MESSAGE_DELETED = 1 << 3,
/**
* Message came from the urgent message system
*/
URGENT = 1 << 4,
/**
* Message has an associated thread, with the same ID as the message
*/
HAS_THREAD = 1 << 5,
/**
* Message is only visible to the user who invoked the interaction
*/
EPHEMERAL = 1 << 6,
/**
* Message is an interaction response and the bot is "thinking"
*/
LOADING = 1 << 7,
/**
* Some roles were not mentioned and added to the thread
*/
FAILED_TO_MENTION_SOME_ROLES_IN_THREAD = 1 << 8,
/**
* Message is hidden from the guild's feed
*/
GUILD_FEED_HIDDEN = 1 << 9,
/**
* Message contains a link that impersonates Discord
*/
SHOULD_SHOW_LINK_NOT_DISCORD_WARNING = 1 << 10,
/**
* Message will not trigger push and desktop notifications
*/
SUPPRESS_NOTIFICATIONS = 1 << 12,
/**
* Message's audio attachment is rendered as a voice message
*/
IS_VOICE_MESSAGE = 1 << 13,
/**
* Message has a forwarded message snapshot attached
*/
HAS_SNAPSHOT = 1 << 14,
/**
* Message contains components from version 2 of the UI kit
*/
IS_COMPONENTS_V2 = 1 << 15,
/**
* Message was triggered by the social layer integration
*/
SENT_BY_SOCIAL_LAYER_INTEGRATION = 1 << 16,
}

View file

@ -0,0 +1,4 @@
export const enum CloudUploadPlatform {
REACT_NATIVE = 0,
WEB = 1,
}

View file

@ -1,6 +1,7 @@
{
"name": "@vencord/discord-types",
"author": "Vencord Contributors",
"private": false,
"description": "Typescript definitions for the webpack modules of the Discord Web app",
"version": "1.0.0",
"license": "LGPL-3.0-or-later",
@ -12,8 +13,10 @@
"directory": "packages/discord-types"
},
"dependencies": {
"@types/react": "^19.0.10",
"moment": "^2.22.2",
"type-fest": "^4.41.0"
},
"peerDependencies": {
"@types/react": "^19.0.10"
}
}

View file

@ -1,45 +1,51 @@
import { CSSProperties, ImgHTMLAttributes, JSX } from "react";
import { Application } from "./Application";
import { User } from "./User";
import { ActivityFlags, ActivityStatusDisplayType, ActivityType } from "../../enums";
export interface ActivityTimestamp {
start?: number;
end?: number;
export interface ActivityAssets {
large_image?: string;
large_text?: string;
large_url?: string;
small_image?: string;
small_text?: string;
small_url?: string;
}
export interface ActivityButton {
label: string;
url: string;
}
export interface Activity {
created_at: number;
id: string;
created_at?: number;
id?: string;
name: string;
type: number;
application_id: string;
type: ActivityType;
emoji?: {
animated: boolean;
id: string;
name: string;
};
state?: string;
flags?: number;
sync_id?: string;
state_url?: string;
details?: string;
application_id?: string;
assets?: {
large_text?: string;
large_image?: string;
small_text?: string;
small_image?: string;
details_url?: string;
url?: string;
flags: ActivityFlags;
status_display_type?: ActivityStatusDisplayType;
timestamps?: {
start?: number;
end?: number;
};
buttons?: Array<string>;
assets?: ActivityAssets;
buttons?: string[];
metadata?: {
button_urls?: Array<string>;
};
timestamps?: ActivityTimestamp;
platform?: string;
party?: {
id?: string;
size?: [number, number];
};
}
export const enum ActivityType {
PLAYING = 0,
STREAMING = 1,
LISTENING = 2,
WATCHING = 3,
COMPETING = 5
}
export type OnlineStatus = "online" | "idle" | "dnd" | "invisible" | "offline" | "unknown" | "streaming";

View file

@ -71,6 +71,7 @@ export class Channel extends DiscordRecord {
isGroupDM(): boolean;
isGuildStageVoice(): boolean;
isGuildVoice(): boolean;
isGuildVocal(): boolean;
isListenModeCapable(): boolean;
isManaged(): boolean;
isMultiUserDM(): boolean;

View file

@ -47,7 +47,7 @@ export class Guild extends DiscordRecord {
};
premiumProgressBarEnabled: boolean;
premiumSubscriberCount: number;
premiumTier: number;
premiumTier: 0 | 1 | 2 | 3;
profile: {
badge: string | undefined;
tag: string | undefined;

View file

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

View file

@ -2,9 +2,9 @@ import { CommandOption } from './Commands';
import { User, UserJSON } from '../User';
import { Embed, EmbedJSON } from './Embed';
import { DiscordRecord } from "../Record";
import { StickerFormatType } from "../../../enums";
import { ApplicationIntegrationType, MessageFlags, MessageType, StickerFormatType } from "../../../enums";
/**
/*
* TODO: looks like discord has moved over to Date instead of Moment;
*/
export class Message extends DiscordRecord {
@ -35,7 +35,7 @@ export class Message extends DiscordRecord {
customRenderedContent: unknown;
editedTimestamp: Date;
embeds: Embed[];
flags: number;
flags: MessageFlags;
giftCodes: string[];
id: string;
interaction: {
@ -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;
@ -100,7 +113,7 @@ export class Message extends DiscordRecord {
stickers: unknown[];
timestamp: moment.Moment;
tts: boolean;
type: number;
type: MessageType;
webhookId: string | undefined;
/**
@ -121,10 +134,13 @@ export class Message extends DiscordRecord {
removeReaction(emoji: ReactionEmoji, fromCurrentUser: boolean): Message;
getChannelId(): string;
hasFlag(flag: number): boolean;
hasFlag(flag: MessageFlags): boolean;
isCommandType(): boolean;
isEdited(): boolean;
isSystemDM(): boolean;
/** Vencord added */
deleted?: boolean;
}
/** A smaller Message object found in FluxDispatcher and elsewhere. */
@ -193,3 +209,9 @@ export interface MessageReaction {
emoji: ReactionEmoji;
me: boolean;
}
// Object.keys(findByProps("REPLYABLE")).map(JSON.stringify).join("|")
export type MessageTypeSets = Record<
"UNDELETABLE" | "GUILD_DISCOVERY_STATUS" | "USER_MESSAGE" | "NOTIFIABLE_SYSTEM_MESSAGE" | "REPLYABLE" | "FORWARDABLE" | "REFERENCED_MESSAGE_AVAILABLE" | "AVAILABLE_IN_GUILD_FEED" | "DEADCHAT_PROMPTS" | "NON_COLLAPSIBLE" | "NON_PARSED" | "AUTOMOD_INCIDENT_ACTIONS" | "SELF_MENTIONABLE_SYSTEM" | "SCHEDULABLE",
Set<MessageType>
>;

View file

@ -1,55 +1,40 @@
import { Moment } from "moment";
import type { ComponentClass, ComponentPropsWithRef, ComponentType, CSSProperties, FunctionComponent, HtmlHTMLAttributes, HTMLProps, JSX, KeyboardEvent, MouseEvent, PointerEvent, PropsWithChildren, ReactNode, Ref, RefObject } from "react";
// copy(find(m => Array.isArray(m) && m.includes("heading-sm/normal")).map(JSON.stringify).join("|"))
export type TextVariant = "heading-sm/normal" | "heading-sm/medium" | "heading-sm/semibold" | "heading-sm/bold" | "heading-sm/extrabold" | "heading-md/normal" | "heading-md/medium" | "heading-md/semibold" | "heading-md/bold" | "heading-md/extrabold" | "heading-lg/normal" | "heading-lg/medium" | "heading-lg/semibold" | "heading-lg/bold" | "heading-lg/extrabold" | "heading-xl/normal" | "heading-xl/medium" | "heading-xl/semibold" | "heading-xl/bold" | "heading-xl/extrabold" | "heading-xxl/normal" | "heading-xxl/medium" | "heading-xxl/semibold" | "heading-xxl/bold" | "heading-xxl/extrabold" | "eyebrow" | "heading-deprecated-12/normal" | "heading-deprecated-12/medium" | "heading-deprecated-12/semibold" | "heading-deprecated-12/bold" | "heading-deprecated-12/extrabold" | "redesign/heading-18/bold" | "text-xxs/normal" | "text-xxs/medium" | "text-xxs/semibold" | "text-xxs/bold" | "text-xs/normal" | "text-xs/medium" | "text-xs/semibold" | "text-xs/bold" | "text-sm/normal" | "text-sm/medium" | "text-sm/semibold" | "text-sm/bold" | "text-md/normal" | "text-md/medium" | "text-md/semibold" | "text-md/bold" | "text-lg/normal" | "text-lg/medium" | "text-lg/semibold" | "text-lg/bold" | "redesign/message-preview/normal" | "redesign/message-preview/medium" | "redesign/message-preview/semibold" | "redesign/message-preview/bold" | "redesign/channel-title/normal" | "redesign/channel-title/medium" | "redesign/channel-title/semibold" | "redesign/channel-title/bold" | "display-sm" | "display-md" | "display-lg" | "code";
export type FormTextTypes = Record<"DEFAULT" | "INPUT_PLACEHOLDER" | "DESCRIPTION" | "LABEL_BOLD" | "LABEL_SELECTED" | "LABEL_DESCRIPTOR" | "ERROR" | "SUCCESS", string>;
export type HeadingTag = `h${1 | 2 | 3 | 4 | 5 | 6}`;
// #region Old compability
export type HeadingTag = `h${1 | 2 | 3 | 4 | 5 | 6}`;
export type Margins = Record<"marginTop16" | "marginTop8" | "marginBottom8" | "marginTop20" | "marginBottom20", string>;
// copy(find(m => Array.isArray(m) && m.includes("heading-sm/normal")).map(JSON.stringify).join("|"))
export type TextVariant = "heading-sm/normal" | "heading-sm/medium" | "heading-sm/semibold" | "heading-sm/bold" | "heading-sm/extrabold" | "heading-md/normal" | "heading-md/medium" | "heading-md/semibold" | "heading-md/bold" | "heading-md/extrabold" | "heading-lg/normal" | "heading-lg/medium" | "heading-lg/semibold" | "heading-lg/bold" | "heading-lg/extrabold" | "heading-xl/normal" | "heading-xl/medium" | "heading-xl/semibold" | "heading-xl/bold" | "heading-xl/extrabold" | "heading-xxl/normal" | "heading-xxl/medium" | "heading-xxl/semibold" | "heading-xxl/bold" | "heading-xxl/extrabold" | "text-xxs/normal" | "text-xxs/medium" | "text-xxs/semibold" | "text-xxs/bold" | "text-xs/normal" | "text-xs/medium" | "text-xs/semibold" | "text-xs/bold" | "text-sm/normal" | "text-sm/medium" | "text-sm/semibold" | "text-sm/bold" | "text-md/normal" | "text-md/medium" | "text-md/semibold" | "text-md/bold" | "text-lg/normal" | "text-lg/medium" | "text-lg/semibold" | "text-lg/bold";
export type TextProps = PropsWithChildren<HtmlHTMLAttributes<HTMLDivElement> & {
variant?: TextVariant;
tag?: "div" | "span" | "p" | "strong" | HeadingTag;
selectable?: boolean;
lineClamp?: number;
}>;
export type Text = ComponentType<TextProps>;
export type Heading = ComponentType<TextProps>;
export type FormTitle = ComponentType<HTMLProps<HTMLTitleElement> & PropsWithChildren<{
/** default is h5 */
tag?: HeadingTag;
faded?: boolean;
disabled?: boolean;
required?: boolean;
error?: ReactNode;
}>>;
export interface ButtonProps extends PropsWithChildren<Omit<HTMLProps<HTMLButtonElement>, "size">> {
/** Button.Looks.FILLED */
look?: string;
/** Button.Colors.BRAND */
color?: string;
/** Button.Sizes.MEDIUM */
size?: string;
export type FormSection = ComponentType<PropsWithChildren<{
/** default is h5 */
tag?: HeadingTag;
className?: string;
titleClassName?: string;
titleId?: string;
title?: ReactNode;
disabled?: boolean;
htmlFor?: unknown;
}>>;
}
export type FormDivider = ComponentType<{
className?: string;
style?: CSSProperties;
}>;
export type Button = ComponentType<ButtonProps> & {
Colors: Record<"BRAND" | "RED" | "GREEN" | "PRIMARY" | "LINK" | "WHITE" | "TRANSPARENT" | "CUSTOM", string>;
Looks: Record<"FILLED" | "LINK", string>;
Sizes: Record<"NONE" | "SMALL" | "MEDIUM" | "LARGE" | "XLARGE" | "MIN", string>;
};
export type FormText = ComponentType<PropsWithChildren<{
disabled?: boolean;
selectable?: boolean;
/** defaults to FormText.Types.DEFAULT */
type?: string;
}> & TextProps> & { Types: FormTextTypes; };
// #endregion
export type Tooltip = ComponentType<{
text: ReactNode | ComponentType;
@ -127,7 +112,6 @@ export type ComboboxPopout = ComponentType<PropsWithChildren<{
className?: string;
listClassName?: string;
autoFocus?: boolean;
multiSelect?: boolean;
maxVisibleItems?: number;
@ -135,51 +119,6 @@ export type ComboboxPopout = ComponentType<PropsWithChildren<{
}>>;
export interface ButtonProps extends PropsWithChildren<Omit<HTMLProps<HTMLButtonElement>, "size">> {
/** Button.Looks.FILLED */
look?: string;
/** Button.Colors.BRAND */
color?: string;
/** Button.Sizes.MEDIUM */
size?: string;
/** Button.BorderColors.BLACK */
borderColor?: string;
wrapperClassName?: string;
className?: string;
innerClassName?: string;
buttonRef?: Ref<HTMLButtonElement>;
focusProps?: any;
submitting?: boolean;
submittingStartedLabel?: string;
submittingFinishedLabel?: string;
}
export type Button = ComponentType<ButtonProps> & {
BorderColors: Record<"BLACK" | "BRAND" | "BRAND_NEW" | "GREEN" | "LINK" | "PRIMARY" | "RED" | "TRANSPARENT" | "WHITE" | "YELLOW", string>;
Colors: Record<"BRAND" | "RED" | "GREEN" | "YELLOW" | "PRIMARY" | "LINK" | "WHITE" | "BLACK" | "TRANSPARENT" | "BRAND_NEW" | "CUSTOM", string>;
Hovers: Record<"DEFAULT" | "BRAND" | "RED" | "GREEN" | "YELLOW" | "PRIMARY" | "LINK" | "WHITE" | "BLACK" | "TRANSPARENT", string>;
Looks: Record<"FILLED" | "INVERTED" | "OUTLINED" | "LINK" | "BLANK", string>;
Sizes: Record<"NONE" | "TINY" | "SMALL" | "MEDIUM" | "LARGE" | "XLARGE" | "MIN" | "MAX" | "ICON", string>;
Link: any;
};
export type Switch = ComponentType<PropsWithChildren<{
value: boolean;
onChange(value: boolean): void;
disabled?: boolean;
hideBorder?: boolean;
className?: string;
style?: CSSProperties;
note?: ReactNode;
tooltipNote?: ReactNode;
}>>;
export type CheckboxAligns = {
CENTER: "center";
TOP: "top";
@ -379,14 +318,6 @@ export type Slider = ComponentClass<PropsWithChildren<{
"aria-describedby"?: string;
}>>;
// TODO - type maybe idk probably not that useful other than the constants
export type Flex = ComponentType<PropsWithChildren<any>> & {
Align: Record<"START" | "END" | "CENTER" | "STRETCH" | "BASELINE", string>;
Direction: Record<"VERTICAL" | "HORIZONTAL" | "HORIZONTAL_REVERSE", string>;
Justify: Record<"START" | "END" | "CENTER" | "BETWEEN" | "AROUND", string>;
Wrap: Record<"NO_WRAP" | "WRAP" | "WRAP_REVERSE", string>;
};
declare enum PopoutAnimation {
NONE = "1",
TRANSLATE = "2",

View file

@ -4,8 +4,8 @@ export * from "./components";
export * from "./flux";
export * from "./fluxEvents";
export * from "./menu";
export * from "./modules";
export * from "./stores";
export * from "./passiveupdatestate";
export * from "./utils";
export * from "./voicestate";
export * as Webpack from "../webpack";

View file

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

View file

@ -0,0 +1,72 @@
import EventEmitter from "events";
import { CloudUploadPlatform } from "../../enums";
interface BaseUploadItem {
platform: CloudUploadPlatform;
id?: string;
origin?: string;
isThumbnail?: boolean;
clip?: unknown;
}
export interface ReactNativeUploadItem extends BaseUploadItem {
platform: CloudUploadPlatform.REACT_NATIVE;
uri: string;
filename?: string;
mimeType?: string;
durationSecs?: number;
waveform?: string;
}
export interface WebUploadItem extends BaseUploadItem {
platform: CloudUploadPlatform.WEB;
file: File;
}
export type CloudUploadItem = ReactNativeUploadItem | WebUploadItem;
export class CloudUpload extends EventEmitter {
constructor(item: CloudUploadItem, channelId: string, showLargeMessageDialog?: boolean, reactNativeFileIndex?: number);
channelId: string;
classification: string;
clip: unknown;
contentHash: unknown;
currentSize: number;
description: string | null;
durationSecs: number | undefined;
etag: string | undefined;
error: unknown;
filename: string;
id: string;
isImage: boolean;
isThumbnail: boolean;
isVideo: boolean;
item: {
file: File;
platform: CloudUploadPlatform;
origin: string;
};
loaded: number;
mimeType: string;
origin: string;
postCompressionSize: number | undefined;
preCompressionSize: number;
responseUrl: string;
sensitive: boolean;
showLargeMessageDialog: boolean;
spoiler: boolean;
startTime: number;
status: "NOT_STARTED" | "STARTED" | "UPLOADING" | "ERROR" | "COMPLETED" | "CANCELLED" | "REMOVED_FROM_MSG_DRAFT";
uniqueId: string;
uploadedFilename: string;
waveform: string | undefined;
// there are many more methods than just these but I didn't find them particularly useful
upload(): Promise<void>;
cancel(): void;
delete(): Promise<void>;
getSize(): number;
maybeConvertToWebP(): Promise<void>;
removeFromMsgDraft(): void;
}

View file

@ -0,0 +1 @@
export * from "./CloudUpload";

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { VoiceState } from "./voicestate";
import { VoiceState } from "./stores";
export interface PassiveUpdateState {
type: string;

View file

@ -1,8 +1,37 @@
import { FluxStore } from "..";
export class MediaEngineStore extends FluxStore {
isSelfMute(): boolean;
isAdvancedVoiceActivitySupported(): boolean;
isAecDumpSupported(): boolean;
isAnyLocalVideoAutoDisabled(): boolean;
isAutomaticGainControlSupported(): boolean;
isDeaf(): boolean;
isEnableHardwareMuteNotice(): boolean;
isEnabled(): boolean;
isExperimentalEncodersSupported(): boolean;
isHardwareMute(): boolean;
isInputProfileCustom(): boolean;
isInteractionRequired(): boolean;
isLocalMute(): boolean;
isLocalMute(userId): boolean;
isLocalVideoAutoDisabled(userId): boolean;
isLocalVideoDisabled(userId): boolean;
isLocalVideoDisabled(): boolean;
isMediaFilterSettingLoading(): boolean;
isMute(): boolean;
isNativeAudioPermissionReady(): boolean;
isNoiseCancellationError(): boolean;
isNoiseCancellationSupported(): boolean;
isNoiseSuppressionSupported(): boolean;
isScreenSharing(): boolean;
isSelfDeaf(): boolean;
isSelfMute(): boolean;
isSelfMutedTemporarily(): boolean;
isSimulcastSupported(): boolean;
isSoundSharing(): boolean;
isSupported(): boolean;
isVideoAvailable(): boolean;
isVideoEnabled(): boolean;
setAv1Enabled(AV1: boolean): void;
setH265Enabled(H265: boolean): void;

View file

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

View file

@ -1,6 +1,11 @@
import { FluxStore } from "..";
import { FluxStore } from "@vencord/discord-types";
export class StreamerModeStore extends FluxStore {
enabled: boolean;
autoToggle: boolean;
get autoToggle(): boolean;
get disableNotifications(): boolean;
get disableSounds(): boolean;
get enableContentProtection(): boolean;
get enabled(): boolean;
get hideInstantInvites(): boolean;
get hidePersonalInformation(): boolean;
}

View file

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

View file

@ -0,0 +1,46 @@
import { DiscordRecord } from "../common";
import { FluxStore } from "./FluxStore";
export type UserVoiceStateRecords = Record<string, VoiceState>;
export type VoiceStates = Record<string, UserVoiceStateRecords>;
export interface VoiceState extends DiscordRecord {
userId: string;
channelId: string | null | undefined;
sessionId: string | null | undefined;
mute: boolean;
deaf: boolean;
stream: boolean;
selfMute: boolean;
selfDeaf: boolean;
selfVideo: boolean;
selfStream: boolean | undefined;
suppress: boolean;
requestToSpeakTimestamp: string | null | undefined;
discoverable: boolean;
isVoiceMuted(): boolean;
isVoiceDeafened(): boolean;
oldChannelId?: string;
guildId?: string;
}
export class VoiceStateStore extends FluxStore {
getAllVoiceStates(): VoiceStates;
getVoiceStates(guildId?: string | null): UserVoiceStateRecords;
getVoiceStatesForChannel(channelId: string): UserVoiceStateRecords;
getVideoVoiceStatesForChannel(channelId: string): UserVoiceStateRecords;
getVoiceState(guildId: string | null, userId: string): VoiceState | undefined;
getUserVoiceChannelId(guildId: string | null, userId: string): string | undefined;
getVoiceStateForChannel(channelId: string, userId?: string): VoiceState | undefined;
getVoiceStateForUser(userId: string): VoiceState | undefined;
getCurrentClientVoiceChannelId(guildId: string | null): string | undefined;
isCurrentClientInVoiceChannel(): boolean;
isInChannel(channelId: string, userId?: string): boolean;
hasVideo(channelId: string): boolean;
}

View file

@ -8,6 +8,7 @@ export * from "./GuildMemberStore";
export * from "./GuildRoleStore";
export * from "./GuildStore";
export * from "./MessageStore";
export * from "./PresenceStore";
export * from "./RelationshipStore";
export * from "./MediaEngineStore";
export * from "./StreamerModeStore";
@ -15,10 +16,12 @@ export * from "./SpellCheckStore";
export * from "./SelectedChannelStore";
export * from "./SelectedGuildStore";
export * from "./StickersStore";
export * from "./StreamerModeStore";
export * from "./ThemeStore";
export * from "./TypingStore";
export * from "./UserProfileStore";
export * from "./UserStore";
export * from "./VoiceStateStore";
export * from "./WindowStore";
/**

View file

@ -6,15 +6,17 @@ import type { FluxEvents } from "./fluxEvents";
export { FluxEvents };
type FluxEventsAutoComplete = LiteralUnion<FluxEvents, string>;
export interface FluxDispatcher {
_actionHandlers: any;
_interceptors: any;
_subscriptions: any;
addInterceptor(interceptor: any): void;
dispatch(event: { [key: string]: unknown; type: FluxEvents; }): Promise<void>;
dispatch(event: { [key: string]: unknown; type: FluxEventsAutoComplete; }): Promise<void>;
isDispatching(): boolean;
subscribe(event: FluxEvents, callback: (data: any) => void): void;
unsubscribe(event: FluxEvents, callback: (data: any) => void): void;
subscribe(event: FluxEventsAutoComplete, callback: (data: any) => void): void;
unsubscribe(event: FluxEventsAutoComplete, callback: (data: any) => void): void;
wait(callback: () => void): void;
}

View file

@ -1,21 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
export interface VoiceState {
userId: string;
channelId?: string;
oldChannelId?: string;
guildId?: string;
deaf: boolean;
mute: boolean;
selfDeaf: boolean;
selfMute: boolean;
selfStream: boolean;
selfVideo: boolean;
sessionId: string;
suppress: boolean;
requestToSpeakTimestamp: string | null;
}

View file

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

View file

@ -1,7 +1,7 @@
{
"name": "@vencord/types",
"name": "@equicord/types",
"private": false,
"version": "1.11.5",
"version": "1.13.8",
"description": "",
"types": "index.d.ts",
"scripts": {
@ -19,9 +19,14 @@
"dependencies": {
"@types/lodash": "4.17.15",
"@types/node": "^22.13.4",
"@types/react": "18.3.1",
"@types/react-dom": "18.3.1",
"standalone-electron-types": "^34.2.0",
"@vencord/discord-types": "^1.0.0",
"highlight.js": "11.11.1",
"moment": "^2.22.2",
"ts-pattern": "^5.6.0",
"type-fest": "^4.35.0"
},
"peerDependencies": {
"@types/react": "18.3.1",
"@types/react-dom": "18.3.1"
}
}

View file

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

1623
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -21,10 +21,10 @@
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 } from "./common.mjs";
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";
const defines = stringifyValues({
IS_STANDALONE,
@ -32,6 +32,7 @@ const defines = stringifyValues({
IS_REPORTER,
IS_COMPANION_TEST,
IS_UPDATER_DISABLED,
IS_ANTI_CRASH_TEST,
IS_WEB: false,
IS_EXTENSION: false,
IS_USERSCRIPT: false,
@ -80,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;
@ -89,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;
@ -103,7 +110,9 @@ const globNativesPlugin = {
code += `export default {${natives}};`;
return {
contents: code,
resolveDir: "./src"
resolveDir: "./src",
watchDirs: pluginDirs.map(d => resolve("src", d)),
watchFiles,
};
});
}

View file

@ -21,10 +21,10 @@
import { readFileSync } from "fs";
import { appendFile, mkdir, readdir, readFile, rm, writeFile } from "fs/promises";
import { join } from "path";
import Zip from "zip-local";
import path, { join } from "path";
import { BUILD_TIMESTAMP, commonOpts, globPlugins, IS_DEV, IS_REPORTER, IS_COMPANION_TEST, VERSION, commonRendererPlugins, buildOrWatchAll, stringifyValues } from "./common.mjs";
import { BUILD_TIMESTAMP, commonOpts, globPlugins, IS_DEV, IS_REPORTER, IS_COMPANION_TEST, VERSION, commonRendererPlugins, buildOrWatchAll, stringifyValues, IS_ANTI_CRASH_TEST } from "./common.mjs";
import AdmZip from "adm-zip";
/**
* @type {import("esbuild").BuildOptions}
@ -48,6 +48,7 @@ const commonOptions = {
IS_DEV,
IS_REPORTER,
IS_COMPANION_TEST,
IS_ANTI_CRASH_TEST,
IS_DISCORD_DESKTOP: false,
IS_VESKTOP: false,
IS_EQUIBOP: false,
@ -104,7 +105,7 @@ const buildConfigs = [
IS_USERSCRIPT: "true",
window: "unsafeWindow",
},
outfile: "dist/Vencord.user.js",
outfile: "dist/Equicord.user.js",
banner: {
js: readFileSync("browser/userscript.meta.js", "utf-8").replace("%version%", `${VERSION}.${new Date().getTime()}`)
},
@ -154,8 +155,8 @@ async function loadDir(dir, basePath = "") {
*/
async function buildExtension(target, files) {
const entries = {
"dist/Vencord.js": await readFile("dist/browser/extension.js"),
"dist/Vencord.css": await readFile("dist/browser/extension.css"),
"dist/Equicord.js": await readFile("dist/browser/extension.js"),
"dist/Equicord.css": await readFile("dist/browser/extension.css"),
...await loadDir("dist/browser/vendor/monaco", "dist/browser/"),
...Object.fromEntries(await Promise.all(files.map(async f => {
let content = await readFile(join("browser", f));
@ -183,9 +184,9 @@ async function buildExtension(target, files) {
console.info("Unpacked Extension written to dist/browser/" + target);
}
const appendCssRuntime = readFile("dist/Vencord.user.css", "utf-8").then(content => {
const appendCssRuntime = readFile("dist/Equicord.user.css", "utf-8").then(content => {
const cssRuntime = `
;document.addEventListener("DOMContentLoaded", () => document.documentElement.appendChild(
;document.addEventListener("DOMContentLoaded", () => document.body.insertAdjacentElement("afterend",
Object.assign(document.createElement("style"), {
textContent: \`${content.replaceAll("`", "\\`")}\`,
id: "vencord-css-core"
@ -193,7 +194,7 @@ const appendCssRuntime = readFile("dist/Vencord.user.css", "utf-8").then(content
), { once: true });
`;
return appendFile("dist/Vencord.user.js", cssRuntime);
return appendFile("dist/Equicord.user.js", cssRuntime);
});
if (!process.argv.includes("--skip-extension")) {
@ -203,14 +204,15 @@ if (!process.argv.includes("--skip-extension")) {
buildExtension("firefox-unpacked", ["background.js", "content.js", "manifestv2.json", "icon.png"]),
]);
Zip.zip("dist/browser/chromium-unpacked", (_err, zip) => {
zip.compress().save("dist/extension-chrome.zip");
console.info("Packed Chromium Extension written to dist/extension-chrome.zip");
});
Zip.zip("dist/browser/firefox-unpacked", (_err, zip) => {
zip.compress().save("dist/extension-firefox.zip");
console.info("Packed Firefox Extension written to dist/extension-firefox.zip");
});
const chromiumZip = new AdmZip();
chromiumZip.addLocalFolder("dist/browser/chromium-unpacked");
chromiumZip.writeZip(path.resolve("dist/extension-chrome.zip"));
console.info("Packed Chromium Extension written to dist/extension-chrome.zip");
const firefoxZip = new AdmZip();
firefoxZip.addLocalFolder("dist/browser/firefox-unpacked");
firefoxZip.writeZip(path.resolve("dist/extension-firefox.zip"));
console.info("Packed Firefox Extension written to dist/extension-firefox.zip");
} else {
await appendCssRuntime;
}

View file

@ -26,7 +26,7 @@ import esbuild, { build, context } from "esbuild";
import { constants as FsConstants, readFileSync } from "fs";
import { access, readdir, readFile } from "fs/promises";
import { minify as minifyHtml } from "html-minifier-terser";
import { dirname, join, relative } from "path";
import { dirname, join, relative, resolve } from "path";
import { fileURLToPath } from "url";
import { promisify } from "util";
@ -42,13 +42,14 @@ export const BUILD_TIMESTAMP = Number(process.env.SOURCE_DATE_EPOCH) || Date.now
export const watch = process.argv.includes("--watch");
export const IS_DEV = watch || process.argv.includes("--dev");
export const IS_REPORTER = process.argv.includes("--reporter");
export const IS_ANTI_CRASH_TEST = process.argv.includes("--anti-crash-test");
export const IS_STANDALONE = process.argv.includes("--standalone");
export const IS_COMPANION_TEST = IS_REPORTER && process.argv.includes("--companion-test");
if (!IS_COMPANION_TEST && process.argv.includes("--companion-test"))
console.error("--companion-test must be run with --reporter for any effect");
export const IS_UPDATER_DISABLED = process.argv.includes("--disable-updater");
export const gitHash = process.env.EQUICORD_HASH || execSync("git rev-parse --short=9 HEAD", { encoding: "utf-8" }).trim();
export const gitHash = process.env.EQUICORD_HASH || execSync("git rev-parse HEAD", { encoding: "utf-8" }).trim();
export const banner = {
js: `
@ -146,7 +147,7 @@ export const globPlugins = kind => ({
});
build.onLoad({ filter, namespace: "import-plugins" }, async () => {
const pluginDirs = ["plugins/_api", "plugins/_core", "plugins", "userplugins", "equicordplugins"];
const pluginDirs = ["plugins/_api", "plugins/_core", "plugins", "userplugins", "equicordplugins", "equicordplugins/_api"];
let code = "";
let pluginsCode = "\n";
let metaCode = "\n";
@ -162,6 +163,7 @@ export const globPlugins = kind => ({
const fileName = file.name;
if (fileName.startsWith("_") || fileName.startsWith(".")) continue;
if (fileName === "index.ts") continue;
if (/\.(zip|rar|7z|tar|gz|bz2)/.test(fileName)) continue;
const target = getPluginTarget(fileName);
@ -193,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)),
};
});
}

121
scripts/generateDevsList.ts Normal file
View file

@ -0,0 +1,121 @@
/*
* 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 { readFileSync, writeFileSync } from "fs";
import { BigIntLiteral, createSourceFile, Identifier, isCallExpression, isIdentifier, isObjectLiteralExpression, isPropertyAssignment, isSatisfiesExpression, isVariableStatement, NamedDeclaration, ObjectLiteralExpression, ScriptTarget, StringLiteral } from "typescript";
interface Dev {
name: string;
id: string;
}
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 () => {
parseDevs();
parseEquicordDevs();
const allDevs = {
vencord: devs,
equicord: equicordDevs,
};
const data = JSON.stringify(allDevs, null, 2);
if (process.argv.length > 2) {
writeFileSync(process.argv[2], data);
} else {
console.log(data);
}
})();

View file

@ -1,283 +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;
}
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,
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)) throw fail("commands is not an array literal");
data.commands = value.elements.map((e) => {
if (isObjectLiteralExpression(e)) {
const nameProperty = e.properties.find((p): p is PropertyAssignment => {
return isPropertyAssignment(p) && isIdentifier(p.name) && p.name.escapedText === 'name';
});
const descriptionProperty = e.properties.find((p): p is PropertyAssignment => {
return 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");
const name = nameArg.text;
return { name, description: "" };
}
throw fail("commands array contains invalid elements");
});
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 "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?)$/, "")
.replace(/^src\/equicordplugins\//, "");
return [data] as const;
}
throw fail("no default export called 'definePlugin' found");
}
async function getEntryPoint(dir: string, dirent: Dirent) {
const base = join(dir, dirent.name);
if (!dirent.isDirectory()) return base;
for (const name of ["index.ts", "index.tsx"]) {
const full = join(base, name);
try {
await access(full);
return full;
} catch { }
}
throw new Error(`${dirent.name}: Couldn't find entry point`);
}
function isPluginFile({ name }: { name: string; }) {
if (name === "index.ts") return false;
return !name.startsWith("_") && !name.startsWith(".");
}
(async () => {
parseDevs();
parseEquicordDevs();
const plugins = [] as PluginData[];
await Promise.all(["src/equicordplugins"].flatMap(dir =>
readdirSync(dir, { withFileTypes: true })
.filter(isPluginFile)
.map(async dirent => {
const [data] = await parseFile(await getEntryPoint(dir, dirent));
plugins.sort().push(data);
})
));
const data = JSON.stringify(plugins);
if (process.argv.length > 2) {
writeFileSync(process.argv[2], data);
} else {
console.log(data);
}
})();

View file

@ -16,266 +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;
}
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,
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)) throw fail("commands is not an array literal");
data.commands = value.elements.map((e) => {
if (isObjectLiteralExpression(e)) {
const nameProperty = e.properties.find((p): p is PropertyAssignment => {
return isPropertyAssignment(p) && isIdentifier(p.name) && p.name.escapedText === 'name';
});
const descriptionProperty = e.properties.find((p): p is PropertyAssignment => {
return 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");
const name = nameArg.text;
return { name, description: "" };
}
throw fail("commands array contains invalid elements");
});
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 "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?)$/, "");
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 =>
readdirSync(dir, { withFileTypes: true })
.filter(isPluginFile)
.map(async dirent => {
const [data] = await parseFile(await getEntryPoint(dir, dirent));
plugins.sort().push(data);
})
));
await Promise.all(
dirs.flatMap(dir =>
readdirSync(dir, { withFileTypes: true })
.filter(isPluginFile)
.map(async dirent => {
const [data] = await parseFile(await getEntryPoint(dir, dirent));
plugins.sort().push(data);
})
)
);
const data = JSON.stringify(plugins);
if (process.argv.length > 2) {
writeFileSync(process.argv[2], data);
if (outputPath) {
writeFileSync(outputPath, data);
} else {
console.log(data);
}

View file

@ -87,14 +87,17 @@ 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() {
const filePath = join("dist", (CANARY ? "reporter-canary.json" : "reporter-stable.json"));
writeFileSync(filePath, JSON.stringify(report, null, 2));
console.log();
console.log("# Equicord Report" + (CANARY ? " (Canary)" : ""));
@ -343,7 +346,7 @@ page.on("console", async e => {
});
page.on("error", e => logStderr("[Error]", e.message));
page.on("pageerror", e => {
page.on("pageerror", (e: any) => {
if (e.message.includes("Sentry successfully disabled")) return;
if (!e.message.startsWith("Object") && !e.message.includes("Cannot find module") && !/^.{1,2}$/.test(e.message)) {

View file

@ -24,17 +24,17 @@ import { BigIntLiteral, createSourceFile, Identifier, isArrayLiteralExpression,
import { getPluginTarget } from "./utils.mjs";
interface Dev {
export interface Dev {
name: string;
id: string;
}
interface Command {
export interface Command {
name: string;
description: string;
}
interface PluginData {
export interface PluginData {
name: string;
description: string;
tags: string[];
@ -47,25 +47,28 @@ interface PluginData {
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) {
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()) {
@ -96,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()) {
@ -127,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) => {
@ -148,6 +151,7 @@ async function parseFile(fileName: string) {
hasCommands: false,
enabledByDefault: false,
required: false,
isModified: false,
tags: [] as string[]
} as PluginData;
@ -166,27 +170,33 @@ async function parseFile(fileName: string) {
break;
case "commands":
data.hasCommands = true;
if (!isArrayLiteralExpression(value)) throw fail("commands is not an array literal");
data.commands = value.elements.map((e) => {
if (isObjectLiteralExpression(e)) {
const nameProperty = e.properties.find((p): p is PropertyAssignment => {
return isPropertyAssignment(p) && isIdentifier(p.name) && p.name.escapedText === 'name';
});
const descriptionProperty = e.properties.find((p): p is PropertyAssignment => {
return 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");
const name = nameArg.text;
return { name, description: "" };
}
throw fail("commands array contains invalid elements");
});
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");
@ -211,6 +221,7 @@ async function parseFile(fileName: string) {
data.dependencies = (elements as NodeArray<StringLiteral>).map(e => e.text);
break;
case "required":
case "isModified":
case "enabledByDefault":
data[key] = value.kind === SyntaxKind.TrueKeyword;
break;
@ -226,10 +237,16 @@ async function parseFile(fileName: string) {
}
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\/plugins\//, "")
.replace(/^src\/equicordplugins\//, "");
return [data] as const;
}
@ -237,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;
@ -252,31 +269,7 @@ async function getEntryPoint(dir: string, dirent: Dirent) {
throw new Error(`${dirent.name}: Couldn't find entry point`);
}
function isPluginFile({ name }: { name: string; }) {
export function isPluginFile({ name }: { name: string; }) {
if (name === "index.ts") return false;
return !name.startsWith("_") && !name.startsWith(".");
}
(async () => {
parseDevs();
parseEquicordDevs();
const plugins = [] as PluginData[];
await Promise.all(["src/plugins", "src/plugins/_core"].flatMap(dir =>
readdirSync(dir, { withFileTypes: true })
.filter(isPluginFile)
.map(async dirent => {
const [data] = await parseFile(await getEntryPoint(dir, dirent));
plugins.sort().push(data);
})
));
const data = JSON.stringify(plugins);
if (process.argv.length > 2) {
writeFileSync(process.argv[2], data);
} else {
console.log(data);
}
})();

View file

@ -20,36 +20,35 @@
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 { openUpdaterModal } from "@components/settings/tabs/updater";
import { addVencordUiStyles } from "@components/css";
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");
Settings.plugins.CharacterCounter.enabled = false;
}
async function syncSettings() {
@ -60,9 +59,9 @@ async function syncSettings() {
// 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 Vencloud App. You can enable it again by connecting this account in Cloud Settings. (note: it will store your preferences separately)",
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;
@ -81,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;
}
@ -107,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;
@ -142,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);
@ -155,7 +168,7 @@ async function init() {
syncSettings();
if (!IS_WEB && !IS_UPDATER_DISABLED) {
if (!IS_DEV && !IS_WEB && !IS_UPDATER_DISABLED) {
runUpdateCheck();
// this tends to get really annoying, so only do this if the user has auto-update without notification enabled
@ -178,16 +191,17 @@ async function init() {
}
}
initPluginManager();
startAllPlugins(StartAt.Init);
init();
document.addEventListener("DOMContentLoaded", () => {
addVencordUiStyles();
startAllPlugins(StartAt.DOMContentLoaded);
// FIXME
if (IS_DISCORD_DESKTOP && Settings.winNativeTitleBar && IS_WINDOWS) {
document.head.append(Object.assign(document.createElement("style"), {
id: "vencord-native-titlebar-style",
textContent: "[class*=titleBar]{display: none!important}"
}));
createAndAppendStyle("vencord-native-titlebar-style").textContent = "[class*=titleBar]{display: none!important}";
}
}, { once: true });

260
src/api/AudioPlayer.ts Normal file
View file

@ -0,0 +1,260 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { findByCodeLazy, findLazy } from "webpack";
let defaultSounds: null | string[] = null;
const findDefaultSounds = findLazy(module => module.resolve && module.id && module.keys().some(key => key.endsWith(".mp3")), false);
const AudioPlayerConstructor = findByCodeLazy("could not play audio");
export type AudioProcessor = (data: PreprocessAudioData) => void;
export type AudioCallback = (() => void);
export type AudioErrorHandler = ((error: Error) => void);
export const audioProcessorFunctions: Record<string, AudioProcessor> = {};
export enum AudioType {
/** An external URL that follows the Content Security Policy. */
URL = "url",
/** A base64-encoded data URI. */
DATA = "data-uri",
/** A Blob URI. */
BLOB = "blob",
/** A file path. */
PATH = "file-path",
/** An internal Discord audio filename (e.g. "discodo"). */
DISCORD = "discord",
/** Any other unrecognized audio type. */
OTHER = "other"
}
export interface PreprocessAudioData {
/** The original audio string passed to the player. */
audio: string;
/** The read-only type of audio of the original audio string. */
readonly type: AudioType;
/** The volume of the original audio between 0 and 100. */
volume: number;
/** The playback speed of the original audio between 0.0625 and 16. */
speed: number;
}
export interface AudioPlayerInternal {
preprocessDataOriginal: PreprocessAudioData;
preprocessDataPrevious: PreprocessAudioData | null;
preprocessDataCurrent: PreprocessAudioData;
audio: string;
_audio: null | Promise<HTMLAudioElement>;
_volume: number;
_speed: number;
outputChannel: string;
type: AudioType;
preload: boolean;
persistent: boolean;
onEnded?: AudioCallback;
onError?: AudioErrorHandler;
processAudio: () => void;
ensureAudio(): Promise<HTMLAudioElement>;
destroyAudio(): void;
loop(): void;
play(): void;
pause(): void;
stop(restart?: boolean): void;
}
export interface AudioPlayerInterface {
/** The internal Discord audio filename (e.g. "discodo"), a data URI, or an external URL that follows the CSP. */
audio: string;
/** The read-only type of audio determined during processing. */
readonly type: AudioType;
/** The duration of the audio in seconds, or null if not yet loaded. */
readonly duration: Promise<number> | null;
/** The current time of the audio in seconds, or null if not yet loaded. */
time: Promise<number> | null;
/** The paused state of the audio, or null if not yet loaded. */
paused: Promise<boolean> | null;
/** The muted state of the audio, or null if not yet loaded. */
muted: Promise<boolean> | null;
/** The volume of the audio between 0 and 100. */
volume: number;
/** The playback speed of the audio between 0.0625 and 16. */
speed: number;
/** Whether to load the audio immediately. If persistent is false, this will only apply until the first playback. */
preload: boolean;
/** Whether the audio element is persistent and not recreated for every playback. */
persistent: boolean;
/** Preloads the audio before playback. Automatically called when persistent is true. */
load(): void;
/** Sets the audio to loop until paused or stopped. */
loop(): void;
/** Plays the audio. */
play(): void;
/** Pauses the audio. */
pause(): void;
/** Stops the audio. */
stop(): void;
/** Plays the audio from the beginning. */
restart(): void;
/** Seeks to a specific time in seconds. */
seek(time: number): void;
/** Mutes the audio. */
mute(): void;
/** Unmutes the audio. */
unmute(): void;
/** Deletes the audio element. Necessary if persistent is true. */
delete(): void;
}
export interface AudioPlayerOptions {
/** The volume of the audio, between 0 and 100, defaulting to 100. */
volume?: number;
/** The playback speed of the audio, between 0.0625 and 16, defaulting to 1. */
speed?: number;
/** Whether to preload the audio as soon as the player is created. */
preload?: boolean;
/** Whether the audio element is persistent and not recreated for every playback. If persistent, you must call delete() to free the memory. Defaults to false. */
persistent?: boolean;
/** An optional callback that is called every time the audio finishes playing. */
onEnded?: AudioCallback;
/** An optional error handler that is called when an error occurs during audio playback. */
onError?: AudioErrorHandler;
}
// Wrap the player to allow reprocessing the audio when properties are changed and to alleviate
// the confusion between the public API accepting 0-100 volume while the internal API uses 0-1 volume.
class AudioPlayerWrapper implements AudioPlayerInterface {
private internalPlayer: AudioPlayerInternal;
constructor(internalPlayer: AudioPlayerInternal) { this.internalPlayer = internalPlayer; }
get audio(): string { return this.internalPlayer.audio; }
set audio(value: string) { this.internalPlayer.preprocessDataOriginal.audio = value; this.internalPlayer.processAudio(); }
get volume(): number { return this.internalPlayer._volume * 100; }
set volume(value: number) { this.internalPlayer.preprocessDataOriginal.volume = Math.max(0, Math.min(1, value / 100)); this.internalPlayer.processAudio(); }
get speed(): number { return this.internalPlayer._speed; }
set speed(value: number) { this.internalPlayer.preprocessDataOriginal.speed = Math.max(0.0625, Math.min(16, value)); this.internalPlayer.processAudio(); }
get time(): Promise<number> | null { return this.internalPlayer._audio?.then(audio => audio.currentTime) ?? null; }
set time(value: number) { this.internalPlayer.ensureAudio().then(audio => audio.currentTime = value); }
get persistent(): boolean { return this.internalPlayer.persistent; }
set persistent(value: boolean) { this.internalPlayer.persistent = value; }
get preload(): boolean { return this.internalPlayer.preload; }
set preload(value: boolean) { this.internalPlayer.preload = value; value && this.internalPlayer.ensureAudio(); }
get muted(): Promise<boolean> | null { return this.internalPlayer._audio?.then(audio => audio.muted) ?? null; }
set muted(value: boolean) { this.internalPlayer.ensureAudio().then(audio => audio.muted = value); }
get paused(): Promise<boolean> | null { return this.internalPlayer._audio?.then(audio => audio.paused) ?? null; }
set paused(value: boolean) { value ? this.internalPlayer.pause() : this.internalPlayer.play(); }
get type(): AudioType { return this.internalPlayer.type; }
get duration(): Promise<number> | null { return this.internalPlayer._audio?.then(audio => audio.duration) ?? null; }
load(): void { this.internalPlayer.ensureAudio(); }
loop(): void { this.internalPlayer.loop(); }
play(): void { this.internalPlayer.play(); }
pause(): void { this.internalPlayer.pause(); }
stop(restart?: boolean): void { this.internalPlayer.stop(restart); }
restart(): void { this.internalPlayer.stop(true); }
seek(time: number): void { this.internalPlayer.ensureAudio().then(audio => audio.currentTime = time); }
mute(): void { this.internalPlayer.ensureAudio().then(audio => audio.muted = true); }
unmute(): void { this.internalPlayer.ensureAudio().then(audio => audio.muted = false); }
delete(): void { this.internalPlayer.destroyAudio(); }
}
/**
* Creates an audio player.
* @param audio The internal Discord audio filename (e.g. "discodo"), a data URI, or an external URL that follows the CSP.
* @param options Additional options for the audio player.
* @param options.volume The volume of the audio, between 0 and 100, defaulting to 100.
* @param options.speed The playback speed of the audio, between 0.0625 and 16, defaulting to 1.
* @param options.preload Whether to load the audio immediately. If persistent is false, this will only apply until the first playback.
* @param options.persistent Whether the audio element is persistent and not recreated for every playback. If persistent, you must call delete() to free the memory. Defaults to false.
* @param options.onEnded An optional callback that is called every time the audio finishes playing.
* @param options.onError An optional error handler that is passed an Error object when an error occurs during audio playback.
* @return The created audio player.
*/
export function createAudioPlayer(
audio: string,
options: AudioPlayerOptions = {}
): AudioPlayerInterface {
const internalPlayer: AudioPlayerInternal = new AudioPlayerConstructor(
audio,
null,
null,
"default",
options
);
return new AudioPlayerWrapper(internalPlayer);
}
/**
* Plays an audio instantly and returns the player.
* @param audio The internal Discord audio filename (e.g. "discodo"), a data URI, or an external URL that follows the CSP.
* @param options Additional options for the audio player.
* @param options.volume The volume of the audio, between 0 and 100, defaulting to 100.
* @param options.speed The playback speed of the audio, between 0.0625 and 16, defaulting to 1.
* @param options.preload Whether to load the audio immediately. If persistent is false, this will only apply until the first playback.
* @param options.persistent Whether the audio element is persistent and not recreated for every playback. If persistent, you must call delete() to free the memory. Defaults to false.
* @param options.onEnded An optional callback that is called every time the audio finishes playing.
* @param options.onError An optional error handler that is passed an Error object when an error occurs during audio playback.
* @return The created audio player.
*/
export function playAudio(audio: string, options: AudioPlayerOptions = {}): AudioPlayerInterface {
const player = createAudioPlayer(audio, options);
player.play();
return player;
}
/**
* Identifies the type of audio based on its string.
* @param audio The audio string to identify.
* @returns The identified AudioType.
*/
export function identifyAudioType(audio: string): AudioType {
if (defaultAudioNames().includes(audio)) return AudioType.DISCORD;
try {
const url = new URL(audio);
if (url.protocol === "http:" || url.protocol === "https:") return AudioType.URL;
if (url.protocol === "data:") return AudioType.DATA;
if (url.protocol === "blob:") return AudioType.BLOB;
if (url.protocol === "file:") return AudioType.PATH;
return AudioType.OTHER;
} catch {
return AudioType.OTHER;
}
}
/**
* Adds a function to process an audio before it is played.
* @param key A unique identifier for this audio processor. Plugin name is recommended.
* @param processor A function that takes an audio, volume (0-100), and type (AudioType), and returns the replacement audio and volume.
*/
export function addAudioProcessor(key: string, processor: AudioProcessor): void {
audioProcessorFunctions[key] = processor;
}
/**
* Removes an audio processor by its key.
* @param key The unique identifier of the audio processor to remove.
*/
export function removeAudioProcessor(key: string): void {
delete audioProcessorFunctions[key];
}
/** Returns an array of all internal Discord audio filenames. */
export function defaultAudioNames(): string[] {
defaultSounds ??= (findDefaultSounds.keys() || []).map(key => {
const match = key.match(/((?:\w|-)+)\.mp3$/);
return match ? match[1] : null;
}).filter(Boolean) as string[];
return defaultSounds;
}

View file

@ -17,9 +17,12 @@
*/
import ErrorBoundary from "@components/ErrorBoundary";
import BadgeAPIPlugin from "plugins/_api/badges";
import globalBadges from "@equicordplugins/globalBadges";
import BadgeAPIPlugin from "@plugins/_api/badges";
import { ComponentType, HTMLProps } from "react";
import { isPluginEnabled } from "./PluginManager";
export const enum BadgePosition {
START,
END
@ -31,10 +34,12 @@ 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<HTMLButtonElement, MouseEvent>, props: BadgeUserArgs): void;
onClick?(event: React.MouseEvent, props: ProfileBadge & BadgeUserArgs): void;
/** Action to perform when you right click the badge */
onContextMenu?(event: React.MouseEvent, props: BadgeUserArgs & BadgeUserArgs): void;
/** Should the user display this badge? */
shouldShow?(userInfo: BadgeUserArgs): boolean;
/** Optional props (e.g. style) for the badge, ignored for component badges */
@ -76,23 +81,56 @@ export function removeProfileBadge(badge: ProfileBadge) {
export function _getBadges(args: BadgeUserArgs) {
const badges = [] as ProfileBadge[];
for (const badge of Badges) {
if (!badge.shouldShow || badge.shouldShow(args)) {
const b = badge.getBadges
? badge.getBadges(args).map(b => {
b.component &&= ErrorBoundary.wrap(b.component, { noop: true });
return b;
})
: [{ ...badge, ...args }];
if (badge.shouldShow && !badge.shouldShow(args)) {
continue;
}
badge.position === BadgePosition.START
? badges.unshift(...b)
: badges.push(...b);
const b = badge.getBadges
? badge.getBadges(args).map(badge => ({
...args,
...badge,
component: badge.component && ErrorBoundary.wrap(badge.component, { noop: true })
}))
: [{ ...args, ...badge }];
if (badge.position === BadgePosition.START) {
badges.unshift(...b);
} else {
badges.push(...b);
}
}
const donorBadges = BadgeAPIPlugin.getDonorBadges(args.userId);
const equicordDonorBadges = BadgeAPIPlugin.getEquicordDonorBadges(args.userId);
if (donorBadges) badges.unshift(...donorBadges);
if (equicordDonorBadges) badges.unshift(...equicordDonorBadges);
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 => ({
...args,
...badge,
}))
);
}
if (equicordDonorBadges) {
badges.unshift(
...equicordDonorBadges.map(badge => ({
...args,
...badge,
}))
);
}
return badges;
}

View file

@ -8,11 +8,16 @@ 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 { Button, ButtonWrapperClasses, 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);
@ -74,57 +79,116 @@ 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;
tooltip: string;
onClick: MouseEventHandler<HTMLButtonElement>;
onContextMenu?: MouseEventHandler<HTMLButtonElement>;
onAuxClick?: MouseEventHandler<HTMLButtonElement>;
buttonProps?: Omit<HTMLProps<HTMLButtonElement>, "size" | "onClick" | "onContextMenu" | "onAuxClick">;
onClick: MouseEventHandler;
onContextMenu?: MouseEventHandler;
onAuxClick?: MouseEventHandler;
buttonProps?: Omit<HTMLProps<HTMLDivElement>, "size" | "onClick" | "onContextMenu" | "onAuxClick">;
}
export const ChatBarButton = ErrorBoundary.wrap((props: ChatBarButtonProps) => {
return (
<Tooltip text={props.tooltip}>
{({ onMouseEnter, onMouseLeave }) => (
<div className={`expression-picker-chat-input-button ${ChannelTextAreaClasses?.buttonContainer ?? ""} vc-chatbar-button`}>
<Button
<Clickable
aria-label={props.tooltip}
size=""
look={Button.Looks.BLANK}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
innerClassName={`${ButtonWrapperClasses.button} ${ChannelTextAreaClasses?.button}`}
className={classes(ButtonWrapperClasses?.button, ChannelTextAreaClasses?.button)}
onClick={props.onClick}
onContextMenu={props.onContextMenu}
onAuxClick={props.onAuxClick}
{...props.buttonProps}
>
<div className={ButtonWrapperClasses.buttonWrapper}>
<div className={ButtonWrapperClasses?.buttonWrapper}>
{props.children}
</div>
</Button>
</Clickable>
</div>
)}
</Tooltip>
);
}, { noop: true });
addContextMenuPatch("textarea-context", (children, args) => {
const { chatBarButtons } = useSettings(["uiElements.chatBarButtons.*"]).uiElements;
const buttons = Array.from(ChatBarButtonMap.entries());
if (!buttons.length) return;
const group = findGroupChildrenByChildId("submit-button", children);
if (!group) return;
const idx = group.findIndex(c => c?.props?.id === "submit-button");
if (idx === -1) return;
group.splice(idx, 0,
<Menu.MenuItem id="vc-chat-buttons" key="vencord-chat-buttons" label="Equicord Buttons">
{buttons.map(([id]) => (
<Menu.MenuCheckboxItem
label={id}
key={id}
id={`vc-chat-button-${id}`}
checked={chatBarButtons[id]?.enabled !== false}
action={() => {
const wasEnabled = chatBarButtons[id]?.enabled !== false;
chatBarButtons[id] ??= {} as any;
chatBarButtons[id].enabled = !wasEnabled;
}}
/>
))}
</Menu.MenuItem>
);
});

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

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

View file

@ -17,7 +17,7 @@
*/
import { Logger } from "@utils/Logger";
import type { Channel, CustomEmoji, Message } from "@vencord/discord-types";
import type { Channel, CloudUpload, CustomEmoji, Message } from "@vencord/discord-types";
import { MessageStore } from "@webpack/common";
import type { Promisable } from "type-fest";
@ -30,32 +30,6 @@ export interface MessageObject {
tts: boolean;
}
export interface Upload {
classification: string;
currentSize: number;
description: string | null;
filename: string;
id: string;
isImage: boolean;
isRemix?: boolean;
isThumbnail: boolean;
isVideo: boolean;
item: {
file: File;
platform: number;
};
loaded: number;
mimeType: string;
preCompressionSize: number;
responseUrl: string;
sensitive: boolean;
showLargeMessageDialog: boolean;
spoiler: boolean;
status: "NOT_STARTED" | "STARTED" | "UPLOADING" | "ERROR" | "COMPLETED" | "CANCELLED";
uniqueId: string;
uploadedFilename: string;
}
export interface MessageReplyOptions {
messageReference: Message["messageReference"];
allowedMentions?: {
@ -66,9 +40,9 @@ export interface MessageReplyOptions {
};
}
export interface MessageExtra {
export interface MessageOptions {
stickers?: string[];
uploads?: Upload[];
uploads?: CloudUpload[];
replyOptions: MessageReplyOptions;
content: string;
channel: Channel;
@ -76,17 +50,17 @@ export interface MessageExtra {
openWarningPopout: (props: any) => any;
}
export type MessageSendListener = (channelId: string, messageObj: MessageObject, extra: MessageExtra) => Promisable<void | { cancel: boolean; }>;
export type MessageSendListener = (channelId: string, messageObj: MessageObject, options: MessageOptions) => Promisable<void | { cancel: boolean; }>;
export type MessageEditListener = (channelId: string, messageId: string, messageObj: MessageObject) => Promisable<void | { cancel: boolean; }>;
const sendListeners = new Set<MessageSendListener>();
const editListeners = new Set<MessageEditListener>();
export async function _handlePreSend(channelId: string, messageObj: MessageObject, extra: MessageExtra, replyOptions: MessageReplyOptions) {
extra.replyOptions = replyOptions;
export async function _handlePreSend(channelId: string, messageObj: MessageObject, options: MessageOptions, replyOptions: MessageReplyOptions) {
options.replyOptions = replyOptions;
for (const listener of sendListeners) {
try {
const result = await listener(channelId, messageObj, extra);
const result = await listener(channelId, messageObj, options);
if (result?.cancel) {
return true;
}

View file

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

View file

@ -16,7 +16,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import ErrorBoundary from "@components/ErrorBoundary";
import { isPrimitiveReactNode } from "@utils/react";
import { waitFor } from "@webpack";
import { ReactNode } from "react";
let NoticesModule: any;
waitFor(m => m.show && m.dismiss && !m.suppressAll, m => NoticesModule = m);
@ -36,7 +39,11 @@ export function nextNotice() {
}
}
export function showNotice(message: string, buttonText: string, onOkClick: () => void) {
noticesQueue.push(["GENERIC", message, buttonText, onOkClick]);
export function showNotice(message: ReactNode, buttonText: string, onOkClick: () => void) {
const notice = isPrimitiveReactNode(message)
? message
: <ErrorBoundary fallback={() => "Error Showing Notice"}>{message}</ErrorBoundary>;
noticesQueue.push(["GENERIC", notice, buttonText, onOkClick]);
if (!currentNotice) nextNotice();
}

View file

@ -21,7 +21,7 @@ import "./styles.css";
import { useSettings } from "@api/Settings";
import ErrorBoundary from "@components/ErrorBoundary";
import { classes } from "@utils/misc";
import { React, useEffect, useMemo, useState, useStateFromStores, WindowStore } from "@webpack/common";
import { React, useEffect, useRef, useState } from "@webpack/common";
import { NotificationData } from "./Notifications";
@ -39,26 +39,38 @@ export default ErrorBoundary.wrap(function NotificationComponent({
dismissOnClick
}: NotificationData & { className?: string; }) {
const { timeout, position } = useSettings(["notifications.timeout", "notifications.position"]).notifications;
const hasFocus = useStateFromStores([WindowStore], () => WindowStore.isFocused());
const [isHover, setIsHover] = useState(false);
const [elapsed, setElapsed] = useState(0);
const start = useMemo(() => Date.now(), [timeout, isHover, hasFocus]);
const start = useRef(Date.now());
const pause = useRef<number | null>(null);
useEffect(() => {
if (isHover || !hasFocus || timeout === 0 || permanent) return void setElapsed(0);
if (timeout === 0 || permanent) return;
if (isHover) {
if (pause.current === null) pause.current = Date.now();
return;
}
if (pause.current !== null) {
const pausedFor = Date.now() - pause.current;
start.current += pausedFor;
pause.current = null;
}
const intervalId = setInterval(() => {
const elapsed = Date.now() - start;
if (elapsed >= timeout)
const elapsedNow = Date.now() - start.current;
if (elapsedNow >= timeout) {
onClose!();
else
setElapsed(elapsed);
} else {
setElapsed(elapsedNow);
}
}, 10);
return () => clearInterval(intervalId);
}, [timeout, isHover, hasFocus]);
}, [timeout, isHover, permanent]);
const timeoutProgress = elapsed / timeout;

View file

@ -18,17 +18,31 @@
import { Settings } from "@api/Settings";
import { Queue } from "@utils/Queue";
import { createRoot } from "@webpack/common";
import { createRoot, WindowStore } from "@webpack/common";
import type { ReactNode } from "react";
import type { Root } from "react-dom/client";
import NotificationComponent from "./NotificationComponent";
import { persistNotification } from "./notificationLog";
import { openNotificationLogModal, persistNotification } from "./notificationLog";
const NotificationQueue = new Queue();
let reactRoot: Root;
let id = 42;
let missedCount = 0;
window.addEventListener("focus", () => {
const { missed, timeout, useNative } = Settings.notifications;
if (!missed) return;
if (missedCount > 0 && timeout > 0 && useNative === "never") {
showNotification({
title: "While you were away",
body: `${missedCount} notifications received`,
onClick: () => openNotificationLogModal(),
});
missedCount = 0;
}
});
function getRoot() {
if (!reactRoot) {
@ -105,7 +119,17 @@ export async function showNotification(data: NotificationData) {
});
n.onclick = onClick;
n.onclose = onClose;
if (!WindowStore.isFocused()) missedCount++;
} else {
NotificationQueue.push(() => _showNotification(data, id++));
NotificationQueue.push(() =>
_showNotification({
...data,
onClose: () => {
data.onClose?.();
if (!WindowStore.isFocused()) missedCount++;
}
}, id++)
);
}
}

View file

@ -19,11 +19,13 @@
import * as DataStore from "@api/DataStore";
import { Settings } from "@api/Settings";
import { classNameFactory } from "@api/Styles";
import { BaseText } from "@components/BaseText";
import { Flex } from "@components/Flex";
import { Paragraph } from "@components/Paragraph";
import { openNotificationSettingsModal } from "@components/settings/tabs/vencord/NotificationSettings";
import { closeModal, ModalCloseButton, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { useAwaiter } from "@utils/react";
import { Alerts, Button, Forms, ListScrollerThin, React, Text, Timestamp, useEffect, useReducer, useState } from "@webpack/common";
import { Alerts, Button, ListScrollerThin, React, Timestamp, useEffect, useReducer, useState } from "@webpack/common";
import { nanoid } from "nanoid";
import type { DispatchWithoutAction } from "react";
@ -132,9 +134,9 @@ export function NotificationLog({ log, pending }: { log: PersistentNotificationD
return (
<div className={cl("container")}>
<div className={cl("empty")} />
<Forms.FormText style={{ textAlign: "center" }}>
<Paragraph style={{ textAlign: "center" }}>
No notifications yet
</Forms.FormText>
</Paragraph>
</div>
);
@ -156,7 +158,7 @@ function LogModal({ modalProps, close }: { modalProps: ModalProps; close(): void
return (
<ModalRoot {...modalProps} size={ModalSize.LARGE} className={cl("modal")}>
<ModalHeader>
<Text variant="heading-lg/semibold" style={{ flexGrow: 1 }}>Notification Log</Text>
<BaseText size="lg" weight="semibold" style={{ flexGrow: 1 }}>Notification Log</BaseText>
<ModalCloseButton onClick={close} />
</ModalHeader>

View file

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

View file

@ -29,6 +29,7 @@ import { addNicknameIcon, removeNicknameIcon } from "@api/NicknameIcons";
import { Settings, SettingsStore } from "@api/Settings";
import { disableStyle, enableStyle } from "@api/Styles";
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";
@ -36,27 +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;
export function isPluginEnabled(p: string) {
return (
Plugins[p]?.required ||
Plugins[p]?.isDependency ||
settings[p]?.enabled
Settings.plugins[p]?.enabled
) ?? false;
}
export function isPluginRequired(p: string) {
return (
Plugins[p]?.required
) ?? false;
}
@ -95,84 +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.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) {
@ -190,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[] = [];
@ -202,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;
@ -225,7 +156,7 @@ export function subscribePluginFluxEvents(p: Plugin, fluxDispatcher: typeof Flux
const wrappedHandler = p.flux[event] = function () {
if (p.name === "Encryptcord" && event === "MESSAGE_CREATE") return;
try {
const res = handler.apply(p, arguments as any);
const res = handler!.apply(p, arguments as any);
return res instanceof Promise
? res.catch(e => logger.error(`${p.name}: Error while handling ${event}\n`, e))
: res;
@ -245,7 +176,7 @@ export function unsubscribePluginFluxEvents(p: Plugin, fluxDispatcher: typeof Fl
logger.debug("Unsubscribing from flux events of plugin", p.name);
for (const [event, handler] of Object.entries(p.flux)) {
fluxDispatcher.unsubscribe(event as FluxEvents, handler);
fluxDispatcher.unsubscribe(event as FluxEvents, handler!);
}
}
}
@ -263,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) {
@ -313,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}`);
@ -327,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) {
@ -375,12 +325,115 @@ export const stopPlugin = traceFunction("stopPlugin", function stopPlugin(p: Plu
if (onBeforeMessageSend) removeMessagePreSendListener(onBeforeMessageSend);
if (onMessageClick) removeMessageClickListener(onMessageClick);
if (renderChatBarButton) removeChatBarButton(name);
if (chatBarButton || renderChatBarButton) removeChatBarButton(name);
if (renderMemberListDecorator) removeMemberListDecorator(name);
if (renderNicknameIcon) removeNicknameIcon(name);
if (renderMessageDecoration) removeMessageDecoration(name);
if (renderMessageAccessory) removeMessageAccessory(name);
if (renderMessagePopoverButton) removeMessagePopoverButton(name);
if (messagePopoverButton || renderMessagePopoverButton) removeMessagePopoverButton(name);
// Custom
if (renderNicknameIcon) removeNicknameIcon(name);
if (headerBarButton) {
if (headerBarButton.location === "channeltoolbar") {
removeChannelToolbarButton(name);
} else {
removeHeaderBarButton(name);
}
}
if (audioProcessor) removeAudioProcessor(name);
if (userAreaButton) removeUserAreaButton(name);
return true;
}, p => `stopPlugin ${p.name}`);
export const initPluginManager = onlyOnce(function init() {
const pluginsValues = Object.values(Plugins);
const settings = Settings.plugins;
const pluginKeysToBind: Array<keyof PluginDef & `${"on" | "render"}${string}`> = [
"onBeforeMessageEdit", "onBeforeMessageSend", "onMessageClick",
"renderChatBarButton", "renderMemberListDecorator", "renderMessageAccessory", "renderMessageDecoration", "renderMessagePopoverButton",
// Custom
"renderNicknameIcon"
];
const neededApiPlugins = new Set<string>();
// First round-trip to mark and force enable dependencies
//
// FIXME: might need to revisit this if there's ever nested (dependencies of dependencies) dependencies since this only
// goes for the top level and their children, but for now this works okay with the current API plugins
for (const p of pluginsValues) if (isPluginEnabled(p.name)) {
p.dependencies?.forEach(d => {
const dep = Plugins[d];
if (!dep) {
const error = new Error(`Plugin ${p.name} has unresolved dependency ${d}`);
if (IS_DEV) {
throw error;
}
logger.warn(error);
return;
}
settings[d].enabled = true;
dep.isDependency = true;
});
if (p.commands?.length) neededApiPlugins.add("CommandsAPI");
if (p.onBeforeMessageEdit || p.onBeforeMessageSend || p.onMessageClick) neededApiPlugins.add("MessageEventsAPI");
if (p.chatBarButton || p.renderChatBarButton) neededApiPlugins.add("ChatInputButtonAPI");
if (p.renderMemberListDecorator) neededApiPlugins.add("MemberListDecoratorsAPI");
if (p.renderMessageAccessory) neededApiPlugins.add("MessageAccessoriesAPI");
if (p.renderMessageDecoration) neededApiPlugins.add("MessageDecorationsAPI");
if (p.messagePopoverButton || p.renderMessagePopoverButton) neededApiPlugins.add("MessagePopoverAPI");
if (p.userProfileBadge) neededApiPlugins.add("BadgeAPI");
// Custom
if (p.renderNicknameIcon) neededApiPlugins.add("NicknameIconsAPI");
if (p.headerBarButton) neededApiPlugins.add("HeaderBarAPI");
if (p.audioProcessor) neededApiPlugins.add("AudioPlayerAPI");
if (p.userAreaButton) neededApiPlugins.add("UserAreaAPI");
for (const key of pluginKeysToBind) {
p[key] &&= p[key].bind(p) as any;
}
}
for (const p of neededApiPlugins) {
Plugins[p].isDependency = true;
settings[p].enabled = true;
}
for (const p of pluginsValues) {
if (p.settings) {
p.options ??= {};
p.settings.pluginName = p.name;
for (const name in p.settings.def) {
const def = p.settings.def[name];
const checks = p.settings.checks?.[name];
p.options[name] = { ...def, ...checks };
}
}
if (p.options) {
for (const name in p.options) {
const opt = p.options[name];
if (opt.onChange != null) {
SettingsStore.addChangeListener(`plugins.${p.name}.${name}`, opt.onChange);
}
}
}
if (p.patches && isPluginEnabled(p.name)) {
if (!IS_REPORTER || isReporterTestable(p, ReporterTestable.Patches)) {
for (const patch of p.patches) {
addPatch(patch, p.name);
}
}
}
}
});

View file

@ -16,18 +16,27 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { debounce } from "@shared/debounce";
import { SettingsStore as SettingsStoreClass } from "@shared/SettingsStore";
import { localStorage } from "@utils/localStorage";
import { Logger } from "@utils/Logger";
import { mergeDefaults } from "@utils/mergeDefaults";
import { putCloudSettings } from "@utils/settingsSync";
import { DefinedSettings, OptionType, SettingsChecks, SettingsDefinition } from "@utils/types";
import { React, useEffect } from "@webpack/common";
import plugins from "~plugins";
const logger = new Logger("Settings");
export interface SettingsPluginUiElement {
enabled: boolean;
// TODO
/** not implemented for now */
order?: number;
}
export type SettingsPluginUiElements = {
/** id will be whatever id the element was registered with. Usually, but not always, the plugin name */
[id: string]: SettingsPluginUiElement;
};
export interface Settings {
autoUpdate: boolean;
autoUpdateNotification: boolean;
@ -35,6 +44,9 @@ export interface Settings {
eagerPatches: boolean;
enabledThemes: string[];
enabledThemeLinks: string[];
enableOnlineThemes: boolean;
pinnedThemes: string[];
themeNames: Record<string, string>;
enableReactDevtools: boolean;
themeLinks: string[];
frameless: boolean;
@ -63,10 +75,16 @@ export interface Settings {
};
};
uiElements: {
messagePopoverButtons: SettingsPluginUiElements;
chatBarButtons: SettingsPluginUiElements;
},
notifications: {
timeout: number;
position: "top-right" | "bottom-right";
useNative: "always" | "never" | "not-focused";
missed: boolean;
logLimit: number;
};
@ -94,6 +112,9 @@ const DefaultSettings: Settings = {
eagerPatches: IS_REPORTER,
enabledThemes: [],
enabledThemeLinks: [],
enableOnlineThemes: true,
pinnedThemes: [],
themeNames: {},
enableReactDevtools: false,
frameless: false,
transparent: false,
@ -103,10 +124,16 @@ const DefaultSettings: Settings = {
winNativeTitleBar: false,
plugins: {},
uiElements: {
chatBarButtons: {},
messagePopoverButtons: {}
},
notifications: {
timeout: 5000,
position: "bottom-right",
useNative: "not-focused",
missed: true,
logLimit: 50
},
@ -125,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({
@ -175,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);
});
}
@ -210,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);
@ -221,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;
@ -274,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: "",
@ -296,7 +343,7 @@ type ResolveUseSettings<T extends object> = {
Key extends string
? T[Key] extends Record<string, unknown>
// @ts-expect-error "Type instantiation is excessively deep and possibly infinite"
? UseSettings<T[Key]> extends string ? `${Key}.${UseSettings<T[Key]>}` : never
? `${Key}.*` | (ResolveUseSettings<T[Key]> extends Record<string, string> ? `${Key}.${ResolveUseSettings<T[Key]>[keyof T[Key]]}` : never)
: Key
: never;
};

View file

@ -1,59 +1,21 @@
/*
* 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 { Alerts, OAuth2AuthorizeModal, UserStore } from "@webpack/common";
import { Logger } from "@utils/Logger";
import { openModal } from "@utils/modal";
import { OAuth2AuthorizeModal, UserStore } from "@webpack/common";
import { Logger } from "./Logger";
import { openModal } from "./modal";
import { relaunch } from "./native";
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;
export async function checkCloudUrlCsp() {
if (IS_WEB) return true;
const { host } = getCloudUrl();
if (host === "api.vencord.dev") return true;
if (host === "cloud.equicord.org") return true;
if (await VencordNative.csp.isDomainAllowed(Settings.cloud.url, ["connect-src"])) {
return true;
}
const res = await VencordNative.csp.requestAddOverride(Settings.cloud.url, ["connect-src"], "Cloud Sync");
if (res === "ok") {
Alerts.show({
title: "Cloud Integration enabled",
body: `${host} has been added to the whitelist. Please restart the app for the changes to take effect.`,
confirmText: "Restart now",
cancelText: "Later!",
onConfirm: relaunch
});
}
return false;
}
const getUserId = () => {
const id = UserStore.getCurrentUser()?.id;
if (!id) throw new Error("User not yet logged in");
@ -104,8 +66,6 @@ export async function authorizeCloud() {
return;
}
if (!await checkCloudUrlCsp()) return;
try {
const oauthConfiguration = await fetch(new URL("/v1/oauth/settings", getCloudUrl()));
var { clientId, redirectUri } = await oauthConfiguration.json();
@ -138,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",
@ -153,7 +113,7 @@ export async function authorizeCloud() {
Settings.cloud.authenticated = false;
}
} catch (e: any) {
cloudLogger.error("Failed to authorize", e);
logger.error("Failed to authorize", e);
showNotification({
title: "Cloud Integration",
body: `Setup failed (${e.toString()}).`

View file

@ -1,124 +1,23 @@
/*
* 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 { DataStore } from "@api/index";
import { showNotification } from "@api/Notifications";
import { PlainSettings, Settings } from "@api/Settings";
import { moment, SettingsRouter, Toasts } from "@webpack/common";
import { Logger } from "@utils/Logger";
import { relaunch } from "@utils/native";
import { openUserSettingsPanel } from "@webpack/common";
import { deflateSync, inflateSync } from "fflate";
import { checkCloudUrlCsp, getCloudAuth, getCloudUrl } from "./cloud";
import { Logger } from "./Logger";
import { relaunch } from "./native";
import { chooseFile, saveFile } from "./web";
import { deauthorizeCloud, getCloudAuth, getCloudUrl } from "./cloudSetup";
import { exportSettings, importSettings } from "./offline";
export async function importSettings(data: string) {
try {
var parsed = JSON.parse(data);
} catch (err) {
console.log(data);
throw new Error("Failed to parse JSON: " + String(err));
}
if ("settings" in parsed && "quickCss" in parsed) {
Object.assign(PlainSettings, parsed.settings);
await VencordNative.settings.set(parsed.settings);
await VencordNative.quickCss.set(parsed.quickCss);
if (parsed.dataStore) await DataStore.setMany(parsed.dataStore);
} else
throw new Error("Invalid Settings. Is this even an Equicord Settings file?");
}
export async function exportSettings({ minify }: { minify?: boolean; } = {}) {
const settings = VencordNative.settings.get();
const quickCss = await VencordNative.quickCss.get();
const dataStore = await DataStore.entries();
return JSON.stringify({ settings, quickCss, dataStore }, null, minify ? undefined : 4);
}
export async function downloadSettingsBackup() {
const filename = `equicord-settings-backup-${moment().format("YYYY-MM-DD")}.json`;
const backup = await exportSettings();
const data = new TextEncoder().encode(backup);
if (IS_DISCORD_DESKTOP) {
DiscordNative.fileManager.saveWithDialog(data, filename);
} else {
saveFile(new File([data], filename, { type: "application/json" }));
}
}
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)}`);
export async function uploadSettingsBackup(showToast = true): Promise<void> {
if (IS_DISCORD_DESKTOP) {
const [file] = await DiscordNative.fileManager.openFiles({
filters: [
{ name: "Equicord Settings Backup", extensions: ["json"] },
{ name: "all", extensions: ["*"] }
]
});
if (file) {
try {
await importSettings(new TextDecoder().decode(file.data));
if (showToast) toastSuccess();
} catch (err) {
new Logger("SettingsSync").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);
if (showToast) toastSuccess();
} catch (err) {
new Logger("SettingsSync").error(err);
if (showToast) toastFailure(err);
}
};
reader.readAsText(file);
}
}
// Cloud settings
const cloudSettingsLogger = new Logger("Cloud:Settings", "#39b7e0");
const logger = new Logger("SettingsSync:Cloud", "#39b7e0");
export async function putCloudSettings(manual?: boolean) {
const settings = await exportSettings({ minify: true });
if (!await checkCloudUrlCsp()) return;
const settings = await exportSettings({ syncDataStore: false, minify: true });
try {
const res = await fetch(new URL("/v1/settings", getCloudUrl()), {
@ -127,11 +26,11 @@ export async function putCloudSettings(manual?: boolean) {
Authorization: await getCloudAuth(),
"Content-Type": "application/octet-stream"
},
body: deflateSync(new TextEncoder().encode(settings))
body: deflateSync(new TextEncoder().encode(settings)) as Uint8Array<ArrayBuffer>
});
if (!res.ok) {
cloudSettingsLogger.error(`Failed to sync up, API returned ${res.status}`);
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}).`,
@ -144,7 +43,7 @@ export async function putCloudSettings(manual?: boolean) {
PlainSettings.cloud.settingsSyncVersion = written;
VencordNative.settings.set(PlainSettings);
cloudSettingsLogger.info("Settings uploaded to cloud successfully");
logger.info("Settings uploaded to cloud successfully");
if (manual) {
showNotification({
@ -154,7 +53,7 @@ export async function putCloudSettings(manual?: boolean) {
});
}
} catch (e: any) {
cloudSettingsLogger.error("Failed to sync up", e);
logger.error("Failed to sync up", e);
showNotification({
title: "Cloud Settings",
body: `Could not synchronize settings to the cloud (${e.toString()}).`,
@ -164,8 +63,6 @@ export async function putCloudSettings(manual?: boolean) {
}
export async function getCloudSettings(shouldNotify = true, force = false) {
if (!await checkCloudUrlCsp()) return;
try {
const res = await fetch(new URL("/v1/settings", getCloudUrl()), {
method: "GET",
@ -180,9 +77,9 @@ export async function getCloudSettings(shouldNotify = true, force = false) {
// 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 Vencloud App. You can enable it again by connecting this account in Cloud Settings. (note: it will store your preferences separately)",
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;
@ -190,7 +87,7 @@ export async function getCloudSettings(shouldNotify = true, force = false) {
}
if (res.status === 404) {
cloudSettingsLogger.info("No settings on the cloud");
logger.info("No settings on the cloud");
if (shouldNotify)
showNotification({
title: "Cloud Settings",
@ -201,7 +98,7 @@ export async function getCloudSettings(shouldNotify = true, force = false) {
}
if (res.status === 304) {
cloudSettingsLogger.info("Settings up to date");
logger.info("Settings up to date");
if (shouldNotify)
showNotification({
title: "Cloud Settings",
@ -212,7 +109,7 @@ export async function getCloudSettings(shouldNotify = true, force = false) {
}
if (!res.ok) {
cloudSettingsLogger.error(`Failed to sync down, API returned ${res.status}`);
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}).`,
@ -238,13 +135,13 @@ export async function getCloudSettings(shouldNotify = true, force = false) {
const data = await res.arrayBuffer();
const settings = new TextDecoder().decode(inflateSync(new Uint8Array(data)));
await importSettings(settings);
await importSettings(settings, "all", true);
// sync with server timestamp instead of local one
PlainSettings.cloud.settingsSyncVersion = written;
VencordNative.settings.set(PlainSettings);
cloudSettingsLogger.info("Settings loaded from cloud successfully");
logger.info("Settings loaded from cloud successfully");
if (shouldNotify)
showNotification({
title: "Cloud Settings",
@ -256,7 +153,7 @@ export async function getCloudSettings(shouldNotify = true, force = false) {
return true;
} catch (e: any) {
cloudSettingsLogger.error("Failed to sync down", e);
logger.error("Failed to sync down", e);
showNotification({
title: "Cloud Settings",
body: `Could not synchronize settings from the cloud (${e.toString()}).`,
@ -268,8 +165,6 @@ export async function getCloudSettings(shouldNotify = true, force = false) {
}
export async function deleteCloudSettings() {
if (!await checkCloudUrlCsp()) return;
try {
const res = await fetch(new URL("/v1/settings", getCloudUrl()), {
method: "DELETE",
@ -277,7 +172,7 @@ export async function deleteCloudSettings() {
});
if (!res.ok) {
cloudSettingsLogger.error(`Failed to delete, API returned ${res.status}`);
logger.error(`Failed to delete, API returned ${res.status}`);
showNotification({
title: "Cloud Settings",
body: `Could not delete settings (API returned ${res.status}).`,
@ -286,14 +181,14 @@ export async function deleteCloudSettings() {
return;
}
cloudSettingsLogger.info("Settings deleted from cloud successfully");
logger.info("Settings deleted from cloud successfully");
showNotification({
title: "Cloud Settings",
body: "Settings deleted from cloud!",
color: "var(--green-360)"
});
} catch (e: any) {
cloudSettingsLogger.error("Failed to delete", e);
logger.error("Failed to delete", e);
showNotification({
title: "Cloud Settings",
body: `Could not delete settings (${e.toString()}).`,
@ -301,3 +196,29 @@ export async function deleteCloudSettings() {
});
}
}
export async function eraseAllCloudData() {
const res = await fetch(new URL("/v1/", getCloudUrl()), {
method: "DELETE",
headers: { Authorization: await getCloudAuth() }
});
if (!res.ok) {
logger.error(`Failed to erase data, API returned ${res.status}`);
showNotification({
title: "Cloud Integrations",
body: `Could not erase all data (API returned ${res.status}), please contact support.`,
color: "var(--red-360)"
});
return;
}
Settings.cloud.authenticated = false;
await deauthorizeCloud();
showNotification({
title: "Cloud Integrations",
body: "Successfully erased all data.",
color: "var(--green-360)"
});
}

View file

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

View file

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

View file

@ -17,18 +17,12 @@
*/
import { Settings, SettingsStore } from "@api/Settings";
import { ThemeStore } from "@webpack/common";
import { createAndAppendStyle } from "@utils/css";
import { ThemeStore } from "@vencord/discord-types";
let style: HTMLStyleElement;
let themesStyle: HTMLStyleElement;
function createStyle(id: string) {
const style = document.createElement("style");
style.id = id;
document.documentElement.append(style);
return style;
}
async function initSystemValues() {
const values = await VencordNative.themes.getSystemValues();
const variables = Object.entries(values)
@ -36,13 +30,13 @@ async function initSystemValues() {
.map(([k, v]) => `--${k}: ${v};`)
.join("");
createStyle("vencord-os-theme-values").textContent = `:root{${variables}}`;
createAndAppendStyle("vencord-os-theme-values").textContent = `:root{${variables}}`;
}
async function toggle(isEnabled: boolean) {
if (!style) {
if (isEnabled) {
style = createStyle("vencord-custom-css");
style = createAndAppendStyle("vencord-custom-css");
VencordNative.quickCss.addChangeListener(css => {
style.textContent = css;
// At the time of writing this, changing textContent resets the disabled state
@ -55,18 +49,19 @@ async function toggle(isEnabled: boolean) {
}
async function initThemes() {
themesStyle ??= createStyle("vencord-themes");
themesStyle ??= createAndAppendStyle("vencord-themes");
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;
@ -108,16 +103,16 @@ document.addEventListener("DOMContentLoaded", () => {
}
}, { once: true });
export function initQuickCssThemeStore() {
export function initQuickCssThemeStore(themeStore: ThemeStore) {
if (IS_USERSCRIPT) return;
initThemes();
let currentTheme = ThemeStore.theme;
ThemeStore.addChangeListener(() => {
if (currentTheme === ThemeStore.theme) return;
let currentTheme = themeStore.theme;
themeStore.addChangeListener(() => {
if (currentTheme === themeStore.theme) return;
currentTheme = ThemeStore.theme;
currentTheme = themeStore.theme;
initThemes();
});
}

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

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

View file

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

View file

@ -16,11 +16,13 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import * as $AudioPlayer from "./AudioPlayer";
import * as $Badges from "./Badges";
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";
@ -30,34 +32,40 @@ 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";
/**
* An API allowing you to listen to Message Clicks or run your own logic
* before a message is sent
* before a message is sent.
*
* If your plugin uses this, you must add MessageEventsAPI to its dependencies
* If your plugin uses this, you must add MessageEventsAPI to its dependencies.
*/
export const MessageEvents = $MessageEventsAPI;
/**
* An API allowing you to create custom notices
* (snackbars on the top, like the Update prompt)
* (snackbars on the top, like the Update prompt).
*/
export const Notices = $Notices;
/**
* An API allowing you to register custom commands
* An API allowing you to register custom commands.
*/
export const Commands = $Commands;
/**
* A wrapper around IndexedDB. This can store arbitrarily
* large data and supports a lot of datatypes (Blob, Map, ...).
* For a full list, see the mdn link below
* For a full list, see the mdn link below.
*
* This should always be preferred over the Settings API if possible, as
* localstorage has very strict size restrictions and blocks the event loop
* localstorage has very strict size restrictions and blocks the event loop.
*
* Make sure your keys are unique (tip: prefix them with ur plugin name)
* and please clean up no longer needed entries.
@ -66,70 +74,99 @@ export const Commands = $Commands;
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm#supported_types}
*/
export const DataStore = $DataStore;
/**
* An API allowing you to add custom components as message accessories
* An API allowing you to add custom components as message accessories.
*/
export const MessageAccessories = $MessageAccessories;
/**
* An API allowing you to add custom buttons in the message popover
* An API allowing you to add custom buttons in the message popover.
*/
export const MessagePopover = $MessagePopover;
/**
* An API allowing you to add badges to user profiles
* An API allowing you to add badges to user profiles.
*/
export const Badges = $Badges;
/**
* An API allowing you to add custom elements to the server list
* An API allowing you to add custom elements to the server list.
*/
export const ServerList = $ServerList;
/**
* An API allowing you to add components as message accessories
* An API allowing you to add components as message accessories.
*/
export const MessageDecorations = $MessageDecorations;
/**
* An API allowing you to add components to member list users, in both DM's and servers
* An API allowing you to add components to member list users, in both DM's and servers.
*/
export const MemberListDecorators = $MemberListDecorators;
/**
* An API allowing you to persist data
* An API allowing you to persist data.
*/
export const Settings = $Settings;
/**
* An API allowing you to dynamically load styles
* An API allowing you to dynamically load styles.
* a
*/
export const Styles = $Styles;
/**
* An API allowing you to display notifications
* An API allowing you to display notifications.
*/
export const Notifications = $Notifications;
/**
* An api allowing you to patch and add/remove items to/from context menus
* An api allowing you to patch and add/remove items to/from context menus.
*/
export const ContextMenu = $ContextMenu;
/**
* An API allowing you to add buttons to the chat input
* An API allowing you to add buttons to the chat input.
*/
export const ChatButtons = $ChatButtons;
/**
* An API allowing you to update and re-render messages
* 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.
*/
export const MessageUpdater = $MessageUpdater;
/**
* An API allowing you to get an user setting
* An API allowing you to get an user setting.
*/
export const UserSettings = $UserSettings;
/**
* An API allowing you to add icons to the nickname, in profiles
* Don't use this
*/
export const Themes = $Themes;
/**
* An API allowing you to add icons to the nickname, in profiles.
*/
export const NicknameIcons = $NicknameIcons;
/**
* Just used to identify if user is on Equicord as Vencord doesnt have this
* An API allowing you to play internal Discord audio files or external audio URLs/URIs.
*/
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.
*/
export const isEquicord = true;

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

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

View file

@ -0,0 +1,8 @@
.vc-text-base {
font-family: var(--font-primary);
color: var(--text-default);
line-height: normal;
/* Discord puts an insane default margin on p tags, so reset that here */
margin: 0;
}

124
src/components/BaseText.tsx Normal file
View file

@ -0,0 +1,124 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./BaseText.css";
import { classNameFactory } from "@api/Styles";
import { classes } from "@utils/misc";
import type { Text as DiscordText } from "@vencord/discord-types";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
const textCls = classNameFactory("vc-text-");
export const TextSizes = {
xxs: "0.625rem",
xs: "0.75rem",
sm: "0.875rem",
md: "1rem",
lg: "1.25rem",
xl: "1.5rem",
xxl: "2rem"
} as const;
export const TextWeights = {
thin: "100",
extralight: "200",
light: "300",
normal: "400",
medium: "500",
semibold: "600",
bold: "700",
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(TextSizes)) {
css += `.${textCls(size)}{font-size:${value};}`;
}
for (const [weight, value] of Object.entries(TextWeights)) {
css += `.${textCls(weight)}{font-weight:${value};}`;
}
return css;
}
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;
};
export function BaseText<T extends TextTag = "div">(props: BaseTextProps<T>): ReactNode {
const {
size = "md",
weight = "normal",
color,
tag: Tag = "div",
children,
className,
style,
...restProps
} = props;
return (
<Tag
className={classes(textCls("base", size, weight), className)}
style={{
...style,
...(color && { color: TextColors[color] })
}}
{...restProps}
>
{children}
</Tag>
);
}
// #region Old compatibility
export const TextCompat: DiscordText = function TextCompat({ color, variant, ...restProps }) {
const newBaseTextProps = restProps as BaseTextProps;
if (variant) {
const [left, right] = variant.split("/");
if (left && right) {
const size = left.split("-").pop();
newBaseTextProps.size = size as TextSize;
newBaseTextProps.weight = right as TextWeight;
}
}
if (color) {
newBaseTextProps.style ??= {};
newBaseTextProps.style.color = `var(--${color}, var(--text-default))`;
}
return <BaseText {...newBaseTextProps} />;
};
// #endregion

198
src/components/Button.css Normal file
View file

@ -0,0 +1,198 @@
.vc-btn-base {
position: relative;
display: flex;
justify-content: center;
align-items: center;
max-width: 100%;
border: 1px solid transparent;
border-radius: var(--radius-sm, 8px);
font-family: var(--font-primary);
text-align: start;
transition: 50ms ease-in;
transition-property: background-color, color, border-color, opacity;
background: var(--control-secondary-background-default);
color: var(--text-default);
white-space: nowrap;
&:hover {
transition: .15s ease-out;
}
&:disabled {
opacity: .5;
pointer-events: none;
cursor: not-allowed;
}
&:focus-visible {
/* stylelint-disable-next-line custom-property-pattern */
box-shadow: 0 0 0 4px var(--__adaptive-focus-ring-color, var(--border-focus, #00b0f4));
}
}
.vc-btn-min,
.vc-btn-xs {
padding: 3px 7px;
min-height: 22px;
min-width: unset;
font-size: 12px;
font-weight: 400;
line-height: 1.3333;
}
.vc-btn-xs {
min-width: 60px;
}
.vc-btn-small {
padding: 3px 11px;
min-height: 30px;
min-width: 60px;
font-size: 14px;
font-weight: 500;
line-height: 1.2857;
}
.vc-btn-medium {
padding: 7px 15px;
min-height: 38px;
min-width: 100px;
font-size: 16px;
font-weight: 500;
line-height: 1.25;
}
.vc-btn-primary {
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-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-secondary-background-default);
border-color: var(--control-secondary-border-default);
color: var(--control-secondary-text-default);
&: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-critical-primary-background-default);
border-color: var(--control-critical-primary-border-default);
color: var(--control-critical-primary-text-default);
&: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-critical-secondary-background-default);
border-color: var(--control-critical-secondary-border-default);
color: var(--control-critical-secondary-text-default);
&: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-overlay-primary-background-default);
border-color: var(--control-overlay-primary-border-default);
color: var(--control-overlay-primary-text-default);
&: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(--redesign-button-positive-background);
color: var(--white);
&:hover {
background-color: var(--redesign-button-positive-pressed-background);
}
}
.vc-btn-none {
background-color: transparent;
border-color: transparent;
color: var(--control-icon-only-icon-default);
&:hover {
background-color: var(--control-icon-only-background-hover);
border-color: var(--control-icon-only-border-hover);
color: var(--control-icon-only-icon-hover);
}
}
.vc-btn-link-icon {
width: 0.875em;
height: 0.875em;
margin-left: 8px;
flex-shrink: 0;
}
.vc-text-btn-base {
display: inline-flex;
justify-content: center;
align-items: center;
gap: var(--space-4, 4px);
background: initial;
color: var(--text-default);
font-size: medium;
font-weight: 400;
margin: 0;
padding: 0;
text-align: start;
text-decoration: none;
max-width: 100%;
white-space: nowrap;
&:disabled {
opacity: 0.5;
}
&:hover {
text-decoration: underline;
}
&:focus-visible {
/* stylelint-disable-next-line custom-property-pattern */
box-shadow: 0 0 0 4px var(--__adaptive-focus-ring-color, var(--border-focus, #00b0f4));
}
}
.vc-text-btn-primary {
color: var(--text-brand);
}
.vc-text-btn-secondary {
color: var(--text-strong, var(--text-default));
}
.vc-text-btn-danger {
color: var(--text-feedback-critical);
}
.vc-text-btn-link {
color: var(--text-link);
}

122
src/components/Button.tsx Normal file
View file

@ -0,0 +1,122 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./Button.css";
import { classNameFactory } from "@api/Styles";
import { classes } from "@utils/misc";
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-");
export type ButtonVariant =
"primary" | "secondary" | "dangerPrimary" | "dangerSecondary" | "overlayPrimary" | "positive" | "link" | "none";
export type ButtonSize = "min" | "xs" | "small" | "medium";
export type ButtonProps = ComponentPropsWithRef<"button"> & {
variant?: ButtonVariant;
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}>
{children}
{variant === "link" && <OpenExternalIcon className={btnCls("link-icon")} />}
</button>
);
}
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"> & {
variant?: TextButtonVariant;
};
export function TextButton({ variant = "primary", className, ...restProps }: TextButtonProps) {
return (
<button className={classes(textBtnCls("base", variant), className)} {...restProps} />
);
}
// #region Old compability
export const ButtonCompat: DiscordButton = function ButtonCompat({ look, color = "BRAND", size = "medium", ...restProps }) {
return look === "LINK"
? <TextButton variant={TextButtonPropsColorMapping[color]} {...restProps as TextButtonProps} />
: <Button variant={ButtonColorMapping[color]} size={size as ButtonSize} {...restProps as ButtonProps} />;
};
/** @deprecated */
ButtonCompat.Looks = {
FILLED: "",
LINK: "LINK"
} as const;
/** @deprecated */
ButtonCompat.Colors = {
BRAND: "BRAND",
PRIMARY: "PRIMARY",
RED: "RED",
TRANSPARENT: "TRANSPARENT",
CUSTOM: "CUSTOM",
GREEN: "GREEN",
LINK: "LINK",
WHITE: "WHITE",
} as const;
const ButtonColorMapping: Record<keyof typeof ButtonCompat["Colors"], ButtonProps["variant"]> = {
BRAND: "primary",
PRIMARY: "secondary",
RED: "dangerPrimary",
TRANSPARENT: "secondary",
CUSTOM: "none",
GREEN: "positive",
LINK: "link",
WHITE: "overlayPrimary"
};
const TextButtonPropsColorMapping: Record<keyof typeof ButtonCompat["Colors"], TextButtonProps["variant"]> = {
BRAND: "primary",
PRIMARY: "primary",
RED: "danger",
TRANSPARENT: "secondary",
CUSTOM: "secondary",
GREEN: "primary",
LINK: "link",
WHITE: "secondary"
};
/** @deprecated */
ButtonCompat.Sizes = {
SMALL: "small",
MEDIUM: "medium",
LARGE: "medium",
XLARGE: "medium",
NONE: "min",
MIN: "min"
} as const;
// #endregion

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

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

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

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

View file

@ -0,0 +1,6 @@
.vc-divider {
height: 1px;
width: 100%;
border: none;
border-top: 1px solid var(--border-subtle);
}

View file

@ -0,0 +1,21 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./Divider.css";
import { classes } from "@utils/misc";
import type { ComponentPropsWithoutRef } from "react";
export type DividerProps = ComponentPropsWithoutRef<"hr">;
export function Divider({ className, ...restProps }: DividerProps) {
return (
<hr
className={classes("vc-divider", className)}
{...restProps}
/>
);
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,47 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./FormSwitch.css";
import { classes } from "@utils/misc";
import type { PropsWithChildren, ReactNode } from "react";
import { Flex } from "./Flex";
import { Span } from "./Span";
import { Switch } from "./Switch";
export interface FormSwitchProps {
title: ReactNode;
description?: ReactNode;
value: boolean;
onChange(value: boolean): void;
className?: string;
disabled?: boolean;
hideBorder?: boolean;
}
export function FormSwitch({ onChange, title, value, description, disabled, className, hideBorder }: FormSwitchProps) {
return (
<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>
</label>
</div>
);
}
export function FormSwitchCompat({ note, children, ...restProps }: PropsWithChildren<any>) {
return <FormSwitch title={children ?? ""} description={note} {...restProps} />;
}

View file

@ -0,0 +1,65 @@
.vc-h1,
.vc-h2 {
color: var(--text-strong);
font-weight: 600;
}
.vc-h3,
.vc-h4,
.vc-h5 {
color: var(--text-strong);
}
.vc-h1 {
font-size: 20px;
line-height: 24px
}
.vc-h2 {
font-size: 16px;
line-height: 20px
}
.vc-h3 {
font-weight: 500;
line-height: 24px
}
.vc-h3,
.vc-h4 {
font-size: 16px
}
.vc-h4 {
font-weight: 600;
letter-spacing: .3px
}
.vc-h4,
.vc-h5 {
line-height: 20px
}
.vc-h5 {
color: var(--text-strong);
font-size: 16px;
font-weight: 500;
margin-bottom: 8px;
text-transform: unset
}
.vc-h1-defaultMargin,
.vc-h2-defaultMargin {
margin-bottom: 20px
}
/* This is copied from Discord, don't ask me why 0 margin */
.vc-h4-defaultMargin {
margin-bottom: 0;
margin-top: 0;
}
.vc-h3-defaultMargin,
.vc-h5-defaultMargin {
margin-bottom: 8px
}

View file

@ -0,0 +1,60 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./Heading.css";
import { classes } from "@utils/misc";
import type { ComponentPropsWithoutRef } from "react";
export type HeadingTag = `h${1 | 2 | 3 | 4 | 5 | 6}`;
export type HeadingProps<Tag extends HeadingTag> = ComponentPropsWithoutRef<Tag> & {
tag?: Tag;
};
/**
* A simple heading component that automatically sizes according to the tag used.
*
* If you need more control, use the BaseText component instead.
*/
export function Heading<T extends HeadingTag>(props: HeadingProps<T>) {
const {
tag: Tag = "h5",
children,
className,
...restProps
} = props;
return (
<Tag className={classes(`vc-${Tag}`, !className && `vc-${Tag}-defaultMargin`, className)} {...restProps}>
{children}
</Tag>
);
}
export function HeadingPrimary({ children, ...restProps }: HeadingProps<"h2">) {
return (
<Heading tag="h2" {...restProps}>
{children}
</Heading>
);
}
export function HeadingSecondary({ children, ...restProps }: HeadingProps<"h3">) {
return (
<Heading tag="h3" {...restProps}>
{children}
</Heading>
);
}
export function HeadingTertiary({ children, ...restProps }: HeadingProps<"h4">) {
return (
<Heading tag="h4" {...restProps}>
{children}
</Heading>
);
}

View file

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

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

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

View file

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

View file

@ -0,0 +1,17 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { BaseText, type BaseTextProps } from "./BaseText";
export type ParagraphProps = BaseTextProps<"p">;
export function Paragraph({ children, size = "sm", weight = "normal", ...restProps }: ParagraphProps) {
return (
<BaseText tag="p" size={size} weight={weight} {...restProps}>
{children}
</BaseText>
);
}

17
src/components/Span.tsx Normal file
View file

@ -0,0 +1,17 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { BaseText, type BaseTextProps } from "./BaseText";
export type SpanProps = BaseTextProps<"span">;
export function Span({ children, size = "sm", weight = "normal", ...restProps }: SpanProps) {
return (
<BaseText tag="span" size={size} weight={weight} {...restProps}>
{children}
</BaseText>
);
}

57
src/components/Switch.css Normal file
View file

@ -0,0 +1,57 @@
.vc-switch-container {
background: var(--primary-400);
border: 1px solid transparent;
border-radius: 16px;
box-sizing: border-box;
cursor: pointer;
height: 28px;
position: relative;
width: 44px;
.high-contrast-mode & {
border-color: var(--border-strong);
}
}
.vc-switch-checked {
background: var(--brand-500);
border-color: var(--control-primary-border-default);
}
.vc-switch-disabled {
cursor: not-allowed;
opacity: 0.3;
}
.vc-switch-focusVisible {
/* stylelint-disable-next-line custom-property-pattern */
box-shadow: 0 0 0 4px var(--__adaptive-focus-ring-color, var(--border-focus, #00b0f4));
}
.vc-switch-slider {
display: block;
height: 20px;
left: 0;
margin: 3px;
position: absolute;
width: 28px;
transition: 100ms transform ease-in-out;
overflow: visible;
}
.vc-switch-input {
border-radius: 14px;
cursor: pointer;
height: 100%;
left: 0;
margin: 0;
opacity: 0;
position: absolute;
top: 0;
width: 100%;
&:disabled {
pointer-events: none;
cursor: not-allowed
}
}

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