Compare commits

..

91 commits

Author SHA1 Message Date
Lewis Crichton 269ed18fcc
merge: dev 2024-01-19 17:00:00 +00:00
Lewis Crichton f793f9eb6b
Merge branch 'dev' into feat/usercss 2024-01-14 22:10:10 +00:00
Lewis Crichton 686bb118b4
fix: settings reset not refreshing 2024-01-14 16:37:24 +00:00
Lewis Crichton 6c551650d0
feat: add reset to default button 2024-01-14 16:24:40 +00:00
Lewis Crichton 3cf60f8daa
fix: broken checkbox default parsing 2024-01-14 16:11:26 +00:00
Lewis Crichton e350883b72
chore: remove unneeded import 2024-01-14 13:28:28 +00:00
Lewis Crichton 0aa399c8ed
Merge branch 'dev' into feat/usercss 2024-01-14 13:13:07 +00:00
Lewis Crichton 2ae1a70f7f
Merge branch 'dev' into feat/usercss 2024-01-07 00:21:11 +00:00
Lewis Crichton e947f6aa74
Merge branch 'feat/usercss' of ssh://github.com/lewisakura/Vencord into feat/usercss 2024-01-04 16:07:54 +00:00
Lewis Crichton 0b8429f40c
Merge branch 'dev' of ssh://github.com/Vendicated/Vencord into feat/usercss 2024-01-04 16:07:32 +00:00
Lewis Crichton 15749dc8ce
Merge branch 'dev' into feat/usercss 2024-01-04 16:07:22 +00:00
Lewis Crichton 02272c5b46
perf: dont use cloned array, iterate backwards 2023-12-31 01:10:32 +00:00
Lewis Crichton 6a3c592d5f
fix: hacky way of checking if themes are valid 2023-12-31 00:29:00 +00:00
Lewis Crichton 1c0dff3666
feat: pass vencord variable when compiling
this is another one of those standards-defining things like
`vc-requiredPlugins`, but i do genuinely think passing the client mod
that's in use here will be beneficial in the long run for usercss
authors since they can write one file that works across multiple.
hopefully other mods that implement usercss will adopt this 🤞
2023-12-28 14:05:25 +00:00
Lewis Crichton 2cafedc7e7
feat: make missing plugins prevent enabling the theme 2023-12-28 13:59:36 +00:00
Lewis Crichton 9b89ef58be
feat: better buttons and icons for theme import/export 2023-12-28 13:50:29 +00:00
Lewis Crichton 18b1fe0413
feat: clipboard copying of theme settings
this also removes the stupid state management that every theme setting
had, which I do not remember the justification for but was completely
unnecessary and broke rendering when you update the settings from
outside of these components
2023-12-28 13:23:18 +00:00
Lewis Crichton 85bd99c2ca
fix: web native stub 2023-12-28 02:44:40 +00:00
Lewis Crichton 0413a2399a
Merge branch 'dev' of ssh://github.com/Vendicated/Vencord into feat/usercss 2023-12-28 02:03:22 +00:00
Lewis Crichton 42307ccc0e
fix: Vencord_cloudSecret check (#2077)
finally got around to fixing it - `null` is never a valid return value,
it's `undefined` 🤦
2023-12-28 02:02:49 +00:00
Lewis Crichton 669c7f3964
chore: error handling 2023-12-27 17:53:06 +00:00
Lewis Crichton 85b510b881
merge: dev 2023-12-26 14:50:01 +00:00
Lewis Crichton c25e8ac8c1
Merge branch 'dev' into feat/usercss 2023-12-13 23:20:31 +00:00
Lewis Crichton 34e1c83756
fix: text strings not being done properly 2023-11-30 19:23:26 +00:00
Lewis Crichton ea864b9a00
fix: compiler not properly inserting booleans for less/stylus 2023-11-30 19:04:22 +00:00
Lewis Crichton d7e5c06e83
Merge branch 'dev' into feat/usercss 2023-11-30 17:15:21 +00:00
Lewis Crichton 39860bd05c
fix: make more specific (@Nuckyz) 2023-11-28 19:52:41 +00:00
Lewis Crichton e3aab2b864
Merge branch 'dev' into feat/usercss 2023-11-28 19:51:50 +00:00
Lewis Crichton 66a27a1e79
Merge branch 'dev' into feat/usercss 2023-11-28 18:58:41 +00:00
V 58c6611abe
VoiceMessages: fix preview being blank 2023-11-27 16:05:52 +01:00
Nuckyz fc10bc1e69
Utility function for loading Discord chunks (#2017) 2023-11-27 02:56:57 -03:00
Lewis Crichton f4b846375f
chore: ???? 2023-11-25 17:03:56 +00:00
Lewis Crichton 6881ddbea7
chore: shhhhhh 2023-11-25 17:02:40 +00:00
Lewis Crichton c981325fb3
fix: colorpicker (sorta)
this now uses the lazy-loaded colorpicker, but this only works if said
colorpicker has already been loaded. need to force load before anything.
2023-11-25 17:00:10 +00:00
Lewis Crichton cbdaf7daa6
Merge branch 'feat/usercss' of ssh://github.com/lewisakura/Vencord into feat/usercss 2023-11-25 16:28:39 +00:00
Lewis Crichton f68351b31b
Merge branch 'dev' of ssh://github.com/Vendicated/Vencord into feat/usercss 2023-11-25 16:28:34 +00:00
Lewis Crichton a911dd17b1
Merge branch 'dev' into feat/usercss 2023-11-25 16:28:22 +00:00
Nuckyz 867730a478
Simplify some components finds; Make undo of patch groups more clear 2023-11-24 23:14:18 -03:00
Lewis Crichton 828a882017
Merge branch 'dev' into feat/usercss 2023-11-21 23:49:25 +00:00
Lewis Crichton a57ab38c8c
Merge branch 'dev' into feat/usercss 2023-11-09 01:36:20 +00:00
Lewis Crichton d544d33564
Merge branch 'dev' into feat/usercss 2023-11-07 23:18:01 +00:00
zImPatrick dc3591ba18
Fix FakeNitro sticker bypass (#1964) 2023-11-07 15:58:10 -03:00
Marvin Witt c0f786804a
fix(dearrow): don't replace thumbnail if only original available (#1959) 2023-11-05 02:06:08 +01:00
Lewis Crichton 31fd035bd3
Merge branch 'dev' into feat/usercss 2023-10-26 21:56:29 +01:00
Lewis Crichton 4dbffcb8b8
merge: dev 2023-10-26 21:45:00 +01:00
Lewis Crichton c12dd258a6
style: grr 2023-10-16 22:54:29 +01:00
Lewis Crichton b6547b463b
feat: @vc-requiredPlugins 2023-10-16 22:53:37 +01:00
Lewis Crichton b7cdb96e09
Merge branch 'dev' into feat/usercss 2023-10-16 21:21:44 +01:00
Lewis Crichton ff32014613
Merge branch 'feat/usercss' of ssh://github.com/lewisakura/Vencord into feat/usercss 2023-10-13 16:22:21 +01:00
Lewis Crichton 791eaa06d4
merge: dev 2023-10-13 16:21:40 +01:00
Lewis Crichton 25857377b6
Merge branch 'dev' into feat/usercss 2023-10-01 10:12:46 +01:00
Lewis Crichton eb31ad994e
merge: dev branch 2023-09-27 21:42:29 +01:00
Lewis Crichton 6fbe24a268
feat: don't parse if not able to compile 2023-09-25 19:06:36 +01:00
Lewis Crichton 5bc24a5d78
feat: guards to prevent compiling this on web 2023-09-25 19:00:03 +01:00
Lewis Crichton 91e093a21d
chore: purify 2023-09-25 18:49:38 +01:00
Lewis Crichton f8232694e7
style: 2x2 switches 2023-09-25 18:39:23 +01:00
Lewis Crichton 03bc5cde22
feat: make colorpicker use props for height/width 2023-09-25 18:32:16 +01:00
Lewis Crichton 4325dcf02e
feat: make the color picker look prettier based on switch 2023-09-25 18:27:49 +01:00
Lewis Crichton 1179a9f5a1
fix: dedupe 2023-09-25 18:11:35 +01:00
Lewis Crichton 7105558640
chore: add back warning lost in merge 2023-09-25 18:06:50 +01:00
Lewis Crichton c019a3cc10
merge: i think i did this right? 2023-09-25 18:05:26 +01:00
Lewis Crichton a79fb2718b
chore: de-bdify 2023-09-15 19:42:01 +01:00
Lewis Crichton 06f2239b1a
Merge branch 'dev' into feat/usercss 2023-09-15 19:40:07 +01:00
Lewis Crichton 1be6738715
perf: memoize relatively intensively computed values 2023-09-10 14:23:19 +01:00
Lewis Crichton 12509f8157
chore: clean lol 2023-09-10 14:11:25 +01:00
Lewis Crichton 74f9b1a022
feat: each settings component handles state, + fix selects again lol 2023-09-10 14:09:00 +01:00
Lewis Crichton 482caf0c5b
style: use switch for special case handling 2023-09-10 13:55:51 +01:00
Lewis Crichton b1bdc48769
fix: redundant padding character in usercss id 2023-09-10 13:51:45 +01:00
Lewis Crichton 141b1a7041
fix: missing styles 2023-09-10 13:43:41 +01:00
Lewis Crichton d43eebe0e4
refactor: split components and modal and whatnot 2023-09-10 13:40:04 +01:00
Lewis Crichton f2dc34e023
Merge branch 'feat/usercss' of ssh://github.com/lewisakura/Vencord into feat/usercss 2023-09-09 19:48:39 +01:00
Lewis Crichton f596941f3a
feat: checkbox type to bools in compiled output 2023-09-09 19:48:32 +01:00
Lewis Crichton e4f4802155
Merge branch 'dev' into feat/usercss 2023-09-09 10:57:00 +01:00
Lewis Crichton b7bd5096b6
fix: select defaults not working 2023-09-09 10:53:49 +01:00
Lewis Crichton 9fdd2c7c17
feat: better colorpicker 2023-09-09 10:43:07 +01:00
Lewis Crichton 51059c29e7
feat: non-exact settings subscriptions for live recompile 2023-09-09 10:17:21 +01:00
Lewis Crichton 64848b2fbf
feat: use built in tinycolor 2023-09-09 10:00:41 +01:00
Lewis Crichton b6e20680ff
feat: my suffering is neverending and all i can think of is popups and modals 2023-09-08 22:19:21 +01:00
Lewis Crichton d361edc47d
style: u love refactors ignoring stuff 2023-09-08 16:36:22 +01:00
Lewis Crichton 7174d2e744
perf: move theme parsing out of natives to prevent duplicate dependencies 2023-09-08 16:35:37 +01:00
Lewis Crichton 9a23571b3e
feat: resiliency against bad usercss 2023-09-08 15:54:25 +01:00
Lewis Crichton 723191ba9b
feat: usercss compilation and better settings storage 2023-09-08 14:59:41 +01:00
Lewis Crichton 0cc420fb45
Merge branch 'dev' into feat/usercss 2023-09-07 16:36:21 +01:00
Lewis Crichton a939034bc1
style: dont add unnecessary space 2023-09-07 16:07:45 +01:00
Lewis Crichton b350087a7a
style: for if 2023-09-03 13:26:07 +01:00
Lewis Crichton 19a87e3e94
feat: ranges with units, loading vars from settings 2023-09-02 22:02:32 +01:00
Lewis Crichton c0dff86cb2
fix: don't add empty :root{} 2023-09-02 21:34:55 +01:00
Lewis Crichton 0d66604be5
style: explicit ype qualifier 2023-09-02 21:29:44 +01:00
Lewis Crichton b7fb178f1f
feat: inject css vars for usercss 2023-09-02 21:27:22 +01:00
Lewis Crichton d689b3273b
style: clean up whackass imports 2023-09-02 18:01:55 +01:00
Lewis Crichton 2ef2baafbe
feat: initial usercss support
Parses UserCSS/UserStyle files (.user.css) but doesn't do anything
special yet with the variables. This is a first step towards
supporting UserCSS themes.
2023-09-02 17:51:17 +01:00
481 changed files with 10439 additions and 20873 deletions

98
.eslintrc.json Normal file
View file

@ -0,0 +1,98 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"ignorePatterns": ["dist", "browser"],
"plugins": [
"@typescript-eslint",
"simple-header",
"simple-import-sort",
"unused-imports",
"path-alias"
],
"settings": {
"import/resolver": {
"alias": {
"map": [
["@webpack", "./src/webpack"],
["@webpack/common", "./src/webpack/common"],
["@utils", "./src/utils"],
["@api", "./src/api"],
["@components", "./src/components"]
]
}
}
},
"rules": {
// Since it's only been a month and Vencord has already been stolen
// by random skids who rebranded it to "AlphaCord" and erased all license
// information
"simple-header/header": [
"error",
{
"files": ["scripts/header-new.txt", "scripts/header-old.txt"],
"templates": { "author": [".*", "Vendicated and contributors"] }
}
],
"quotes": ["error", "double", { "avoidEscape": true }],
"jsx-quotes": ["error", "prefer-double"],
"no-mixed-spaces-and-tabs": "error",
"indent": ["error", 4, { "SwitchCase": 1 }],
"arrow-parens": ["error", "as-needed"],
"eol-last": ["error", "always"],
"@typescript-eslint/func-call-spacing": ["error", "never"],
"no-multi-spaces": "error",
"no-trailing-spaces": "error",
"no-whitespace-before-property": "error",
"semi": ["error", "always"],
"semi-style": ["error", "last"],
"space-in-parens": ["error", "never"],
"block-spacing": ["error", "always"],
"object-curly-spacing": ["error", "always"],
"eqeqeq": ["error", "always", { "null": "ignore" }],
"spaced-comment": ["error", "always", { "markers": ["!"] }],
"yoda": "error",
"prefer-destructuring": ["error", {
"VariableDeclarator": { "array": false, "object": true },
"AssignmentExpression": { "array": false, "object": false }
}],
"operator-assignment": ["error", "always"],
"no-useless-computed-key": "error",
"no-unneeded-ternary": ["error", { "defaultAssignment": false }],
"no-invalid-regexp": "error",
"no-constant-condition": ["error", { "checkLoops": false }],
"no-duplicate-imports": "error",
"no-extra-semi": "error",
"dot-notation": "error",
"no-useless-escape": [
"error",
{
"extra": "i"
}
],
"no-fallthrough": "error",
"for-direction": "error",
"no-async-promise-executor": "error",
"no-cond-assign": "error",
"no-dupe-else-if": "error",
"no-duplicate-case": "error",
"no-irregular-whitespace": "error",
"no-loss-of-precision": "error",
"no-misleading-character-class": "error",
"no-prototype-builtins": "error",
"no-regex-spaces": "error",
"no-shadow-restricted-names": "error",
"no-unexpected-multiline": "error",
"no-unsafe-optional-chaining": "error",
"no-useless-backreference": "error",
"use-isnan": "error",
"prefer-const": "error",
"prefer-spread": "error",
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error",
"unused-imports/no-unused-imports": "error",
"path-alias/no-relative": "error"
}
}

View file

@ -12,8 +12,7 @@ body:
DO NOT USE THIS FORM, unless DO NOT USE THIS FORM, unless
- you are a vencord contributor - you are a vencord contributor
- you were given explicit permission to use this form by a moderator in our support server - you were given explicit permission to use this form by a moderator in our support server
- you are filing a security related report
DO NOT USE THIS FORM FOR SECURITY RELATED ISSUES. [CREATE A SECURITY ADVISORY INSTEAD.](https://github.com/Vendicated/Vencord/security/advisories/new)
- type: textarea - type: textarea
id: content id: content

View file

@ -18,21 +18,21 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v3
- uses: pnpm/action-setup@v3 # Install pnpm using packageManager key in package.json - uses: pnpm/action-setup@v2 # Install pnpm using packageManager key in package.json
- name: Use Node.js 20 - name: Use Node.js 19
uses: actions/setup-node@v4 uses: actions/setup-node@v3
with: with:
node-version: 20 node-version: 19
cache: "pnpm" cache: "pnpm"
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: Build web - name: Build web
run: pnpm buildWebStandalone run: pnpm buildWeb --standalone
- name: Build - name: Build
run: pnpm build --standalone run: pnpm build --standalone

View file

@ -13,7 +13,7 @@ jobs:
if: github.repository == 'Vendicated/Vencord' if: github.repository == 'Vendicated/Vencord'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v3
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: pixta-dev/repository-mirroring-action@674e65a7d483ca28dafaacba0d07351bdcc8bd75 # v1.1.1 - uses: pixta-dev/repository-mirroring-action@674e65a7d483ca28dafaacba0d07351bdcc8bd75 # v1.1.1

View file

@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v3
- name: check that tag matches package.json version - name: check that tag matches package.json version
run: | run: |
@ -20,19 +20,19 @@ jobs:
exit 1 exit 1
fi fi
- uses: pnpm/action-setup@v3 # Install pnpm using packageManager key in package.json - uses: pnpm/action-setup@v2 # Install pnpm using packageManager key in package.json
- name: Use Node.js 19 - name: Use Node.js 19
uses: actions/setup-node@v4 uses: actions/setup-node@v3
with: with:
node-version: 20 node-version: 19
cache: "pnpm" cache: "pnpm"
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: Build web - name: Build web
run: pnpm buildWebStandalone run: pnpm buildWeb --standalone
- name: Publish extension - name: Publish extension
run: | run: |

View file

@ -11,40 +11,37 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v3
if: ${{ github.event_name == 'schedule' }} if: ${{ github.event_name == 'schedule' }}
with: with:
ref: dev ref: dev
- uses: actions/checkout@v4 - uses: actions/checkout@v3
if: ${{ github.event_name == 'workflow_dispatch' }} if: ${{ github.event_name == 'workflow_dispatch' }}
- uses: pnpm/action-setup@v3 # Install pnpm using packageManager key in package.json - uses: pnpm/action-setup@v2 # Install pnpm using packageManager key in package.json
- name: Use Node.js 20 - name: Use Node.js 19
uses: actions/setup-node@v4 uses: actions/setup-node@v3
with: with:
node-version: 20 node-version: 19
cache: "pnpm" cache: "pnpm"
- name: Install dependencies - name: Install dependencies
run: | run: |
pnpm install --frozen-lockfile pnpm install --frozen-lockfile
pnpm add puppeteer
- name: Install Google Chrome sudo apt-get install -y chromium-browser
id: setup-chrome
uses: browser-actions/setup-chrome@82b9ce628cc5595478a9ebadc480958a36457dc2
with:
chrome-version: stable
- name: Build Vencord Reporter Version - name: Build web
run: pnpm buildReporter run: pnpm buildWeb --standalone --dev
- name: Create Report - name: Create Report
timeout-minutes: 10 timeout-minutes: 10
run: | run: |
export PATH="$PWD/node_modules/.bin:$PATH" export PATH="$PWD/node_modules/.bin:$PATH"
export CHROMIUM_BIN=${{ steps.setup-chrome.outputs.chrome-path }} export CHROMIUM_BIN=$(which chromium-browser)
esbuild scripts/generateReport.ts > dist/report.mjs esbuild scripts/generateReport.ts > dist/report.mjs
node dist/report.mjs >> $GITHUB_STEP_SUMMARY node dist/report.mjs >> $GITHUB_STEP_SUMMARY
@ -57,7 +54,7 @@ jobs:
if: success() || failure() # even run if previous one failed if: success() || failure() # even run if previous one failed
run: | run: |
export PATH="$PWD/node_modules/.bin:$PATH" export PATH="$PWD/node_modules/.bin:$PATH"
export CHROMIUM_BIN=${{ steps.setup-chrome.outputs.chrome-path }} export CHROMIUM_BIN=$(which chromium-browser)
export USE_CANARY=true export USE_CANARY=true
esbuild scripts/generateReport.ts > dist/report.mjs esbuild scripts/generateReport.ts > dist/report.mjs

View file

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

2
.gitignore vendored
View file

@ -18,5 +18,7 @@ lerna-debug.log*
.pnpm-debug.log* .pnpm-debug.log*
*.tsbuildinfo *.tsbuildinfo
src/userplugins
ExtensionCache/ ExtensionCache/
settings/ settings/

3
.gitmodules vendored
View file

@ -1,3 +0,0 @@
[submodule "src/userplugins/vc-message-logger-enhanced"]
path = src/userplugins/vc-message-logger-enhanced
url = https://github.com/Syncxv/vc-message-logger-enhanced.git

1
.npmrc
View file

@ -1,2 +1 @@
strict-peer-dependencies=false strict-peer-dependencies=false
package-manager-strict=false

View file

@ -1,11 +1,6 @@
{ {
"extends": "stylelint-config-standard", "extends": "stylelint-config-standard",
"rules": { "rules": {
"selector-class-pattern": [ "indentation": 4
"^[a-z][a-zA-Z0-9]*(-[a-z0-9][a-zA-Z0-9]*)*$",
{
"message": "Expected class selector to be kebab-case with camelCase segments"
}
]
} }
} }

View file

@ -1,9 +1,11 @@
{ {
"recommendations": [ "recommendations": [
"dbaeumer.vscode-eslint", "dbaeumer.vscode-eslint",
"eamodio.gitlens",
"EditorConfig.EditorConfig", "EditorConfig.EditorConfig",
"ExodiusStudios.comment-anchors",
"formulahendry.auto-rename-tag",
"GregorBiswanger.json2ts", "GregorBiswanger.json2ts",
"stylelint.vscode-stylelint", "stylelint.vscode-stylelint"
"Vendicated.vencord-companion"
] ]
} }

View file

@ -16,6 +16,5 @@ DON'T
Repetitive violations of these guidelines might get your access to the repository restricted. Repetitive violations of these guidelines might get your access to the repository restricted.
If you feel like a user is violating these guidelines or feel treated unfairly, please refrain from vigilantism
and instead report the issue to a moderator! The best way is joining our [official Discord community](https://vencord.dev/discord) If you feel like a user is violating these guidelines or feel treated unfairly, please refrain from publicly challenging them and instead contact a Moderator on our Discord server or send an email to vendicated+conduct@riseup.net!
and opening a modmail ticket.

View file

@ -1,55 +1,82 @@
# Contributing to Vencord # Contribution Guide
Vencord is a community project and welcomes any kind of contribution from anyone! First of all, thank you for contributing! :3
We have development documentation for new contributors, which can be found at <https://docs.vencord.dev>. To ensure your contribution is robust, please follow the below guide!
All contributions should be made in accordance with our [Code of Conduct](./CODE_OF_CONDUCT.md). For a friendly introduction to plugins, see [Megu's Plugin Guide!](docs/2_PLUGINS.md)
## How to contribute ## Style Guide
Contributions can be sent via pull requests. If you're new to Git, check [this guide](https://opensource.com/article/19/7/create-pull-request-github). - This project has a very minimal .editorconfig. Make sure your editor supports this!
If you are using VSCode, it should automatically recommend you the extension; If not,
please install the Editorconfig extension
- Try to follow the formatting in the rest of the project and stay consistent
- Follow the file naming convention. File names should usually be camelCase, unless they export a Class
or React Component, in which case they should be PascalCase
Pull requests can be made either to the `main` or the `dev` branch. However, unless you're an advanced user, I recommend sticking to `main`. This is because the dev branch might contain unstable changes and be force pushed frequently, which could cause conflicts in your pull request. ## Contributing a Plugin
## Write a plugin Because plugins modify code directly, incompatibilities are a problem.
Writing a plugin is the primary way to contribute. Thus, 3rd party plugins are not supported, instead all plugins are part of Vencord itself.
This way we can ensure compatibility and high quality patches.
Before starting your plugin: Follow the below guide to make your first plugin!
- Check existing pull requests to see if someone is already working on a similar plugin
- Check our [plugin requests tracker](https://github.com/Vencord/plugin-requests/issues) 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://github.com/Vencord/plugin-requests/issues/new?assignees=&labels=&projects=&template=request.yml) 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
### Plugin Rules ### Finding the right module to patch
- No simple slash command plugins like `/cat`. Instead, make a [user installable Discord bot](https://discord.com/developers/docs/change-log#userinstallable-apps-preview) If the thing you want to patch is an action performed when interacting with a part of the UI, use React DevTools.
- No simple text replace plugins like Let me Google that for you. The TextReplace plugin can do this They come preinstalled and can be found as the "Components" tab in DevTools.
- No raw DOM manipulation. Use proper patches and React Use the Selector (top left) to select the UI Element. Now you can see all callbacks, props or jump to the source
- No FakeDeafen or FakeMute directly.
- No StereoMic
- No plugins that simply hide or redesign ui elements. This can be done with CSS
- No selfbots or API spam (animated status, message pruner, auto reply, nitro snipers, etc)
- No untrusted third party APIs. Popular services like Google or GitHub are fine, but absolutely no self hosted ones
- No plugins that require the user to enter their own API key
- Do not introduce new dependencies unless absolutely necessary and warranted
## Improve Vencord itself If it is anything else, or you're too lazy to use React DevTools, hit `CTRL + Shift + F` while in DevTools and
enter a search term, for example "getUser" to search all source files.
Look at the results until you find something promising. Set a breakpoint and trigger the execution of that part of Code to inspect arguments, locals, etc...
If you have any ideas on how to improve Vencord itself, or want to propose a new plugin API, feel free to open a feature request so we can discuss. ### Writing a robust patch
Or if you notice any bugs or typos, feel free to fix them! ##### "find"
## Contribute to our Documentation First you need to find a good `find` value. This should be a string that is unique to your module.
If you want to patch the `getUser` function, usually a good first try is `getUser:` or `function getUser()`,
depending on how the module is structured. Again, make sure this string is unique to your module and is not
found in any other module. To verify this, search for it in all bundles (CTRL + Shift + F)
The source code of our documentation is available at <https://github.com/Vencord/Docs> ##### "match"
If you see anything outdated, incorrect or lacking, please fix it! This is the regex that will operate on the module found with "find". Just like in find, you should make sure
If you think a new page should be added, feel free to suggest it via an issue and we can discuss. this only matches exactly the part you want to patch and no other parts in the file.
## Help out users in our Discord community The easiest way to write and test your regex is the following:
We have an open support channel in our [Discord community](https://vencord.dev/discord). - Get the ID of the module you want to patch. To do this, go to it in the sources tab and scroll up until you
Helping out users there is always appreciated! The more, the merrier. see something like `447887: (e,t,n)=>{` (Obviously the number will differ).
- Now paste the following into the console: `Vencord.Webpack.wreq.m[447887].toString()` (Changing the number to your ID)
- Now either test regexes on this string in the console or use a tool like https://regex101.com
Also pay attention to the following:
- Never hardcode variable or parameter names or any other minified names. They will change in the future. The only Exception to this rule
are the react props parameter which seems to always be `e`, but even then only rely on this if it is necessary.
Instead, use one of the following approaches where applicable:
- Match 1 or 2 of any character: `.{1,2}`, for example to match the variable name in `var a=b`, `var (.{1,2})=`
- Match any but a guaranteed terminating character: `[^;]+`, for example to match the entire assigned value in `var a=b||c||func();`,
`var .{1,2}=([^;]+);`
- If you don't care about that part, just match a bunch of chars: `.{0,50}`, for example to extract the variable "b" in `createElement("div",{a:"foo",c:"bar"},b)`, `createElement\("div".{0,30},(.{1,2})\),`. Note the `.{0,30}`, this is essentially the same as `.+`, but safer as you can't end up accidently eating thousands of characters
- Additionally, as you might have noticed, all of the above approaches use regex groups (`(...)`) to capture the variable name. You can then use those groups in your replacement to access those variables dynamically
#### "replace"
This is the replacement for the match. This is the second argument to [String.replace](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace), so refer to those docs for info.
Never hardcode minified variable or parameter names here. Instead, use capture groups in your regex to capture the variable names
and use those in your replacement
Make sure your replacement does not introduce any whitespace. While this might seem weird, random whitespace may mess up other patches.
This includes spaces, tabs and especially newlines
---
And that's it! Now open a Pull Request with your Plugin

View file

@ -5,8 +5,8 @@
The cutest Discord client mod The cutest Discord client mod
| ![image](https://github.com/Vendicated/Vencord/assets/45497981/706722b1-32de-4d99-bee9-93993b504334) | | ![image](https://github.com/Vendicated/Vencord/assets/45497981/706722b1-32de-4d99-bee9-93993b504334) |
| :--------------------------------------------------------------------------------------------------: | |:--:|
| A screenshot of vencord showcasing the [vencord-theme](https://github.com/synqat/vencord-theme) | | A screenshot of vencord showcasing the [vencord-theme](https://github.com/synqat/vencord-theme) |
## Features ## Features
@ -32,9 +32,9 @@ https://discord.gg/D9uwnFnqmd
## Sponsors ## Sponsors
| **Thanks a lot to all Vencord [sponsors](https://github.com/sponsors/Vendicated)!!** | | **Thanks a lot to all Vencord [sponsors](https://github.com/sponsors/Vendicated)!!** |
| :------------------------------------------------------------------------------------------: | |:--:|
| [![](https://meow.vendicated.dev/sponsors.png)](https://github.com/sponsors/Vendicated) | | [![](https://meow.vendicated.dev/sponsors.png)](https://github.com/sponsors/Vendicated) |
| *generated using [github-sponsor-graph](https://github.com/Vendicated/github-sponsor-graph)* | | *generated using [github-sponsor-graph](https://github.com/Vendicated/github-sponsor-graph)* |

View file

@ -62,7 +62,7 @@ function GM_fetch(url, opt) {
resp.arrayBuffer = () => blobTo("arrayBuffer", blob); resp.arrayBuffer = () => blobTo("arrayBuffer", blob);
resp.text = () => blobTo("text", blob); resp.text = () => blobTo("text", blob);
resp.json = async () => JSON.parse(await blobTo("text", blob)); resp.json = async () => JSON.parse(await blobTo("text", blob));
resp.headers = parseHeaders(resp.responseHeaders); resp.headers = new Headers(parseHeaders(resp.responseHeaders));
resp.ok = resp.status >= 200 && resp.status < 300; resp.ok = resp.status >= 200 && resp.status < 300;
resolve(resp); resolve(resp);
}; };

View file

@ -19,14 +19,12 @@
/// <reference path="../src/modules.d.ts" /> /// <reference path="../src/modules.d.ts" />
/// <reference path="../src/globals.d.ts" /> /// <reference path="../src/globals.d.ts" />
import monacoHtmlLocal from "file://monacoWin.html?minify"; import monacoHtmlLocal from "~fileContent/monacoWin.html";
import monacoHtmlCdn from "file://../src/main/monacoWin.html?minify"; import monacoHtmlCdn from "~fileContent/../src/main/monacoWin.html";
import * as DataStore from "../src/api/DataStore"; import * as DataStore from "../src/api/DataStore";
import { debounce } from "../src/utils"; import { debounce } from "../src/utils";
import { EXTENSION_BASE_URL } from "../src/utils/web-metadata"; import { EXTENSION_BASE_URL } from "../src/utils/web-metadata";
import { getTheme, Theme } from "../src/utils/discord"; import { getTheme, Theme } from "../src/utils/discord";
import { getThemeInfo } from "../src/main/themes";
import { Settings } from "../src/Vencord";
// Discord deletes this so need to store in variable // Discord deletes this so need to store in variable
const { localStorage } = window; const { localStorage } = window;
@ -47,7 +45,7 @@ window.VencordNative = {
deleteTheme: (fileName: string) => DataStore.del(fileName, themeStore), deleteTheme: (fileName: string) => DataStore.del(fileName, themeStore),
getThemesDir: async () => "", getThemesDir: async () => "",
getThemesList: () => DataStore.entries(themeStore).then(entries => getThemesList: () => DataStore.entries(themeStore).then(entries =>
entries.map(([name, css]) => getThemeInfo(css, name.toString())) entries.map(([name, css]) => ({ fileName: name as string, content: css }))
), ),
getThemeData: (fileName: string) => DataStore.get(fileName, themeStore), getThemeData: (fileName: string) => DataStore.get(fileName, themeStore),
getSystemValues: async () => ({}), getSystemValues: async () => ({}),
@ -97,15 +95,8 @@ window.VencordNative = {
}, },
settings: { settings: {
get: () => { get: () => localStorage.getItem("VencordSettings") || "{}",
try { set: async (s: string) => localStorage.setItem("VencordSettings", s),
return JSON.parse(localStorage.getItem("VencordSettings") || "{}");
} catch (e) {
console.error("Failed to parse settings from localStorage: ", e);
return {};
}
},
set: async (s: Settings) => localStorage.setItem("VencordSettings", JSON.stringify(s)),
getSettingsDir: async () => "LocalStorage" getSettingsDir: async () => "LocalStorage"
}, },

View file

@ -2,22 +2,23 @@ if (typeof browser === "undefined") {
var browser = chrome; var browser = chrome;
} }
const script = document.createElement("script");
script.src = browser.runtime.getURL("dist/Vencord.js");
script.id = "vencord-script";
Object.assign(script.dataset, {
extensionBaseUrl: browser.runtime.getURL(""),
version: browser.runtime.getManifest().version
});
const style = document.createElement("link"); const style = document.createElement("link");
style.type = "text/css"; style.type = "text/css";
style.rel = "stylesheet"; style.rel = "stylesheet";
style.href = browser.runtime.getURL("dist/Vencord.css"); style.href = browser.runtime.getURL("dist/Vencord.css");
document.documentElement.append(script);
document.addEventListener( document.addEventListener(
"DOMContentLoaded", "DOMContentLoaded",
() => { () => document.documentElement.append(style),
document.documentElement.append(style);
window.postMessage({
type: "vencord:meta",
meta: {
EXTENSION_VERSION: browser.runtime.getManifest().version,
EXTENSION_BASE_URL: browser.runtime.getURL(""),
}
});
},
{ once: true } { once: true }
); );

View file

@ -1,6 +1,6 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"minimum_chrome_version": "111", "minimum_chrome_version": "91",
"name": "Vencord Web", "name": "Vencord Web",
"description": "The cutest Discord mod now in your browser", "description": "The cutest Discord mod now in your browser",
@ -22,15 +22,7 @@
"run_at": "document_start", "run_at": "document_start",
"matches": ["*://*.discord.com/*"], "matches": ["*://*.discord.com/*"],
"js": ["content.js"], "js": ["content.js"],
"all_frames": true, "all_frames": true
"world": "ISOLATED"
},
{
"run_at": "document_start",
"matches": ["*://*.discord.com/*"],
"js": ["dist/Vencord.js"],
"all_frames": true,
"world": "MAIN"
} }
], ],

View file

@ -22,15 +22,7 @@
"run_at": "document_start", "run_at": "document_start",
"matches": ["*://*.discord.com/*"], "matches": ["*://*.discord.com/*"],
"js": ["content.js"], "js": ["content.js"],
"all_frames": true, "all_frames": true
"world": "ISOLATED"
},
{
"run_at": "document_start",
"matches": ["*://*.discord.com/*"],
"js": ["dist/Vencord.js"],
"all_frames": true,
"world": "MAIN"
} }
], ],
@ -43,7 +35,7 @@
"browser_specific_settings": { "browser_specific_settings": {
"gecko": { "gecko": {
"id": "vencord-firefox@vendicated.dev", "id": "vencord-firefox@vendicated.dev",
"strict_min_version": "128.0" "strict_min_version": "91.0"
} }
} }
} }

View file

@ -5,7 +5,6 @@
// @author Vendicated (https://github.com/Vendicated) // @author Vendicated (https://github.com/Vendicated)
// @namespace https://github.com/Vendicated/Vencord // @namespace https://github.com/Vendicated/Vencord
// @supportURL https://github.com/Vendicated/Vencord // @supportURL https://github.com/Vendicated/Vencord
// @icon https://raw.githubusercontent.com/Vendicated/Vencord/refs/heads/main/browser/icon.png
// @license GPL-3.0 // @license GPL-3.0
// @match *://*.discord.com/* // @match *://*.discord.com/*
// @grant GM_xmlhttpRequest // @grant GM_xmlhttpRequest

99
docs/1_INSTALLING.md Normal file
View file

@ -0,0 +1,99 @@
> [!WARNING]
> These instructions are only for advanced users. If you're not a Developer, you should use our [graphical installer](https://github.com/Vendicated/VencordInstaller#usage) instead.
> No support will be provided for installing in this fashion. If you cannot figure it out, you should just stick to a regular install.
# Installation Guide
Welcome to Megu's Installation Guide! In this file, you will learn about how to download, install, and uninstall Vencord!
## Sections
- [Installation Guide](#installation-guide)
- [Sections](#sections)
- [Dependencies](#dependencies)
- [Installing Vencord](#installing-vencord)
- [Updating Vencord](#updating-vencord)
- [Uninstalling Vencord](#uninstalling-vencord)
## Dependencies
- Install Git from https://git-scm.com/download
- Install Node.JS LTS from here: https://nodejs.dev/en/
## Installing Vencord
Install `pnpm`:
> :exclamation: This next command may need to be run as admin/root depending on your system, and you may need to close and reopen your terminal for pnpm to be in your PATH.
```shell
npm i -g pnpm
```
> :exclamation: **IMPORTANT** Make sure you aren't using an admin/root terminal from here onwards. It **will** mess up your Discord/Vencord instance and you **will** most likely have to reinstall.
Clone Vencord:
```shell
git clone https://github.com/Vendicated/Vencord
cd Vencord
```
Install dependencies:
```shell
pnpm install --frozen-lockfile
```
Build Vencord:
```shell
pnpm build
```
Inject vencord into your client:
```shell
pnpm inject
```
Then fully close Discord from your taskbar or task manager, and restart it. Vencord should be injected - you can check this by looking for the Vencord section in Discord settings.
## Updating Vencord
If you're using Discord already, go into the `Updater` tab in settings.
Sometimes it may be necessary to manually update if the GUI updater fails.
To pull latest changes:
```shell
git pull
```
If this fails, you likely need to reset your local changes to vencord to resolve merge errors:
> :exclamation: This command will remove any local changes you've made to vencord. Make sure you back up if you made any code changes you don't want to lose!
```shell
git reset --hard
git pull
```
and then to build the changes:
```shell
pnpm build
```
Then just refresh your client
## Uninstalling Vencord
Simply run:
```shell
pnpm uninject
```
If you need more help, ask in the support channel in our [Discord Server](https://discord.gg/D9uwnFnqmd).

111
docs/2_PLUGINS.md Normal file
View file

@ -0,0 +1,111 @@
# Plugins Guide
Welcome to Megu's Plugin Guide! In this file, you will learn about how to write your own plugin!
You don't need to run `pnpm build` every time you make a change. Instead, use `pnpm watch` - this will auto-compile Vencord whenever you make a change. If using code patches (recommended), you will need to CTRL+R to load the changes.
## Plugin Entrypoint
> If it doesn't already exist, create a folder called `userplugins` in the `src` directory of this repo.
1. Create a folder in `src/userplugins/` with the name of your plugin. For example, `src/userplugins/epicPlugin/` - All of your plugin files will go here.
2. Create a file in that folder called `index.ts`
3. In `index.ts`, copy-paste the following template code:
```ts
import definePlugin from "@utils/types";
export default definePlugin({
name: "Epic Plugin",
description: "This plugin is absolutely epic",
authors: [
{
id: 12345n,
name: "Your Name",
},
],
patches: [],
// Delete these two below if you are only using code patches
start() {},
stop() {},
});
```
Change the name, description, and authors to your own information.
Replace `12345n` with your user ID ending in `n` (e.g., `545581357812678656n`). If you don't want to share your Discord account, use `0n` instead!
## How Plugins Work In Vencord
Vencord uses a different way of making mods than you're used to.
Instead of monkeypatching webpack, we directly modify the code before Discord loads it.
This is _significantly_ more efficient than monkeypatching webpack, and is surprisingly easy, but it may be confusing at first.
## Making your patch
For an in-depth guide into patching code, see [CONTRIBUTING.md](../CONTRIBUTING.md)
in the `index.ts` file we made earlier, you'll see a `patches` array.
> You'll see examples of how patches are used in all the existing plugins, and it'll be easier to understand by looking at those examples, so do that first, and then return here!
> For a good example of a plugin using code patches AND runtime patching, check `src/plugins/unindent.ts`, which uses code patches to run custom runtime code.
One of the patches in the `isStaff` plugin, looks like this:
```ts
{
match: /(\w+)\.isStaff=function\(\){return\s*!1};/,
replace: "$1.isStaff=function(){return true};",
},
```
The above regex matches the string in discord that will look something like:
```js
abc.isStaff = function () {
return !1;
};
```
Remember that Discord code is minified, so there won't be any newlines, and there will only be spaces where necessary. So the source code looks something like:
```
abc.isStaff=function(){return!1;}
```
You can find these snippets by opening the devtools (`ctrl+shift+i`) and pressing `ctrl+shift+f`, searching for what you're looking to modify in there, and beautifying the file to make it more readable.
In the `match` regex in the example shown above, you'll notice at the start there is a `(\w+)`.
Anything in the brackets will be accessible in the `replace` string using `$<number>`. e.g., the first pair of brackets will be `$1`, the second will be `$2`, etc.
The replacement string we used is:
```
"$1.isStaff=function(){return true;};"
```
Which, using the above example, would replace the code with:
> **Note**
> In this example, `$1` becomes `abc`
```js
abc.isStaff = function () {
return true;
};
```
The match value _can_ be a string, rather than regex, however usually regex will be better suited, as it can work with unknown values, whereas strings must be exact matches.
Once you've made your plugin, make sure you run `pnpm test` and make sure your code is nice and clean!
If you want to publish your plugin into the Vencord repo, move your plugin from `src/userplugins` into the `src/plugins` folder and open a PR!
> **Warning**
> Make sure you've read [CONTRIBUTING.md](../CONTRIBUTING.md) before opening a PR
If you need more help, ask in the support channel in our [Discord Server](https://discord.gg/D9uwnFnqmd).

View file

@ -1,126 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
// @ts-check
import stylistic from "@stylistic/eslint-plugin";
import pathAlias from "eslint-plugin-path-alias";
import header from "eslint-plugin-simple-header";
import simpleImportSort from "eslint-plugin-simple-import-sort";
import unusedImports from "eslint-plugin-unused-imports";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist", "browser", "packages/vencord-types"] },
{
files: ["src/**/*.{tsx,ts,mts,mjs,js,jsx}", "eslint.config.mjs"],
plugins: {
"simple-header": header,
"@stylistic": stylistic,
"@typescript-eslint": tseslint.plugin,
"simple-import-sort": simpleImportSort,
"unused-imports": unusedImports,
"path-alias": pathAlias,
},
settings: {
"import/resolver": {
map: [
["@webpack", "./src/webpack"],
["@webpack/common", "./src/webpack/common"],
["@utils", "./src/utils"],
["@api", "./src/api"],
["@components", "./src/components"]
]
}
},
languageOptions: {
parser: tseslint.parser,
parserOptions: {
project: ["./tsconfig.json"],
tsconfigRootDir: import.meta.dirname
}
},
rules: {
/*
* Since it's only been a month and Vencord has already been stolen
* by random skids who rebranded it to "AlphaCord" and erased all license
* information
*/
"simple-header/header": [
"error",
{
"files": ["scripts/header-new.txt", "scripts/header-old.txt"],
"templates": { "author": [".*", "Vendicated and contributors"] }
}
],
// Style Rules
"@stylistic/jsx-quotes": ["error", "prefer-double"],
"@stylistic/quotes": ["error", "double", { "avoidEscape": true }],
"@stylistic/no-mixed-spaces-and-tabs": "error",
"@stylistic/arrow-parens": ["error", "as-needed"],
"@stylistic/eol-last": ["error", "always"],
"@stylistic/no-multi-spaces": "error",
"@stylistic/no-trailing-spaces": "error",
"@stylistic/no-whitespace-before-property": "error",
"@stylistic/semi": ["error", "always"],
"@stylistic/semi-style": ["error", "last"],
"@stylistic/space-in-parens": ["error", "never"],
"@stylistic/block-spacing": ["error", "always"],
"@stylistic/object-curly-spacing": ["error", "always"],
"@stylistic/spaced-comment": ["error", "always", { "markers": ["!"] }],
"@stylistic/no-extra-semi": "error",
// TS Rules
"@stylistic/func-call-spacing": ["error", "never"],
// ESLint Rules
"yoda": "error",
"eqeqeq": ["error", "always", { "null": "ignore" }],
"prefer-destructuring": ["error", {
"VariableDeclarator": { "array": false, "object": true },
"AssignmentExpression": { "array": false, "object": false }
}],
"operator-assignment": ["error", "always"],
"no-useless-computed-key": "error",
"no-unneeded-ternary": ["error", { "defaultAssignment": false }],
"no-invalid-regexp": "error",
"no-constant-condition": ["error", { "checkLoops": false }],
"no-duplicate-imports": "error",
"dot-notation": "error",
"no-useless-escape": [
"error",
{
"extra": "i"
}
],
"no-fallthrough": "error",
"for-direction": "error",
"no-async-promise-executor": "error",
"no-cond-assign": "error",
"no-dupe-else-if": "error",
"no-duplicate-case": "error",
"no-irregular-whitespace": "error",
"no-loss-of-precision": "error",
"no-misleading-character-class": "error",
"no-prototype-builtins": "error",
"no-regex-spaces": "error",
"no-shadow-restricted-names": "error",
"no-unexpected-multiline": "error",
"no-unsafe-optional-chaining": "error",
"no-useless-backreference": "error",
"use-isnan": "error",
"prefer-const": "error",
"prefer-spread": "error",
// Plugin Rules
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error",
"unused-imports/no-unused-imports": "error",
"path-alias/no-relative": "error"
}
}
);

View file

@ -1,7 +1,7 @@
{ {
"name": "vencord", "name": "vencord",
"private": "true", "private": "true",
"version": "1.10.7", "version": "1.6.7",
"description": "The cutest Discord client mod", "description": "The cutest Discord client mod",
"homepage": "https://github.com/Vendicated/Vencord#readme", "homepage": "https://github.com/Vendicated/Vencord#readme",
"bugs": { "bugs": {
@ -13,77 +13,74 @@
}, },
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"author": "Vendicated", "author": "Vendicated",
"directories": {
"doc": "docs"
},
"scripts": { "scripts": {
"build": "node --require=./scripts/suppressExperimentalWarnings.js scripts/build/build.mjs", "build": "node --require=./scripts/suppressExperimentalWarnings.js scripts/build/build.mjs",
"buildStandalone": "pnpm build --standalone",
"buildWeb": "node --require=./scripts/suppressExperimentalWarnings.js scripts/build/buildWeb.mjs", "buildWeb": "node --require=./scripts/suppressExperimentalWarnings.js scripts/build/buildWeb.mjs",
"buildWebStandalone": "pnpm buildWeb --standalone",
"buildReporter": "pnpm buildWebStandalone --reporter --skip-extension",
"buildReporterDesktop": "pnpm build --reporter",
"watch": "pnpm build --watch",
"dev": "pnpm watch",
"watchWeb": "pnpm buildWeb --watch",
"generatePluginJson": "tsx scripts/generatePluginList.ts", "generatePluginJson": "tsx scripts/generatePluginList.ts",
"generateTypes": "tspc --emitDeclarationOnly --declaration --outDir packages/vencord-types",
"inject": "node scripts/runInstaller.mjs", "inject": "node scripts/runInstaller.mjs",
"uninject": "node scripts/runInstaller.mjs", "lint": "eslint . --ext .js,.jsx,.ts,.tsx --ignore-pattern src/userplugins",
"lint": "eslint",
"lint-styles": "stylelint \"src/**/*.css\" --ignore-pattern src/userplugins", "lint-styles": "stylelint \"src/**/*.css\" --ignore-pattern src/userplugins",
"lint:fix": "pnpm lint --fix", "lint:fix": "pnpm lint --fix",
"test": "pnpm buildStandalone && pnpm lint && pnpm lint-styles && pnpm testTsc && pnpm generatePluginJson", "test": "pnpm build && pnpm lint && pnpm lint-styles && pnpm testTsc && pnpm generatePluginJson",
"testWeb": "pnpm lint && pnpm buildWeb && pnpm testTsc", "testWeb": "pnpm lint && pnpm buildWeb && pnpm testTsc",
"testTsc": "tsc --noEmit" "testTsc": "tsc --noEmit",
"uninject": "node scripts/runInstaller.mjs",
"watch": "node --require=./scripts/suppressExperimentalWarnings.js scripts/build/build.mjs --watch"
}, },
"dependencies": { "dependencies": {
"@intrnl/xxhash64": "^0.1.2", "@sapphi-red/web-noise-suppressor": "0.3.3",
"@sapphi-red/web-noise-suppressor": "0.3.5",
"@vap/core": "0.0.12", "@vap/core": "0.0.12",
"@vap/shiki": "0.10.5", "@vap/shiki": "0.10.5",
"fflate": "^0.8.2", "eslint-plugin-simple-header": "^1.0.2",
"fflate": "^0.7.4",
"gifenc": "github:mattdesl/gifenc#64842fca317b112a8590f8fef2bf3825da8f6fe3", "gifenc": "github:mattdesl/gifenc#64842fca317b112a8590f8fef2bf3825da8f6fe3",
"monaco-editor": "^0.50.0", "monaco-editor": "^0.43.0",
"nanoid": "^5.0.7", "nanoid": "^4.0.2",
"usercss-meta": "^0.12.0",
"virtual-merge": "^1.0.1" "virtual-merge": "^1.0.1"
}, },
"devDependencies": { "devDependencies": {
"@stylistic/eslint-plugin": "^2.6.1", "@types/chrome": "^0.0.246",
"@types/chrome": "^0.0.269", "@types/diff": "^5.0.3",
"@types/diff": "^5.2.1", "@types/less": "^3.0.4",
"@types/lodash": "^4.17.7", "@types/lodash": "^4.14.194",
"@types/node": "^22.0.3", "@types/node": "^18.16.3",
"@types/react": "^18.3.3", "@types/react": "^18.2.0",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.2.1",
"@types/yazl": "^2.4.5", "@types/stylus": "^0.48.39",
"diff": "^5.2.0", "@types/tinycolor2": "^1.4.3",
"@types/yazl": "^2.4.2",
"@typescript-eslint/eslint-plugin": "^5.59.1",
"@typescript-eslint/parser": "^5.59.1",
"diff": "^5.1.0",
"discord-types": "^1.3.26", "discord-types": "^1.3.26",
"esbuild": "^0.15.18", "esbuild": "^0.15.18",
"eslint": "^9.8.0", "eslint": "^8.46.0",
"eslint-import-resolver-alias": "^1.1.2", "eslint-import-resolver-alias": "^1.1.2",
"eslint-plugin-path-alias": "2.1.0", "eslint-plugin-path-alias": "^1.0.0",
"eslint-plugin-simple-header": "^1.1.1", "eslint-plugin-simple-import-sort": "^10.0.0",
"eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-unused-imports": "^2.0.0",
"eslint-plugin-unused-imports": "^4.0.1", "highlight.js": "10.6.0",
"highlight.js": "10.7.3", "moment": "^2.29.4",
"html-minifier-terser": "^7.2.0", "puppeteer-core": "^19.11.1",
"moment": "^2.30.1",
"puppeteer-core": "^22.15.0",
"standalone-electron-types": "^1.0.0", "standalone-electron-types": "^1.0.0",
"stylelint": "^16.8.1", "stylelint": "^15.6.0",
"stylelint-config-standard": "^36.0.1", "stylelint-config-standard": "^33.0.0",
"ts-patch": "^3.2.1", "tsx": "^3.12.7",
"ts-pattern": "^5.3.1", "type-fest": "^3.9.0",
"tsx": "^4.16.5", "typescript": "^5.0.4",
"type-fest": "^4.23.0", "zip-local": "^0.3.5",
"typescript": "^5.5.4", "zustand": "^3.7.2"
"typescript-eslint": "^8.0.0",
"typescript-transform-paths": "^3.4.7",
"zip-local": "^0.3.5"
}, },
"packageManager": "pnpm@9.1.0", "packageManager": "pnpm@8.10.2",
"pnpm": { "pnpm": {
"patchedDependencies": { "patchedDependencies": {
"eslint@9.8.0": "patches/eslint@9.8.0.patch", "eslint-plugin-path-alias@1.0.0": "patches/eslint-plugin-path-alias@1.0.0.patch",
"eslint-plugin-path-alias@2.1.0": "patches/eslint-plugin-path-alias@2.1.0.patch" "eslint@8.46.0": "patches/eslint@8.46.0.patch",
"@types/less@3.0.4": "patches/@types__less@3.0.4.patch"
}, },
"peerDependencyRules": { "peerDependencyRules": {
"ignoreMissing": [ "ignoreMissing": [
@ -107,6 +104,6 @@
}, },
"engines": { "engines": {
"node": ">=18", "node": ">=18",
"pnpm": ">=9" "pnpm": ">=8"
} }
} }

View file

@ -1,7 +0,0 @@
*
!.*ignore
!package.json
!*.md
!prepare.ts
!index.d.ts
!globals.d.ts

View file

@ -1,4 +0,0 @@
node_modules
prepare.ts
.gitignore
HOW2PUB.md

View file

@ -1,5 +0,0 @@
# How to publish
1. run `pnpm generateTypes` in the project root
2. bump package.json version
3. npm publish

View file

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

View file

@ -1,24 +0,0 @@
/*
* 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/>.
*/
declare global {
export var VencordNative: typeof import("./VencordNative").default;
export var Vencord: typeof import("./Vencord");
}
export { };

View file

@ -1,5 +0,0 @@
/* eslint-disable */
/// <reference path="Vencord.d.ts" />
/// <reference path="globals.d.ts" />
/// <reference path="modules.d.ts" />

View file

@ -1,28 +0,0 @@
{
"name": "@vencord/types",
"private": false,
"version": "0.1.3",
"description": "",
"types": "index.d.ts",
"scripts": {
"prepublishOnly": "tsx ./prepare.ts",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "Vencord",
"license": "GPL-3.0",
"devDependencies": {
"@types/fs-extra": "^11.0.4",
"fs-extra": "^11.2.0",
"tsx": "^3.12.6"
},
"dependencies": {
"@types/lodash": "^4.14.191",
"@types/node": "^18.11.18",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.0.10",
"discord-types": "^1.3.26",
"standalone-electron-types": "^1.0.0",
"type-fest": "^3.5.3"
}
}

View file

@ -1,47 +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 { cpSync, moveSync, readdirSync, rmSync } from "fs-extra";
import { join } from "path";
readdirSync(join(__dirname, "src"))
.forEach(child => moveSync(join(__dirname, "src", child), join(__dirname, child), { overwrite: true }));
const VencordSrc = join(__dirname, "..", "..", "src");
for (const file of ["preload.d.ts", "userplugins", "main", "debug", "src", "browser", "scripts"]) {
rmSync(join(__dirname, file), { recursive: true, force: true });
}
function copyDtsFiles(from: string, to: string) {
for (const file of readdirSync(from, { withFileTypes: true })) {
// bad
if (from === VencordSrc && file.name === "globals.d.ts") continue;
const fullFrom = join(from, file.name);
const fullTo = join(to, file.name);
if (file.isDirectory()) {
copyDtsFiles(fullFrom, fullTo);
} else if (file.name.endsWith(".d.ts")) {
cpSync(fullFrom, fullTo);
}
}
}
copyDtsFiles(VencordSrc, __dirname);

View file

@ -0,0 +1,13 @@
diff --git a/index.d.ts b/index.d.ts
index eb4f07d47b932fb9cc8c8cd451ab107f648bd013..18a3e15a1997734e1773718e5be55d252ed9478c 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -306,7 +306,5 @@ interface LessStatic {
}
declare module "less" {
- export = less;
+ export = LessStatic;
}
-
-declare var less: LessStatic;

View file

@ -0,0 +1,13 @@
diff --git a/lib/rules/no-relative.js b/lib/rules/no-relative.js
index 71594c83f1f4f733ffcc6047d7f7084348335dbe..d8623d87c89499c442171db3272cba07c9efabbe 100644
--- a/lib/rules/no-relative.js
+++ b/lib/rules/no-relative.js
@@ -41,7 +41,7 @@ module.exports = {
ImportDeclaration(node) {
const importPath = node.source.value;
- if (!/^(\.?\.\/)/.test(importPath)) {
+ if (!/^(\.\.\/)/.test(importPath)) {
return;
}

View file

@ -1,14 +0,0 @@
diff --git a/dist/index.js b/dist/index.js
index 67de6fb139070fd0e49beca65e3b63c531202e16..aa2883c8126e4952a42872ee920f59547a066430 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -1 +1 @@
-var C=Object.create;var f=Object.defineProperty;var I=Object.getOwnPropertyDescriptor;var U=Object.getOwnPropertyNames;var S=Object.getPrototypeOf,F=Object.prototype.hasOwnProperty;var $=(e,t)=>{for(var r in t)f(e,r,{get:t[r],enumerable:!0})},y=(e,t,r,i)=>{if(t&&typeof t=="object"||typeof t=="function")for(let s of U(t))!F.call(e,s)&&s!==r&&f(e,s,{get:()=>t[s],enumerable:!(i=I(t,s))||i.enumerable});return e};var b=(e,t,r)=>(r=e!=null?C(S(e)):{},y(t||!e||!e.__esModule?f(r,"default",{value:e,enumerable:!0}):r,e)),D=e=>y(f({},"__esModule",{value:!0}),e);var N={};$(N,{default:()=>J});module.exports=D(N);var h="eslint-plugin-path-alias",v="2.0.0";var l=require("path"),M=b(require("nanomatch"));function j(e){return`https://github/com/msfragala/eslint-plugin-path-alias/blob/master/docs/rules/${e}.md`}var R=require("get-tsconfig"),a=require("path"),w=b(require("find-pkg")),O=require("fs");function P(e){if(e.options[0]?.paths)return z(e);let t=e.getFilename?.()??e.filename,r=(0,R.getTsconfig)(t);if(r?.config?.compilerOptions?.paths)return q(r);let i=w.default.sync((0,a.dirname)(t));if(!i)return;let s=JSON.parse((0,O.readFileSync)(i).toString());if(s?.imports)return L(s,i)}function L(e,t){let r=new Map,i=e.imports??{},s=(0,a.dirname)(t);return Object.entries(i).forEach(([o,n])=>{if(!n||typeof n!="string")return;let p=(0,a.resolve)(s,n);r.set(o,[p])}),r}function q(e){let t=new Map,r=e?.config?.compilerOptions?.paths??{},i=(0,a.dirname)(e.path);return e.config.compilerOptions?.baseUrl&&(i=(0,a.resolve)((0,a.dirname)(e.path),e.config.compilerOptions.baseUrl)),Object.entries(r).forEach(([s,o])=>{s=s.replace(/\/\*$/,""),o=o.map(n=>(0,a.resolve)(i,n.replace(/\/\*$/,""))),t.set(s,o)}),t}function z(e){let t=new Map,r=e.options[0]?.paths??{};return Object.entries(r).forEach(([i,s])=>{if(!s||typeof s!="string")return;if(s.startsWith("/")){t.set(i,[s]);return}let o=e.getCwd?.()??e.cwd,n=(0,a.resolve)(o,s);t.set(i,[n])}),t}var T={meta:{type:"suggestion",docs:{description:"Ensure imports use path aliases whenever possible vs. relative paths",url:j("no-relative")},fixable:"code",schema:[{type:"object",properties:{exceptions:{type:"array",items:{type:"string"}},paths:{type:"object"}},additionalProperties:!1}],messages:{shouldUseAlias:"Import should use path alias instead of relative path"}},create(e){let t=e.options[0]?.exceptions,r=e.getFilename?.()??e.filename,i=P(e);return i?.size?{ImportExpression(s){if(s.source.type!=="Literal"||typeof s.source.value!="string")return;let o=s.source.raw,n=s.source.value;if(!/^(\.?\.\/)/.test(n))return;let p=(0,l.resolve)((0,l.dirname)(r),n);if(A(p,t))return;let c=k(p,i);c&&e.report({node:s,messageId:"shouldUseAlias",data:{alias:c},fix(m){let g=E(p,c,i.get(c)),d=o.replace(n,g);return m.replaceText(s.source,d)}})},ImportDeclaration(s){if(typeof s.source.value!="string")return;let o=s.source.value;if(!/^(\.?\.\/)/.test(o))return;let n=(0,l.resolve)((0,l.dirname)(r),o),p=A(n,t),u=k(n,i);p||u&&e.report({node:s,messageId:"shouldUseAlias",data:{alias:u},fix(c){let m=s.source.raw,g=E(n,u,i.get(u)),d=m.replace(o,g);return c.replaceText(s.source,d)}})}}:{}}};function k(e,t){return Array.from(t.keys()).find(r=>t.get(r).some(s=>e.indexOf(s)===0))}function A(e,t){if(!t)return!1;let r=(0,l.basename)(e);return(0,M.default)(r,t).includes(r)}function E(e,t,r){for(let i of r)if(e.indexOf(i)===0)return e.replace(i,t)}var J={name:h,version:v,meta:{name:h,version:v},rules:{"no-relative":T}};
+var C=Object.create;var f=Object.defineProperty;var I=Object.getOwnPropertyDescriptor;var U=Object.getOwnPropertyNames;var S=Object.getPrototypeOf,F=Object.prototype.hasOwnProperty;var $=(e,t)=>{for(var r in t)f(e,r,{get:t[r],enumerable:!0})},y=(e,t,r,i)=>{if(t&&typeof t=="object"||typeof t=="function")for(let s of U(t))!F.call(e,s)&&s!==r&&f(e,s,{get:()=>t[s],enumerable:!(i=I(t,s))||i.enumerable});return e};var b=(e,t,r)=>(r=e!=null?C(S(e)):{},y(t||!e||!e.__esModule?f(r,"default",{value:e,enumerable:!0}):r,e)),D=e=>y(f({},"__esModule",{value:!0}),e);var N={};$(N,{default:()=>J});module.exports=D(N);var h="eslint-plugin-path-alias",v="2.0.0";var l=require("path"),M=b(require("nanomatch"));function j(e){return`https://github/com/msfragala/eslint-plugin-path-alias/blob/master/docs/rules/${e}.md`}var R=require("get-tsconfig"),a=require("path"),w=b(require("find-pkg")),O=require("fs");function P(e){if(e.options[0]?.paths)return z(e);let t=e.getFilename?.()??e.filename,r=(0,R.getTsconfig)(t);if(r?.config?.compilerOptions?.paths)return q(r);let i=w.default.sync((0,a.dirname)(t));if(!i)return;let s=JSON.parse((0,O.readFileSync)(i).toString());if(s?.imports)return L(s,i)}function L(e,t){let r=new Map,i=e.imports??{},s=(0,a.dirname)(t);return Object.entries(i).forEach(([o,n])=>{if(!n||typeof n!="string")return;let p=(0,a.resolve)(s,n);r.set(o,[p])}),r}function q(e){let t=new Map,r=e?.config?.compilerOptions?.paths??{},i=(0,a.dirname)(e.path);return e.config.compilerOptions?.baseUrl&&(i=(0,a.resolve)((0,a.dirname)(e.path),e.config.compilerOptions.baseUrl)),Object.entries(r).forEach(([s,o])=>{s=s.replace(/\/\*$/,""),o=o.map(n=>(0,a.resolve)(i,n.replace(/\/\*$/,""))),t.set(s,o)}),t}function z(e){let t=new Map,r=e.options[0]?.paths??{};return Object.entries(r).forEach(([i,s])=>{if(!s||typeof s!="string")return;if(s.startsWith("/")){t.set(i,[s]);return}let o=e.getCwd?.()??e.cwd,n=(0,a.resolve)(o,s);t.set(i,[n])}),t}var T={meta:{type:"suggestion",docs:{description:"Ensure imports use path aliases whenever possible vs. relative paths",url:j("no-relative")},fixable:"code",schema:[{type:"object",properties:{exceptions:{type:"array",items:{type:"string"}},paths:{type:"object"}},additionalProperties:!1}],messages:{shouldUseAlias:"Import should use path alias instead of relative path"}},create(e){let t=e.options[0]?.exceptions,r=e.getFilename?.()??e.filename,i=P(e);return i?.size?{ImportExpression(s){if(s.source.type!=="Literal"||typeof s.source.value!="string")return;let o=s.source.raw,n=s.source.value;if(!/^(\.\.\/)/.test(n))return;let p=(0,l.resolve)((0,l.dirname)(r),n);if(A(p,t))return;let c=k(p,i);c&&e.report({node:s,messageId:"shouldUseAlias",data:{alias:c},fix(m){let g=E(p,c,i.get(c)),d=o.replace(n,g);return m.replaceText(s.source,d)}})},ImportDeclaration(s){if(typeof s.source.value!="string")return;let o=s.source.value;if(!/^(\.\.\/)/.test(o))return;let n=(0,l.resolve)((0,l.dirname)(r),o),p=A(n,t),u=k(n,i);p||u&&e.report({node:s,messageId:"shouldUseAlias",data:{alias:u},fix(c){let m=s.source.raw,g=E(n,u,i.get(u)),d=m.replace(o,g);return c.replaceText(s.source,d)}})}}:{}}};function k(e,t){return Array.from(t.keys()).find(r=>t.get(r).some(s=>e.indexOf(s)===0))}function A(e,t){if(!t)return!1;let r=(0,l.basename)(e);return(0,M.default)(r,t).includes(r)}function E(e,t,r){for(let i of r)if(e.indexOf(i)===0)return e.replace(i,t)}var J={name:h,version:v,meta:{name:h,version:v},rules:{"no-relative":T}};
diff --git a/dist/index.mjs b/dist/index.mjs
index 96de18e06d4cc413e11af038cd760e4804c32e59..27e8c4e3e2c942400cc3982e52159904ca6eedfa 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -1 +1 @@
-var d="eslint-plugin-path-alias",h="2.0.0";import{dirname as x,resolve as j,basename as I}from"path";import U from"nanomatch";function y(e){return`https://github/com/msfragala/eslint-plugin-path-alias/blob/master/docs/rules/${e}.md`}import{getTsconfig as k}from"get-tsconfig";import{resolve as c,dirname as u}from"path";import A from"find-pkg";import{readFileSync as E}from"fs";function b(e){if(e.options[0]?.paths)return C(e);let s=e.getFilename?.()??e.filename,i=k(s);if(i?.config?.compilerOptions?.paths)return T(i);let r=A.sync(u(s));if(!r)return;let t=JSON.parse(E(r).toString());if(t?.imports)return M(t,r)}function M(e,s){let i=new Map,r=e.imports??{},t=u(s);return Object.entries(r).forEach(([o,n])=>{if(!n||typeof n!="string")return;let a=c(t,n);i.set(o,[a])}),i}function T(e){let s=new Map,i=e?.config?.compilerOptions?.paths??{},r=u(e.path);return e.config.compilerOptions?.baseUrl&&(r=c(u(e.path),e.config.compilerOptions.baseUrl)),Object.entries(i).forEach(([t,o])=>{t=t.replace(/\/\*$/,""),o=o.map(n=>c(r,n.replace(/\/\*$/,""))),s.set(t,o)}),s}function C(e){let s=new Map,i=e.options[0]?.paths??{};return Object.entries(i).forEach(([r,t])=>{if(!t||typeof t!="string")return;if(t.startsWith("/")){s.set(r,[t]);return}let o=e.getCwd?.()??e.cwd,n=c(o,t);s.set(r,[n])}),s}var P={meta:{type:"suggestion",docs:{description:"Ensure imports use path aliases whenever possible vs. relative paths",url:y("no-relative")},fixable:"code",schema:[{type:"object",properties:{exceptions:{type:"array",items:{type:"string"}},paths:{type:"object"}},additionalProperties:!1}],messages:{shouldUseAlias:"Import should use path alias instead of relative path"}},create(e){let s=e.options[0]?.exceptions,i=e.getFilename?.()??e.filename,r=b(e);return r?.size?{ImportExpression(t){if(t.source.type!=="Literal"||typeof t.source.value!="string")return;let o=t.source.raw,n=t.source.value;if(!/^(\.?\.\/)/.test(n))return;let a=j(x(i),n);if(w(a,s))return;let l=R(a,r);l&&e.report({node:t,messageId:"shouldUseAlias",data:{alias:l},fix(f){let m=O(a,l,r.get(l)),g=o.replace(n,m);return f.replaceText(t.source,g)}})},ImportDeclaration(t){if(typeof t.source.value!="string")return;let o=t.source.value;if(!/^(\.?\.\/)/.test(o))return;let n=j(x(i),o),a=w(n,s),p=R(n,r);a||p&&e.report({node:t,messageId:"shouldUseAlias",data:{alias:p},fix(l){let f=t.source.raw,m=O(n,p,r.get(p)),g=f.replace(o,m);return l.replaceText(t.source,g)}})}}:{}}};function R(e,s){return Array.from(s.keys()).find(i=>s.get(i).some(t=>e.indexOf(t)===0))}function w(e,s){if(!s)return!1;let i=I(e);return U(i,s).includes(i)}function O(e,s,i){for(let r of i)if(e.indexOf(r)===0)return e.replace(r,s)}var Q={name:d,version:h,meta:{name:d,version:h},rules:{"no-relative":P}};export{Q as default};
+var d="eslint-plugin-path-alias",h="2.0.0";import{dirname as x,resolve as j,basename as I}from"path";import U from"nanomatch";function y(e){return`https://github/com/msfragala/eslint-plugin-path-alias/blob/master/docs/rules/${e}.md`}import{getTsconfig as k}from"get-tsconfig";import{resolve as c,dirname as u}from"path";import A from"find-pkg";import{readFileSync as E}from"fs";function b(e){if(e.options[0]?.paths)return C(e);let s=e.getFilename?.()??e.filename,i=k(s);if(i?.config?.compilerOptions?.paths)return T(i);let r=A.sync(u(s));if(!r)return;let t=JSON.parse(E(r).toString());if(t?.imports)return M(t,r)}function M(e,s){let i=new Map,r=e.imports??{},t=u(s);return Object.entries(r).forEach(([o,n])=>{if(!n||typeof n!="string")return;let a=c(t,n);i.set(o,[a])}),i}function T(e){let s=new Map,i=e?.config?.compilerOptions?.paths??{},r=u(e.path);return e.config.compilerOptions?.baseUrl&&(r=c(u(e.path),e.config.compilerOptions.baseUrl)),Object.entries(i).forEach(([t,o])=>{t=t.replace(/\/\*$/,""),o=o.map(n=>c(r,n.replace(/\/\*$/,""))),s.set(t,o)}),s}function C(e){let s=new Map,i=e.options[0]?.paths??{};return Object.entries(i).forEach(([r,t])=>{if(!t||typeof t!="string")return;if(t.startsWith("/")){s.set(r,[t]);return}let o=e.getCwd?.()??e.cwd,n=c(o,t);s.set(r,[n])}),s}var P={meta:{type:"suggestion",docs:{description:"Ensure imports use path aliases whenever possible vs. relative paths",url:y("no-relative")},fixable:"code",schema:[{type:"object",properties:{exceptions:{type:"array",items:{type:"string"}},paths:{type:"object"}},additionalProperties:!1}],messages:{shouldUseAlias:"Import should use path alias instead of relative path"}},create(e){let s=e.options[0]?.exceptions,i=e.getFilename?.()??e.filename,r=b(e);return r?.size?{ImportExpression(t){if(t.source.type!=="Literal"||typeof t.source.value!="string")return;let o=t.source.raw,n=t.source.value;if(!/^(\.\.\/)/.test(n))return;let a=j(x(i),n);if(w(a,s))return;let l=R(a,r);l&&e.report({node:t,messageId:"shouldUseAlias",data:{alias:l},fix(f){let m=O(a,l,r.get(l)),g=o.replace(n,m);return f.replaceText(t.source,g)}})},ImportDeclaration(t){if(typeof t.source.value!="string")return;let o=t.source.value;if(!/^(\.\.\/)/.test(o))return;let n=j(x(i),o),a=w(n,s),p=R(n,r);a||p&&e.report({node:t,messageId:"shouldUseAlias",data:{alias:p},fix(l){let f=t.source.raw,m=O(n,p,r.get(p)),g=f.replace(o,m);return l.replaceText(t.source,g)}})}}:{}}};function R(e,s){return Array.from(s.keys()).find(i=>s.get(i).some(t=>e.indexOf(t)===0))}function w(e,s){if(!s)return!1;let i=I(e);return U(i,s).includes(i)}function O(e,s,i){for(let r of i)if(e.indexOf(r)===0)return e.replace(r,s)}var Q={name:d,version:h,meta:{name:d,version:h},rules:{"no-relative":P}};export{Q as default};

File diff suppressed because it is too large Load diff

View file

@ -1,2 +0,0 @@
packages:
- packages/*

View file

@ -21,21 +21,19 @@ import esbuild from "esbuild";
import { readdir } from "fs/promises"; import { readdir } from "fs/promises";
import { join } from "path"; import { join } from "path";
import { BUILD_TIMESTAMP, commonOpts, exists, globPlugins, IS_DEV, IS_REPORTER, IS_STANDALONE, IS_UPDATER_DISABLED, resolvePluginName, VERSION, commonRendererPlugins, watch } from "./common.mjs"; import { BUILD_TIMESTAMP, commonOpts, existsAsync, globPlugins, isDev, isStandalone, updaterDisabled, VERSION, watch } from "./common.mjs";
const defines = { const defines = {
IS_STANDALONE, IS_STANDALONE: isStandalone,
IS_DEV, IS_DEV: JSON.stringify(isDev),
IS_REPORTER, IS_UPDATER_DISABLED: updaterDisabled,
IS_UPDATER_DISABLED,
IS_WEB: false, IS_WEB: false,
IS_EXTENSION: false, IS_EXTENSION: false,
VERSION: JSON.stringify(VERSION), VERSION: JSON.stringify(VERSION),
BUILD_TIMESTAMP BUILD_TIMESTAMP,
}; };
if (defines.IS_STANDALONE === "false")
if (defines.IS_STANDALONE === false) // If this is a local build (not standalone), optimise
// If this is a local build (not standalone), optimize
// for the specific platform we're on // for the specific platform we're on
defines["process.platform"] = JSON.stringify(process.platform); defines["process.platform"] = JSON.stringify(process.platform);
@ -48,7 +46,7 @@ const nodeCommonOpts = {
platform: "node", platform: "node",
target: ["esnext"], target: ["esnext"],
external: ["electron", "original-fs", "~pluginNatives", ...commonOpts.external], external: ["electron", "original-fs", "~pluginNatives", ...commonOpts.external],
define: defines define: defines,
}; };
const sourceMapFooter = s => watch ? "" : `//# sourceMappingURL=vencord://${s}.js.map`; const sourceMapFooter = s => watch ? "" : `//# sourceMappingURL=vencord://${s}.js.map`;
@ -75,21 +73,23 @@ const globNativesPlugin = {
let i = 0; let i = 0;
for (const dir of pluginDirs) { for (const dir of pluginDirs) {
const dirPath = join("src", dir); const dirPath = join("src", dir);
if (!await exists(dirPath)) continue; if (!await existsAsync(dirPath)) continue;
const plugins = await readdir(dirPath, { withFileTypes: true }); const plugins = await readdir(dirPath);
for (const file of plugins) { for (const p of plugins) {
const fileName = file.name; const nativePath = join(dirPath, p, "native.ts");
const nativePath = join(dirPath, fileName, "native.ts"); const indexNativePath = join(dirPath, p, "native/index.ts");
const indexNativePath = join(dirPath, fileName, "native/index.ts");
if (!(await exists(nativePath)) && !(await exists(indexNativePath))) if (!(await existsAsync(nativePath)) && !(await existsAsync(indexNativePath)))
continue; continue;
const pluginName = await resolvePluginName(dirPath, file); const nameParts = p.split(".");
const namePartsWithoutTarget = nameParts.length === 1 ? nameParts : nameParts.slice(0, -1);
// pluginName.thing.desktop -> PluginName.thing
const cleanPluginName = p[0].toUpperCase() + namePartsWithoutTarget.join(".").slice(1);
const mod = `p${i}`; const mod = `p${i}`;
code += `import * as ${mod} from "./${dir}/${fileName}/native";\n`; code += `import * as ${mod} from "./${dir}/${p}/native";\n`;
natives += `${JSON.stringify(pluginName)}:${mod},\n`; natives += `${JSON.stringify(cleanPluginName)}:${mod},\n`;
i++; i++;
} }
} }
@ -131,7 +131,7 @@ await Promise.all([
sourcemap, sourcemap,
plugins: [ plugins: [
globPlugins("discordDesktop"), globPlugins("discordDesktop"),
...commonRendererPlugins ...commonOpts.plugins
], ],
define: { define: {
...defines, ...defines,
@ -180,7 +180,7 @@ await Promise.all([
sourcemap, sourcemap,
plugins: [ plugins: [
globPlugins("vencordDesktop"), globPlugins("vencordDesktop"),
...commonRendererPlugins ...commonOpts.plugins
], ],
define: { define: {
...defines, ...defines,

View file

@ -23,7 +23,7 @@ import { appendFile, mkdir, readdir, readFile, rm, writeFile } from "fs/promises
import { join } from "path"; import { join } from "path";
import Zip from "zip-local"; import Zip from "zip-local";
import { BUILD_TIMESTAMP, commonOpts, globPlugins, IS_DEV, IS_REPORTER, VERSION, commonRendererPlugins } from "./common.mjs"; import { BUILD_TIMESTAMP, commonOpts, globPlugins, isDev, VERSION } from "./common.mjs";
/** /**
* @type {esbuild.BuildOptions} * @type {esbuild.BuildOptions}
@ -33,23 +33,22 @@ const commonOptions = {
entryPoints: ["browser/Vencord.ts"], entryPoints: ["browser/Vencord.ts"],
globalName: "Vencord", globalName: "Vencord",
format: "iife", format: "iife",
external: ["~plugins", "~git-hash", "/assets/*"], external: ["plugins", "git-hash", "/assets/*"],
plugins: [ plugins: [
globPlugins("web"), globPlugins("web"),
...commonRendererPlugins ...commonOpts.plugins,
], ],
target: ["esnext"], target: ["esnext"],
define: { define: {
IS_WEB: true, IS_WEB: "true",
IS_EXTENSION: false, IS_EXTENSION: "false",
IS_STANDALONE: true, IS_STANDALONE: "true",
IS_DEV, IS_DEV: JSON.stringify(isDev),
IS_REPORTER, IS_DISCORD_DESKTOP: "false",
IS_DISCORD_DESKTOP: false, IS_VESKTOP: "false",
IS_VESKTOP: false, IS_UPDATER_DISABLED: "true",
IS_UPDATER_DISABLED: true,
VERSION: JSON.stringify(VERSION), VERSION: JSON.stringify(VERSION),
BUILD_TIMESTAMP BUILD_TIMESTAMP,
} }
}; };
@ -88,16 +87,16 @@ await Promise.all(
esbuild.build({ esbuild.build({
...commonOptions, ...commonOptions,
outfile: "dist/browser.js", outfile: "dist/browser.js",
footer: { js: "//# sourceURL=VencordWeb" } footer: { js: "//# sourceURL=VencordWeb" },
}), }),
esbuild.build({ esbuild.build({
...commonOptions, ...commonOptions,
outfile: "dist/extension.js", outfile: "dist/extension.js",
define: { define: {
...commonOptions?.define, ...commonOptions?.define,
IS_EXTENSION: true, IS_EXTENSION: "true",
}, },
footer: { js: "//# sourceURL=VencordWeb" } footer: { js: "//# sourceURL=VencordWeb" },
}), }),
esbuild.build({ esbuild.build({
...commonOptions, ...commonOptions,
@ -113,15 +112,10 @@ await Promise.all(
footer: { footer: {
// UserScripts get wrapped in an iife, so define Vencord prop on window that returns our local // UserScripts get wrapped in an iife, so define Vencord prop on window that returns our local
js: "Object.defineProperty(unsafeWindow,'Vencord',{get:()=>Vencord});" js: "Object.defineProperty(unsafeWindow,'Vencord',{get:()=>Vencord});"
} },
}) })
] ]
).catch(err => { );
console.error("Build failed");
console.error(err.message);
if (!commonOpts.watch)
process.exit(1);
});;
/** /**
* @type {(dir: string) => Promise<string[]>} * @type {(dir: string) => Promise<string[]>}
@ -171,7 +165,7 @@ async function buildExtension(target, files) {
f.startsWith("manifest") ? "manifest.json" : f, f.startsWith("manifest") ? "manifest.json" : f,
content content
]; ];
}))) }))),
}; };
await rm(target, { recursive: true, force: true }); await rm(target, { recursive: true, force: true });
@ -198,19 +192,14 @@ const appendCssRuntime = readFile("dist/Vencord.user.css", "utf-8").then(content
return appendFile("dist/Vencord.user.js", cssRuntime); return appendFile("dist/Vencord.user.js", cssRuntime);
}); });
if (!process.argv.includes("--skip-extension")) { await Promise.all([
await Promise.all([ appendCssRuntime,
appendCssRuntime, buildExtension("chromium-unpacked", ["modifyResponseHeaders.json", "content.js", "manifest.json", "icon.png"]),
buildExtension("chromium-unpacked", ["modifyResponseHeaders.json", "content.js", "manifest.json", "icon.png"]), buildExtension("firefox-unpacked", ["background.js", "content.js", "manifestv2.json", "icon.png"]),
buildExtension("firefox-unpacked", ["background.js", "content.js", "manifestv2.json", "icon.png"]), ]);
]);
Zip.sync.zip("dist/chromium-unpacked").compress().save("dist/extension-chrome.zip"); Zip.sync.zip("dist/chromium-unpacked").compress().save("dist/extension-chrome.zip");
console.info("Packed Chromium Extension written to dist/extension-chrome.zip"); console.info("Packed Chromium Extension written to dist/extension-chrome.zip");
Zip.sync.zip("dist/firefox-unpacked").compress().save("dist/extension-firefox.zip"); Zip.sync.zip("dist/firefox-unpacked").compress().save("dist/extension-firefox.zip");
console.info("Packed Firefox Extension written to dist/extension-firefox.zip"); console.info("Packed Firefox Extension written to dist/extension-firefox.zip");
} else {
await appendCssRuntime;
}

View file

@ -20,68 +20,36 @@ import "../suppressExperimentalWarnings.js";
import "../checkNodeVersion.js"; import "../checkNodeVersion.js";
import { exec, execSync } from "child_process"; import { exec, execSync } from "child_process";
import esbuild from "esbuild";
import { constants as FsConstants, readFileSync } from "fs"; import { constants as FsConstants, readFileSync } from "fs";
import { access, readdir, readFile } from "fs/promises"; import { access, readdir, readFile } from "fs/promises";
import { minify as minifyHtml } from "html-minifier-terser";
import { join, relative } from "path"; import { join, relative } from "path";
import { promisify } from "util"; import { promisify } from "util";
// wtf is this assert syntax
import PackageJSON from "../../package.json" assert { type: "json" };
import { getPluginTarget } from "../utils.mjs"; import { getPluginTarget } from "../utils.mjs";
import { builtinModules } from "module";
/** @type {import("../../package.json")} */
const PackageJSON = JSON.parse(readFileSync("package.json"));
export const VERSION = PackageJSON.version; export const VERSION = PackageJSON.version;
// https://reproducible-builds.org/docs/source-date-epoch/ // https://reproducible-builds.org/docs/source-date-epoch/
export const BUILD_TIMESTAMP = Number(process.env.SOURCE_DATE_EPOCH) || Date.now(); export const BUILD_TIMESTAMP = Number(process.env.SOURCE_DATE_EPOCH) || Date.now();
export const watch = process.argv.includes("--watch"); export const watch = process.argv.includes("--watch");
export const IS_DEV = watch || process.argv.includes("--dev"); export const isDev = watch || process.argv.includes("--dev");
export const IS_REPORTER = process.argv.includes("--reporter"); export const isStandalone = JSON.stringify(process.argv.includes("--standalone"));
export const IS_STANDALONE = process.argv.includes("--standalone"); export const updaterDisabled = JSON.stringify(process.argv.includes("--disable-updater"));
export const IS_UPDATER_DISABLED = process.argv.includes("--disable-updater");
export const gitHash = process.env.VENCORD_HASH || execSync("git rev-parse --short HEAD", { encoding: "utf-8" }).trim(); export const gitHash = process.env.VENCORD_HASH || execSync("git rev-parse --short HEAD", { encoding: "utf-8" }).trim();
export const banner = { export const banner = {
js: ` js: `
// Vencord ${gitHash} // Vencord ${gitHash}
// Standalone: ${IS_STANDALONE} // Standalone: ${isStandalone}
// Platform: ${IS_STANDALONE === false ? process.platform : "Universal"} // Platform: ${isStandalone === "false" ? process.platform : "Universal"}
// Updater Disabled: ${IS_UPDATER_DISABLED} // Updater disabled: ${updaterDisabled}
`.trim() `.trim()
}; };
const PluginDefinitionNameMatcher = /definePlugin\(\{\s*(["'])?name\1:\s*(["'`])(.+?)\2/; const isWeb = process.argv.slice(0, 2).some(f => f.endsWith("buildWeb.mjs"));
/**
* @param {string} base
* @param {import("fs").Dirent} dirent
*/
export async function resolvePluginName(base, dirent) {
const fullPath = join(base, dirent.name);
const content = dirent.isFile()
? await readFile(fullPath, "utf-8")
: await (async () => {
for (const file of ["index.ts", "index.tsx"]) {
try {
return await readFile(join(fullPath, file), "utf-8");
} catch {
continue;
}
}
throw new Error(`Invalid plugin ${fullPath}: could not resolve entry point`);
})();
return PluginDefinitionNameMatcher.exec(content)?.[3] export function existsAsync(path) {
?? (() => { return access(path, FsConstants.F_OK)
throw new Error(`Invalid plugin ${fullPath}: must contain definePlugin call with simple string name property as first property`);
})();
}
export async function exists(path) {
return await access(path, FsConstants.F_OK)
.then(() => true) .then(() => true)
.catch(() => false); .catch(() => false);
} }
@ -95,7 +63,7 @@ export const makeAllPackagesExternalPlugin = {
setup(build) { setup(build) {
const filter = /^[^./]|^\.[^./]|^\.\.[^/]/; // Must not start with "/" or "./" or "../" const filter = /^[^./]|^\.[^./]|^\.\.[^/]/; // Must not start with "/" or "./" or "../"
build.onResolve({ filter }, args => ({ path: args.path, external: true })); build.onResolve({ filter }, args => ({ path: args.path, external: true }));
} },
}; };
/** /**
@ -115,48 +83,31 @@ export const globPlugins = kind => ({
build.onLoad({ filter, namespace: "import-plugins" }, async () => { build.onLoad({ filter, namespace: "import-plugins" }, async () => {
const pluginDirs = ["plugins/_api", "plugins/_core", "plugins", "userplugins"]; const pluginDirs = ["plugins/_api", "plugins/_core", "plugins", "userplugins"];
let code = ""; let code = "";
let pluginsCode = "\n"; let plugins = "\n";
let metaCode = "\n";
let excludedCode = "\n";
let i = 0; let i = 0;
for (const dir of pluginDirs) { for (const dir of pluginDirs) {
const userPlugin = dir === "userplugins"; if (!await existsAsync(`./src/${dir}`)) continue;
const files = await readdir(`./src/${dir}`);
const fullDir = `./src/${dir}`;
if (!await exists(fullDir)) continue;
const files = await readdir(fullDir, { withFileTypes: true });
for (const file of files) { for (const file of files) {
const fileName = file.name; if (file.startsWith("_") || file.startsWith(".")) continue;
if (fileName.startsWith("_") || fileName.startsWith(".")) continue; if (file === "index.ts") continue;
if (fileName === "index.ts") continue;
const target = getPluginTarget(fileName); const target = getPluginTarget(file);
if (target) {
if (target && !IS_REPORTER) { if (target === "dev" && !watch) continue;
const excluded = if (target === "web" && kind === "discordDesktop") continue;
(target === "dev" && !IS_DEV) || if (target === "desktop" && kind === "web") continue;
(target === "web" && kind === "discordDesktop") || if (target === "discordDesktop" && kind !== "discordDesktop") continue;
(target === "desktop" && kind === "web") || if (target === "vencordDesktop" && kind !== "vencordDesktop") continue;
(target === "discordDesktop" && kind !== "discordDesktop") ||
(target === "vencordDesktop" && kind !== "vencordDesktop");
if (excluded) {
const name = await resolvePluginName(fullDir, file);
excludedCode += `${JSON.stringify(name)}:${JSON.stringify(target)},\n`;
continue;
}
} }
const folderName = `src/${dir}/${fileName}`.replace(/^src\/plugins\//, "");
const mod = `p${i}`; const mod = `p${i}`;
code += `import ${mod} from "./${dir}/${fileName.replace(/\.tsx?$/, "")}";\n`; code += `import ${mod} from "./${dir}/${file.replace(/\.tsx?$/, "")}";\n`;
pluginsCode += `[${mod}.name]:${mod},\n`; plugins += `[${mod}.name]:${mod},\n`;
metaCode += `[${mod}.name]:${JSON.stringify({ folderName, userPlugin })},\n`; // TODO: add excluded plugins to display in the UI?
i++; i++;
} }
} }
code += `export default {${pluginsCode}};export const PluginMeta={${metaCode}};export const ExcludedPlugins={${excludedCode}};`; code += `export default {${plugins}};`;
return { return {
contents: code, contents: code,
resolveDir: "./src" resolveDir: "./src"
@ -209,60 +160,21 @@ export const gitRemotePlugin = {
/** /**
* @type {import("esbuild").Plugin} * @type {import("esbuild").Plugin}
*/ */
export const fileUrlPlugin = { export const fileIncludePlugin = {
name: "file-uri-plugin", name: "file-include-plugin",
setup: build => { setup: build => {
const filter = /^file:\/\/.+$/; const filter = /^~fileContent\/.+$/;
build.onResolve({ filter }, args => ({ build.onResolve({ filter }, args => ({
namespace: "file-uri", namespace: "include-file",
path: args.path, path: args.path,
pluginData: { pluginData: {
uri: args.path, path: join(args.resolveDir, args.path.slice("include-file/".length))
path: join(args.resolveDir, args.path.slice("file://".length).split("?")[0])
} }
})); }));
build.onLoad({ filter, namespace: "file-uri" }, async ({ pluginData: { path, uri } }) => { build.onLoad({ filter, namespace: "include-file" }, async ({ pluginData: { path } }) => {
const { searchParams } = new URL(uri); const [name, format] = path.split(";");
const base64 = searchParams.has("base64");
const minify = IS_STANDALONE === true && searchParams.has("minify");
const noTrim = searchParams.get("trim") === "false";
const encoding = base64 ? "base64" : "utf-8";
let content;
if (!minify) {
content = await readFile(path, encoding);
if (!noTrim) content = content.trimEnd();
} else {
if (path.endsWith(".html")) {
content = await minifyHtml(await readFile(path, "utf-8"), {
collapseWhitespace: true,
removeComments: true,
minifyCSS: true,
minifyJS: true,
removeEmptyAttributes: true,
removeRedundantAttributes: true,
removeScriptTypeAttributes: true,
removeStyleLinkTypeAttributes: true,
useShortDoctype: true
});
} else if (/[mc]?[jt]sx?$/.test(path)) {
const res = await esbuild.build({
entryPoints: [path],
write: false,
minify: true
});
content = res.outputFiles[0].text;
} else {
throw new Error(`Don't know how to minify file type: ${path}`);
}
if (base64)
content = Buffer.from(content).toString("base64");
}
return { return {
contents: `export default ${JSON.stringify(content)}` contents: `export default ${JSON.stringify(await readFile(name, format ?? "utf-8"))}`
}; };
}); });
} }
@ -293,18 +205,6 @@ export const stylePlugin = {
} }
}; };
/**
* @type {(filter: RegExp, message: string) => import("esbuild").Plugin}
*/
export const banImportPlugin = (filter, message) => ({
name: "ban-imports",
setup: build => {
build.onResolve({ filter }, () => {
return { errors: [{ text: message }] };
});
}
});
/** /**
* @type {import("esbuild").BuildOptions} * @type {import("esbuild").BuildOptions}
*/ */
@ -316,7 +216,7 @@ export const commonOpts = {
sourcemap: watch ? "inline" : "", sourcemap: watch ? "inline" : "",
legalComments: "linked", legalComments: "linked",
banner, banner,
plugins: [fileUrlPlugin, gitHashPlugin, gitRemotePlugin, stylePlugin], plugins: [fileIncludePlugin, gitHashPlugin, gitRemotePlugin, stylePlugin],
external: ["~plugins", "~git-hash", "~git-remote", "/assets/*"], external: ["~plugins", "~git-hash", "~git-remote", "/assets/*"],
inject: ["./scripts/build/inject/react.mjs"], inject: ["./scripts/build/inject/react.mjs"],
jsxFactory: "VencordCreateElement", jsxFactory: "VencordCreateElement",
@ -324,16 +224,3 @@ export const commonOpts = {
// Work around https://github.com/evanw/esbuild/issues/2460 // Work around https://github.com/evanw/esbuild/issues/2460
tsconfig: "./scripts/build/tsconfig.esbuild.json" tsconfig: "./scripts/build/tsconfig.esbuild.json"
}; };
const escapedBuiltinModules = builtinModules
.map(m => m.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&"))
.join("|");
const builtinModuleRegex = new RegExp(`^(node:)?(${escapedBuiltinModules})$`);
export const commonRendererPlugins = [
banImportPlugin(builtinModuleRegex, "Cannot import node inbuilt modules in browser code. You need to use a native.ts file"),
banImportPlugin(/^react$/, "Cannot import from react. React and hooks should be imported from @webpack/common"),
banImportPlugin(/^electron(\/.*)?$/, "Cannot import electron in browser code. You need to use a native.ts file"),
banImportPlugin(/^ts-pattern$/, "Cannot import from ts-pattern. match and P should be imported from @webpack/common"),
...commonOpts.plugins
];

View file

@ -39,7 +39,7 @@ interface PluginData {
hasCommands: boolean; hasCommands: boolean;
required: boolean; required: boolean;
enabledByDefault: boolean; enabledByDefault: boolean;
target: "discordDesktop" | "vencordDesktop" | "desktop" | "web" | "dev"; target: "discordDesktop" | "vencordDesktop" | "web" | "dev";
filePath: string; filePath: string;
} }

View file

@ -16,8 +16,6 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
/* eslint-disable no-fallthrough */
// eslint-disable-next-line spaced-comment // eslint-disable-next-line spaced-comment
/// <reference types="../src/globals" /> /// <reference types="../src/globals" />
// eslint-disable-next-line spaced-comment // eslint-disable-next-line spaced-comment
@ -36,18 +34,16 @@ for (const variable of ["DISCORD_TOKEN", "CHROMIUM_BIN"]) {
const CANARY = process.env.USE_CANARY === "true"; const CANARY = process.env.USE_CANARY === "true";
const browser = await pup.launch({ const browser = await pup.launch({
headless: true, headless: "new",
executablePath: process.env.CHROMIUM_BIN executablePath: process.env.CHROMIUM_BIN
}); });
const page = await browser.newPage(); const page = await browser.newPage();
await page.setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36"); await page.setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36");
await page.setBypassCSP(true);
async function maybeGetError(handle: JSHandle): Promise<string | undefined> { function maybeGetError(handle: JSHandle) {
return await (handle as JSHandle<Error>)?.getProperty("message") return (handle as JSHandle<Error>)?.getProperty("message")
.then(m => m?.jsonValue()) .then(m => m.jsonValue());
.catch(() => undefined);
} }
const report = { const report = {
@ -63,7 +59,6 @@ const report = {
error: string; error: string;
}[], }[],
otherErrors: [] as string[], otherErrors: [] as string[],
ignoredErrors: [] as string[],
badWebpackFinds: [] as string[] badWebpackFinds: [] as string[]
}; };
@ -72,15 +67,12 @@ const IGNORED_DISCORD_ERRORS = [
"Unable to process domain list delta: Client revision number is null", "Unable to process domain list delta: Client revision number is null",
"Downloading the full bad domains file", "Downloading the full bad domains file",
/\[GatewaySocket\].{0,110}Cannot access '/, /\[GatewaySocket\].{0,110}Cannot access '/,
"search for 'name' in undefined", "search for 'name' in undefined"
"Attempting to set fast connect zstd when unsupported"
] as Array<string | RegExp>; ] as Array<string | RegExp>;
function toCodeBlock(s: string, indentation = 0, isDiscord = false) { function toCodeBlock(s: string) {
s = s.replace(/```/g, "`\u200B`\u200B`"); s = s.replace(/```/g, "`\u200B`\u200B`");
return "```" + s + " ```";
const indentationStr = Array(!isDiscord ? indentation : 0).fill(" ").join("");
return `\`\`\`\n${s.split("\n").map(s => indentationStr + s).join("\n")}\n${indentationStr}\`\`\``;
} }
async function printReport() { async function printReport() {
@ -94,35 +86,44 @@ async function printReport() {
report.badPatches.forEach(p => { report.badPatches.forEach(p => {
console.log(`- ${p.plugin} (${p.type})`); console.log(`- ${p.plugin} (${p.type})`);
console.log(` - ID: \`${p.id}\``); console.log(` - ID: \`${p.id}\``);
console.log(` - Match: ${toCodeBlock(p.match, " - Match: ".length)}`); console.log(` - Match: ${toCodeBlock(p.match)}`);
if (p.error) console.log(` - Error: ${toCodeBlock(p.error, " - Error: ".length)}`); if (p.error) console.log(` - Error: ${toCodeBlock(p.error)}`);
}); });
console.log(); console.log();
console.log("## Bad Webpack Finds"); console.log("## Bad Webpack Finds");
report.badWebpackFinds.forEach(p => console.log("- " + toCodeBlock(p, "- ".length))); report.badWebpackFinds.forEach(p => console.log("- " + p));
console.log(); console.log();
console.log("## Bad Starts"); console.log("## Bad Starts");
report.badStarts.forEach(p => { report.badStarts.forEach(p => {
console.log(`- ${p.plugin}`); console.log(`- ${p.plugin}`);
console.log(` - Error: ${toCodeBlock(p.error, " - Error: ".length)}`); console.log(` - Error: ${toCodeBlock(p.error)}`);
}); });
console.log(); console.log();
const ignoredErrors = [] as string[];
report.otherErrors = report.otherErrors.filter(e => {
if (IGNORED_DISCORD_ERRORS.some(regex => e.match(regex))) {
ignoredErrors.push(e);
return false;
}
return true;
});
console.log("## Discord Errors"); console.log("## Discord Errors");
report.otherErrors.forEach(e => { report.otherErrors.forEach(e => {
console.log(`- ${toCodeBlock(e, "- ".length)}`); console.log(`- ${toCodeBlock(e)}`);
}); });
console.log(); console.log();
console.log("## Ignored Discord Errors"); console.log("## Ignored Discord Errors");
report.ignoredErrors.forEach(e => { ignoredErrors.forEach(e => {
console.log(`- ${toCodeBlock(e, "- ".length)}`); console.log(`- ${toCodeBlock(e)}`);
}); });
console.log(); console.log();
@ -136,6 +137,7 @@ async function printReport() {
body: JSON.stringify({ body: JSON.stringify({
description: "Here's the latest Vencord Report!", description: "Here's the latest Vencord Report!",
username: "Vencord Reporter" + (CANARY ? " (Canary)" : ""), username: "Vencord Reporter" + (CANARY ? " (Canary)" : ""),
avatar_url: "https://cdn.discordapp.com/avatars/1017176847865352332/c312b6b44179ae6817de7e4b09e9c6af.webp?size=512",
embeds: [ embeds: [
{ {
title: "Bad Patches", title: "Bad Patches",
@ -143,16 +145,16 @@ async function printReport() {
const lines = [ const lines = [
`**__${p.plugin} (${p.type}):__**`, `**__${p.plugin} (${p.type}):__**`,
`ID: \`${p.id}\``, `ID: \`${p.id}\``,
`Match: ${toCodeBlock(p.match, "Match: ".length, true)}` `Match: ${toCodeBlock(p.match)}`
]; ];
if (p.error) lines.push(`Error: ${toCodeBlock(p.error, "Error: ".length, true)}`); if (p.error) lines.push(`Error: ${toCodeBlock(p.error)}`);
return lines.join("\n"); return lines.join("\n");
}).join("\n\n") || "None", }).join("\n\n") || "None",
color: report.badPatches.length ? 0xff0000 : 0x00ff00 color: report.badPatches.length ? 0xff0000 : 0x00ff00
}, },
{ {
title: "Bad Webpack Finds", title: "Bad Webpack Finds",
description: report.badWebpackFinds.map(f => toCodeBlock(f, 0, true)).join("\n") || "None", description: report.badWebpackFinds.map(toCodeBlock).join("\n") || "None",
color: report.badWebpackFinds.length ? 0xff0000 : 0x00ff00 color: report.badWebpackFinds.length ? 0xff0000 : 0x00ff00
}, },
{ {
@ -160,7 +162,7 @@ async function printReport() {
description: report.badStarts.map(p => { description: report.badStarts.map(p => {
const lines = [ const lines = [
`**__${p.plugin}:__**`, `**__${p.plugin}:__**`,
toCodeBlock(p.error, 0, true) toCodeBlock(p.error)
]; ];
return lines.join("\n"); return lines.join("\n");
} }
@ -169,7 +171,7 @@ async function printReport() {
}, },
{ {
title: "Discord Errors", title: "Discord Errors",
description: report.otherErrors.length ? toCodeBlock(report.otherErrors.join("\n"), 0, true) : "None", description: report.otherErrors.length ? toCodeBlock(report.otherErrors.join("\n")) : "None",
color: report.otherErrors.length ? 0xff0000 : 0x00ff00 color: report.otherErrors.length ? 0xff0000 : 0x00ff00
} }
] ]
@ -185,39 +187,33 @@ page.on("console", async e => {
const level = e.type(); const level = e.type();
const rawArgs = e.args(); const rawArgs = e.args();
async function getText() {
try {
return await Promise.all(
e.args().map(async a => {
return await maybeGetError(a) || await a.jsonValue();
})
).then(a => a.join(" ").trim());
} catch {
return e.text();
}
}
const firstArg = await rawArgs[0]?.jsonValue(); const firstArg = await rawArgs[0]?.jsonValue();
if (firstArg === "[PUPPETEER_TEST_DONE_SIGNAL]") {
await browser.close();
await printReport();
process.exit();
}
const isVencord = firstArg === "[Vencord]"; const isVencord = firstArg === "[Vencord]";
const isDebug = firstArg === "[PUP_DEBUG]"; const isDebug = firstArg === "[PUP_DEBUG]";
const isWebpackFindFail = firstArg === "[PUP_WEBPACK_FIND_FAIL]";
if (isWebpackFindFail) {
process.exitCode = 1;
report.badWebpackFinds.push(await rawArgs[1].jsonValue() as string);
}
outer:
if (isVencord) { if (isVencord) {
try { const args = await Promise.all(e.args().map(a => a.jsonValue()));
var args = await Promise.all(e.args().map(a => a.jsonValue()));
} catch {
break outer;
}
const [, tag, message, otherMessage] = args as Array<string>; const [, tag, message] = args as Array<string>;
const cause = await maybeGetError(e.args()[3]);
switch (tag) { switch (tag) {
case "WebpackInterceptor:": case "WebpackInterceptor:":
const patchFailMatch = message.match(/Patch by (.+?) (had no effect|errored|found no module) \(Module id is (.+?)\): (.+)/)!; const patchFailMatch = message.match(/Patch by (.+?) (had no effect|errored|found no module) \(Module id is (.+?)\): (.+)/)!;
if (!patchFailMatch) break; if (!patchFailMatch) break;
console.error(await getText());
process.exitCode = 1; process.exitCode = 1;
const [, plugin, type, id, regex] = patchFailMatch; const [, plugin, type, id, regex] = patchFailMatch;
@ -225,8 +221,8 @@ page.on("console", async e => {
plugin, plugin,
type, type,
id, id,
match: regex.replace(/\(\?:\[A-Za-z_\$\]\[\\w\$\]\*\)/g, "\\i"), match: regex.replace(/\[A-Za-z_\$\]\[\\w\$\]\*/g, "\\i"),
error: await maybeGetError(e.args()[3]) error: cause
}); });
break; break;
@ -234,86 +230,248 @@ page.on("console", async e => {
const failedToStartMatch = message.match(/Failed to start (.+)/); const failedToStartMatch = message.match(/Failed to start (.+)/);
if (!failedToStartMatch) break; if (!failedToStartMatch) break;
console.error(await getText());
process.exitCode = 1; process.exitCode = 1;
const [, name] = failedToStartMatch; const [, name] = failedToStartMatch;
report.badStarts.push({ report.badStarts.push({
plugin: name, plugin: name,
error: await maybeGetError(e.args()[3]) ?? "Unknown error" error: cause
}); });
break; break;
case "LazyChunkLoader:":
console.error(await getText());
switch (message) {
case "A fatal error occurred:":
process.exit(1);
}
break;
case "Reporter:":
console.error(await getText());
switch (message) {
case "A fatal error occurred:":
process.exit(1);
case "Webpack Find Fail:":
process.exitCode = 1;
report.badWebpackFinds.push(otherMessage);
break;
case "Finished test":
await browser.close();
await printReport();
process.exit();
}
} }
} }
if (isDebug) { if (isDebug) {
console.error(await getText()); console.error(e.text());
} else if (level === "error") { } else if (level === "error") {
const text = await getText(); const text = await Promise.all(
e.args().map(async a => {
try {
return await maybeGetError(a) || await a.jsonValue();
} catch (e) {
return a.toString();
}
})
).then(a => a.join(" ").trim());
if (text.length && !text.startsWith("Failed to load resource: the server responded with a status of") && !text.includes("Webpack")) { if (text.length && !text.startsWith("Failed to load resource: the server responded with a status of") && !text.includes("Webpack")) {
if (IGNORED_DISCORD_ERRORS.some(regex => text.match(regex))) { console.error("[Unexpected Error]", text);
report.ignoredErrors.push(text); report.otherErrors.push(text);
} else { }
console.error("[Unexpected Error]", text); }
report.otherErrors.push(text); });
page.on("error", e => console.error("[Error]", e));
page.on("pageerror", e => console.error("[Page Error]", e));
await page.setBypassCSP(true);
function runTime(token: string) {
console.log("[PUP_DEBUG]", "Starting test...");
try {
// Spoof languages to not be suspicious
Object.defineProperty(navigator, "languages", {
get: function () {
return ["en-US", "en"];
},
});
// Monkey patch Logger to not log with custom css
// @ts-ignore
Vencord.Util.Logger.prototype._log = function (level, levelColor, args) {
if (level === "warn" || level === "error")
console[level]("[Vencord]", this.name + ":", ...args);
};
// Force enable all plugins and patches
Vencord.Plugins.patches.length = 0;
Object.values(Vencord.Plugins.plugins).forEach(p => {
// Needs native server to run
if (p.name === "WebRichPresence (arRPC)") return;
Vencord.Settings.plugins[p.name].enabled = true;
p.patches?.forEach(patch => {
patch.plugin = p.name;
delete patch.predicate;
delete patch.group;
if (!Array.isArray(patch.replacement))
patch.replacement = [patch.replacement];
patch.replacement.forEach(r => {
delete r.predicate;
});
Vencord.Plugins.patches.push(patch);
});
});
Vencord.Webpack.waitFor(
"loginToken",
m => {
console.log("[PUP_DEBUG]", "Logging in with token...");
m.loginToken(token);
} }
} );
// Force load all chunks
Vencord.Webpack.onceReady.then(() => setTimeout(async () => {
console.log("[PUP_DEBUG]", "Webpack is ready!");
const { wreq } = Vencord.Webpack;
console.log("[PUP_DEBUG]", "Loading all chunks...");
let chunks = null as Record<number, string[]> | null;
const sym = Symbol("Vencord.chunksExtract");
Object.defineProperty(Object.prototype, sym, {
get() {
chunks = this;
},
set() { },
configurable: true,
});
await (wreq as any).el(sym);
delete Object.prototype[sym];
const validChunksEntryPoints = new Set<string>();
const validChunks = new Set<string>();
const invalidChunks = new Set<string>();
if (!chunks) throw new Error("Failed to get chunks");
for (const entryPoint in chunks) {
const chunkIds = chunks[entryPoint];
let invalidEntryPoint = false;
for (const id of chunkIds) {
if (wreq.u(id) == null || wreq.u(id) === "undefined.js") continue;
const isWasm = await fetch(wreq.p + wreq.u(id))
.then(r => r.text())
.then(t => t.includes(".module.wasm") || !t.includes("(this.webpackChunkdiscord_app=this.webpackChunkdiscord_app||[]).push"));
if (isWasm) {
invalidChunks.add(id);
invalidEntryPoint = true;
continue;
}
validChunks.add(id);
}
if (!invalidEntryPoint)
validChunksEntryPoints.add(entryPoint);
}
for (const entryPoint of validChunksEntryPoints) {
try {
// Loads all chunks required for an entry point
await (wreq as any).el(entryPoint);
} catch (err) { }
}
// Matches "id" or id:
const chunkIdRegex = /(?:"(\d+?)")|(?:(\d+?):)/g;
const wreqU = wreq.u.toString();
const allChunks = [] as string[];
let currentMatch: RegExpExecArray | null;
while ((currentMatch = chunkIdRegex.exec(wreqU)) != null) {
const id = currentMatch[1] ?? currentMatch[2];
if (id == null) continue;
allChunks.push(id);
}
if (allChunks.length === 0) throw new Error("Failed to get all chunks");
const chunksLeft = allChunks.filter(id => {
return !(validChunks.has(id) || invalidChunks.has(id));
});
for (const id of chunksLeft) {
const isWasm = await fetch(wreq.p + wreq.u(id))
.then(r => r.text())
.then(t => t.includes(".module.wasm") || !t.includes("(this.webpackChunkdiscord_app=this.webpackChunkdiscord_app||[]).push"));
// Loads a chunk
if (!isWasm) await wreq.e(id as any);
}
// Make sure every chunk has finished loading
await new Promise(r => setTimeout(r, 1000));
for (const entryPoint of validChunksEntryPoints) {
try {
if (wreq.m[entryPoint]) wreq(entryPoint as any);
} catch (err) {
console.error(err);
}
}
console.log("[PUP_DEBUG]", "Finished loading all chunks!");
for (const patch of Vencord.Plugins.patches) {
if (!patch.all) {
new Vencord.Util.Logger("WebpackInterceptor").warn(`Patch by ${patch.plugin} found no module (Module id is -): ${patch.find}`);
}
}
for (const [searchType, args] of Vencord.Webpack.lazyWebpackSearchHistory) {
let method = searchType;
if (searchType === "findComponent") method = "find";
if (searchType === "findExportedComponent") method = "findByProps";
if (searchType === "waitFor" || searchType === "waitForComponent" || searchType === "waitForStore") {
if (typeof args[0] === "string") method = "findByProps";
else method = "find";
}
try {
let result: any;
if (method === "proxyLazyWebpack" || method === "LazyComponentWebpack") {
const [factory] = args;
result = factory();
} else if (method === "extractAndLoadChunks") {
const [code, matcher] = args;
const module = Vencord.Webpack.findModuleFactory(...code);
if (module) result = module.toString().match(Vencord.Util.canonicalizeMatch(matcher));
} else {
// @ts-ignore
result = Vencord.Webpack[method](...args);
}
if (result == null || ("$$vencordInternal" in result && result.$$vencordInternal() == null)) throw "a rock at ben shapiro";
} catch (e) {
let logMessage = searchType;
if (method === "find" || method === "proxyLazyWebpack" || method === "LazyComponentWebpack") logMessage += `(${args[0].toString().slice(0, 147)}...)`;
else if (method === "extractAndLoadChunks") logMessage += `([${args[0].map(arg => `"${arg}"`).join(", ")}], ${args[1].toString()})`;
else logMessage += `(${args.map(arg => `"${arg}"`).join(", ")})`;
console.log("[PUP_WEBPACK_FIND_FAIL]", logMessage);
}
}
setTimeout(() => console.log("[PUPPETEER_TEST_DONE_SIGNAL]"), 1000);
}, 1000));
} catch (e) {
console.log("[PUP_DEBUG]", "A fatal error occurred:", e);
process.exit(1);
} }
});
page.on("error", e => console.error("[Error]", e.message));
page.on("pageerror", e => {
if (e.message.includes("Sentry successfully disabled")) return;
if (!e.message.startsWith("Object") && !e.message.includes("Cannot find module")) {
console.error("[Page Error]", e.message);
report.otherErrors.push(e.message);
} else {
report.ignoredErrors.push(e.message);
}
});
async function reporterRuntime(token: string) {
Vencord.Webpack.waitFor(
"loginToken",
m => {
console.log("[PUP_DEBUG]", "Logging in with token...");
m.loginToken(token);
}
);
} }
await page.evaluateOnNewDocument(` await page.evaluateOnNewDocument(`
if (location.host.endsWith("discord.com")) { ${readFileSync("./dist/browser.js", "utf-8")}
${readFileSync("./dist/browser.js", "utf-8")};
(${reporterRuntime.toString()})(${JSON.stringify(process.env.DISCORD_TOKEN)}); ;(${runTime.toString()})(${JSON.stringify(process.env.DISCORD_TOKEN)});
}
`); `);
await page.goto(CANARY ? "https://canary.discord.com/login" : "https://discord.com/login"); await page.goto(CANARY ? "https://canary.discord.com/login" : "https://discord.com/login");

View file

@ -35,11 +35,11 @@ const ETAG_FILE = join(FILE_DIR, "etag.txt");
function getFilename() { function getFilename() {
switch (process.platform) { switch (process.platform) {
case "win32": case "win32":
return "VencordInstallerCli.exe"; return "VencordInstaller.exe";
case "darwin": case "darwin":
return "VencordInstaller.MacOS.zip"; return "VencordInstaller.MacOS.zip";
case "linux": case "linux":
return "VencordInstallerCli-linux"; return "VencordInstaller-" + (process.env.WAYLAND_DISPLAY ? "wayland" : "x11");
default: default:
throw new Error("Unsupported platform: " + process.platform); throw new Error("Unsupported platform: " + process.platform);
} }
@ -118,15 +118,11 @@ const installerBin = await ensureBinary();
console.log("Now running Installer..."); console.log("Now running Installer...");
try { execFileSync(installerBin, {
execFileSync(installerBin, { stdio: "inherit",
stdio: "inherit", env: {
env: { ...process.env,
...process.env, VENCORD_USER_DATA_DIR: BASE_DIR,
VENCORD_USER_DATA_DIR: BASE_DIR, VENCORD_DEV_INSTALL: "1"
VENCORD_DEV_INSTALL: "1" }
} });
});
} catch {
console.error("Something went wrong. Please check the logs above.");
}

View file

@ -1,2 +0,0 @@
git remote add upstream https://github.com/Vendicated/Vencord.git
git remote set-url --pull upstream DISABLED

View file

@ -17,7 +17,6 @@
*/ */
export * as Api from "./api"; export * as Api from "./api";
export * as Components from "./components";
export * as Plugins from "./plugins"; export * as Plugins from "./plugins";
export * as Util from "./utils"; export * as Util from "./utils";
export * as QuickCss from "./utils/quickCss"; export * as QuickCss from "./utils/quickCss";
@ -28,7 +27,6 @@ export { PlainSettings, Settings };
import "./utils/quickCss"; import "./utils/quickCss";
import "./webpack/patchWebpack"; import "./webpack/patchWebpack";
import { openUpdaterModal } from "@components/VencordSettings/UpdaterTab";
import { StartAt } from "@utils/types"; import { StartAt } from "@utils/types";
import { get as dsGet } from "./api/DataStore"; import { get as dsGet } from "./api/DataStore";
@ -42,10 +40,6 @@ import { checkForUpdates, update, UpdateLogger } from "./utils/updater";
import { onceReady } from "./webpack"; import { onceReady } from "./webpack";
import { SettingsRouter } from "./webpack/common"; import { SettingsRouter } from "./webpack/common";
if (IS_REPORTER) {
require("./debug/runReporter");
}
async function syncSettings() { async function syncSettings() {
// pre-check for local shared settings // pre-check for local shared settings
if ( if (
@ -91,7 +85,7 @@ async function init() {
syncSettings(); syncSettings();
if (!IS_WEB && !IS_UPDATER_DISABLED) { if (!IS_WEB) {
try { try {
const isOutdated = await checkForUpdates(); const isOutdated = await checkForUpdates();
if (!isOutdated) return; if (!isOutdated) return;
@ -109,13 +103,16 @@ async function init() {
return; return;
} }
setTimeout(() => showNotification({ if (Settings.notifyAboutUpdates)
title: "A Vencord update is available!", setTimeout(() => showNotification({
body: "Click here to view the update", title: "A Vencord update is available!",
permanent: true, body: "Click here to view the update",
noPersist: true, permanent: true,
onClick: openUpdaterModal! noPersist: true,
}), 10_000); onClick() {
SettingsRouter.open("VencordUpdater");
}
}), 10_000);
} catch (err) { } catch (err) {
UpdateLogger.error("Failed to check for updates", err); UpdateLogger.error("Failed to check for updates", err);
} }

View file

@ -4,12 +4,10 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
*/ */
import { PluginIpcMappings } from "@main/ipcPlugins"; import { IpcEvents } from "@utils/IpcEvents";
import type { UserThemeHeader } from "@main/themes";
import { IpcEvents } from "@shared/IpcEvents";
import { IpcRes } from "@utils/types"; import { IpcRes } from "@utils/types";
import type { Settings } from "api/Settings";
import { ipcRenderer } from "electron"; import { ipcRenderer } from "electron";
import { PluginIpcMappings } from "main/ipcPlugins";
function invoke<T = any>(event: IpcEvents, ...args: any[]) { function invoke<T = any>(event: IpcEvents, ...args: any[]) {
return ipcRenderer.invoke(event, ...args) as Promise<T>; return ipcRenderer.invoke(event, ...args) as Promise<T>;
@ -34,7 +32,7 @@ export default {
uploadTheme: (fileName: string, fileData: string) => invoke<void>(IpcEvents.UPLOAD_THEME, fileName, fileData), uploadTheme: (fileName: string, fileData: string) => invoke<void>(IpcEvents.UPLOAD_THEME, fileName, fileData),
deleteTheme: (fileName: string) => invoke<void>(IpcEvents.DELETE_THEME, fileName), deleteTheme: (fileName: string) => invoke<void>(IpcEvents.DELETE_THEME, fileName),
getThemesDir: () => invoke<string>(IpcEvents.GET_THEMES_DIR), getThemesDir: () => invoke<string>(IpcEvents.GET_THEMES_DIR),
getThemesList: () => invoke<Array<UserThemeHeader>>(IpcEvents.GET_THEMES_LIST), getThemesList: () => invoke<Array<{ fileName: string; content: string; }>>(IpcEvents.GET_THEMES_LIST),
getThemeData: (fileName: string) => invoke<string | undefined>(IpcEvents.GET_THEME_DATA, fileName), getThemeData: (fileName: string) => invoke<string | undefined>(IpcEvents.GET_THEME_DATA, fileName),
getSystemValues: () => invoke<Record<string, string>>(IpcEvents.GET_THEME_SYSTEM_VALUES), getSystemValues: () => invoke<Record<string, string>>(IpcEvents.GET_THEME_SYSTEM_VALUES),
}, },
@ -47,8 +45,8 @@ export default {
}, },
settings: { settings: {
get: () => sendSync<Settings>(IpcEvents.GET_SETTINGS), get: () => sendSync<string>(IpcEvents.GET_SETTINGS),
set: (settings: Settings, pathToNotify?: string) => invoke<void>(IpcEvents.SET_SETTINGS, settings, pathToNotify), set: (settings: string) => invoke<void>(IpcEvents.SET_SETTINGS, settings),
getSettingsDir: () => invoke<string>(IpcEvents.GET_SETTINGS_DIR), getSettingsDir: () => invoke<string>(IpcEvents.GET_SETTINGS_DIR),
}, },

View file

@ -17,6 +17,7 @@
*/ */
import ErrorBoundary from "@components/ErrorBoundary"; import ErrorBoundary from "@components/ErrorBoundary";
import { User } from "discord-types/general";
import { ComponentType, HTMLProps } from "react"; import { ComponentType, HTMLProps } from "react";
import Plugins from "~plugins"; import Plugins from "~plugins";
@ -35,7 +36,7 @@ export interface ProfileBadge {
image?: string; image?: string;
link?: string; link?: string;
/** Action to perform when you click the badge */ /** Action to perform when you click the badge */
onClick?(event: React.MouseEvent<HTMLButtonElement, MouseEvent>, props: BadgeUserArgs): void; onClick?(): void;
/** Should the user display this badge? */ /** Should the user display this badge? */
shouldShow?(userInfo: BadgeUserArgs): boolean; shouldShow?(userInfo: BadgeUserArgs): boolean;
/** Optional props (e.g. style) for the badge, ignored for component badges */ /** Optional props (e.g. style) for the badge, ignored for component badges */
@ -44,11 +45,6 @@ export interface ProfileBadge {
position?: BadgePosition; position?: BadgePosition;
/** The badge name to display, Discord uses this. Required for component badges */ /** The badge name to display, Discord uses this. Required for component badges */
key?: string; key?: string;
/**
* Allows dynamically returning multiple badges
*/
getBadges?(userInfo: BadgeUserArgs): ProfileBadge[];
} }
const Badges = new Set<ProfileBadge>(); const Badges = new Set<ProfileBadge>();
@ -78,27 +74,22 @@ export function _getBadges(args: BadgeUserArgs) {
const badges = [] as ProfileBadge[]; const badges = [] as ProfileBadge[];
for (const badge of Badges) { for (const badge of Badges) {
if (!badge.shouldShow || badge.shouldShow(args)) { 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 }];
badge.position === BadgePosition.START badge.position === BadgePosition.START
? badges.unshift(...b) ? badges.unshift({ ...badge, ...args })
: badges.push(...b); : badges.push({ ...badge, ...args });
} }
} }
const donorBadges = (Plugins.BadgeAPI as unknown as typeof import("../plugins/_api/badges").default).getDonorBadges(args.userId); const donorBadges = (Plugins.BadgeAPI as unknown as typeof import("../plugins/_api/badges").default).getDonorBadges(args.user.id);
if (donorBadges) badges.unshift(...donorBadges); if (donorBadges) badges.unshift(...donorBadges);
return badges; return badges;
} }
export interface BadgeUserArgs { export interface BadgeUserArgs {
userId: string; user: User;
guildId: string; profile: Profile;
premiumSince: Date;
premiumGuildSince?: Date;
} }
interface ConnectedAccount { interface ConnectedAccount {

View file

@ -1,4 +0,0 @@
.vc-chatbar-button {
display: flex;
align-items: center;
}

View file

@ -1,128 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./ChatButton.css";
import ErrorBoundary from "@components/ErrorBoundary";
import { Logger } from "@utils/Logger";
import { waitFor } from "@webpack";
import { Button, ButtonLooks, ButtonWrapperClasses, Tooltip } from "@webpack/common";
import { Channel } from "discord-types/general";
import { HTMLProps, MouseEventHandler, ReactNode } from "react";
let ChannelTextAreaClasses: Record<"button" | "buttonContainer", string>;
waitFor(["buttonContainer", "channelTextArea"], m => ChannelTextAreaClasses = m);
export interface ChatBarProps {
channel: Channel;
disabled: boolean;
isEmpty: boolean;
type: {
analyticsName: string;
attachments: boolean;
autocomplete: {
addReactionShortcut: boolean,
forceChatLayer: boolean,
reactions: boolean;
},
commands: {
enabled: boolean;
},
drafts: {
type: number,
commandType: number,
autoSave: boolean;
},
emojis: {
button: boolean;
},
gifs: {
button: boolean,
allowSending: boolean;
},
gifts: {
button: boolean;
},
permissions: {
requireSendMessages: boolean;
},
showThreadPromptOnReply: boolean,
stickers: {
button: boolean,
allowSending: boolean,
autoSuggest: boolean;
},
users: {
allowMentioning: boolean;
},
submit: {
button: boolean,
ignorePreference: boolean,
disableEnterToSubmit: boolean,
clearOnSubmit: boolean,
useDisabledStylesOnSubmit: boolean;
},
uploadLongMessages: boolean,
upsellLongMessages: {
iconOnly: boolean;
},
showCharacterCount: boolean,
sedReplace: boolean;
};
}
export type ChatBarButton = (props: ChatBarProps & { isMainChat: boolean; }) => JSX.Element | null;
const buttonFactories = new Map<string, ChatBarButton>();
const logger = new Logger("ChatButtons");
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>
);
}
}
export const addChatBarButton = (id: string, button: ChatBarButton) => buttonFactories.set(id, button);
export const removeChatBarButton = (id: string) => buttonFactories.delete(id);
export interface ChatBarButtonProps {
children: ReactNode;
tooltip: string;
onClick: MouseEventHandler<HTMLButtonElement>;
onContextMenu?: MouseEventHandler<HTMLButtonElement>;
buttonProps?: Omit<HTMLProps<HTMLButtonElement>, "size" | "onClick" | "onContextMenu">;
}
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
aria-label={props.tooltip}
size=""
look={ButtonLooks.BLANK}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
innerClassName={`${ButtonWrapperClasses.button} ${ChannelTextAreaClasses?.button}`}
onClick={props.onClick}
onContextMenu={props.onContextMenu}
{...props.buttonProps}
>
<div className={ButtonWrapperClasses.buttonWrapper}>
{props.children}
</div>
</Button>
</div>
)}
</Tooltip>
);
}, { noop: true });

View file

@ -16,15 +16,15 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { mergeDefaults } from "@utils/mergeDefaults"; import { mergeDefaults } from "@utils/misc";
import { findByCodeLazy } from "@webpack"; import { findByPropsLazy } from "@webpack";
import { MessageActions, SnowflakeUtils } from "@webpack/common"; import { MessageActions, SnowflakeUtils } from "@webpack/common";
import { Message } from "discord-types/general"; import { Message } from "discord-types/general";
import type { PartialDeep } from "type-fest"; import type { PartialDeep } from "type-fest";
import { Argument } from "./types"; import { Argument } from "./types";
const createBotMessage = findByCodeLazy('username:"Clyde"'); const MessageCreator = findByPropsLazy("createBotMessage");
export function generateId() { export function generateId() {
return `-${SnowflakeUtils.fromTimestamp(Date.now())}`; return `-${SnowflakeUtils.fromTimestamp(Date.now())}`;
@ -37,7 +37,7 @@ export function generateId() {
* @returns {Message} * @returns {Message}
*/ */
export function sendBotMessage(channelId: string, message: PartialDeep<Message>): Message { export function sendBotMessage(channelId: string, message: PartialDeep<Message>): Message {
const botMessage = createBotMessage({ channelId, content: "", embeds: [] }); const botMessage = MessageCreator.createBotMessage({ channelId, content: "", embeds: [] });
MessageActions.receiveMessage(channelId, mergeDefaults(message, botMessage)); MessageActions.receiveMessage(channelId, mergeDefaults(message, botMessage));

View file

@ -16,7 +16,6 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { Logger } from "@utils/Logger";
import { makeCodeblock } from "@utils/text"; import { makeCodeblock } from "@utils/text";
import { sendBotMessage } from "./commandHelpers"; import { sendBotMessage } from "./commandHelpers";
@ -47,10 +46,10 @@ export let RequiredMessageOption: Option = ReqPlaceholder;
export const _init = function (cmds: Command[]) { export const _init = function (cmds: Command[]) {
try { try {
BUILT_IN = cmds; BUILT_IN = cmds;
OptionalMessageOption = cmds.find(c => (c.untranslatedName || c.displayName) === "shrug")!.options![0]; OptionalMessageOption = cmds.find(c => c.name === "shrug")!.options![0];
RequiredMessageOption = cmds.find(c => (c.untranslatedName || c.displayName) === "me")!.options![0]; RequiredMessageOption = cmds.find(c => c.name === "me")!.options![0];
} catch (e) { } catch (e) {
new Logger("CommandsAPI").error("Failed to load CommandsApi", e, " - cmds is", cmds); console.error("Failed to load CommandsApi");
} }
return cmds; return cmds;
} as never; } as never;
@ -139,8 +138,6 @@ export function registerCommand<C extends Command>(command: C, plugin: string) {
throw new Error(`Command '${command.name}' already exists.`); throw new Error(`Command '${command.name}' already exists.`);
command.isVencordCommand = true; command.isVencordCommand = true;
command.untranslatedName ??= command.name;
command.untranslatedDescription ??= command.description;
command.id ??= `-${BUILT_IN.length + 1}`; command.id ??= `-${BUILT_IN.length + 1}`;
command.applicationId ??= "-1"; // BUILT_IN; command.applicationId ??= "-1"; // BUILT_IN;
command.type ??= ApplicationCommandType.CHAT_INPUT; command.type ??= ApplicationCommandType.CHAT_INPUT;

View file

@ -93,10 +93,8 @@ export interface Command {
isVencordCommand?: boolean; isVencordCommand?: boolean;
name: string; name: string;
untranslatedName?: string;
displayName?: string; displayName?: string;
description: string; description: string;
untranslatedDescription?: string;
displayDescription?: string; displayDescription?: string;
options?: Option[]; options?: Option[];

View file

@ -17,20 +17,22 @@
*/ */
import { Logger } from "@utils/Logger"; import { Logger } from "@utils/Logger";
import { Menu, React } from "@webpack/common";
import type { ReactElement } from "react"; import type { ReactElement } from "react";
type ContextMenuPatchCallbackReturn = (() => void) | void;
/** /**
* @param children The rendered context menu elements * @param children The rendered context menu elements
* @param args Any arguments passed into making the context menu, like the guild, channel, user or message for example * @param args Any arguments passed into making the context menu, like the guild, channel, user or message for example
* @returns A callback which is only ran once used to modify the context menu elements (Use to avoid duplicates)
*/ */
export type NavContextMenuPatchCallback = (children: Array<ReactElement | null>, ...args: Array<any>) => void; export type NavContextMenuPatchCallback = (children: Array<ReactElement | null>, ...args: Array<any>) => ContextMenuPatchCallbackReturn;
/** /**
* @param navId The navId of the context menu being patched * @param navId The navId of the context menu being patched
* @param children The rendered context menu elements * @param children The rendered context menu elements
* @param args Any arguments passed into making the context menu, like the guild, channel, user or message for example * @param args Any arguments passed into making the context menu, like the guild, channel, user or message for example
* @returns A callback which is only ran once used to modify the context menu elements (Use to avoid duplicates)
*/ */
export type GlobalContextMenuPatchCallback = (navId: string, children: Array<ReactElement | null>, ...args: Array<any>) => void; export type GlobalContextMenuPatchCallback = (navId: string, children: Array<ReactElement | null>, ...args: Array<any>) => ContextMenuPatchCallbackReturn;
const ContextMenuLogger = new Logger("ContextMenu"); const ContextMenuLogger = new Logger("ContextMenu");
@ -90,21 +92,15 @@ export function removeGlobalContextMenuPatch(patch: GlobalContextMenuPatchCallba
* A helper function for finding the children array of a group nested inside a context menu based on the id(s) of its children * A helper function for finding the children array of a group nested inside a context menu based on the id(s) of its children
* @param id The id of the child. If an array is specified, all ids will be tried * @param id The id of the child. If an array is specified, all ids will be tried
* @param children The context menu children * @param children The context menu children
* @param matchSubstring Whether to check if the id is a substring of the child id
*/ */
export function findGroupChildrenByChildId(id: string | string[], children: Array<ReactElement | null | undefined>, matchSubstring = false): Array<ReactElement | null | undefined> | null { export function findGroupChildrenByChildId(id: string | string[], children: Array<ReactElement | null>, _itemsArray?: Array<ReactElement | null>): Array<ReactElement | null> | null {
for (const child of children) { for (const child of children) {
if (child == null) continue; if (child == null) continue;
if (Array.isArray(child)) {
const found = findGroupChildrenByChildId(id, child, matchSubstring);
if (found !== null) return found;
}
if ( if (
(Array.isArray(id) && id.some(id => matchSubstring ? child.props?.id?.includes(id) : child.props?.id === id)) (Array.isArray(id) && id.some(id => child.props?.id === id))
|| (matchSubstring ? child.props?.id?.includes(id) : child.props?.id === id) || child.props?.id === id
) return children; ) return _itemsArray ?? null;
let nextChildren = child.props?.children; let nextChildren = child.props?.children;
if (nextChildren) { if (nextChildren) {
@ -113,7 +109,7 @@ export function findGroupChildrenByChildId(id: string | string[], children: Arra
child.props.children = nextChildren; child.props.children = nextChildren;
} }
const found = findGroupChildrenByChildId(id, nextChildren, matchSubstring); const found = findGroupChildrenByChildId(id, nextChildren, nextChildren);
if (found !== null) return found; if (found !== null) return found;
} }
} }
@ -130,12 +126,9 @@ interface ContextMenuProps {
onClose: (callback: (...args: Array<any>) => any) => void; onClose: (callback: (...args: Array<any>) => any) => void;
} }
export function _usePatchContextMenu(props: ContextMenuProps) { const patchedMenus = new WeakSet();
props = {
...props,
children: cloneMenuChildren(props.children),
};
export function _patchContextMenu(props: ContextMenuProps) {
props.contextMenuApiArguments ??= []; props.contextMenuApiArguments ??= [];
const contextMenuPatches = navPatches.get(props.navId); const contextMenuPatches = navPatches.get(props.navId);
@ -144,7 +137,8 @@ export function _usePatchContextMenu(props: ContextMenuProps) {
if (contextMenuPatches) { if (contextMenuPatches) {
for (const patch of contextMenuPatches) { for (const patch of contextMenuPatches) {
try { try {
patch(props.children, ...props.contextMenuApiArguments); const callback = patch(props.children, ...props.contextMenuApiArguments);
if (!patchedMenus.has(props)) callback?.();
} catch (err) { } catch (err) {
ContextMenuLogger.error(`Patch for ${props.navId} errored,`, err); ContextMenuLogger.error(`Patch for ${props.navId} errored,`, err);
} }
@ -153,30 +147,12 @@ export function _usePatchContextMenu(props: ContextMenuProps) {
for (const patch of globalPatches) { for (const patch of globalPatches) {
try { try {
patch(props.navId, props.children, ...props.contextMenuApiArguments); const callback = patch(props.navId, props.children, ...props.contextMenuApiArguments);
if (!patchedMenus.has(props)) callback?.();
} catch (err) { } catch (err) {
ContextMenuLogger.error("Global patch errored,", err); ContextMenuLogger.error("Global patch errored,", err);
} }
} }
return props; patchedMenus.add(props);
}
function cloneMenuChildren(obj: ReactElement | Array<ReactElement | null> | null) {
if (Array.isArray(obj)) {
return obj.map(cloneMenuChildren);
}
if (React.isValidElement(obj)) {
obj = React.cloneElement(obj);
if (
obj?.props?.children &&
(obj.type !== Menu.MenuControlItem || obj.type === Menu.MenuControlItem && obj.props.control != null)
) {
obj.props.children = cloneMenuChildren(obj.props.children);
}
}
return obj;
} }

View file

@ -49,7 +49,7 @@ let defaultGetStoreFunc: UseStore | undefined;
function defaultGetStore() { function defaultGetStore() {
if (!defaultGetStoreFunc) { if (!defaultGetStoreFunc) {
defaultGetStoreFunc = createStore(!IS_REPORTER ? "VencordData" : "VencordDataReporter", "VencordStore"); defaultGetStoreFunc = createStore("VencordData", "VencordStore");
} }
return defaultGetStoreFunc; return defaultGetStoreFunc;
} }

View file

@ -74,7 +74,7 @@ export interface MessageExtra {
} }
export type SendListener = (channelId: string, messageObj: MessageObject, extra: MessageExtra) => Promisable<void | { cancel: boolean; }>; export type SendListener = (channelId: string, messageObj: MessageObject, extra: MessageExtra) => Promisable<void | { cancel: boolean; }>;
export type EditListener = (channelId: string, messageId: string, messageObj: MessageObject) => Promisable<void | { cancel: boolean; }>; export type EditListener = (channelId: string, messageId: string, messageObj: MessageObject) => Promisable<void>;
const sendListeners = new Set<SendListener>(); const sendListeners = new Set<SendListener>();
const editListeners = new Set<EditListener>(); const editListeners = new Set<EditListener>();
@ -84,7 +84,7 @@ export async function _handlePreSend(channelId: string, messageObj: MessageObjec
for (const listener of sendListeners) { for (const listener of sendListeners) {
try { try {
const result = await listener(channelId, messageObj, extra); const result = await listener(channelId, messageObj, extra);
if (result?.cancel) { if (result && result.cancel === true) {
return true; return true;
} }
} catch (e) { } catch (e) {
@ -97,15 +97,11 @@ export async function _handlePreSend(channelId: string, messageObj: MessageObjec
export async function _handlePreEdit(channelId: string, messageId: string, messageObj: MessageObject) { export async function _handlePreEdit(channelId: string, messageId: string, messageObj: MessageObject) {
for (const listener of editListeners) { for (const listener of editListeners) {
try { try {
const result = await listener(channelId, messageId, messageObj); await listener(channelId, messageId, messageObj);
if (result?.cancel) {
return true;
}
} catch (e) { } catch (e) {
MessageEventsLogger.error("MessageEditHandler: Listener encountered an unknown error\n", e); MessageEventsLogger.error("MessageEditHandler: Listener encountered an unknown error\n", e);
} }
} }
return false;
} }
/** /**

View file

@ -16,17 +16,16 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import ErrorBoundary from "@components/ErrorBoundary";
import { Logger } from "@utils/Logger"; import { Logger } from "@utils/Logger";
import { Channel, Message } from "discord-types/general"; import { Channel, Message } from "discord-types/general";
import type { ComponentType, MouseEventHandler } from "react"; import type { MouseEventHandler } from "react";
const logger = new Logger("MessagePopover"); const logger = new Logger("MessagePopover");
export interface ButtonItem { export interface ButtonItem {
key?: string, key?: string,
label: string, label: string,
icon: ComponentType<any>, icon: React.ComponentType<any>,
message: Message, message: Message,
channel: Channel, channel: Channel,
onClick?: MouseEventHandler<HTMLButtonElement>, onClick?: MouseEventHandler<HTMLButtonElement>,
@ -49,26 +48,22 @@ export function removeButton(identifier: string) {
} }
export function _buildPopoverElements( export function _buildPopoverElements(
Component: React.ComponentType<ButtonItem>, msg: Message,
message: Message makeButton: (item: ButtonItem) => React.ComponentType
) { ) {
const items: React.ReactNode[] = []; const items = [] as React.ComponentType[];
for (const [identifier, getItem] of buttons.entries()) { for (const [identifier, getItem] of buttons.entries()) {
try { try {
const item = getItem(message); const item = getItem(msg);
if (item) { if (item) {
item.key ??= identifier; item.key ??= identifier;
items.push( items.push(makeButton(item));
<ErrorBoundary noop>
<Component {...item} />
</ErrorBoundary>
);
} }
} catch (err) { } catch (err) {
logger.error(`[${identifier}]`, err); logger.error(`[${identifier}]`, err);
} }
} }
return <>{items}</>; return items;
} }

View file

@ -1,29 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { MessageCache, MessageStore } from "@webpack/common";
import { FluxStore } from "@webpack/types";
import { Message } from "discord-types/general";
/**
* Update and re-render a message
* @param channelId The channel id of the message
* @param messageId The message id
* @param fields The fields of the message to change. Leave empty if you just want to re-render
*/
export function updateMessage(channelId: string, messageId: string, fields?: Partial<Message & Record<string, any>>) {
const channelMessageCache = MessageCache.getOrCreate(channelId);
if (!channelMessageCache.has(messageId)) return;
// To cause a message to re-render, we basically need to create a new instance of the message and obtain a new reference
// If we have fields to modify we can use the merge method of the class, otherwise we just create a new instance with the old fields
const newChannelMessageCache = channelMessageCache.update(messageId, (oldMessage: any) => {
return fields ? oldMessage.merge(fields) : new oldMessage.constructor(oldMessage);
});
MessageCache.commit(newChannelMessageCache);
(MessageStore as unknown as FluxStore).emitChange();
}

View file

@ -113,7 +113,7 @@ export default ErrorBoundary.wrap(function NotificationComponent({
{timeout !== 0 && !permanent && ( {timeout !== 0 && !permanent && (
<div <div
className="vc-notification-progressbar" className="vc-notification-progressbar"
style={{ width: `${(1 - timeoutProgress) * 100}%`, backgroundColor: color || "var(--brand-500)" }} style={{ width: `${(1 - timeoutProgress) * 100}%`, backgroundColor: color || "var(--brand-experiment)" }}
/> />
)} )}
</button> </button>

View file

@ -100,7 +100,6 @@ export async function showNotification(data: NotificationData) {
const n = new Notification(title, { const n = new Notification(title, {
body, body,
icon, icon,
// @ts-expect-error ts is drunk
image image
}); });
n.onclick = onClick; n.onclick = onClick;

View file

@ -19,11 +19,9 @@
import * as DataStore from "@api/DataStore"; import * as DataStore from "@api/DataStore";
import { Settings } from "@api/Settings"; import { Settings } from "@api/Settings";
import { classNameFactory } from "@api/Styles"; import { classNameFactory } from "@api/Styles";
import { Flex } from "@components/Flex";
import { openNotificationSettingsModal } from "@components/VencordSettings/NotificationSettings";
import { closeModal, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal"; import { closeModal, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { useAwaiter } from "@utils/react"; import { useAwaiter } from "@utils/react";
import { Alerts, Button, Forms, React, Text, Timestamp, useEffect, useReducer, useState } from "@webpack/common"; import { Alerts, Button, Forms, moment, React, Text, Timestamp, useEffect, useReducer, useState } from "@webpack/common";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import type { DispatchWithoutAction } from "react"; import type { DispatchWithoutAction } from "react";
@ -131,7 +129,7 @@ function NotificationEntry({ data }: { data: PersistentNotificationData; }) {
richBody={ richBody={
<div className={cl("body")}> <div className={cl("body")}>
{data.body} {data.body}
<Timestamp timestamp={new Date(data.timestamp)} className={cl("timestamp")} /> <Timestamp timestamp={moment(data.timestamp)} className={cl("timestamp")} />
</div> </div>
} }
/> />
@ -172,31 +170,24 @@ function LogModal({ modalProps, close }: { modalProps: ModalProps; close(): void
</ModalContent> </ModalContent>
<ModalFooter> <ModalFooter>
<Flex> <Button
<Button onClick={openNotificationSettingsModal}> disabled={log.length === 0}
Notification Settings onClick={() => {
</Button> Alerts.show({
title: "Are you sure?",
<Button body: `This will permanently remove ${log.length} notification${log.length === 1 ? "" : "s"}. This action cannot be undone.`,
disabled={log.length === 0} async onConfirm() {
color={Button.Colors.RED} await DataStore.set(KEY, []);
onClick={() => { signals.forEach(x => x());
Alerts.show({ },
title: "Are you sure?", confirmText: "Do it!",
body: `This will permanently remove ${log.length} notification${log.length === 1 ? "" : "s"}. This action cannot be undone.`, confirmColor: "vc-notification-log-danger-btn",
async onConfirm() { cancelText: "Nevermind"
await DataStore.set(KEY, []); });
signals.forEach(x => x()); }}
}, >
confirmText: "Do it!", Clear Notification Log
confirmColor: "vc-notification-log-danger-btn", </Button>
cancelText: "Nevermind"
});
}}
>
Clear Notification Log
</Button>
</Flex>
</ModalFooter> </ModalFooter>
</ModalRoot> </ModalRoot>
); );

View file

@ -16,11 +16,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { debounce } from "@shared/debounce"; import { debounce } from "@utils/debounce";
import { SettingsStore as SettingsStoreClass } from "@shared/SettingsStore";
import { localStorage } from "@utils/localStorage"; import { localStorage } from "@utils/localStorage";
import { Logger } from "@utils/Logger"; import { Logger } from "@utils/Logger";
import { mergeDefaults } from "@utils/mergeDefaults"; import { mergeDefaults } from "@utils/misc";
import { putCloudSettings } from "@utils/settingsSync"; import { putCloudSettings } from "@utils/settingsSync";
import { DefinedSettings, OptionType, SettingsChecks, SettingsDefinition } from "@utils/types"; import { DefinedSettings, OptionType, SettingsChecks, SettingsDefinition } from "@utils/types";
import { React } from "@webpack/common"; import { React } from "@webpack/common";
@ -29,6 +28,7 @@ import plugins from "~plugins";
const logger = new Logger("Settings"); const logger = new Logger("Settings");
export interface Settings { export interface Settings {
notifyAboutUpdates: boolean;
autoUpdate: boolean; autoUpdate: boolean;
autoUpdateNotification: boolean, autoUpdateNotification: boolean,
useQuickCss: boolean; useQuickCss: boolean;
@ -52,6 +52,7 @@ export interface Settings {
| "under-page" | "under-page"
| "window" | "window"
| undefined; | undefined;
macosTranslucency: boolean | undefined;
disableMinSize: boolean; disableMinSize: boolean;
winNativeTitleBar: boolean; winNativeTitleBar: boolean;
plugins: { plugins: {
@ -74,10 +75,17 @@ export interface Settings {
settingsSync: boolean; settingsSync: boolean;
settingsSyncVersion: number; settingsSyncVersion: number;
}; };
userCssVars: {
[themeId: string]: {
[varName: string]: string;
};
};
} }
const DefaultSettings: Settings = { const DefaultSettings: Settings = {
autoUpdate: true, notifyAboutUpdates: true,
autoUpdate: false,
autoUpdateNotification: true, autoUpdateNotification: true,
useQuickCss: true, useQuickCss: true,
themeLinks: [], themeLinks: [],
@ -86,6 +94,8 @@ const DefaultSettings: Settings = {
frameless: false, frameless: false,
transparent: false, transparent: false,
winCtrlQ: false, winCtrlQ: false,
// Replaced by macosVibrancyStyle
macosTranslucency: undefined,
macosVibrancyStyle: undefined, macosVibrancyStyle: undefined,
disableMinSize: false, disableMinSize: false,
winNativeTitleBar: false, winNativeTitleBar: false,
@ -103,11 +113,18 @@ const DefaultSettings: Settings = {
url: "https://api.vencord.dev/", url: "https://api.vencord.dev/",
settingsSync: false, settingsSync: false,
settingsSyncVersion: 0 settingsSyncVersion: 0
} },
userCssVars: {}
}; };
const settings = !IS_REPORTER ? VencordNative.settings.get() : {} as Settings; try {
mergeDefaults(settings, DefaultSettings); var settings = JSON.parse(VencordNative.settings.get()) as Settings;
mergeDefaults(settings, DefaultSettings);
} catch (err) {
var settings = mergeDefaults({} as Settings, DefaultSettings);
logger.error("An error occurred while loading the settings. Corrupt settings file?\n", err);
}
const saveSettingsOnFrequentAction = debounce(async () => { const saveSettingsOnFrequentAction = debounce(async () => {
if (Settings.cloud.settingsSync && Settings.cloud.authenticated) { if (Settings.cloud.settingsSync && Settings.cloud.authenticated) {
@ -116,52 +133,79 @@ const saveSettingsOnFrequentAction = debounce(async () => {
} }
}, 60_000); }, 60_000);
type SubscriptionCallback = ((newValue: any, path: string) => void) & { _paths?: Array<string>; _exact?: boolean; };
const subscriptions = new Set<SubscriptionCallback>();
export const SettingsStore = new SettingsStoreClass(settings, { const proxyCache = {} as Record<string, any>;
readOnly: true,
getDefaultValue({
target,
key,
path
}) {
const v = target[key];
if (!plugins) return v; // plugins not initialised yet. this means this path was reached by being called on the top level
if (path === "plugins" && key in plugins) // Wraps the passed settings object in a Proxy to nicely handle change listeners and default values
return target[key] = { function makeProxy(settings: any, root = settings, path = ""): Settings {
enabled: IS_REPORTER || plugins[key].required || plugins[key].enabledByDefault || false return proxyCache[path] ??= new Proxy(settings, {
}; get(target, p: string) {
const v = target[p];
// Since the property is not set, check if this is a plugin's setting and if so, try to resolve // using "in" is important in the following cases to properly handle falsy or nullish values
// the default value. if (!(p in target)) {
if (path.startsWith("plugins.")) { // Return empty for plugins with no settings
const plugin = path.slice("plugins.".length); if (path === "plugins" && p in plugins)
if (plugin in plugins) { return target[p] = makeProxy({
const setting = plugins[plugin].options?.[key]; enabled: plugins[p].required ?? plugins[p].enabledByDefault ?? false
if (!setting) return v; }, root, `plugins.${p}`);
if ("default" in setting) // Since the property is not set, check if this is a plugin's setting and if so, try to resolve
// normal setting with a default value // the default value.
return (target[key] = setting.default); if (path.startsWith("plugins.")) {
const plugin = path.slice("plugins.".length);
if (plugin in plugins) {
const setting = plugins[plugin].options?.[p];
if (!setting) return v;
if ("default" in setting)
// normal setting with a default value
return (target[p] = setting.default);
if (setting.type === OptionType.SELECT) {
const def = setting.options.find(o => o.default);
if (def)
target[p] = def.value;
return def?.value;
}
}
}
return v;
}
if (setting.type === OptionType.SELECT) { // Recursively proxy Objects with the updated property path
const def = setting.options.find(o => o.default); if (typeof v === "object" && !Array.isArray(v) && v !== null)
if (def) return makeProxy(v, root, `${path}${path && "."}${p}`);
target[key] = def.value;
return def?.value; // primitive or similar, no need to proxy further
return v;
},
set(target, p: string, v) {
// avoid unnecessary updates to React Components and other listeners
if (target[p] === v) return true;
target[p] = v;
// Call any listeners that are listening to a setting of this path
const setPath = `${path}${path && "."}${p}`;
delete proxyCache[setPath];
for (const subscription of subscriptions) {
if (
!subscription._paths ||
(subscription._exact
? subscription._paths.includes(setPath)
: subscription._paths.some(p => setPath.startsWith(p)))
) {
subscription(v, setPath);
} }
} }
// And don't forget to persist the settings!
PlainSettings.cloud.settingsSyncVersion = Date.now();
localStorage.Vencord_settingsDirty = true;
saveSettingsOnFrequentAction();
VencordNative.settings.set(JSON.stringify(root, null, 4));
return true;
} }
return v;
}
});
if (!IS_REPORTER) {
SettingsStore.addGlobalChangeListener((_, path) => {
SettingsStore.plain.cloud.settingsSyncVersion = Date.now();
localStorage.Vencord_settingsDirty = true;
saveSettingsOnFrequentAction();
VencordNative.settings.set(SettingsStore.plain, path);
}); });
} }
@ -179,7 +223,7 @@ export const PlainSettings = settings;
* the updated settings to disk. * the updated settings to disk.
* This recursively proxies objects. If you need the object non proxied, use {@link PlainSettings} * This recursively proxies objects. If you need the object non proxied, use {@link PlainSettings}
*/ */
export const Settings = SettingsStore.store; export const Settings = makeProxy(settings);
/** /**
* Settings hook for React components. Returns a smart settings * Settings hook for React components. Returns a smart settings
@ -189,24 +233,50 @@ export const Settings = SettingsStore.store;
* @returns Settings * @returns Settings
*/ */
// TODO: Representing paths as essentially "string[].join('.')" wont allow dots in paths, change to "paths?: string[][]" later // TODO: Representing paths as essentially "string[].join('.')" wont allow dots in paths, change to "paths?: string[][]" later
export function useSettings(paths?: UseSettings<Settings>[]) { export function useSettings(paths?: UseSettings<Settings>[], exact = true) {
const [, forceUpdate] = React.useReducer(() => ({}), {}); const [, forceUpdate] = React.useReducer(() => ({}), {});
const onUpdate: SubscriptionCallback = paths
? (value, path) =>
(exact
? paths.includes(path as UseSettings<Settings>)
: paths.some(p => path.startsWith(p))) && forceUpdate()
: forceUpdate;
React.useEffect(() => { React.useEffect(() => {
if (paths) { subscriptions.add(onUpdate);
paths.forEach(p => SettingsStore.addChangeListener(p, forceUpdate)); return () => void subscriptions.delete(onUpdate);
return () => paths.forEach(p => SettingsStore.removeChangeListener(p, forceUpdate));
} else {
SettingsStore.addGlobalChangeListener(forceUpdate);
return () => SettingsStore.removeGlobalChangeListener(forceUpdate);
}
}, []); }, []);
return SettingsStore.store; return Settings;
}
// Resolves a possibly nested prop in the form of "some.nested.prop" to type of T.some.nested.prop
type ResolvePropDeep<T, P> = P extends "" ? T :
P extends `${infer Pre}.${infer Suf}` ?
Pre extends keyof T ? ResolvePropDeep<T[Pre], Suf> : never : P extends keyof T ? T[P] : never;
/**
* Add a settings listener that will be invoked whenever the desired setting is updated
* @param path Path to the setting that you want to watch, for example "plugins.Unindent.enabled" will fire your callback
* whenever Unindent is toggled. Pass an empty string to get notified for all changes
* @param onUpdate Callback function whenever a setting matching path is updated. It gets passed the new value and the path
* to the updated setting. This path will be the same as your path argument, unless it was an empty string.
*
* @example addSettingsListener("", (newValue, path) => console.log(`${path} is now ${newValue}`))
* addSettingsListener("plugins.Unindent.enabled", v => console.log("Unindent is now", v ? "enabled" : "disabled"))
*/
export function addSettingsListener<Path extends keyof Settings>(path: Path, onUpdate: (newValue: Settings[Path], path: Path) => void, exact?: boolean): void;
export function addSettingsListener<Path extends string>(path: Path, onUpdate: (newValue: Path extends "" ? any : ResolvePropDeep<Settings, Path>, path: Path extends "" ? string : Path) => void, exact?: boolean): void;
export function addSettingsListener(path: string, onUpdate: (newValue: any, path: string) => void, exact = true) {
if (path)
((onUpdate as SubscriptionCallback)._paths ??= []).push(path);
(onUpdate as SubscriptionCallback)._exact = exact;
subscriptions.add(onUpdate);
} }
export function migratePluginSettings(name: string, ...oldNames: string[]) { export function migratePluginSettings(name: string, ...oldNames: string[]) {
const { plugins } = SettingsStore.plain; const { plugins } = settings;
if (name in plugins) return; if (name in plugins) return;
for (const oldName of oldNames) { for (const oldName of oldNames) {
@ -214,7 +284,7 @@ export function migratePluginSettings(name: string, ...oldNames: string[]) {
logger.info(`Migrating settings from old name ${oldName} to ${name}`); logger.info(`Migrating settings from old name ${oldName} to ${name}`);
plugins[name] = plugins[oldName]; plugins[name] = plugins[oldName];
delete plugins[oldName]; delete plugins[oldName];
SettingsStore.markAsChanged(); VencordNative.settings.set(JSON.stringify(settings, null, 4));
break; break;
} }
} }
@ -230,10 +300,6 @@ export function definePluginSettings<
if (!definedSettings.pluginName) throw new Error("Cannot access settings before plugin is initialized"); if (!definedSettings.pluginName) throw new Error("Cannot access settings before plugin is initialized");
return Settings.plugins[definedSettings.pluginName] as any; return Settings.plugins[definedSettings.pluginName] as any;
}, },
get plain() {
if (!definedSettings.pluginName) throw new Error("Cannot access settings before plugin is initialized");
return PlainSettings.plugins[definedSettings.pluginName] as any;
},
use: settings => useSettings( use: settings => useSettings(
settings?.map(name => `plugins.${definedSettings.pluginName}.${name}`) as UseSettings<Settings>[] settings?.map(name => `plugins.${definedSettings.pluginName}.${name}`) as UseSettings<Settings>[]
).plugins[definedSettings.pluginName] as any, ).plugins[definedSettings.pluginName] as any,

View file

@ -1,81 +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 { proxyLazy } from "@utils/lazy";
import { Logger } from "@utils/Logger";
import { findModuleId, proxyLazyWebpack, wreq } from "@webpack";
interface UserSettingDefinition<T> {
/**
* Get the setting value
*/
getSetting(): T;
/**
* Update the setting value
* @param value The new value
*/
updateSetting(value: T): Promise<void>;
/**
* Update the setting value
* @param value A callback that accepts the old value as the first argument, and returns the new value
*/
updateSetting(value: (old: T) => T): Promise<void>;
/**
* Stateful React hook for this setting value
*/
useSetting(): T;
userSettingsAPIGroup: string;
userSettingsAPIName: string;
}
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.");
return wreq(modId as any);
});
/**
* Get the setting with the given setting group and name.
*
* @param group The setting group
* @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.");
for (const key in UserSettings) {
const userSetting = UserSettings[key];
if (userSetting.userSettingsAPIGroup === group && userSetting.userSettingsAPIName === name) {
return userSetting;
}
}
}
/**
* {@link getUserSettingDefinition}, lazy.
*
* Get the setting with the given setting group and name.
*
* @param group The setting group
* @param name The name of the setting
*/
export function getUserSettingLazy<T = any>(group: string, name: string) {
return proxyLazy(() => getUserSetting<T>(group, name));
}

View file

@ -17,7 +17,6 @@
*/ */
import * as $Badges from "./Badges"; import * as $Badges from "./Badges";
import * as $ChatButtons from "./ChatButtons";
import * as $Commands from "./Commands"; import * as $Commands from "./Commands";
import * as $ContextMenu from "./ContextMenu"; import * as $ContextMenu from "./ContextMenu";
import * as $DataStore from "./DataStore"; import * as $DataStore from "./DataStore";
@ -26,13 +25,11 @@ import * as $MessageAccessories from "./MessageAccessories";
import * as $MessageDecorations from "./MessageDecorations"; import * as $MessageDecorations from "./MessageDecorations";
import * as $MessageEventsAPI from "./MessageEvents"; import * as $MessageEventsAPI from "./MessageEvents";
import * as $MessagePopover from "./MessagePopover"; import * as $MessagePopover from "./MessagePopover";
import * as $MessageUpdater from "./MessageUpdater";
import * as $Notices from "./Notices"; import * as $Notices from "./Notices";
import * as $Notifications from "./Notifications"; import * as $Notifications from "./Notifications";
import * as $ServerList from "./ServerList"; import * as $ServerList from "./ServerList";
import * as $Settings from "./Settings"; import * as $Settings from "./Settings";
import * as $Styles from "./Styles"; import * as $Styles from "./Styles";
import * as $UserSettings from "./UserSettings";
/** /**
* An API allowing you to listen to Message Clicks or run your own logic * An API allowing you to listen to Message Clicks or run your own logic
@ -107,18 +104,3 @@ 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; export const ContextMenu = $ContextMenu;
/**
* 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
*/
export const MessageUpdater = $MessageUpdater;
/**
* An API allowing you to get an user setting
*/
export const UserSettings = $UserSettings;

View file

@ -1,6 +1,7 @@
.vc-expandableheader-center-flex { .vc-expandableheader-center-flex {
display: flex; display: flex;
place-items: center; justify-items: center;
align-items: center;
} }
.vc-expandableheader-btn { .vc-expandableheader-btn {

View file

@ -16,12 +16,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import "./ExpandableHeader.css";
import { classNameFactory } from "@api/Styles"; import { classNameFactory } from "@api/Styles";
import { Text, Tooltip, useState } from "@webpack/common"; import { Text, Tooltip, useState } from "@webpack/common";
export const cl = classNameFactory("vc-expandableheader-");
const cl = classNameFactory("vc-expandableheader-"); import "./ExpandableHeader.css";
export interface ExpandableHeaderProps { export interface ExpandableHeaderProps {
onMoreClick?: () => void; onMoreClick?: () => void;
@ -31,20 +29,10 @@ export interface ExpandableHeaderProps {
headerText: string; headerText: string;
children: React.ReactNode; children: React.ReactNode;
buttons?: React.ReactNode[]; buttons?: React.ReactNode[];
forceOpen?: boolean;
} }
export function ExpandableHeader({ export default function ExpandableHeader({ children, onMoreClick, buttons, moreTooltipText, defaultState = false, onDropDownClick, headerText }: ExpandableHeaderProps) {
children, const [showContent, setShowContent] = useState(defaultState);
onMoreClick,
buttons,
moreTooltipText,
onDropDownClick,
headerText,
defaultState = false,
forceOpen = false,
}: ExpandableHeaderProps) {
const [showContent, setShowContent] = useState(defaultState || forceOpen);
return ( return (
<> <>
@ -100,7 +88,6 @@ export function ExpandableHeader({
setShowContent(v => !v); setShowContent(v => !v);
onDropDownClick?.(showContent); onDropDownClick?.(showContent);
}} }}
disabled={forceOpen}
> >
<svg <svg
width="24" width="24"

View file

@ -1,28 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { CSSProperties } from "react";
interface Props {
columns: number;
gap?: string;
inline?: boolean;
}
export function Grid(props: Props & JSX.IntrinsicElements["div"]) {
const style: CSSProperties = {
display: props.inline ? "inline-grid" : "grid",
gridTemplateColumns: `repeat(${props.columns}, 1fr)`,
gap: props.gap,
...props.style
};
return (
<div {...props} style={style}>
{props.children}
</div>
);
}

View file

@ -18,16 +18,19 @@
import "./iconStyles.css"; import "./iconStyles.css";
import { getIntlMessage, getTheme, Theme } from "@utils/discord";
import { classes } from "@utils/misc"; import { classes } from "@utils/misc";
import type { PropsWithChildren } from "react"; import { i18n } from "@webpack/common";
import type { PropsWithChildren, SVGProps } from "react";
interface BaseIconProps extends IconProps { interface BaseIconProps extends IconProps {
viewBox: string; viewBox: string;
} }
type IconProps = JSX.IntrinsicElements["svg"]; interface IconProps extends SVGProps<SVGSVGElement> {
type ImageProps = JSX.IntrinsicElements["img"]; className?: string;
height?: string | number;
width?: string | number;
}
function Icon({ height = 24, width = 24, className, children, viewBox, ...svgProps }: PropsWithChildren<BaseIconProps>) { function Icon({ height = 24, width = 24, className, children, viewBox, ...svgProps }: PropsWithChildren<BaseIconProps>) {
return ( return (
@ -64,7 +67,8 @@ export function LinkIcon({ height = 24, width = 24, className }: IconProps) {
} }
/** /**
* Discord's copy icon, as seen in the user panel popout on the right of the username and in large code blocks * Discord's copy icon, as seen in the user popout right of the username when clicking
* your own username in the bottom left user panel
*/ */
export function CopyIcon(props: IconProps) { export function CopyIcon(props: IconProps) {
return ( return (
@ -74,9 +78,8 @@ export function CopyIcon(props: IconProps) {
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<g fill="currentColor"> <g fill="currentColor">
<path d="M3 16a1 1 0 0 1-1-1v-5a8 8 0 0 1 8-8h5a1 1 0 0 1 1 1v.5a.5.5 0 0 1-.5.5H10a6 6 0 0 0-6 6v5.5a.5.5 0 0 1-.5.5H3Z" /> <path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1z" />
<path d="M6 18a4 4 0 0 0 4 4h8a4 4 0 0 0 4-4v-4h-3a5 5 0 0 1-5-5V6h-4a4 4 0 0 0-4 4v8Z" /> <path d="M15 5H8c-1.1 0-1.99.9-1.99 2L6 21c0 1.1.89 2 1.99 2H19c1.1 0 2-.9 2-2V11l-6-6zM8 21V7h6v5h5v9H8z" />
<path d="M21.73 12a3 3 0 0 0-.6-.88l-4.25-4.24a3 3 0 0 0-.88-.61V9a3 3 0 0 0 3 3h2.73Z" />
</g> </g>
</Icon> </Icon>
); );
@ -132,7 +135,7 @@ export function InfoIcon(props: IconProps) {
export function OwnerCrownIcon(props: IconProps) { export function OwnerCrownIcon(props: IconProps) {
return ( return (
<Icon <Icon
aria-label={getIntlMessage("GUILD_OWNER")} aria-label={i18n.Messages.GUILD_OWNER}
{...props} {...props}
className={classes(props.className, "vc-owner-crown-icon")} className={classes(props.className, "vc-owner-crown-icon")}
role="img" role="img"
@ -253,6 +256,24 @@ export function DeleteIcon(props: IconProps) {
); );
} }
/**
* A plugin icon, created by CorellanStoma. https://github.com/CreArts-Community/Settings-Icons
*/
export function PluginIcon(props: IconProps) {
return (
<Icon
{...props}
className={classes(props.className, "vc-plugin-icon")}
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5C13 2.12 11.88 1 10.5 1S8 2.12 8 3.5V5H4c-1.1 0-1.99.9-1.99 2v3.8H3.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7s2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5c1.38 0 2.5-1.12 2.5-2.5S21.88 11 20.5 11z"
/>
</Icon>
);
}
export function PlusIcon(props: IconProps) { export function PlusIcon(props: IconProps) {
return ( return (
<Icon <Icon
@ -288,141 +309,39 @@ export function NoEntrySignIcon(props: IconProps) {
); );
} }
export function SafetyIcon(props: IconProps) { export function PasteIcon(props: IconProps) {
return ( return (
<Icon <Icon
{...props} {...props}
className={classes(props.className, "vc-safety-icon")} className={classes(props.className, "vc-paste-icon")}
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
> >
<path <rect width="8" height="4" x="8" y="2" rx="1" ry="1" />
fill="currentColor" <path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
fillRule="evenodd"
clipRule="evenodd"
d="M4.27 5.22A2.66 2.66 0 0 0 3 7.5v2.3c0 5.6 3.3 10.68 8.42 12.95.37.17.79.17 1.16 0A14.18 14.18 0 0 0 21 9.78V7.5c0-.93-.48-1.78-1.27-2.27l-6.17-3.76a3 3 0 0 0-3.12 0L4.27 5.22ZM6 7.68l6-3.66V12H6.22C6.08 11.28 6 10.54 6 9.78v-2.1Zm6 12.01V12h5.78A11.19 11.19 0 0 1 12 19.7Z"
/>
</Icon>
);
}
export function NotesIcon(props: IconProps) {
return (
<Icon
{...props}
className={classes(props.className, "vc-notes-icon")}
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M8 3C7.44771 3 7 3.44772 7 4V5C7 5.55228 7.44772 6 8 6H16C16.5523 6 17 5.55228 17 5V4C17 3.44772 16.5523 3 16 3H15.1245C14.7288 3 14.3535 2.82424 14.1002 2.52025L13.3668 1.64018C13.0288 1.23454 12.528 1 12 1C11.472 1 10.9712 1.23454 10.6332 1.64018L9.8998 2.52025C9.64647 2.82424 9.27121 3 8.8755 3H8Z"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
fill="currentColor"
d="M19 4.49996V4.99996C19 6.65681 17.6569 7.99996 16 7.99996H8C6.34315 7.99996 5 6.65681 5 4.99996V4.49996C5 4.22382 4.77446 3.99559 4.50209 4.04109C3.08221 4.27826 2 5.51273 2 6.99996V19C2 20.6568 3.34315 22 5 22H19C20.6569 22 22 20.6568 22 19V6.99996C22 5.51273 20.9178 4.27826 19.4979 4.04109C19.2255 3.99559 19 4.22382 19 4.49996ZM8 12C7.44772 12 7 12.4477 7 13C7 13.5522 7.44772 14 8 14H16C16.5523 14 17 13.5522 17 13C17 12.4477 16.5523 12 16 12H8ZM7 17C7 16.4477 7.44772 16 8 16H13C13.5523 16 14 16.4477 14 17C14 17.5522 13.5523 18 13 18H8C7.44772 18 7 17.5522 7 17Z"
/>
</Icon> </Icon>
); );
} }
export function FolderIcon(props: IconProps) { export function ResetIcon(props: IconProps) {
return ( return (
<Icon <Icon
{...props} {...props}
className={classes(props.className, "vc-folder-icon")} width="24"
height="24"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
> >
<path <path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
fill="currentColor" <path d="M3 3v5h5" />
d="M2 5a3 3 0 0 1 3-3h3.93a2 2 0 0 1 1.66.9L12 5h7a3 3 0 0 1 3 3v11a3 3 0 0 1-3 3H5a3 3 0 0 1-3-3V5Z"
/>
</Icon> </Icon>
); );
} }
export function LogIcon(props: IconProps) {
return (
<Icon
{...props}
className={classes(props.className, "vc-log-icon")}
viewBox="0 0 24 24"
>
<path
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
d="M3.11 8H6v10.82c0 .86.37 1.68 1 2.27.46.43 1.02.71 1.63.84A1 1 0 0 0 9 22h10a4 4 0 0 0 4-4v-1a2 2 0 0 0-2-2h-1V5a3 3 0 0 0-3-3H4.67c-.87 0-1.7.32-2.34.9-.63.6-1 1.42-1 2.28 0 .71.3 1.35.52 1.75a5.35 5.35 0 0 0 .48.7l.01.01h.01L3.11 7l-.76.65a1 1 0 0 0 .76.35Zm1.56-4c-.38 0-.72.14-.97.37-.24.23-.37.52-.37.81a1.69 1.69 0 0 0 .3.82H6v-.83c0-.29-.13-.58-.37-.8C5.4 4.14 5.04 4 4.67 4Zm5 13a3.58 3.58 0 0 1 0 3H19a2 2 0 0 0 2-2v-1H9.66ZM3.86 6.35ZM11 8a1 1 0 1 0 0 2h5a1 1 0 1 0 0-2h-5Zm-1 5a1 1 0 0 1 1-1h5a1 1 0 1 1 0 2h-5a1 1 0 0 1-1-1Z"
/>
</Icon>
);
}
export function RestartIcon(props: IconProps) {
return (
<Icon
{...props}
className={classes(props.className, "vc-restart-icon")}
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M4 12a8 8 0 0 1 14.93-4H15a1 1 0 1 0 0 2h6a1 1 0 0 0 1-1V3a1 1 0 1 0-2 0v3a9.98 9.98 0 0 0-18 6 10 10 0 0 0 16.29 7.78 1 1 0 0 0-1.26-1.56A8 8 0 0 1 4 12Z"
/>
</Icon>
);
}
export function PaintbrushIcon(props: IconProps) {
return (
<Icon
{...props}
className={classes(props.className, "vc-paintbrush-icon")}
viewBox="0 0 24 24"
>
<path
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
d="M15.35 7.24C15.9 6.67 16 5.8 16 5a3 3 0 1 1 3 3c-.8 0-1.67.09-2.24.65a1.5 1.5 0 0 0 0 2.11l1.12 1.12a3 3 0 0 1 0 4.24l-5 5a3 3 0 0 1-4.25 0l-5.76-5.75a3 3 0 0 1 0-4.24l4.04-4.04.97-.97a3 3 0 0 1 4.24 0l1.12 1.12c.58.58 1.52.58 2.1 0ZM6.9 9.9 4.3 12.54a1 1 0 0 0 0 1.42l2.17 2.17.83-.84a1 1 0 0 1 1.42 1.42l-.84.83.59.59 1.83-1.84a1 1 0 0 1 1.42 1.42l-1.84 1.83.17.17a1 1 0 0 0 1.42 0l2.63-2.62L6.9 9.9Z"
/>
</Icon>
);
}
export function PencilIcon(props: IconProps) {
return (
<Icon
{...props}
className={classes(props.className, "vc-pencil-icon")}
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="m13.96 5.46 4.58 4.58a1 1 0 0 0 1.42 0l1.38-1.38a2 2 0 0 0 0-2.82l-3.18-3.18a2 2 0 0 0-2.82 0l-1.38 1.38a1 1 0 0 0 0 1.42ZM2.11 20.16l.73-4.22a3 3 0 0 1 .83-1.61l7.87-7.87a1 1 0 0 1 1.42 0l4.58 4.58a1 1 0 0 1 0 1.42l-7.87 7.87a3 3 0 0 1-1.6.83l-4.23.73a1.5 1.5 0 0 1-1.73-1.73Z"
/>
</Icon>
);
}
const WebsiteIconDark = "/assets/e1e96d89e192de1997f73730db26e94f.svg";
const WebsiteIconLight = "/assets/730f58bcfd5a57a5e22460c445a0c6cf.svg";
const GithubIconLight = "/assets/3ff98ad75ac94fa883af5ed62d17c459.svg";
const GithubIconDark = "/assets/6a853b4c87fce386cbfef4a2efbacb09.svg";
export function GithubIcon(props: ImageProps) {
const src = getTheme() === Theme.Light
? GithubIconLight
: GithubIconDark;
return <img {...props} src={src} />;
}
export function WebsiteIcon(props: ImageProps) {
const src = getTheme() === Theme.Light
? WebsiteIconLight
: WebsiteIconDark;
return <img {...props} src={src} />;
}

View file

@ -9,18 +9,20 @@ import "./contributorModal.css";
import { useSettings } from "@api/Settings"; import { useSettings } from "@api/Settings";
import { classNameFactory } from "@api/Styles"; import { classNameFactory } from "@api/Styles";
import ErrorBoundary from "@components/ErrorBoundary"; import ErrorBoundary from "@components/ErrorBoundary";
import { Link } from "@components/Link";
import { DevsById } from "@utils/constants"; import { DevsById } from "@utils/constants";
import { fetchUserProfile } from "@utils/discord"; import { fetchUserProfile, getTheme, Theme } from "@utils/discord";
import { classes, pluralise } from "@utils/misc";
import { ModalContent, ModalRoot, openModal } from "@utils/modal"; import { ModalContent, ModalRoot, openModal } from "@utils/modal";
import { Forms, showToast, useEffect, useMemo, UserProfileStore, useStateFromStores } from "@webpack/common"; import { Forms, MaskedLink, showToast, useEffect, useMemo, UserProfileStore, useStateFromStores } from "@webpack/common";
import { User } from "discord-types/general"; import { User } from "discord-types/general";
import Plugins from "~plugins"; import Plugins from "~plugins";
import { PluginCard } from "."; import { PluginCard } from ".";
import { GithubButton, WebsiteButton } from "./LinkIconButton";
const WebsiteIconDark = "/assets/e1e96d89e192de1997f73730db26e94f.svg";
const WebsiteIconLight = "/assets/730f58bcfd5a57a5e22460c445a0c6cf.svg";
const GithubIconLight = "/assets/3ff98ad75ac94fa883af5ed62d17c459.svg";
const GithubIconDark = "/assets/6a853b4c87fce386cbfef4a2efbacb09.svg";
const cl = classNameFactory("vc-author-modal-"); const cl = classNameFactory("vc-author-modal-");
@ -36,6 +38,16 @@ export function openContributorModal(user: User) {
); );
} }
function GithubIcon() {
const src = getTheme() === Theme.Light ? GithubIconLight : GithubIconDark;
return <img src={src} alt="GitHub" />;
}
function WebsiteIcon() {
const src = getTheme() === Theme.Light ? WebsiteIconLight : WebsiteIconDark;
return <img src={src} alt="Website" />;
}
function ContributorModal({ user }: { user: User; }) { function ContributorModal({ user }: { user: User; }) {
useSettings(); useSettings();
@ -60,8 +72,6 @@ function ContributorModal({ user }: { user: User; }) {
.sort((a, b) => Number(a.required ?? false) - Number(b.required ?? false)); .sort((a, b) => Number(a.required ?? false) - Number(b.required ?? false));
}, [user.id, user.username]); }, [user.id, user.username]);
const ContributedHyperLink = <Link href="https://vencord.dev/source">contributed</Link>;
return ( return (
<> <>
<div className={cl("header")}> <div className={cl("header")}>
@ -72,44 +82,32 @@ function ContributorModal({ user }: { user: User; }) {
/> />
<Forms.FormTitle tag="h2" className={cl("name")}>{user.username}</Forms.FormTitle> <Forms.FormTitle tag="h2" className={cl("name")}>{user.username}</Forms.FormTitle>
<div className={classes("vc-settings-modal-links", cl("links"))}> <div className={cl("links")}>
{website && ( {website && (
<WebsiteButton <MaskedLink
text={website} href={"https://" + website}
href={`https://${website}`} >
/> <WebsiteIcon />
</MaskedLink>
)} )}
{githubName && ( {githubName && (
<GithubButton <MaskedLink href={`https://github.com/${githubName}`}>
text={githubName} <GithubIcon />
href={`https://github.com/${githubName}`} </MaskedLink>
/>
)} )}
</div> </div>
</div> </div>
{plugins.length ? ( <div className={cl("plugins")}>
<Forms.FormText> {plugins.map(p =>
This person has {ContributedHyperLink} to {pluralise(plugins.length, "plugin")}! <PluginCard
</Forms.FormText> key={p.name}
) : ( plugin={p}
<Forms.FormText> disabled={p.required ?? false}
This person has not made any plugins. They likely {ContributedHyperLink} to Vencord in other ways! onRestartNeeded={() => showToast("Restart to apply changes!")}
</Forms.FormText> />
)} )}
</div>
{!!plugins.length && (
<div className={cl("plugins")}>
{plugins.map(p =>
<PluginCard
key={p.name}
plugin={p}
disabled={p.required ?? false}
onRestartNeeded={() => showToast("Restart to apply changes!")}
/>
)}
</div>
)}
</> </>
); );
} }

View file

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

View file

@ -1,39 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./LinkIconButton.css";
import { MaskedLink, Tooltip } from "@webpack/common";
import { GithubIcon, WebsiteIcon } from "..";
export function GithubLinkIcon() {
return <GithubIcon aria-hidden className={"vc-settings-modal-link-icon"} />;
}
export function WebsiteLinkIcon() {
return <WebsiteIcon aria-hidden className={"vc-settings-modal-link-icon"} />;
}
interface Props {
text: string;
href: string;
}
function LinkIcon({ text, href, Icon }: Props & { Icon: React.ComponentType; }) {
return (
<Tooltip text={text}>
{props => (
<MaskedLink {...props} href={href}>
<Icon />
</MaskedLink>
)}
</Tooltip>
);
}
export const WebsiteButton = (props: Props) => <LinkIcon {...props} Icon={WebsiteLinkIcon} />;
export const GithubButton = (props: Props) => <LinkIcon {...props} Icon={GithubLinkIcon} />;

View file

@ -1,7 +0,0 @@
.vc-plugin-modal-info {
align-items: center;
}
.vc-plugin-modal-description {
flex-grow: 1;
}

View file

@ -16,26 +16,20 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import "./PluginModal.css";
import { generateId } from "@api/Commands"; import { generateId } from "@api/Commands";
import { useSettings } from "@api/Settings"; import { useSettings } from "@api/Settings";
import { classNameFactory } from "@api/Styles";
import ErrorBoundary from "@components/ErrorBoundary"; import ErrorBoundary from "@components/ErrorBoundary";
import { Flex } from "@components/Flex"; import { Flex } from "@components/Flex";
import { gitRemote } from "@shared/vencordUserAgent";
import { proxyLazy } from "@utils/lazy"; import { proxyLazy } from "@utils/lazy";
import { Margins } from "@utils/margins"; import { Margins } from "@utils/margins";
import { classes, isObjectEmpty } from "@utils/misc"; import { classes, isObjectEmpty } from "@utils/misc";
import { ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal"; import { ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize } from "@utils/modal";
import { OptionType, Plugin } from "@utils/types"; import { OptionType, Plugin } from "@utils/types";
import { findByPropsLazy, findComponentByCodeLazy } from "@webpack"; import { findByPropsLazy, findComponentByCodeLazy } from "@webpack";
import { Button, Clickable, FluxDispatcher, Forms, React, Text, Tooltip, UserStore, UserUtils } from "@webpack/common"; import { Button, Clickable, FluxDispatcher, Forms, React, Text, Tooltip, UserStore, UserUtils } from "@webpack/common";
import { User } from "discord-types/general"; import { User } from "discord-types/general";
import { Constructor } from "type-fest"; import { Constructor } from "type-fest";
import { PluginMeta } from "~plugins";
import { import {
ISettingElementProps, ISettingElementProps,
SettingBooleanComponent, SettingBooleanComponent,
@ -46,9 +40,6 @@ import {
SettingTextComponent SettingTextComponent
} from "./components"; } from "./components";
import { openContributorModal } from "./ContributorModal"; import { openContributorModal } from "./ContributorModal";
import { GithubButton, WebsiteButton } from "./LinkIconButton";
const cl = classNameFactory("vc-plugin-modal-");
const UserSummaryItem = findComponentByCodeLazy("defaultRenderUser", "showDefaultAvatarsForNullUsers"); const UserSummaryItem = findComponentByCodeLazy("defaultRenderUser", "showDefaultAvatarsForNullUsers");
const AvatarStyles = findByPropsLazy("moreUsers", "emptyUser", "avatarContainer", "clickableAvatar"); const AvatarStyles = findByPropsLazy("moreUsers", "emptyUser", "avatarContainer", "clickableAvatar");
@ -189,54 +180,16 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti
); );
} }
/*
function switchToPopout() {
onClose();
const PopoutKey = `DISCORD_VENCORD_PLUGIN_SETTINGS_MODAL_${plugin.name}`;
PopoutActions.open(
PopoutKey,
() => <PluginModal
transitionState={transitionState}
plugin={plugin}
onRestartNeeded={onRestartNeeded}
onClose={() => PopoutActions.close(PopoutKey)}
/>
);
}
*/
const pluginMeta = PluginMeta[plugin.name];
return ( return (
<ModalRoot transitionState={transitionState} size={ModalSize.MEDIUM} className="vc-text-selectable"> <ModalRoot transitionState={transitionState} size={ModalSize.MEDIUM} className="vc-text-selectable">
<ModalHeader separator={false}> <ModalHeader separator={false}>
<Text variant="heading-lg/semibold" style={{ flexGrow: 1 }}>{plugin.name}</Text> <Text variant="heading-lg/semibold" style={{ flexGrow: 1 }}>{plugin.name}</Text>
{/*
<Button look={Button.Looks.BLANK} onClick={switchToPopout}>
<OpenExternalIcon aria-label="Open in Popout" />
</Button>
*/}
<ModalCloseButton onClick={onClose} /> <ModalCloseButton onClick={onClose} />
</ModalHeader> </ModalHeader>
<ModalContent> <ModalContent>
<Forms.FormSection> <Forms.FormSection>
<Flex className={cl("info")}> <Forms.FormTitle tag="h3">About {plugin.name}</Forms.FormTitle>
<Forms.FormText className={cl("description")}>{plugin.description}</Forms.FormText> <Forms.FormText>{plugin.description}</Forms.FormText>
{!pluginMeta.userPlugin && (
<div className="vc-settings-modal-links">
<WebsiteButton
text="View more info"
href={`https://vencord.dev/plugins/${plugin.name}`}
/>
<GithubButton
text="View source code"
href={`https://github.com/${gitRemote}/tree/main/src/plugins/${pluginMeta.folderName}`}
/>
</div>
)}
</Flex>
<Forms.FormTitle tag="h3" style={{ marginTop: 8, marginBottom: 0 }}>Authors</Forms.FormTitle> <Forms.FormTitle tag="h3" style={{ marginTop: 8, marginBottom: 0 }}>Authors</Forms.FormTitle>
<div style={{ width: "fit-content", marginBottom: 8 }}> <div style={{ width: "fit-content", marginBottom: 8 }}>
<UserSummaryItem <UserSummaryItem
@ -310,13 +263,3 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti
</ModalRoot> </ModalRoot>
); );
} }
export function openPluginModal(plugin: Plugin, onRestartNeeded?: (pluginName: string) => void) {
openModal(modalProps => (
<PluginModal
{...modalProps}
plugin={plugin}
onRestartNeeded={() => onRestartNeeded?.(plugin.name)}
/>
));
}

View file

@ -16,8 +16,6 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { Margins } from "@utils/margins";
import { wordsFromCamel, wordsToTitle } from "@utils/text";
import { OptionType, PluginOptionNumber } from "@utils/types"; import { OptionType, PluginOptionNumber } from "@utils/types";
import { Forms, React, TextInput } from "@webpack/common"; import { Forms, React, TextInput } from "@webpack/common";
@ -56,8 +54,7 @@ export function SettingNumericComponent({ option, pluginSettings, definedSetting
return ( return (
<Forms.FormSection> <Forms.FormSection>
<Forms.FormTitle>{wordsToTitle(wordsFromCamel(id))}</Forms.FormTitle> <Forms.FormTitle>{option.description}</Forms.FormTitle>
<Forms.FormText className={Margins.bottom20} type="description">{option.description}</Forms.FormText>
<TextInput <TextInput
type="number" type="number"
pattern="-?[0-9]+" pattern="-?[0-9]+"

View file

@ -16,8 +16,6 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { Margins } from "@utils/margins";
import { wordsFromCamel, wordsToTitle } from "@utils/text";
import { PluginOptionSelect } from "@utils/types"; import { PluginOptionSelect } from "@utils/types";
import { Forms, React, Select } from "@webpack/common"; import { Forms, React, Select } from "@webpack/common";
@ -46,8 +44,7 @@ export function SettingSelectComponent({ option, pluginSettings, definedSettings
return ( return (
<Forms.FormSection> <Forms.FormSection>
<Forms.FormTitle>{wordsToTitle(wordsFromCamel(id))}</Forms.FormTitle> <Forms.FormTitle>{option.description}</Forms.FormTitle>
<Forms.FormText className={Margins.bottom16} type="description">{option.description}</Forms.FormText>
<Select <Select
isDisabled={option.disabled?.call(definedSettings) ?? false} isDisabled={option.disabled?.call(definedSettings) ?? false}
options={option.options} options={option.options}

View file

@ -16,8 +16,6 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { Margins } from "@utils/margins";
import { wordsFromCamel, wordsToTitle } from "@utils/text";
import { PluginOptionSlider } from "@utils/types"; import { PluginOptionSlider } from "@utils/types";
import { Forms, React, Slider } from "@webpack/common"; import { Forms, React, Slider } from "@webpack/common";
@ -52,8 +50,7 @@ export function SettingSliderComponent({ option, pluginSettings, definedSettings
return ( return (
<Forms.FormSection> <Forms.FormSection>
<Forms.FormTitle>{wordsToTitle(wordsFromCamel(id))}</Forms.FormTitle> <Forms.FormTitle>{option.description}</Forms.FormTitle>
<Forms.FormText className={Margins.bottom20} type="description">{option.description}</Forms.FormText>
<Slider <Slider
disabled={option.disabled?.call(definedSettings) ?? false} disabled={option.disabled?.call(definedSettings) ?? false}
markers={option.markers} markers={option.markers}

View file

@ -16,8 +16,6 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { Margins } from "@utils/margins";
import { wordsFromCamel, wordsToTitle } from "@utils/text";
import { PluginOptionString } from "@utils/types"; import { PluginOptionString } from "@utils/types";
import { Forms, React, TextInput } from "@webpack/common"; import { Forms, React, TextInput } from "@webpack/common";
@ -43,8 +41,7 @@ export function SettingTextComponent({ option, pluginSettings, definedSettings,
return ( return (
<Forms.FormSection> <Forms.FormSection>
<Forms.FormTitle>{wordsToTitle(wordsFromCamel(id))}</Forms.FormTitle> <Forms.FormTitle>{option.description}</Forms.FormTitle>
<Forms.FormText className={Margins.bottom20} type="description">{option.description}</Forms.FormText>
<TextInput <TextInput
type="text" type="text"
value={state} value={state}

View file

@ -25,13 +25,11 @@
display: block; display: block;
position: absolute; position: absolute;
height: 100%; height: 100%;
width: 32px; width: 16px;
background: var(--background-tertiary); background: var(--background-tertiary);
z-index: -1; z-index: -1;
left: -32px; left: -16px;
top: 0; top: 0;
border-top-left-radius: 9999px;
border-bottom-left-radius: 9999px;
} }
.vc-author-modal-avatar { .vc-author-modal-avatar {
@ -42,10 +40,19 @@
.vc-author-modal-links { .vc-author-modal-links {
margin-left: auto; margin-left: auto;
display: flex;
gap: 0.2em;
}
.vc-author-modal-links img {
height: 32px;
width: 32px;
border-radius: 50%;
border: 4px solid var(--background-tertiary);
box-sizing: border-box
} }
.vc-author-modal-plugins { .vc-author-modal-plugins {
display: grid; display: grid;
gap: 0.5em; gap: 0.5em;
margin-top: 0.75em;
} }

View file

@ -23,28 +23,28 @@ import { showNotice } from "@api/Notices";
import { Settings, useSettings } from "@api/Settings"; import { Settings, useSettings } from "@api/Settings";
import { classNameFactory } from "@api/Styles"; import { classNameFactory } from "@api/Styles";
import { CogWheel, InfoIcon } from "@components/Icons"; import { CogWheel, InfoIcon } from "@components/Icons";
import { openPluginModal } from "@components/PluginSettings/PluginModal"; import PluginModal from "@components/PluginSettings/PluginModal";
import { AddonCard } from "@components/VencordSettings/AddonCard"; import { AddonCard } from "@components/VencordSettings/AddonCard";
import { SettingsTab } from "@components/VencordSettings/shared"; import { SettingsTab } from "@components/VencordSettings/shared";
import { ChangeList } from "@utils/ChangeList"; import { ChangeList } from "@utils/ChangeList";
import { proxyLazy } from "@utils/lazy";
import { Logger } from "@utils/Logger"; import { Logger } from "@utils/Logger";
import { Margins } from "@utils/margins"; import { Margins } from "@utils/margins";
import { classes, isObjectEmpty } from "@utils/misc"; import { classes, isObjectEmpty } from "@utils/misc";
import { openModalLazy } from "@utils/modal";
import { useAwaiter } from "@utils/react"; import { useAwaiter } from "@utils/react";
import { Plugin } from "@utils/types"; import { Plugin } from "@utils/types";
import { findByPropsLazy } from "@webpack"; import { findByPropsLazy } from "@webpack";
import { Alerts, Button, Card, Forms, lodash, Parser, React, Select, Text, TextInput, Toasts, Tooltip, useMemo } from "@webpack/common"; import { Alerts, Button, Card, Forms, lodash, Parser, React, Select, Text, TextInput, Toasts, Tooltip } from "@webpack/common";
import Plugins, { ExcludedPlugins } from "~plugins"; import Plugins from "~plugins";
import { startDependenciesRecursive, startPlugin, stopPlugin } from "../../plugins";
// Avoid circular dependency
const { startDependenciesRecursive, startPlugin, stopPlugin } = proxyLazy(() => require("../../plugins"));
const cl = classNameFactory("vc-plugins-"); const cl = classNameFactory("vc-plugins-");
const logger = new Logger("PluginSettings", "#a6d189"); const logger = new Logger("PluginSettings", "#a6d189");
const InputStyles = findByPropsLazy("inputWrapper", "inputDefault", "error"); const InputStyles = findByPropsLazy("inputDefault", "inputWrapper");
const ButtonClasses = findByPropsLazy("button", "disabled", "enabled"); const ButtonClasses = findByPropsLazy("button", "disabled", "enabled");
@ -68,7 +68,7 @@ function ReloadRequiredCard({ required }: { required: boolean; }) {
<Forms.FormText className={cl("dep-text")}> <Forms.FormText className={cl("dep-text")}>
Restart now to apply new plugins and their settings Restart now to apply new plugins and their settings
</Forms.FormText> </Forms.FormText>
<Button onClick={() => location.reload()}> <Button color={Button.Colors.YELLOW} onClick={() => location.reload()}>
Restart Restart
</Button> </Button>
</> </>
@ -93,7 +93,15 @@ interface PluginCardProps extends React.HTMLProps<HTMLDivElement> {
export function PluginCard({ plugin, disabled, onRestartNeeded, onMouseEnter, onMouseLeave, isNew }: PluginCardProps) { export function PluginCard({ plugin, disabled, onRestartNeeded, onMouseEnter, onMouseLeave, isNew }: PluginCardProps) {
const settings = Settings.plugins[plugin.name]; const settings = Settings.plugins[plugin.name];
const isEnabled = () => Vencord.Plugins.isPluginEnabled(plugin.name); const isEnabled = () => settings.enabled ?? false;
function openModal() {
openModalLazy(async () => {
return modalProps => {
return <PluginModal {...modalProps} plugin={plugin} onRestartNeeded={() => onRestartNeeded(plugin.name)} />;
};
});
}
function toggleEnabled() { function toggleEnabled() {
const wasEnabled = isEnabled(); const wasEnabled = isEnabled();
@ -151,11 +159,7 @@ export function PluginCard({ plugin, disabled, onRestartNeeded, onMouseEnter, on
onMouseEnter={onMouseEnter} onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave} onMouseLeave={onMouseLeave}
infoButton={ infoButton={
<button <button role="switch" onClick={() => openModal()} className={classes(ButtonClasses.button, cl("info-button"))}>
role="switch"
onClick={() => openPluginModal(plugin, onRestartNeeded)}
className={classes(ButtonClasses.button, cl("info-button"))}
>
{plugin.options && !isObjectEmpty(plugin.options) {plugin.options && !isObjectEmpty(plugin.options)
? <CogWheel /> ? <CogWheel />
: <InfoIcon />} : <InfoIcon />}
@ -172,37 +176,6 @@ const enum SearchStatus {
NEW NEW
} }
function ExcludedPluginsList({ search }: { search: string; }) {
const matchingExcludedPlugins = Object.entries(ExcludedPlugins)
.filter(([name]) => name.toLowerCase().includes(search));
const ExcludedReasons: Record<"web" | "discordDesktop" | "vencordDesktop" | "desktop" | "dev", string> = {
desktop: "Discord Desktop app or Vesktop",
discordDesktop: "Discord Desktop app",
vencordDesktop: "Vesktop app",
web: "Vesktop app and the Web version of Discord",
dev: "Developer version of Vencord"
};
return (
<Text variant="text-md/normal" className={Margins.top16}>
{matchingExcludedPlugins.length
? <>
<Forms.FormText>Are you looking for:</Forms.FormText>
<ul>
{matchingExcludedPlugins.map(([name, reason]) => (
<li key={name}>
<b>{name}</b>: Only available on the {ExcludedReasons[reason]}
</li>
))}
</ul>
</>
: "No plugins meet the search criteria."
}
</Text>
);
}
export default function PluginSettings() { export default function PluginSettings() {
const settings = useSettings(); const settings = useSettings();
const changes = React.useMemo(() => new ChangeList<string>(), []); const changes = React.useMemo(() => new ChangeList<string>(), []);
@ -241,27 +214,26 @@ export default function PluginSettings() {
return o; return o;
}, []); }, []);
const sortedPlugins = useMemo(() => Object.values(Plugins) const sortedPlugins = React.useMemo(() => Object.values(Plugins)
.sort((a, b) => a.name.localeCompare(b.name)), []); .sort((a, b) => a.name.localeCompare(b.name)), []);
const [searchValue, setSearchValue] = React.useState({ value: "", status: SearchStatus.ALL }); const [searchValue, setSearchValue] = React.useState({ value: "", status: SearchStatus.ALL });
const search = searchValue.value.toLowerCase();
const onSearch = (query: string) => setSearchValue(prev => ({ ...prev, value: query })); const onSearch = (query: string) => setSearchValue(prev => ({ ...prev, value: query }));
const onStatusChange = (status: SearchStatus) => setSearchValue(prev => ({ ...prev, status })); const onStatusChange = (status: SearchStatus) => setSearchValue(prev => ({ ...prev, status }));
const pluginFilter = (plugin: typeof Plugins[keyof typeof Plugins]) => { const pluginFilter = (plugin: typeof Plugins[keyof typeof Plugins]) => {
const { status } = searchValue; const enabled = settings.plugins[plugin.name]?.enabled;
const enabled = Vencord.Plugins.isPluginEnabled(plugin.name); if (enabled && searchValue.status === SearchStatus.DISABLED) return false;
if (enabled && status === SearchStatus.DISABLED) return false; if (!enabled && searchValue.status === SearchStatus.ENABLED) return false;
if (!enabled && status === SearchStatus.ENABLED) return false; if (searchValue.status === SearchStatus.NEW && !newPlugins?.includes(plugin.name)) return false;
if (status === SearchStatus.NEW && !newPlugins?.includes(plugin.name)) return false; if (!searchValue.value.length) return true;
if (!search.length) return true;
const v = searchValue.value.toLowerCase();
return ( return (
plugin.name.toLowerCase().includes(search) || plugin.name.toLowerCase().includes(v) ||
plugin.description.toLowerCase().includes(search) || plugin.description.toLowerCase().includes(v) ||
plugin.tags?.some(t => t.toLowerCase().includes(search)) plugin.tags?.some(t => t.toLowerCase().includes(v))
); );
}; };
@ -282,48 +254,53 @@ export default function PluginSettings() {
return lodash.isEqual(newPlugins, sortedPluginNames) ? [] : newPlugins; return lodash.isEqual(newPlugins, sortedPluginNames) ? [] : newPlugins;
})); }));
const plugins = [] as JSX.Element[]; type P = JSX.Element | JSX.Element[];
const requiredPlugins = [] as JSX.Element[]; let plugins: P, requiredPlugins: P;
if (sortedPlugins?.length) {
plugins = [];
requiredPlugins = [];
const showApi = searchValue.value.includes("API"); for (const p of sortedPlugins) {
for (const p of sortedPlugins) { if (!p.options && p.name.endsWith("API") && searchValue.value !== "API")
if (p.hidden || (!p.options && p.name.endsWith("API") && !showApi)) continue;
continue;
if (!pluginFilter(p)) continue; if (!pluginFilter(p)) continue;
const isRequired = p.required || p.isDependency || depMap[p.name]?.some(d => settings.plugins[d].enabled); const isRequired = p.required || depMap[p.name]?.some(d => settings.plugins[d].enabled);
if (isRequired) { if (isRequired) {
const tooltipText = p.required || !depMap[p.name] const tooltipText = p.required
? "This plugin is required for Vencord to function." ? "This plugin is required for Vencord to function."
: makeDependencyList(depMap[p.name]?.filter(d => settings.plugins[d].enabled)); : makeDependencyList(depMap[p.name]?.filter(d => settings.plugins[d].enabled));
requiredPlugins.push(
<Tooltip text={tooltipText} key={p.name}>
{({ onMouseLeave, onMouseEnter }) => (
<PluginCard
onMouseLeave={onMouseLeave}
onMouseEnter={onMouseEnter}
onRestartNeeded={name => changes.handleChange(name)}
disabled={true}
plugin={p}
/>
)}
</Tooltip>
);
} else {
plugins.push(
<PluginCard
onRestartNeeded={name => changes.handleChange(name)}
disabled={false}
plugin={p}
isNew={newPlugins?.includes(p.name)}
key={p.name}
/>
);
}
requiredPlugins.push(
<Tooltip text={tooltipText} key={p.name}>
{({ onMouseLeave, onMouseEnter }) => (
<PluginCard
onMouseLeave={onMouseLeave}
onMouseEnter={onMouseEnter}
onRestartNeeded={name => changes.handleChange(name)}
disabled={true}
plugin={p}
key={p.name}
/>
)}
</Tooltip>
);
} else {
plugins.push(
<PluginCard
onRestartNeeded={name => changes.handleChange(name)}
disabled={false}
plugin={p}
isNew={newPlugins?.includes(p.name)}
key={p.name}
/>
);
} }
} else {
plugins = requiredPlugins = <Text variant="text-md/normal">No plugins meet search criteria.</Text>;
} }
return ( return (
@ -334,10 +311,11 @@ export default function PluginSettings() {
Filters Filters
</Forms.FormTitle> </Forms.FormTitle>
<div className={classes(Margins.bottom20, cl("filter-controls"))}> <div className={cl("filter-controls")}>
<TextInput autoFocus value={searchValue.value} placeholder="Search for a plugin..." onChange={onSearch} /> <TextInput autoFocus value={searchValue.value} placeholder="Search for a plugin..." onChange={onSearch} className={Margins.bottom20} />
<div className={InputStyles.inputWrapper}> <div className={InputStyles.inputWrapper}>
<Select <Select
className={InputStyles.inputDefault}
options={[ options={[
{ label: "Show All", value: SearchStatus.ALL, default: true }, { label: "Show All", value: SearchStatus.ALL, default: true },
{ label: "Show Enabled", value: SearchStatus.ENABLED }, { label: "Show Enabled", value: SearchStatus.ENABLED },
@ -348,25 +326,15 @@ export default function PluginSettings() {
select={onStatusChange} select={onStatusChange}
isSelected={v => v === searchValue.status} isSelected={v => v === searchValue.status}
closeOnSelect={true} closeOnSelect={true}
className={InputStyles.inputDefault}
/> />
</div> </div>
</div> </div>
<Forms.FormTitle className={Margins.top20}>Plugins</Forms.FormTitle> <Forms.FormTitle className={Margins.top20}>Plugins</Forms.FormTitle>
{plugins.length || requiredPlugins.length <div className={cl("grid")}>
? ( {plugins}
<div className={cl("grid")}> </div>
{plugins.length
? plugins
: <Text variant="text-md/normal">No plugins meet the search criteria.</Text>
}
</div>
)
: <ExcludedPluginsList search={search} />
}
<Forms.FormDivider className={Margins.top20} /> <Forms.FormDivider className={Margins.top20} />
@ -374,10 +342,7 @@ export default function PluginSettings() {
Required Plugins Required Plugins
</Forms.FormTitle> </Forms.FormTitle>
<div className={cl("grid")}> <div className={cl("grid")}>
{requiredPlugins.length {requiredPlugins}
? requiredPlugins
: <Text variant="text-md/normal">No plugins meet the search criteria.</Text>
}
</div> </div>
</SettingsTab > </SettingsTab >
); );

View file

@ -78,7 +78,6 @@
.vc-plugins-restart-card button { .vc-plugins-restart-card button {
margin-top: 0.5em; margin-top: 0.5em;
background: var(--info-warning-foreground) !important;
} }
.vc-plugins-info-button svg:not(:hover, :focus) { .vc-plugins-info-button svg:not(:hover, :focus) {

View file

@ -0,0 +1,487 @@
/*
* 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/>.
*/
import "./themesStyles.css";
import { Settings, useSettings } from "@api/Settings";
import { classNameFactory } from "@api/Styles";
import { Flex } from "@components/Flex";
import { CogWheel, DeleteIcon, PluginIcon } from "@components/Icons";
import { Link } from "@components/Link";
import PluginModal from "@components/PluginSettings/PluginModal";
import { AddonCard } from "@components/VencordSettings/AddonCard";
import { SettingsTab, wrapTab } from "@components/VencordSettings/shared";
import { openInviteModal } from "@utils/discord";
import { Margins } from "@utils/margins";
import { classes } from "@utils/misc";
import { openModal } from "@utils/modal";
import { showItemInFolder } from "@utils/native";
import { useAwaiter } from "@utils/react";
import type { ThemeHeader } from "@utils/themes";
import { getThemeInfo, stripBOM, type UserThemeHeader } from "@utils/themes/bd";
import { usercssParse } from "@utils/themes/usercss";
import { findByPropsLazy, findLazy } from "@webpack";
import { Button, Card, Forms, React, showToast, TabBar, TextArea, Tooltip, useEffect, useMemo, useRef, useState } from "@webpack/common";
import type { ComponentType, Ref, SyntheticEvent } from "react";
import type { UserstyleHeader } from "usercss-meta";
import { isPluginEnabled } from "../../plugins";
import { UserCSSSettingsModal } from "./UserCSSModal";
type FileInput = ComponentType<{
ref: Ref<HTMLInputElement>;
onChange: (e: SyntheticEvent<HTMLInputElement>) => void;
multiple?: boolean;
filters?: { name?: string; extensions: string[]; }[];
}>;
const InviteActions = findByPropsLazy("resolveInvite");
const FileInput: FileInput = findLazy(m => m.prototype?.activateUploadDialogue && m.prototype.setRef);
const TextAreaProps = findLazy(m => typeof m.textarea === "string");
const cl = classNameFactory("vc-settings-theme-");
function Validator({ link }: { link: string; }) {
const [res, err, pending] = useAwaiter(() => fetch(link).then(res => {
if (res.status > 300) throw `${res.status} ${res.statusText}`;
const contentType = res.headers.get("Content-Type");
if (!contentType?.startsWith("text/css") && !contentType?.startsWith("text/plain"))
throw "Not a CSS file. Remember to use the raw link!";
return "Okay!";
}));
const text = pending
? "Checking..."
: err
? `Error: ${err instanceof Error ? err.message : String(err)}`
: "Valid!";
return <Forms.FormText style={{
color: pending ? "var(--text-muted)" : err ? "var(--text-danger)" : "var(--text-positive)"
}}>{text}</Forms.FormText>;
}
function Validators({ themeLinks }: { themeLinks: string[]; }) {
if (!themeLinks.length) return null;
return (
<>
<Forms.FormTitle className={Margins.top20} tag="h5">Validator</Forms.FormTitle>
<Forms.FormText>This section will tell you whether your themes can successfully be loaded</Forms.FormText>
<div>
{themeLinks.map(link => (
<Card style={{
padding: ".5em",
marginBottom: ".5em",
marginTop: ".5em"
}} key={link}>
<Forms.FormTitle tag="h5" style={{
overflowWrap: "break-word"
}}>
{link}
</Forms.FormTitle>
<Validator link={link} />
</Card>
))}
</div>
</>
);
}
interface OtherThemeCardProps {
theme: UserThemeHeader;
enabled: boolean;
onChange: (enabled: boolean) => void;
onDelete: () => void;
}
interface UserCSSCardProps {
theme: UserstyleHeader;
enabled: boolean;
onChange: (enabled: boolean) => void;
onDelete: () => void;
onSettingsReset: () => void;
}
function UserCSSThemeCard({ theme, enabled, onChange, onDelete, onSettingsReset }: UserCSSCardProps) {
const missingPlugins = useMemo(() =>
theme.requiredPlugins?.filter(p => !isPluginEnabled(p)), [theme]);
return (
<AddonCard
name={theme.name ?? "Unknown"}
description={theme.description}
author={theme.author ?? "Unknown"}
enabled={enabled}
setEnabled={onChange}
disabled={missingPlugins && missingPlugins.length > 0}
infoButton={
<>
{missingPlugins && missingPlugins.length > 0 && (
<Tooltip text={"The following plugins are required, but aren't enabled: " + missingPlugins.join(", ")}>
{({ onMouseLeave, onMouseEnter }) => (
<div
style={{ color: "var(--status-danger" }}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<PluginIcon />
</div>
)}
</Tooltip>
)}
{theme.vars && (
<div style={{ cursor: "pointer" }} onClick={
() => openModal(modalProps =>
<UserCSSSettingsModal modalProps={modalProps} theme={theme} onSettingsReset={onSettingsReset} />)
}>
<CogWheel />
</div>
)}
{IS_WEB && (
<div style={{ cursor: "pointer", color: "var(--status-danger" }} onClick={onDelete}>
<DeleteIcon />
</div>
)}
</>
}
footer={
<Flex flexDirection="row" style={{ gap: "0.2em" }}>
{!!theme.homepageURL && <Link href={theme.homepageURL}>Homepage</Link>}
{!!(theme.homepageURL && theme.supportURL) && " • "}
{!!theme.supportURL && <Link href={theme.supportURL}>Support</Link>}
</Flex>
}
/>
);
}
function OtherThemeCard({ theme, enabled, onChange, onDelete }: OtherThemeCardProps) {
return (
<AddonCard
name={theme.name}
description={theme.description}
author={theme.author}
enabled={enabled}
setEnabled={onChange}
infoButton={
IS_WEB && (
<div style={{ cursor: "pointer", color: "var(--status-danger" }} onClick={onDelete}>
<DeleteIcon />
</div>
)
}
footer={
<Flex flexDirection="row" style={{ gap: "0.2em" }}>
{!!theme.website && <Link href={theme.website}>Website</Link>}
{!!(theme.website && theme.invite) && " • "}
{!!theme.invite && (
<Link
href={`https://discord.gg/${theme.invite}`}
onClick={async e => {
e.preventDefault();
theme.invite != null && openInviteModal(theme.invite).catch(() => showToast("Invalid or expired invite"));
}}
>
Discord Server
</Link>
)}
</Flex>
}
/>
);
}
enum ThemeTab {
LOCAL,
ONLINE
}
function ThemesTab() {
const settings = useSettings(["themeLinks", "enabledThemes"]);
const fileInputRef = useRef<HTMLInputElement>(null);
const [currentTab, setCurrentTab] = useState(ThemeTab.LOCAL);
const [themeText, setThemeText] = useState(settings.themeLinks.join("\n"));
const [userThemes, setUserThemes] = useState<ThemeHeader[] | null>(null);
const [themeDir, , themeDirPending] = useAwaiter(VencordNative.themes.getThemesDir);
useEffect(() => {
refreshLocalThemes();
}, []);
async function refreshLocalThemes() {
const themes = await VencordNative.themes.getThemesList();
const themeInfo: ThemeHeader[] = [];
for (const { fileName, content } of themes) {
if (!fileName.endsWith(".css")) continue;
if ((!IS_WEB || "armcord" in window) && fileName.endsWith(".user.css")) {
// handle it as usercss
const header = await usercssParse(content, fileName);
themeInfo.push({
type: "usercss",
header
});
Settings.userCssVars[header.id] ??= {};
for (const [name, varInfo] of Object.entries(header.vars ?? {})) {
let normalizedValue = "";
switch (varInfo.type) {
case "text":
case "color":
case "checkbox":
normalizedValue = varInfo.default;
break;
case "select":
normalizedValue = varInfo.options.find(v => v.name === varInfo.default)!.value;
break;
case "range":
normalizedValue = `${varInfo.default}${varInfo.units}`;
break;
case "number":
normalizedValue = String(varInfo.default);
break;
}
Settings.userCssVars[header.id][name] ??= normalizedValue;
}
} else {
// presumably BD but could also be plain css
themeInfo.push({
type: "other",
header: getThemeInfo(stripBOM(content), fileName)
});
}
}
setUserThemes(themeInfo);
}
// When a local theme is enabled/disabled, update the settings
function onLocalThemeChange(fileName: string, value: boolean) {
if (value) {
if (settings.enabledThemes.includes(fileName)) return;
settings.enabledThemes = [...settings.enabledThemes, fileName];
} else {
settings.enabledThemes = settings.enabledThemes.filter(f => f !== fileName);
}
}
async function onFileUpload(e: SyntheticEvent<HTMLInputElement>) {
e.stopPropagation();
e.preventDefault();
if (!e.currentTarget?.files?.length) return;
const { files } = e.currentTarget;
const uploads = Array.from(files, file => {
const { name } = file;
if (!name.endsWith(".css")) return;
return new Promise<void>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
VencordNative.themes.uploadTheme(name, reader.result as string)
.then(resolve)
.catch(reject);
};
reader.readAsText(file);
});
});
await Promise.all(uploads);
refreshLocalThemes();
}
function renderLocalThemes() {
return (
<>
<Card className="vc-settings-card">
<Forms.FormTitle tag="h5">Find Themes:</Forms.FormTitle>
<div style={{ marginBottom: ".5em", display: "flex", flexDirection: "column" }}>
<Link style={{ marginRight: ".5em" }} href="https://betterdiscord.app/themes">
BetterDiscord Themes
</Link>
<Link href="https://github.com/search?q=discord+theme">GitHub</Link>
</div>
<Forms.FormText>If using the BD site, click on "Download" and place the downloaded .theme.css file into your themes folder.</Forms.FormText>
</Card>
<Forms.FormSection title="Local Themes">
<Card className="vc-settings-quick-actions-card">
<>
{IS_WEB ?
(
<Button
size={Button.Sizes.SMALL}
disabled={themeDirPending}
>
Upload Theme
<FileInput
ref={fileInputRef}
onChange={onFileUpload}
multiple={true}
filters={[{ extensions: ["css"] }]}
/>
</Button>
) : (
<Button
onClick={() => showItemInFolder(themeDir!)}
size={Button.Sizes.SMALL}
disabled={themeDirPending}
>
Open Themes Folder
</Button>
)}
<Button
onClick={refreshLocalThemes}
size={Button.Sizes.SMALL}
>
Load missing Themes
</Button>
<Button
onClick={() => VencordNative.quickCss.openEditor()}
size={Button.Sizes.SMALL}
>
Edit QuickCSS
</Button>
{Vencord.Settings.plugins.ClientTheme.enabled && (
<Button
onClick={() => openModal(modalProps => (
<PluginModal
{...modalProps}
plugin={Vencord.Plugins.plugins.ClientTheme}
onRestartNeeded={() => { }}
/>
))}
size={Button.Sizes.SMALL}
>
Edit ClientTheme
</Button>
)}
</>
</Card>
<div className={cl("grid")}>
{userThemes?.map(({ type, header: theme }: ThemeHeader) => (
type === "other" ? (
<OtherThemeCard
key={theme.fileName}
enabled={settings.enabledThemes.includes(theme.fileName)}
onChange={enabled => onLocalThemeChange(theme.fileName, enabled)}
onDelete={async () => {
onLocalThemeChange(theme.fileName, false);
await VencordNative.themes.deleteTheme(theme.fileName);
refreshLocalThemes();
}}
theme={theme as UserThemeHeader}
/>
) : (
<UserCSSThemeCard
key={theme.fileName}
enabled={settings.enabledThemes.includes(theme.fileName)}
onChange={enabled => onLocalThemeChange(theme.fileName, enabled)}
onDelete={async () => {
onLocalThemeChange(theme.fileName, false);
await VencordNative.themes.deleteTheme(theme.fileName);
refreshLocalThemes();
}}
onSettingsReset={refreshLocalThemes}
theme={theme as UserstyleHeader}
/>
)))}
</div>
</Forms.FormSection>
</>
);
}
// When the user leaves the online theme textbox, update the settings
function onBlur() {
settings.themeLinks = [...new Set(
themeText
.trim()
.split(/\n+/)
.map(s => s.trim())
.filter(Boolean)
)];
}
function renderOnlineThemes() {
return (
<>
<Card className="vc-settings-card vc-text-selectable">
<Forms.FormTitle tag="h5">Paste links to css files here</Forms.FormTitle>
<Forms.FormText>One link per line</Forms.FormText>
<Forms.FormText>Make sure to use direct links to files (raw or github.io)!</Forms.FormText>
</Card>
<Forms.FormSection title="Online Themes" tag="h5">
<TextArea
value={themeText}
onChange={setThemeText}
className={classes(TextAreaProps.textarea, "vc-settings-theme-links")}
placeholder="Theme Links"
spellCheck={false}
onBlur={onBlur}
rows={10}
/>
<Validators themeLinks={settings.themeLinks} />
</Forms.FormSection>
</>
);
}
return (
<SettingsTab title="Themes">
<TabBar
type="top"
look="brand"
className="vc-settings-tab-bar"
selectedItem={currentTab}
onItemSelect={setCurrentTab}
>
<TabBar.Item
className="vc-settings-tab-bar-item"
id={ThemeTab.LOCAL}
>
Local Themes
</TabBar.Item>
<TabBar.Item
className="vc-settings-tab-bar-item"
id={ThemeTab.ONLINE}
>
Online Themes
</TabBar.Item>
</TabBar>
{currentTab === ThemeTab.LOCAL && renderLocalThemes()}
{currentTab === ThemeTab.ONLINE && renderOnlineThemes()}
</SettingsTab>
);
}
export default wrapTab(ThemesTab, "Themes");

View file

@ -0,0 +1,205 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Settings, useSettings } from "@api/Settings";
import { Flex } from "@components/Flex";
import { CopyIcon, PasteIcon, ResetIcon } from "@components/Icons";
import { copyWithToast } from "@utils/misc";
import { ModalCloseButton, ModalContent, ModalHeader, ModalProps, ModalRoot } from "@utils/modal";
import { showToast, Text, Toasts, Tooltip } from "@webpack/common";
import { type ReactNode } from "react";
import { UserstyleHeader } from "usercss-meta";
import { SettingBooleanComponent, SettingColorComponent, SettingNumberComponent, SettingRangeComponent, SettingSelectComponent, SettingTextComponent } from "./components";
interface UserCSSSettingsModalProps {
modalProps: ModalProps;
theme: UserstyleHeader;
onSettingsReset: () => void;
}
function ExportButton({ themeSettings }: { themeSettings: Settings["userCssVars"][""]; }) {
return <Tooltip text={"Copy theme settings"}>
{({ onMouseLeave, onMouseEnter }) => (
<div
style={{ cursor: "pointer" }}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onClick={() => {
copyWithToast(JSON.stringify(themeSettings), "Copied theme settings to clipboard.");
}}>
<CopyIcon />
</div>
)}
</Tooltip>;
}
function ImportButton({ themeSettings }: { themeSettings: Settings["userCssVars"][""]; }) {
return <Tooltip text={"Paste theme settings"}>
{({ onMouseLeave, onMouseEnter }) => (
<div
style={{ cursor: "pointer" }}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onClick={async () => {
const clip = (await navigator.clipboard.read())[0];
if (!clip) return showToast("Your clipboard is empty.", Toasts.Type.FAILURE);
if (!clip.types.includes("text/plain"))
return showToast("Your clipboard doesn't have valid settings data.", Toasts.Type.FAILURE);
try {
var potentialSettings: Record<string, string> =
JSON.parse(await clip.getType("text/plain").then(b => b.text()));
} catch (e) {
return showToast("Your clipboard doesn't have valid settings data.", Toasts.Type.FAILURE);
}
for (const [key, value] of Object.entries(potentialSettings)) {
if (Object.prototype.hasOwnProperty.call(themeSettings, key))
themeSettings[key] = value;
}
showToast("Pasted theme settings from clipboard.", Toasts.Type.SUCCESS);
}}>
<PasteIcon />
</div>
)}
</Tooltip>;
}
interface ResetButtonProps {
themeSettings: Settings["userCssVars"];
themeId: string;
close: () => Promise<void>;
onReset: () => void;
}
function ResetButton({ themeSettings, themeId, close, onReset }: ResetButtonProps) {
return <Tooltip text={"Reset settings to default"}>
{({ onMouseLeave, onMouseEnter }) => (
<div
style={{ cursor: "pointer" }}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onClick={async () => {
await close(); // close the modal first to stop rendering
delete themeSettings[themeId];
onReset();
}}>
<ResetIcon />
</div>
)}
</Tooltip>;
}
export function UserCSSSettingsModal({ modalProps, theme, onSettingsReset }: UserCSSSettingsModalProps) {
// @ts-expect-error UseSettings<> can't determine this is a valid key
const { userCssVars } = useSettings(["userCssVars"], false);
const themeVars = userCssVars[theme.id];
const controls: ReactNode[] = [];
for (const [name, varInfo] of Object.entries(theme.vars)) {
switch (varInfo.type) {
case "text": {
controls.push(
<SettingTextComponent
label={varInfo.label}
name={name}
themeSettings={themeVars}
/>
);
break;
}
case "checkbox": {
controls.push(
<SettingBooleanComponent
label={varInfo.label}
name={name}
themeSettings={themeVars}
/>
);
break;
}
case "color": {
controls.push(
<SettingColorComponent
label={varInfo.label}
name={name}
themeSettings={themeVars}
/>
);
break;
}
case "number": {
controls.push(
<SettingNumberComponent
label={varInfo.label}
name={name}
themeSettings={themeVars}
/>
);
break;
}
case "select": {
controls.push(
<SettingSelectComponent
label={varInfo.label}
name={name}
options={varInfo.options}
default={varInfo.default}
themeSettings={themeVars}
/>
);
break;
}
case "range": {
controls.push(
<SettingRangeComponent
label={varInfo.label}
name={name}
default={varInfo.default}
min={varInfo.min}
max={varInfo.max}
step={varInfo.step}
themeSettings={themeVars}
/>
);
break;
}
}
}
return (
<ModalRoot {...modalProps}>
<ModalHeader separator={false}>
<Text variant="heading-lg/semibold" style={{ flexGrow: 1 }}>Settings for {theme.name}</Text>
<Flex style={{ gap: 4, marginRight: 4 }} className="vc-settings-usercss-ie-buttons">
<ExportButton themeSettings={themeVars} />
<ImportButton themeSettings={themeVars} />
<ResetButton themeSettings={userCssVars} themeId={theme.id} close={modalProps.onClose} onReset={onSettingsReset} />
</Flex>
<ModalCloseButton onClick={modalProps.onClose} />
</ModalHeader>
<ModalContent>
<Flex flexDirection="column" style={{ gap: 12, marginBottom: 16 }}>
{controls}
</Flex>
</ModalContent>
</ModalRoot>
);
}

View file

@ -0,0 +1,35 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Forms, Switch } from "@webpack/common";
interface Props {
label: string;
name: string;
themeSettings: Record<string, string>;
}
export function SettingBooleanComponent({ label, name, themeSettings }: Props) {
function handleChange(value: boolean) {
const corrected = value ? "1" : "0";
themeSettings[name] = corrected;
}
return (
<Forms.FormSection>
<Switch
key={name}
value={themeSettings[name] === "1"}
onChange={handleChange}
hideBorder
style={{ marginBottom: "0.5em" }}
>
{label}
</Switch>
</Forms.FormSection>
);
}

View file

@ -0,0 +1,52 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./colorStyles.css";
import { classNameFactory } from "@api/Styles";
import { findByCodeLazy, findComponentByCodeLazy } from "@webpack";
import { Forms, useMemo } from "@webpack/common";
interface ColorPickerProps {
color: number | null;
showEyeDropper?: boolean;
onChange(value: number | null): void;
}
const ColorPicker = findComponentByCodeLazy<ColorPickerProps>(".Messages.USER_SETTINGS_PROFILE_COLOR_SELECT_COLOR", ".BACKGROUND_PRIMARY)");
// TinyColor is completely unmangled and it's duplicated in two modules! Fun!
const TinyColor: tinycolor.Constructor = findByCodeLazy("this._gradientType=");
const cl = classNameFactory("vc-usercss-settings-color-");
interface Props {
label: string;
name: string;
themeSettings: Record<string, string>;
}
export function SettingColorComponent({ label, name, themeSettings }: Props) {
function handleChange(value: number) {
const corrected = "#" + (value?.toString(16).padStart(6, "0") ?? "000000");
themeSettings[name] = corrected;
}
const normalizedValue = useMemo(() => parseInt(TinyColor(themeSettings[name]).toHex(), 16), [themeSettings[name]]);
return (
<Forms.FormSection>
<div className={cl("swatch-row")}>
<span>{label}</span>
<ColorPicker
key={name}
color={normalizedValue}
onChange={handleChange}
/>
</div>
</Forms.FormSection>
);
}

View file

@ -0,0 +1,32 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Forms, TextInput } from "@webpack/common";
interface Props {
label: string;
name: string;
themeSettings: Record<string, string>;
}
export function SettingNumberComponent({ label, name, themeSettings }: Props) {
function handleChange(value: string) {
themeSettings[name] = value;
}
return (
<Forms.FormSection>
<Forms.FormTitle tag="h5">{label}</Forms.FormTitle>
<TextInput
type="number"
pattern="-?[0-9]+"
key={name}
value={themeSettings[name]}
onChange={handleChange}
/>
</Forms.FormSection>
);
}

View file

@ -0,0 +1,52 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Forms, Slider, useMemo } from "@webpack/common";
interface Props {
label: string;
name: string;
default: number;
min?: number;
max?: number;
step?: number;
themeSettings: Record<string, string>;
}
export function SettingRangeComponent({ label, name, default: def, min, max, step, themeSettings }: Props) {
function handleChange(value: number) {
const corrected = value.toString();
themeSettings[name] = corrected;
}
const markers = useMemo(() => {
const markers: number[] = [];
// defaults taken from https://github.com/openstyles/stylus/wiki/Writing-UserCSS#default-value
for (let i = (min ?? 0); i <= (max ?? 10); i += (step ?? 1)) {
markers.push(i);
}
return markers;
}, [min, max, step]);
return (
<Forms.FormSection>
<Forms.FormTitle tag="h5">{label}</Forms.FormTitle>
<Slider
initialValue={parseInt(themeSettings[name], 10)}
defaultValue={def}
onValueChange={handleChange}
minValue={min}
maxValue={max}
markers={markers}
stickToMarkers={true}
/>
</Forms.FormSection>
);
}

View file

@ -0,0 +1,51 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { identity } from "@utils/misc";
import { ComponentTypes, Forms, Select, useMemo } from "@webpack/common";
interface Props {
label: string;
name: string;
options: {
name: string;
label: string;
value: string;
}[];
default: string;
themeSettings: Record<string, string>;
}
export function SettingSelectComponent({ label, name, options, default: def, themeSettings }: Props) {
function handleChange(value: string) {
themeSettings[name] = value;
}
const opts = useMemo(() => options.map(option => ({
disabled: false,
key: option.name,
value: option.value,
default: def === option.name,
label: option.label
} satisfies ComponentTypes.SelectOption)), [options, def]);
return (
<Forms.FormSection>
<Forms.FormTitle tag="h5">{label}</Forms.FormTitle>
<Select
placeholder={label}
key={name}
options={opts}
closeOnSelect={true}
select={handleChange}
isSelected={v => v === themeSettings[name]}
serialize={identity}
/>
</Forms.FormSection>
);
}

View file

@ -0,0 +1,30 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Forms, TextInput } from "@webpack/common";
interface Props {
label: string;
name: string;
themeSettings: Record<string, string>;
}
export function SettingTextComponent({ label, name, themeSettings }: Props) {
function handleChange(value: string) {
themeSettings[name] = value;
}
return (
<Forms.FormSection>
<Forms.FormTitle tag="h5">{label}</Forms.FormTitle>
<TextInput
key={name}
value={themeSettings[name]}
onChange={handleChange}
/>
</Forms.FormSection>
);
}

View file

@ -0,0 +1,19 @@
.vc-usercss-settings-color-swatch-row {
display: flex;
flex-direction: row;
width: 100%;
align-items: center;
}
.vc-usercss-settings-color-swatch-row > span {
display: block;
flex: 1;
overflow: hidden;
margin-top: 0;
margin-bottom: 0;
color: var(--header-primary);
line-height: 24px;
font-size: 16px;
font-weight: 500;
word-wrap: break-word;
}

View file

@ -0,0 +1,12 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
export * from "./SettingBooleanComponent";
export * from "./SettingColorComponent";
export * from "./SettingNumberComponent";
export * from "./SettingRangeComponent";
export * from "./SettingSelectComponent";
export * from "./SettingTextComponent";

View file

@ -27,3 +27,14 @@
.vc-settings-theme-author::before { .vc-settings-theme-author::before {
content: "by "; content: "by ";
} }
.vc-settings-usercss-ie-buttons > div {
color: var(--interactive-normal);
opacity: .5;
padding: 4px;
}
.vc-settings-usercss-ie-buttons > div:hover {
color: var(--interactive-hover);
opacity: 1;
}

View file

@ -21,7 +21,7 @@ import "./addonCard.css";
import { classNameFactory } from "@api/Styles"; import { classNameFactory } from "@api/Styles";
import { Badge } from "@components/Badge"; import { Badge } from "@components/Badge";
import { Switch } from "@components/Switch"; import { Switch } from "@components/Switch";
import { Text, useRef } from "@webpack/common"; import { Text } from "@webpack/common";
import type { MouseEventHandler, ReactNode } from "react"; import type { MouseEventHandler, ReactNode } from "react";
const cl = classNameFactory("vc-addon-"); const cl = classNameFactory("vc-addon-");
@ -42,8 +42,6 @@ interface Props {
} }
export function AddonCard({ disabled, isNew, name, infoButton, footer, author, enabled, setEnabled, description, onMouseEnter, onMouseLeave }: Props) { export function AddonCard({ disabled, isNew, name, infoButton, footer, author, enabled, setEnabled, description, onMouseEnter, onMouseLeave }: Props) {
const titleRef = useRef<HTMLDivElement>(null);
const titleContainerRef = useRef<HTMLDivElement>(null);
return ( return (
<div <div
className={cl("card", { "card-disabled": disabled })} className={cl("card", { "card-disabled": disabled })}
@ -53,21 +51,7 @@ export function AddonCard({ disabled, isNew, name, infoButton, footer, author, e
<div className={cl("header")}> <div className={cl("header")}>
<div className={cl("name-author")}> <div className={cl("name-author")}>
<Text variant="text-md/bold" className={cl("name")}> <Text variant="text-md/bold" className={cl("name")}>
<div ref={titleContainerRef} className={cl("title-container")}> {name}{isNew && <Badge text="NEW" color="#ED4245" />}
<div
ref={titleRef}
className={cl("title")}
onMouseOver={() => {
const title = titleRef.current!;
const titleContainer = titleContainerRef.current!;
title.style.setProperty("--offset", `${titleContainer.clientWidth - title.scrollWidth}px`);
title.style.setProperty("--duration", `${Math.max(0.5, (title.scrollWidth - titleContainer.clientWidth) / 7)}s`);
}}
>
{name}
</div>
</div>{isNew && <Badge text="NEW" color="#ED4245" />}
</Text> </Text>
{!!author && ( {!!author && (
<Text variant="text-md/normal" className={cl("author")}> <Text variant="text-md/normal" className={cl("author")}>

View file

@ -19,7 +19,6 @@
import { showNotification } from "@api/Notifications"; import { showNotification } from "@api/Notifications";
import { Settings, useSettings } from "@api/Settings"; import { Settings, useSettings } from "@api/Settings";
import { CheckedTextInput } from "@components/CheckedTextInput"; import { CheckedTextInput } from "@components/CheckedTextInput";
import { Grid } from "@components/Grid";
import { Link } from "@components/Link"; import { Link } from "@components/Link";
import { authorizeCloud, cloudLogger, deauthorizeCloud, getCloudAuth, getCloudUrl } from "@utils/cloud"; import { authorizeCloud, cloudLogger, deauthorizeCloud, getCloudAuth, getCloudUrl } from "@utils/cloud";
import { Margins } from "@utils/margins"; import { Margins } from "@utils/margins";
@ -40,7 +39,9 @@ function validateUrl(url: string) {
async function eraseAllData() { async function eraseAllData() {
const res = await fetch(new URL("/v1/", getCloudUrl()), { const res = await fetch(new URL("/v1/", getCloudUrl()), {
method: "DELETE", method: "DELETE",
headers: { Authorization: await getCloudAuth() } headers: new Headers({
Authorization: await getCloudAuth()
})
}); });
if (!res.ok) { if (!res.ok) {
@ -86,9 +87,7 @@ function SettingsSyncSection() {
size={Button.Sizes.SMALL} size={Button.Sizes.SMALL}
disabled={!sectionEnabled} disabled={!sectionEnabled}
onClick={() => putCloudSettings(true)} onClick={() => putCloudSettings(true)}
> >Sync to Cloud</Button>
Sync to Cloud
</Button>
<Tooltip text="This will overwrite your local settings with the ones on the cloud. Use wisely!"> <Tooltip text="This will overwrite your local settings with the ones on the cloud. Use wisely!">
{({ onMouseLeave, onMouseEnter }) => ( {({ onMouseLeave, onMouseEnter }) => (
<Button <Button
@ -98,9 +97,7 @@ function SettingsSyncSection() {
color={Button.Colors.RED} color={Button.Colors.RED}
disabled={!sectionEnabled} disabled={!sectionEnabled}
onClick={() => getCloudSettings(true, true)} onClick={() => getCloudSettings(true, true)}
> >Sync from Cloud</Button>
Sync from Cloud
</Button>
)} )}
</Tooltip> </Tooltip>
<Button <Button
@ -108,9 +105,7 @@ function SettingsSyncSection() {
color={Button.Colors.RED} color={Button.Colors.RED}
disabled={!sectionEnabled} disabled={!sectionEnabled}
onClick={() => deleteCloudSettings()} onClick={() => deleteCloudSettings()}
> >Delete Cloud Settings</Button>
Delete Cloud Settings
</Button>
</div> </div>
</Forms.FormSection> </Forms.FormSection>
); );
@ -131,12 +126,7 @@ function CloudTab() {
<Switch <Switch
key="backend" key="backend"
value={settings.cloud.authenticated} value={settings.cloud.authenticated}
onChange={v => { onChange={v => { v && authorizeCloud(); if (!v) settings.cloud.authenticated = v; }}
if (v)
authorizeCloud();
else
settings.cloud.authenticated = v;
}}
note="This will request authorization if you have not yet set up cloud integrations." note="This will request authorization if you have not yet set up cloud integrations."
> >
Enable Cloud Integrations Enable Cloud Integrations
@ -148,43 +138,23 @@ function CloudTab() {
<CheckedTextInput <CheckedTextInput
key="backendUrl" key="backendUrl"
value={settings.cloud.url} value={settings.cloud.url}
onChange={async v => { onChange={v => { settings.cloud.url = v; settings.cloud.authenticated = false; deauthorizeCloud(); }}
settings.cloud.url = v;
settings.cloud.authenticated = false;
deauthorizeCloud();
}}
validate={validateUrl} validate={validateUrl}
/> />
<Button
<Grid columns={2} gap="1em" className={Margins.top8}> className={Margins.top8}
<Button size={Button.Sizes.MEDIUM}
size={Button.Sizes.MEDIUM} color={Button.Colors.RED}
disabled={!settings.cloud.authenticated} disabled={!settings.cloud.authenticated}
onClick={async () => { onClick={() => Alerts.show({
await deauthorizeCloud(); title: "Are you sure?",
settings.cloud.authenticated = false; body: "Once your data is erased, we cannot recover it. There's no going back!",
await authorizeCloud(); onConfirm: eraseAllData,
}} confirmText: "Erase it!",
> confirmColor: "vc-cloud-erase-data-danger-btn",
Reauthorise cancelText: "Nevermind"
</Button> })}
<Button >Erase All Data</Button>
size={Button.Sizes.MEDIUM}
color={Button.Colors.RED}
disabled={!settings.cloud.authenticated}
onClick={() => Alerts.show({
title: "Are you sure?",
body: "Once your data is erased, we cannot recover it. There's no going back!",
onConfirm: eraseAllData,
confirmText: "Erase it!",
confirmColor: "vc-cloud-erase-data-danger-btn",
cancelText: "Nevermind"
})}
>
Erase All Data
</Button>
</Grid>
<Forms.FormDivider className={Margins.top16} /> <Forms.FormDivider className={Margins.top16} />
</Forms.FormSection > </Forms.FormSection >
<SettingsSyncSection /> <SettingsSyncSection />

View file

@ -1,106 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { useSettings } from "@api/Settings";
import { Margins } from "@utils/margins";
import { identity } from "@utils/misc";
import { ModalCloseButton, ModalContent, ModalHeader, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { Forms, Select, Slider, Text } from "@webpack/common";
import { ErrorCard } from "..";
export function NotificationSettings() {
const settings = useSettings().notifications;
return (
<div style={{ padding: "1em 0" }}>
<Forms.FormTitle tag="h5">Notification Style</Forms.FormTitle>
{settings.useNative !== "never" && Notification?.permission === "denied" && (
<ErrorCard style={{ padding: "1em" }} className={Margins.bottom8}>
<Forms.FormTitle tag="h5">Desktop Notification Permission denied</Forms.FormTitle>
<Forms.FormText>You have denied Notification Permissions. Thus, Desktop notifications will not work!</Forms.FormText>
</ErrorCard>
)}
<Forms.FormText className={Margins.bottom8}>
Some plugins may show you notifications. These come in two styles:
<ul>
<li><strong>Vencord Notifications</strong>: These are in-app notifications</li>
<li><strong>Desktop Notifications</strong>: Native Desktop notifications (like when you get a ping)</li>
</ul>
</Forms.FormText>
<Select
placeholder="Notification Style"
options={[
{ label: "Only use Desktop notifications when Discord is not focused", value: "not-focused", default: true },
{ label: "Always use Desktop notifications", value: "always" },
{ label: "Always use Vencord notifications", value: "never" },
] satisfies Array<{ value: typeof settings["useNative"]; } & Record<string, any>>}
closeOnSelect={true}
select={v => settings.useNative = v}
isSelected={v => v === settings.useNative}
serialize={identity}
/>
<Forms.FormTitle tag="h5" className={Margins.top16 + " " + Margins.bottom8}>Notification Position</Forms.FormTitle>
<Select
isDisabled={settings.useNative === "always"}
placeholder="Notification Position"
options={[
{ label: "Bottom Right", value: "bottom-right", default: true },
{ label: "Top Right", value: "top-right" },
] satisfies Array<{ value: typeof settings["position"]; } & Record<string, any>>}
select={v => settings.position = v}
isSelected={v => v === settings.position}
serialize={identity}
/>
<Forms.FormTitle tag="h5" className={Margins.top16 + " " + Margins.bottom8}>Notification Timeout</Forms.FormTitle>
<Forms.FormText className={Margins.bottom16}>Set to 0s to never automatically time out</Forms.FormText>
<Slider
disabled={settings.useNative === "always"}
markers={[0, 1000, 2500, 5000, 10_000, 20_000]}
minValue={0}
maxValue={20_000}
initialValue={settings.timeout}
onValueChange={v => settings.timeout = v}
onValueRender={v => (v / 1000).toFixed(2) + "s"}
onMarkerRender={v => (v / 1000) + "s"}
stickToMarkers={false}
/>
<Forms.FormTitle tag="h5" className={Margins.top16 + " " + Margins.bottom8}>Notification Log Limit</Forms.FormTitle>
<Forms.FormText className={Margins.bottom16}>
The amount of notifications to save in the log until old ones are removed.
Set to <code>0</code> to disable Notification log and <code></code> to never automatically remove old Notifications
</Forms.FormText>
<Slider
markers={[0, 25, 50, 75, 100, 200]}
minValue={0}
maxValue={200}
stickToMarkers={true}
initialValue={settings.logLimit}
onValueChange={v => settings.logLimit = v}
onValueRender={v => v === 200 ? "∞" : v}
onMarkerRender={v => v === 200 ? "∞" : v}
/>
</div>
);
}
export function openNotificationSettingsModal() {
openModal(props => (
<ModalRoot {...props} size={ModalSize.MEDIUM}>
<ModalHeader>
<Text variant="heading-lg/semibold" style={{ flexGrow: 1 }}>Notification Settings</Text>
<ModalCloseButton onClick={props.onClose} />
</ModalHeader>
<ModalContent>
<NotificationSettings />
</ModalContent>
</ModalRoot>
));
}

View file

@ -16,14 +16,15 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { CheckedTextInput } from "@components/CheckedTextInput";
import { CodeBlock } from "@components/CodeBlock"; import { CodeBlock } from "@components/CodeBlock";
import { debounce } from "@shared/debounce"; import { debounce } from "@utils/debounce";
import { Margins } from "@utils/margins"; import { Margins } from "@utils/margins";
import { canonicalizeMatch, canonicalizeReplace } from "@utils/patches"; import { canonicalizeMatch, canonicalizeReplace } from "@utils/patches";
import { makeCodeblock } from "@utils/text"; import { makeCodeblock } from "@utils/text";
import { Patch, ReplaceFn } from "@utils/types"; import { ReplaceFn } from "@utils/types";
import { search } from "@webpack"; import { search } from "@webpack";
import { Button, Clipboard, Forms, Parser, React, Switch, TextArea, TextInput } from "@webpack/common"; import { Button, Clipboard, Forms, Parser, React, Switch, TextInput } from "@webpack/common";
import { SettingsTab, wrapTab } from "./shared"; import { SettingsTab, wrapTab } from "./shared";
@ -46,7 +47,7 @@ const findCandidates = debounce(function ({ find, setModule, setError }) {
interface ReplacementComponentProps { interface ReplacementComponentProps {
module: [id: number, factory: Function]; module: [id: number, factory: Function];
match: string; match: string | RegExp;
replacement: string | ReplaceFn; replacement: string | ReplaceFn;
setReplacementError(error: any): void; setReplacementError(error: any): void;
} }
@ -57,13 +58,7 @@ function ReplacementComponent({ module, match, replacement, setReplacementError
const [patchedCode, matchResult, diff] = React.useMemo(() => { const [patchedCode, matchResult, diff] = React.useMemo(() => {
const src: string = fact.toString().replaceAll("\n", ""); const src: string = fact.toString().replaceAll("\n", "");
const canonicalMatch = canonicalizeMatch(match);
try {
new RegExp(match);
} catch (e) {
return ["", [], []];
}
const canonicalMatch = canonicalizeMatch(new RegExp(match));
try { try {
const canonicalReplace = canonicalizeReplace(replacement, "YourPlugin"); const canonicalReplace = canonicalizeReplace(replacement, "YourPlugin");
var patched = src.replace(canonicalMatch, canonicalReplace as string); var patched = src.replace(canonicalMatch, canonicalReplace as string);
@ -185,8 +180,7 @@ function ReplacementInput({ replacement, setReplacement, replacementError }) {
return ( return (
<> <>
{/* FormTitle adds a class if className is not set, so we set it to an empty string to prevent that */} <Forms.FormTitle>replacement</Forms.FormTitle>
<Forms.FormTitle className="">replacement</Forms.FormTitle>
<TextInput <TextInput
value={replacement?.toString()} value={replacement?.toString()}
onChange={onChange} onChange={onChange}
@ -194,7 +188,7 @@ function ReplacementInput({ replacement, setReplacement, replacementError }) {
/> />
{!isFunc && ( {!isFunc && (
<div className="vc-text-selectable"> <div className="vc-text-selectable">
<Forms.FormTitle className={Margins.top8}>Cheat Sheet</Forms.FormTitle> <Forms.FormTitle>Cheat Sheet</Forms.FormTitle>
{Object.entries({ {Object.entries({
"\\i": "Special regex escape sequence that matches identifiers (varnames, classnames, etc.)", "\\i": "Special regex escape sequence that matches identifiers (varnames, classnames, etc.)",
"$$": "Insert a $", "$$": "Insert a $",
@ -224,66 +218,8 @@ function ReplacementInput({ replacement, setReplacement, replacementError }) {
); );
} }
interface FullPatchInputProps {
setFind(v: string): void;
setParsedFind(v: string | RegExp): void;
setMatch(v: string): void;
setReplacement(v: string | ReplaceFn): void;
}
function FullPatchInput({ setFind, setParsedFind, setMatch, setReplacement }: FullPatchInputProps) {
const [fullPatch, setFullPatch] = React.useState<string>("");
const [fullPatchError, setFullPatchError] = React.useState<string>("");
function update() {
if (fullPatch === "") {
setFullPatchError("");
setFind("");
setParsedFind("");
setMatch("");
setReplacement("");
return;
}
try {
const parsed = (0, eval)(`([${fullPatch}][0])`) as Patch;
if (!parsed.find) throw new Error("No 'find' field");
if (!parsed.replacement) throw new Error("No 'replacement' field");
if (parsed.replacement instanceof Array) {
if (parsed.replacement.length === 0) throw new Error("Invalid replacement");
parsed.replacement = {
match: parsed.replacement[0].match,
replace: parsed.replacement[0].replace
};
}
if (!parsed.replacement.match) throw new Error("No 'replacement.match' field");
if (!parsed.replacement.replace) throw new Error("No 'replacement.replace' field");
setFind(parsed.find instanceof RegExp ? parsed.find.toString() : parsed.find);
setParsedFind(parsed.find);
setMatch(parsed.replacement.match instanceof RegExp ? parsed.replacement.match.source : parsed.replacement.match);
setReplacement(parsed.replacement.replace);
setFullPatchError("");
} catch (e) {
setFullPatchError((e as Error).message);
}
}
return <>
<Forms.FormText className={Margins.bottom8}>Paste your full JSON patch here to fill out the fields</Forms.FormText>
<TextArea value={fullPatch} onChange={setFullPatch} onBlur={update} />
{fullPatchError !== "" && <Forms.FormText style={{ color: "var(--text-danger)" }}>{fullPatchError}</Forms.FormText>}
</>;
}
function PatchHelper() { function PatchHelper() {
const [find, setFind] = React.useState<string>(""); const [find, setFind] = React.useState<string>("");
const [parsedFind, setParsedFind] = React.useState<string | RegExp>("");
const [match, setMatch] = React.useState<string>(""); const [match, setMatch] = React.useState<string>("");
const [replacement, setReplacement] = React.useState<string | ReplaceFn>(""); const [replacement, setReplacement] = React.useState<string | ReplaceFn>("");
@ -291,60 +227,40 @@ function PatchHelper() {
const [module, setModule] = React.useState<[number, Function]>(); const [module, setModule] = React.useState<[number, Function]>();
const [findError, setFindError] = React.useState<string>(); const [findError, setFindError] = React.useState<string>();
const [matchError, setMatchError] = React.useState<string>();
const code = React.useMemo(() => { const code = React.useMemo(() => {
return ` return `
{ {
find: ${parsedFind instanceof RegExp ? parsedFind.toString() : JSON.stringify(parsedFind)}, find: ${JSON.stringify(find)},
replacement: { replacement: {
match: /${match.replace(/(?<!\\)\//g, "\\/")}/, match: /${match.replace(/(?<!\\)\//g, "\\/")}/,
replace: ${typeof replacement === "function" ? replacement.toString() : JSON.stringify(replacement)} replace: ${typeof replacement === "function" ? replacement.toString() : JSON.stringify(replacement)}
} }
} }
`.trim(); `.trim();
}, [parsedFind, match, replacement]); }, [find, match, replacement]);
function onFindChange(v: string) { function onFindChange(v: string) {
setFindError(void 0);
setFind(v); setFind(v);
if (v.length) {
findCandidates({ find: v, setModule, setError: setFindError });
}
}
function onMatchChange(v: string) {
try { try {
let parsedFind = v as string | RegExp; new RegExp(v);
if (/^\/.+?\/$/.test(v)) parsedFind = new RegExp(v.slice(1, -1));
setFindError(void 0); setFindError(void 0);
setParsedFind(parsedFind); setMatch(v);
if (v.length) {
findCandidates({ find: parsedFind, setModule, setError: setFindError });
}
} catch (e: any) { } catch (e: any) {
setFindError((e as Error).message); setFindError((e as Error).message);
} }
} }
function onMatchChange(v: string) {
setMatch(v);
try {
new RegExp(v);
setMatchError(void 0);
} catch (e: any) {
setMatchError((e as Error).message);
}
}
return ( return (
<SettingsTab title="Patch Helper"> <SettingsTab title="Patch Helper">
<Forms.FormTitle>full patch</Forms.FormTitle> <Forms.FormTitle>find</Forms.FormTitle>
<FullPatchInput
setFind={onFindChange}
setParsedFind={setParsedFind}
setMatch={onMatchChange}
setReplacement={setReplacement}
/>
<Forms.FormTitle className={Margins.top8}>find</Forms.FormTitle>
<TextInput <TextInput
type="text" type="text"
value={find} value={find}
@ -352,15 +268,19 @@ function PatchHelper() {
error={findError} error={findError}
/> />
<Forms.FormTitle className={Margins.top8}>match</Forms.FormTitle> <Forms.FormTitle>match</Forms.FormTitle>
<TextInput <CheckedTextInput
type="text"
value={match} value={match}
onChange={onMatchChange} onChange={onMatchChange}
error={matchError} validate={v => {
try {
return (new RegExp(v), true);
} catch (e) {
return (e as Error).message;
}
}}
/> />
<div className={Margins.top8} />
<ReplacementInput <ReplacementInput
replacement={replacement} replacement={replacement}
setReplacement={setReplacement} setReplacement={setReplacement}
@ -371,7 +291,7 @@ function PatchHelper() {
{module && ( {module && (
<ReplacementComponent <ReplacementComponent
module={module} module={module}
match={match} match={new RegExp(match)}
replacement={replacement} replacement={replacement}
setReplacementError={setReplacementError} setReplacementError={setReplacementError}
/> />
@ -382,7 +302,6 @@ function PatchHelper() {
<Forms.FormTitle className={Margins.top20}>Code</Forms.FormTitle> <Forms.FormTitle className={Margins.top20}>Code</Forms.FormTitle>
<CodeBlock lang="js" content={code} /> <CodeBlock lang="js" content={code} />
<Button onClick={() => Clipboard.copy(code)}>Copy to Clipboard</Button> <Button onClick={() => Clipboard.copy(code)}>Copy to Clipboard</Button>
<Button className={Margins.top8} onClick={() => Clipboard.copy("```ts\n" + code + "\n```")}>Copy as Codeblock</Button>
</> </>
)} )}
</SettingsTab> </SettingsTab>

View file

@ -1,356 +0,0 @@
/*
* 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/>.
*/
import { Settings, useSettings } from "@api/Settings";
import { classNameFactory } from "@api/Styles";
import { Flex } from "@components/Flex";
import { DeleteIcon, FolderIcon, PaintbrushIcon, PencilIcon, PlusIcon, RestartIcon } from "@components/Icons";
import { Link } from "@components/Link";
import { openPluginModal } from "@components/PluginSettings/PluginModal";
import type { UserThemeHeader } from "@main/themes";
import { openInviteModal } from "@utils/discord";
import { Margins } from "@utils/margins";
import { showItemInFolder } from "@utils/native";
import { useAwaiter } from "@utils/react";
import { findLazy } from "@webpack";
import { Card, Forms, React, showToast, TabBar, TextArea, useEffect, useRef, useState } from "@webpack/common";
import type { ComponentType, Ref, SyntheticEvent } from "react";
import Plugins from "~plugins";
import { AddonCard } from "./AddonCard";
import { QuickAction, QuickActionCard } from "./quickActions";
import { SettingsTab, wrapTab } from "./shared";
type FileInput = ComponentType<{
ref: Ref<HTMLInputElement>;
onChange: (e: SyntheticEvent<HTMLInputElement>) => void;
multiple?: boolean;
filters?: { name?: string; extensions: string[]; }[];
}>;
const FileInput: FileInput = findLazy(m => m.prototype?.activateUploadDialogue && m.prototype.setRef);
const cl = classNameFactory("vc-settings-theme-");
function Validator({ link }: { link: string; }) {
const [res, err, pending] = useAwaiter(() => fetch(link).then(res => {
if (res.status > 300) throw `${res.status} ${res.statusText}`;
const contentType = res.headers.get("Content-Type");
if (!contentType?.startsWith("text/css") && !contentType?.startsWith("text/plain"))
throw "Not a CSS file. Remember to use the raw link!";
return "Okay!";
}));
const text = pending
? "Checking..."
: err
? `Error: ${err instanceof Error ? err.message : String(err)}`
: "Valid!";
return <Forms.FormText style={{
color: pending ? "var(--text-muted)" : err ? "var(--text-danger)" : "var(--text-positive)"
}}>{text}</Forms.FormText>;
}
function Validators({ themeLinks }: { themeLinks: string[]; }) {
if (!themeLinks.length) return null;
return (
<>
<Forms.FormTitle className={Margins.top20} tag="h5">Validator</Forms.FormTitle>
<Forms.FormText>This section will tell you whether your themes can successfully be loaded</Forms.FormText>
<div>
{themeLinks.map(rawLink => {
const { label, link } = (() => {
const match = /^@(light|dark) (.*)/.exec(rawLink);
if (!match) return { label: rawLink, link: rawLink };
const [, mode, link] = match;
return { label: `[${mode} mode only] ${link}`, link };
})();
return <Card style={{
padding: ".5em",
marginBottom: ".5em",
marginTop: ".5em"
}} key={link}>
<Forms.FormTitle tag="h5" style={{
overflowWrap: "break-word"
}}>
{label}
</Forms.FormTitle>
<Validator link={link} />
</Card>;
})}
</div>
</>
);
}
interface ThemeCardProps {
theme: UserThemeHeader;
enabled: boolean;
onChange: (enabled: boolean) => void;
onDelete: () => void;
}
function ThemeCard({ theme, enabled, onChange, onDelete }: ThemeCardProps) {
return (
<AddonCard
name={theme.name}
description={theme.description}
author={theme.author}
enabled={enabled}
setEnabled={onChange}
infoButton={
IS_WEB && (
<div style={{ cursor: "pointer", color: "var(--status-danger" }} onClick={onDelete}>
<DeleteIcon />
</div>
)
}
footer={
<Flex flexDirection="row" style={{ gap: "0.2em" }}>
{!!theme.website && <Link href={theme.website}>Website</Link>}
{!!(theme.website && theme.invite) && " • "}
{!!theme.invite && (
<Link
href={`https://discord.gg/${theme.invite}`}
onClick={async e => {
e.preventDefault();
theme.invite != null && openInviteModal(theme.invite).catch(() => showToast("Invalid or expired invite"));
}}
>
Discord Server
</Link>
)}
</Flex>
}
/>
);
}
enum ThemeTab {
LOCAL,
ONLINE
}
function ThemesTab() {
const settings = useSettings(["themeLinks", "enabledThemes"]);
const fileInputRef = useRef<HTMLInputElement>(null);
const [currentTab, setCurrentTab] = useState(ThemeTab.LOCAL);
const [themeText, setThemeText] = useState(settings.themeLinks.join("\n"));
const [userThemes, setUserThemes] = useState<UserThemeHeader[] | null>(null);
const [themeDir, , themeDirPending] = useAwaiter(VencordNative.themes.getThemesDir);
useEffect(() => {
refreshLocalThemes();
}, []);
async function refreshLocalThemes() {
const themes = await VencordNative.themes.getThemesList();
setUserThemes(themes);
}
// When a local theme is enabled/disabled, update the settings
function onLocalThemeChange(fileName: string, value: boolean) {
if (value) {
if (settings.enabledThemes.includes(fileName)) return;
settings.enabledThemes = [...settings.enabledThemes, fileName];
} else {
settings.enabledThemes = settings.enabledThemes.filter(f => f !== fileName);
}
}
async function onFileUpload(e: SyntheticEvent<HTMLInputElement>) {
e.stopPropagation();
e.preventDefault();
if (!e.currentTarget?.files?.length) return;
const { files } = e.currentTarget;
const uploads = Array.from(files, file => {
const { name } = file;
if (!name.endsWith(".css")) return;
return new Promise<void>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
VencordNative.themes.uploadTheme(name, reader.result as string)
.then(resolve)
.catch(reject);
};
reader.readAsText(file);
});
});
await Promise.all(uploads);
refreshLocalThemes();
}
function renderLocalThemes() {
return (
<>
<Card className="vc-settings-card">
<Forms.FormTitle tag="h5">Find Themes:</Forms.FormTitle>
<div style={{ marginBottom: ".5em", display: "flex", flexDirection: "column" }}>
<Link style={{ marginRight: ".5em" }} href="https://betterdiscord.app/themes">
BetterDiscord Themes
</Link>
<Link href="https://github.com/search?q=discord+theme">GitHub</Link>
</div>
<Forms.FormText>If using the BD site, click on "Download" and place the downloaded .theme.css file into your themes folder.</Forms.FormText>
</Card>
<Forms.FormSection title="Local Themes">
<QuickActionCard>
<>
{IS_WEB ?
(
<QuickAction
text={
<span style={{ position: "relative" }}>
Upload Theme
<FileInput
ref={fileInputRef}
onChange={onFileUpload}
multiple={true}
filters={[{ extensions: ["css"] }]}
/>
</span>
}
Icon={PlusIcon}
/>
) : (
<QuickAction
text="Open Themes Folder"
action={() => showItemInFolder(themeDir!)}
disabled={themeDirPending}
Icon={FolderIcon}
/>
)}
<QuickAction
text="Load missing Themes"
action={refreshLocalThemes}
Icon={RestartIcon}
/>
<QuickAction
text="Edit QuickCSS"
action={() => VencordNative.quickCss.openEditor()}
Icon={PaintbrushIcon}
/>
{Settings.plugins.ClientTheme.enabled && (
<QuickAction
text="Edit ClientTheme"
action={() => openPluginModal(Plugins.ClientTheme)}
Icon={PencilIcon}
/>
)}
</>
</QuickActionCard>
<div className={cl("grid")}>
{userThemes?.map(theme => (
<ThemeCard
key={theme.fileName}
enabled={settings.enabledThemes.includes(theme.fileName)}
onChange={enabled => onLocalThemeChange(theme.fileName, enabled)}
onDelete={async () => {
onLocalThemeChange(theme.fileName, false);
await VencordNative.themes.deleteTheme(theme.fileName);
refreshLocalThemes();
}}
theme={theme}
/>
))}
</div>
</Forms.FormSection>
</>
);
}
// When the user leaves the online theme textbox, update the settings
function onBlur() {
settings.themeLinks = [...new Set(
themeText
.trim()
.split(/\n+/)
.map(s => s.trim())
.filter(Boolean)
)];
}
function renderOnlineThemes() {
return (
<>
<Card className="vc-settings-card vc-text-selectable">
<Forms.FormTitle tag="h5">Paste links to css files here</Forms.FormTitle>
<Forms.FormText>One link per line</Forms.FormText>
<Forms.FormText>You can prefix lines with @light or @dark to toggle them based on your Discord theme</Forms.FormText>
<Forms.FormText>Make sure to use direct links to files (raw or github.io)!</Forms.FormText>
</Card>
<Forms.FormSection title="Online Themes" tag="h5">
<TextArea
value={themeText}
onChange={setThemeText}
className={"vc-settings-theme-links"}
placeholder="Theme Links"
spellCheck={false}
onBlur={onBlur}
rows={10}
/>
<Validators themeLinks={settings.themeLinks} />
</Forms.FormSection>
</>
);
}
return (
<SettingsTab title="Themes">
<TabBar
type="top"
look="brand"
className="vc-settings-tab-bar"
selectedItem={currentTab}
onItemSelect={setCurrentTab}
>
<TabBar.Item
className="vc-settings-tab-bar-item"
id={ThemeTab.LOCAL}
>
Local Themes
</TabBar.Item>
<TabBar.Item
className="vc-settings-tab-bar-item"
id={ThemeTab.ONLINE}
>
Online Themes
</TabBar.Item>
</TabBar>
{currentTab === ThemeTab.LOCAL && renderLocalThemes()}
{currentTab === ThemeTab.ONLINE && renderOnlineThemes()}
</SettingsTab>
);
}
export default wrapTab(ThemesTab, "Themes");

View file

@ -22,7 +22,6 @@ import { Flex } from "@components/Flex";
import { Link } from "@components/Link"; import { Link } from "@components/Link";
import { Margins } from "@utils/margins"; import { Margins } from "@utils/margins";
import { classes } from "@utils/misc"; import { classes } from "@utils/misc";
import { ModalCloseButton, ModalContent, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { relaunch } from "@utils/native"; import { relaunch } from "@utils/native";
import { useAwaiter } from "@utils/react"; import { useAwaiter } from "@utils/react";
import { changes, checkForUpdates, getRepo, isNewer, update, updateError, UpdateLogger } from "@utils/updater"; import { changes, checkForUpdates, getRepo, isNewer, update, updateError, UpdateLogger } from "@utils/updater";
@ -30,7 +29,7 @@ import { Alerts, Button, Card, Forms, Parser, React, Switch, Toasts } from "@web
import gitHash from "~git-hash"; import gitHash from "~git-hash";
import { handleSettingsTabError, SettingsTab, wrapTab } from "./shared"; import { SettingsTab, wrapTab } from "./shared";
function withDispatcher(dispatcher: React.Dispatch<React.SetStateAction<boolean>>, action: () => any) { function withDispatcher(dispatcher: React.Dispatch<React.SetStateAction<boolean>>, action: () => any) {
return async () => { return async () => {
@ -39,24 +38,21 @@ function withDispatcher(dispatcher: React.Dispatch<React.SetStateAction<boolean>
await action(); await action();
} catch (e: any) { } catch (e: any) {
UpdateLogger.error("Failed to update", e); UpdateLogger.error("Failed to update", e);
let err: string;
if (!e) { if (!e) {
err = "An unknown error occurred (error is undefined).\nPlease try again."; var err = "An unknown error occurred (error is undefined).\nPlease try again.";
} else if (e.code && e.cmd) { } else if (e.code && e.cmd) {
const { code, path, cmd, stderr } = e; const { code, path, cmd, stderr } = e;
if (code === "ENOENT") if (code === "ENOENT")
err = `Command \`${path}\` not found.\nPlease install it and try again`; var err = `Command \`${path}\` not found.\nPlease install it and try again`;
else { else {
err = `An error occurred while running \`${cmd}\`:\n`; var err = `An error occurred while running \`${cmd}\`:\n`;
err += stderr || `Code \`${code}\`. See the console for more info`; err += stderr || `Code \`${code}\`. See the console for more info`;
} }
} else { } else {
err = "An unknown error occurred. See the console for more info."; var err = "An unknown error occurred. See the console for more info.";
} }
Alerts.show({ Alerts.show({
title: "Oops!", title: "Oops!",
body: ( body: (
@ -190,7 +186,7 @@ function Newer(props: CommonProps) {
} }
function Updater() { function Updater() {
const settings = useSettings(["autoUpdate", "autoUpdateNotification"]); const settings = useSettings(["notifyAboutUpdates", "autoUpdate", "autoUpdateNotification"]);
const [repo, err, repoPending] = useAwaiter(getRepo, { fallbackValue: "Loading..." }); const [repo, err, repoPending] = useAwaiter(getRepo, { fallbackValue: "Loading..." });
@ -207,6 +203,14 @@ function Updater() {
return ( return (
<SettingsTab title="Vencord Updater"> <SettingsTab title="Vencord Updater">
<Forms.FormTitle tag="h5">Updater Settings</Forms.FormTitle> <Forms.FormTitle tag="h5">Updater Settings</Forms.FormTitle>
<Switch
value={settings.notifyAboutUpdates}
onChange={(v: boolean) => settings.notifyAboutUpdates = v}
note="Shows a notification on startup"
disabled={settings.autoUpdate}
>
Get notified about new updates
</Switch>
<Switch <Switch
value={settings.autoUpdate} value={settings.autoUpdate}
onChange={(v: boolean) => settings.autoUpdate = v} onChange={(v: boolean) => settings.autoUpdate = v}
@ -249,20 +253,3 @@ function Updater() {
} }
export default IS_UPDATER_DISABLED ? null : wrapTab(Updater, "Updater"); export default IS_UPDATER_DISABLED ? null : wrapTab(Updater, "Updater");
export const openUpdaterModal = IS_UPDATER_DISABLED ? null : function () {
const UpdaterTab = wrapTab(Updater, "Updater");
try {
openModal(wrapTab((modalProps: ModalProps) => (
<ModalRoot {...modalProps} size={ModalSize.MEDIUM}>
<ModalContent className="vc-updater-modal">
<ModalCloseButton onClick={modalProps.onClose} className="vc-updater-modal-close-button" />
<UpdaterTab />
</ModalContent>
</ModalRoot>
), "UpdaterModal"));
} catch {
handleSettingsTabError();
}
};

View file

@ -17,20 +17,16 @@
*/ */
import { openNotificationLogModal } from "@api/Notifications/notificationLog"; import { openNotificationLogModal } from "@api/Notifications/notificationLog";
import { useSettings } from "@api/Settings"; import { Settings, useSettings } from "@api/Settings";
import { classNameFactory } from "@api/Styles"; import { classNameFactory } from "@api/Styles";
import DonateButton from "@components/DonateButton"; import DonateButton from "@components/DonateButton";
import { openPluginModal } from "@components/PluginSettings/PluginModal"; import { ErrorCard } from "@components/ErrorCard";
import { gitRemote } from "@shared/vencordUserAgent";
import { Margins } from "@utils/margins"; import { Margins } from "@utils/margins";
import { identity } from "@utils/misc"; import { identity } from "@utils/misc";
import { relaunch, showItemInFolder } from "@utils/native"; import { relaunch, showItemInFolder } from "@utils/native";
import { useAwaiter } from "@utils/react"; import { useAwaiter } from "@utils/react";
import { Button, Card, Forms, React, Select, Switch } from "@webpack/common"; import { Button, Card, Forms, React, Select, Slider, Switch } from "@webpack/common";
import { Flex, FolderIcon, GithubIcon, LogIcon, PaintbrushIcon, RestartIcon } from "..";
import { openNotificationSettingsModal } from "./NotificationSettings";
import { QuickAction, QuickActionCard } from "./quickActions";
import { SettingsTab, wrapTab } from "./shared"; import { SettingsTab, wrapTab } from "./shared";
const cl = classNameFactory("vc-settings-"); const cl = classNameFactory("vc-settings-");
@ -42,7 +38,6 @@ type KeysOfType<Object, Type> = {
[K in keyof Object]: Object[K] extends Type ? K : never; [K in keyof Object]: Object[K] extends Type ? K : never;
}[keyof Object]; }[keyof Object];
function VencordSettings() { function VencordSettings() {
const [settingsDir, , settingsDirPending] = useAwaiter(VencordNative.settings.getSettingsDir, { const [settingsDir, , settingsDirPending] = useAwaiter(VencordNative.settings.getSettingsDir, {
fallbackValue: "Loading..." fallbackValue: "Loading..."
@ -55,6 +50,14 @@ function VencordSettings() {
const isMac = navigator.platform.toLowerCase().startsWith("mac"); const isMac = navigator.platform.toLowerCase().startsWith("mac");
const needsVibrancySettings = IS_DISCORD_DESKTOP && isMac; const needsVibrancySettings = IS_DISCORD_DESKTOP && isMac;
// One-time migration of the old setting to the new one if necessary.
React.useEffect(() => {
if (settings.macosTranslucency === true && !settings.macosVibrancyStyle) {
settings.macosVibrancyStyle = "sidebar";
settings.macosTranslucency = undefined;
}
}, []);
const Switches: Array<false | { const Switches: Array<false | {
key: KeysOfType<typeof settings, boolean>; key: KeysOfType<typeof settings, boolean>;
title: string; title: string;
@ -83,7 +86,7 @@ function VencordSettings() {
!IS_WEB && { !IS_WEB && {
key: "transparent", key: "transparent",
title: "Enable window transparency.", title: "Enable window transparency.",
note: "You need a theme that supports transparency or this will do nothing. WILL STOP THE WINDOW FROM BEING RESIZABLE!! Requires a full restart" note: "You need a theme that supports transparency or this will do nothing. Will stop the window from being resizable. Requires a full restart"
}, },
!IS_WEB && isWindows && { !IS_WEB && isWindows && {
key: "winCtrlQ", key: "winCtrlQ",
@ -101,53 +104,45 @@ function VencordSettings() {
<SettingsTab title="Vencord Settings"> <SettingsTab title="Vencord Settings">
<DonateCard image={donateImage} /> <DonateCard image={donateImage} />
<Forms.FormSection title="Quick Actions"> <Forms.FormSection title="Quick Actions">
<QuickActionCard> <Card className={cl("quick-actions-card")}>
<QuickAction <React.Fragment>
Icon={LogIcon} {!IS_WEB && (
text="Notification Log" <Button
action={openNotificationLogModal} onClick={relaunch}
/> size={Button.Sizes.SMALL}>
<QuickAction Restart Client
Icon={PaintbrushIcon} </Button>
text="Edit QuickCSS" )}
action={() => VencordNative.quickCss.openEditor()} <Button
/> onClick={() => VencordNative.quickCss.openEditor()}
{!IS_WEB && ( size={Button.Sizes.SMALL}
<QuickAction disabled={settingsDir === "Loading..."}>
Icon={RestartIcon} Open QuickCSS File
text="Relaunch Discord" </Button>
action={relaunch} {!IS_WEB && (
/> <Button
)} onClick={() => showItemInFolder(settingsDir)}
{!IS_WEB && ( size={Button.Sizes.SMALL}
<QuickAction disabled={settingsDirPending}>
Icon={FolderIcon} Open Settings Folder
text="Open Settings Folder" </Button>
action={() => showItemInFolder(settingsDir)} )}
/> <Button
)} onClick={() => VencordNative.native.openExternal("https://github.com/Vendicated/Vencord")}
<QuickAction size={Button.Sizes.SMALL}
Icon={GithubIcon} disabled={settingsDirPending}>
text="View Source Code" Open in GitHub
action={() => VencordNative.native.openExternal("https://github.com/" + gitRemote)} </Button>
/> </React.Fragment>
</QuickActionCard> </Card>
</Forms.FormSection> </Forms.FormSection>
<Forms.FormDivider /> <Forms.FormDivider />
<Forms.FormSection className={Margins.top16} title="Settings" tag="h5"> <Forms.FormSection className={Margins.top16} title="Settings" tag="h5">
<Forms.FormText className={Margins.bottom20} style={{ color: "var(--text-muted)" }}> <Forms.FormText className={Margins.bottom20}>
Hint: You can change the position of this settings section in the Hint: You can change the position of this settings section in the settings of the "Settings" plugin!
{" "}<Button
look={Button.Looks.BLANK}
style={{ color: "var(--text-link)", display: "inline-block" }}
onClick={() => openPluginModal(Vencord.Plugins.plugins.Settings)}
>
settings of the Settings plugin
</Button>!
</Forms.FormText> </Forms.FormText>
{Switches.map(s => s && ( {Switches.map(s => s && (
<Switch <Switch
key={s.key} key={s.key}
@ -169,7 +164,7 @@ function VencordSettings() {
options={[ options={[
// Sorted from most opaque to most transparent // Sorted from most opaque to most transparent
{ {
label: "No vibrancy", value: undefined label: "No vibrancy", default: !settings.macosTranslucency, value: undefined
}, },
{ {
label: "Under Page (window tinting)", label: "Under Page (window tinting)",
@ -196,8 +191,9 @@ function VencordSettings() {
value: "header" value: "header"
}, },
{ {
label: "Sidebar", label: "Sidebar (old value for transparent windows)",
value: "sidebar" value: "sidebar",
default: settings.macosTranslucency
}, },
{ {
label: "Tooltip", label: "Tooltip",
@ -225,20 +221,94 @@ function VencordSettings() {
serialize={identity} /> serialize={identity} />
</>} </>}
<Forms.FormSection className={Margins.top16} title="Vencord Notifications" tag="h5"> {typeof Notification !== "undefined" && <NotificationSection settings={settings.notifications} />}
<Flex>
<Button onClick={openNotificationSettingsModal}>
Notification Settings
</Button>
<Button onClick={openNotificationLogModal}>
View Notification Log
</Button>
</Flex>
</Forms.FormSection>
</SettingsTab> </SettingsTab>
); );
} }
function NotificationSection({ settings }: { settings: typeof Settings["notifications"]; }) {
return (
<>
<Forms.FormTitle tag="h5">Notification Style</Forms.FormTitle>
{settings.useNative !== "never" && Notification?.permission === "denied" && (
<ErrorCard style={{ padding: "1em" }} className={Margins.bottom8}>
<Forms.FormTitle tag="h5">Desktop Notification Permission denied</Forms.FormTitle>
<Forms.FormText>You have denied Notification Permissions. Thus, Desktop notifications will not work!</Forms.FormText>
</ErrorCard>
)}
<Forms.FormText className={Margins.bottom8}>
Some plugins may show you notifications. These come in two styles:
<ul>
<li><strong>Vencord Notifications</strong>: These are in-app notifications</li>
<li><strong>Desktop Notifications</strong>: Native Desktop notifications (like when you get a ping)</li>
</ul>
</Forms.FormText>
<Select
placeholder="Notification Style"
options={[
{ label: "Only use Desktop notifications when Discord is not focused", value: "not-focused", default: true },
{ label: "Always use Desktop notifications", value: "always" },
{ label: "Always use Vencord notifications", value: "never" },
] satisfies Array<{ value: typeof settings["useNative"]; } & Record<string, any>>}
closeOnSelect={true}
select={v => settings.useNative = v}
isSelected={v => v === settings.useNative}
serialize={identity}
/>
<Forms.FormTitle tag="h5" className={Margins.top16 + " " + Margins.bottom8}>Notification Position</Forms.FormTitle>
<Select
isDisabled={settings.useNative === "always"}
placeholder="Notification Position"
options={[
{ label: "Bottom Right", value: "bottom-right", default: true },
{ label: "Top Right", value: "top-right" },
] satisfies Array<{ value: typeof settings["position"]; } & Record<string, any>>}
select={v => settings.position = v}
isSelected={v => v === settings.position}
serialize={identity}
/>
<Forms.FormTitle tag="h5" className={Margins.top16 + " " + Margins.bottom8}>Notification Timeout</Forms.FormTitle>
<Forms.FormText className={Margins.bottom16}>Set to 0s to never automatically time out</Forms.FormText>
<Slider
disabled={settings.useNative === "always"}
markers={[0, 1000, 2500, 5000, 10_000, 20_000]}
minValue={0}
maxValue={20_000}
initialValue={settings.timeout}
onValueChange={v => settings.timeout = v}
onValueRender={v => (v / 1000).toFixed(2) + "s"}
onMarkerRender={v => (v / 1000) + "s"}
stickToMarkers={false}
/>
<Forms.FormTitle tag="h5" className={Margins.top16 + " " + Margins.bottom8}>Notification Log Limit</Forms.FormTitle>
<Forms.FormText className={Margins.bottom16}>
The amount of notifications to save in the log until old ones are removed.
Set to <code>0</code> to disable Notification log and <code></code> to never automatically remove old Notifications
</Forms.FormText>
<Slider
markers={[0, 25, 50, 75, 100, 200]}
minValue={0}
maxValue={200}
stickToMarkers={true}
initialValue={settings.logLimit}
onValueChange={v => settings.logLimit = v}
onValueRender={v => v === 200 ? "∞" : v}
onMarkerRender={v => v === 200 ? "∞" : v}
/>
<Button
onClick={openNotificationLogModal}
disabled={settings.logLimit === 0}
>
Open Notification Log
</Button>
</>
);
}
interface DonateCardProps { interface DonateCardProps {
image: string; image: string;
} }

View file

@ -62,36 +62,3 @@
.vc-addon-author::before { .vc-addon-author::before {
content: "by "; content: "by ";
} }
.vc-addon-title-container {
width: 100%;
overflow: hidden;
height: 1.25em;
position: relative;
}
.vc-addon-title {
position: absolute;
inset: 0;
overflow: hidden;
text-overflow: ellipsis;
}
@keyframes vc-addon-title {
0% {
transform: translateX(0);
}
50% {
transform: translateX(var(--offset));
}
100% {
transform: translateX(0);
}
}
.vc-addon-title:hover {
overflow: visible;
animation: vc-addon-title var(--duration) linear infinite;
}

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