forked!!
This commit is contained in:
parent
538b87062a
commit
ea7451bcdc
326 changed files with 24876 additions and 2280 deletions
168
.eslintrc.json
168
.eslintrc.json
|
|
@ -1,23 +1,40 @@
|
|||
{
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"ignorePatterns": ["dist", "browser"],
|
||||
"ignorePatterns": [
|
||||
"dist",
|
||||
"browser"
|
||||
],
|
||||
"plugins": [
|
||||
"@typescript-eslint",
|
||||
"simple-header",
|
||||
"simple-import-sort",
|
||||
"unused-imports",
|
||||
"path-alias"
|
||||
"unused-imports"
|
||||
],
|
||||
"settings": {
|
||||
"import/resolver": {
|
||||
"alias": {
|
||||
"map": [
|
||||
["@webpack", "./src/webpack"],
|
||||
["@webpack/common", "./src/webpack/common"],
|
||||
["@utils", "./src/utils"],
|
||||
["@api", "./src/api"],
|
||||
["@components", "./src/components"]
|
||||
[
|
||||
"@webpack",
|
||||
"./src/webpack"
|
||||
],
|
||||
[
|
||||
"@webpack/common",
|
||||
"./src/webpack/common"
|
||||
],
|
||||
[
|
||||
"@utils",
|
||||
"./src/utils"
|
||||
],
|
||||
[
|
||||
"@api",
|
||||
"./src/api"
|
||||
],
|
||||
[
|
||||
"@components",
|
||||
"./src/components"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -29,37 +46,120 @@
|
|||
"simple-header/header": [
|
||||
"error",
|
||||
{
|
||||
"files": ["scripts/header-new.txt", "scripts/header-old.txt"],
|
||||
"templates": { "author": [".*", "Vendicated and contributors"] }
|
||||
"files": [
|
||||
"scripts/header-new.txt",
|
||||
"scripts/header-old.txt"
|
||||
],
|
||||
"templates": {
|
||||
"author": [
|
||||
".*",
|
||||
"Vendicated and contributors"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"quotes": ["error", "double", { "avoidEscape": true }],
|
||||
"jsx-quotes": ["error", "prefer-double"],
|
||||
"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"],
|
||||
"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": ["!"] }],
|
||||
"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"],
|
||||
"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-unneeded-ternary": [
|
||||
"error",
|
||||
{
|
||||
"defaultAssignment": false
|
||||
}
|
||||
],
|
||||
"no-invalid-regexp": "error",
|
||||
"no-constant-condition": ["error", { "checkLoops": false }],
|
||||
"no-constant-condition": [
|
||||
"error",
|
||||
{
|
||||
"checkLoops": false
|
||||
}
|
||||
],
|
||||
"no-duplicate-imports": "error",
|
||||
"no-extra-semi": "error",
|
||||
"dot-notation": "error",
|
||||
|
|
@ -87,12 +187,8 @@
|
|||
"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"
|
||||
"unused-imports/no-unused-imports": "error"
|
||||
}
|
||||
}
|
||||
}
|
||||
2
.github/ISSUE_TEMPLATE/blank.yml
vendored
2
.github/ISSUE_TEMPLATE/blank.yml
vendored
|
|
@ -10,7 +10,7 @@ body:
|
|||
This form is ONLY FOR DEVELOPERS. YOUR ISSUE WILL BE CLOSED AND YOU WILL POSSIBLY BE BLOCKED FROM THE REPOSITORY IF YOU IGNORE THIS.
|
||||
|
||||
DO NOT USE THIS FORM, unless
|
||||
- you are a vencord contributor
|
||||
- you are a equicord/vencord contributor
|
||||
- you were given explicit permission to use this form by a moderator in our support server
|
||||
- you are filing a security related report
|
||||
|
||||
|
|
|
|||
8
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
8
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
|
@ -1,5 +1,5 @@
|
|||
name: Bug/Crash Report
|
||||
description: Create a bug or crash report for Vencord. ALWAYS FIRST USE OUR SUPPORT CHANNEL! ONLY USE THIS FORM IF YOU ARE A CONTRIBUTOR OR WERE TOLD TO DO SO IN THE SUPPORT CHANNEL.
|
||||
description: Create a bug or crash report for Equicord. ALWAYS FIRST USE OUR SUPPORT CHANNEL! ONLY USE THIS FORM IF YOU ARE A CONTRIBUTOR OR WERE TOLD TO DO SO IN THE SUPPORT CHANNEL.
|
||||
labels: [bug]
|
||||
title: "[Bug] <title>"
|
||||
|
||||
|
|
@ -12,10 +12,10 @@ body:
|
|||
This form is ONLY FOR DEVELOPERS. YOUR ISSUE WILL BE CLOSED AND YOU WILL POSSIBLY BE BLOCKED FROM THE REPOSITORY IF YOU IGNORE THIS.
|
||||
|
||||
DO NOT USE THIS FORM, unless
|
||||
- you are a vencord contributor
|
||||
- you are a equicord/vencord contributor
|
||||
- you were given explicit permission to use this form by a moderator in our support server
|
||||
|
||||
DO NOT USE THIS FORM FOR SECURITY RELATED ISSUES. [CREATE A SECURITY ADVISORY INSTEAD.](https://github.com/Vendicated/Vencord/security/advisories/new)
|
||||
DO NOT USE THIS FORM FOR SECURITY RELATED ISSUES. [CREATE A SECURITY ADVISORY INSTEAD.](https://github.com/Equicord/Equicord/security/advisories/new)
|
||||
|
||||
- type: input
|
||||
id: discord
|
||||
|
|
@ -40,7 +40,7 @@ body:
|
|||
attributes:
|
||||
label: What is the expected behaviour?
|
||||
description: Simply detail what the expected behaviour is.
|
||||
placeholder: I expect Vencord/Discord to open the ... page instead of ..., it prevents me from doing ...
|
||||
placeholder: I expect Equicord/Discord to open the ... page instead of ..., it prevents me from doing ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
|
|
|||
12
.github/ISSUE_TEMPLATE/config.yml
vendored
12
.github/ISSUE_TEMPLATE/config.yml
vendored
|
|
@ -1,8 +1,8 @@
|
|||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Vencord Support Server
|
||||
url: https://discord.gg/D9uwnFnqmd
|
||||
about: If you need help regarding Vencord, please join our support server!
|
||||
- name: Vencord Installer
|
||||
url: https://github.com/Vencord/Installer
|
||||
about: You can find the Vencord Installer here
|
||||
- name: Equicord Support Server
|
||||
url: https://discord.gg/XVAwhj6Z6c
|
||||
about: If you need help regarding Equicord, please join our support server!
|
||||
- name: Equicord Installer
|
||||
url: https://github.com/Equicord/Installer
|
||||
about: You can find the Equicord Installer here
|
||||
|
|
|
|||
121
.github/workflows/build.yml
vendored
121
.github/workflows/build.yml
vendored
|
|
@ -1,80 +1,71 @@
|
|||
name: Build DevBuild
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- .github/workflows/build.yml
|
||||
- src/**
|
||||
- browser/**
|
||||
- scripts/build/**
|
||||
- package.json
|
||||
- pnpm-lock.yaml
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- .github/workflows/build.yml
|
||||
- src/**
|
||||
- browser/**
|
||||
- scripts/build/**
|
||||
- package.json
|
||||
- pnpm-lock.yaml
|
||||
env:
|
||||
FORCE_COLOR: true
|
||||
FORCE_COLOR: true
|
||||
|
||||
jobs:
|
||||
Build:
|
||||
runs-on: ubuntu-latest
|
||||
DetermineRunner:
|
||||
name: Determine Runner
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
runner: ${{ steps.set-runner.outputs.runner }}
|
||||
steps:
|
||||
- name: Determine which runner to use
|
||||
id: set-runner
|
||||
uses: benjaminmichaelis/get-soonest-available-runner@v1.1.0
|
||||
with:
|
||||
primary-runner: "self-hosted"
|
||||
fallback-runner: "ubuntu-latest"
|
||||
min-available-runners: 1
|
||||
github-token: ${{ env.GITHUB_TOKEN }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.ETOKEN }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
Build:
|
||||
name: Build Equicord
|
||||
needs: DetermineRunner
|
||||
runs-on: ${{ needs.DetermineRunner.outputs.runner}}
|
||||
|
||||
- uses: pnpm/action-setup@v2 # Install pnpm using packageManager key in package.json
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Use Node.js 19
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 19
|
||||
cache: "pnpm"
|
||||
- uses: pnpm/action-setup@v3
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Build web
|
||||
run: pnpm buildWeb --standalone
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: pnpm build --standalone
|
||||
- name: Build web
|
||||
run: pnpm buildWeb --standalone
|
||||
|
||||
- name: Generate plugin list
|
||||
run: pnpm generatePluginJson dist/plugins.json dist/plugin-readmes.json
|
||||
- name: Build
|
||||
run: pnpm build --standalone
|
||||
|
||||
- name: Clean up obsolete files
|
||||
run: |
|
||||
rm -rf dist/*-unpacked dist/monaco Vencord.user.css vencordDesktopRenderer.css vencordDesktopRenderer.css.map
|
||||
- name: Generate plugin list
|
||||
run: pnpm generatePluginJson dist/plugins.json dist/plugin-readmes.json
|
||||
|
||||
- name: Get some values needed for the release
|
||||
id: release_values
|
||||
run: |
|
||||
echo "release_tag=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
||||
- name: Clean up obsolete files
|
||||
run: |
|
||||
rm -rf dist/*-unpacked dist/monaco Vencord.user.css vencordDesktopRenderer.css vencordDesktopRenderer.css.map
|
||||
|
||||
- name: Upload DevBuild as release
|
||||
if: github.repository == 'Vendicated/Vencord'
|
||||
run: |
|
||||
gh release upload devbuild --clobber dist/*
|
||||
gh release edit devbuild --title "DevBuild $RELEASE_TAG"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_TAG: ${{ env.release_tag }}
|
||||
|
||||
- name: Upload DevBuild to builds repo
|
||||
if: github.repository == 'Vendicated/Vencord'
|
||||
run: |
|
||||
git config --global user.name "$USERNAME"
|
||||
git config --global user.email actions@github.com
|
||||
|
||||
git clone https://$USERNAME:$API_TOKEN@github.com/$GH_REPO.git upload
|
||||
cd upload
|
||||
|
||||
GLOBIGNORE=.git:.gitignore:README.md:LICENSE
|
||||
rm -rf *
|
||||
cp -r ../dist/* .
|
||||
|
||||
git add -A
|
||||
git commit -m "Builds for https://github.com/$GITHUB_REPOSITORY/commit/$GITHUB_SHA"
|
||||
git push --force https://$USERNAME:$API_TOKEN@github.com/$GH_REPO.git
|
||||
env:
|
||||
API_TOKEN: ${{ secrets.BUILDS_TOKEN }}
|
||||
GH_REPO: Vencord/builds
|
||||
USERNAME: GitHub-Actions
|
||||
- name: Upload DevBuild as release
|
||||
if: github.repository == 'Equicord/Equicord'
|
||||
run: |
|
||||
gh release upload latest --clobber dist/*
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.ETOKEN }}
|
||||
|
|
|
|||
52
.github/workflows/codeberg-mirror.yml
vendored
52
.github/workflows/codeberg-mirror.yml
vendored
|
|
@ -1,22 +1,40 @@
|
|||
name: Sync to Codeberg
|
||||
concurrency:
|
||||
group: ${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
group: ${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
on:
|
||||
push:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 */6 * * *"
|
||||
push:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 */6 * * *"
|
||||
|
||||
jobs:
|
||||
codeberg:
|
||||
if: github.repository == 'Vendicated/Vencord'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: pixta-dev/repository-mirroring-action@674e65a7d483ca28dafaacba0d07351bdcc8bd75 # v1.1.1
|
||||
with:
|
||||
target_repo_url: "git@codeberg.org:Vee/cord.git"
|
||||
ssh_private_key: ${{ secrets.CODEBERG_SSH_PRIVATE_KEY }}
|
||||
DetermineRunner:
|
||||
name: Determine Runner
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
runner: ${{ steps.set-runner.outputs.runner }}
|
||||
steps:
|
||||
- name: Determine which runner to use
|
||||
id: set-runner
|
||||
uses: benjaminmichaelis/get-soonest-available-runner@v1.1.0
|
||||
with:
|
||||
primary-runner: "self-hosted"
|
||||
fallback-runner: "ubuntu-latest"
|
||||
min-available-runners: 1
|
||||
github-token: ${{ env.GITHUB_TOKEN }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.ETOKEN }}
|
||||
|
||||
codeberg:
|
||||
if: github.repository == 'Equicord/Equicord'
|
||||
needs: DetermineRunner
|
||||
runs-on: ${{ needs.DetermineRunner.outputs.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: pixta-dev/repository-mirroring-action@674e65a7d483ca28dafaacba0d07351bdcc8bd75 # v1.1.1
|
||||
with:
|
||||
target_repo_url: "git@codeberg.org:thororen/Equicord.git"
|
||||
ssh_private_key: ${{ secrets.CODEBERG }}
|
||||
|
|
|
|||
45
.github/workflows/publish.yml
vendored
45
.github/workflows/publish.yml
vendored
|
|
@ -1,45 +0,0 @@
|
|||
name: Release Browser Extension
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
|
||||
jobs:
|
||||
Publish:
|
||||
if: github.repository == 'Vendicated/Vencord'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: check that tag matches package.json version
|
||||
run: |
|
||||
pkg_version="v$(jq -r .version < package.json)"
|
||||
if [[ "${{ github.ref_name }}" != "$pkg_version" ]]; then
|
||||
echo "Tag ${{ github.ref_name }} does not match package.json version $pkg_version" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: pnpm/action-setup@v2 # Install pnpm using packageManager key in package.json
|
||||
|
||||
- name: Use Node.js 19
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 19
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build web
|
||||
run: pnpm buildWeb --standalone
|
||||
|
||||
- name: Publish extension
|
||||
run: |
|
||||
cd dist/chromium-unpacked
|
||||
pnpx chrome-webstore-upload-cli@2.1.0 upload --auto-publish
|
||||
env:
|
||||
EXTENSION_ID: ${{ secrets.CHROME_EXTENSION_ID }}
|
||||
CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }}
|
||||
CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }}
|
||||
REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }}
|
||||
97
.github/workflows/reportBrokenPlugins.yml
vendored
97
.github/workflows/reportBrokenPlugins.yml
vendored
|
|
@ -1,64 +1,63 @@
|
|||
name: Test Patches
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# Every day at midnight
|
||||
- cron: 0 0 * * *
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# Every day at midnight
|
||||
- cron: 0 0 * * *
|
||||
|
||||
jobs:
|
||||
TestPlugins:
|
||||
if: github.repository == 'Vendicated/Vencord'
|
||||
runs-on: ubuntu-latest
|
||||
TestPlugins:
|
||||
name: Test Patches
|
||||
if: github.repository == 'Equicord/Equicord'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
if: ${{ github.event_name == 'schedule' }}
|
||||
with:
|
||||
ref: dev
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
if: ${{ github.event_name == 'schedule' }}
|
||||
|
||||
- uses: actions/checkout@v3
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
- uses: actions/checkout@v4
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
|
||||
- uses: pnpm/action-setup@v2 # Install pnpm using packageManager key in package.json
|
||||
- uses: pnpm/action-setup@v3 # Install pnpm using packageManager key in package.json
|
||||
|
||||
- name: Use Node.js 19
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 19
|
||||
cache: "pnpm"
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm add puppeteer
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm add puppeteer
|
||||
|
||||
sudo apt-get install -y chromium-browser
|
||||
sudo apt-get install -y chromium-browser
|
||||
|
||||
- name: Build web
|
||||
run: pnpm buildWeb --standalone --dev
|
||||
- name: Build web
|
||||
run: pnpm buildWeb --standalone --dev
|
||||
|
||||
- name: Create Report
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
export PATH="$PWD/node_modules/.bin:$PATH"
|
||||
export CHROMIUM_BIN=$(which chromium-browser)
|
||||
- name: Create Report
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
export PATH="$PWD/node_modules/.bin:$PATH"
|
||||
export CHROMIUM_BIN=$(which chromium-browser)
|
||||
|
||||
esbuild scripts/generateReport.ts > dist/report.mjs
|
||||
node dist/report.mjs >> $GITHUB_STEP_SUMMARY
|
||||
env:
|
||||
DISCORD_TOKEN: ${{ secrets.DISCORD_TOKEN }}
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
esbuild scripts/generateReport.ts > dist/report.mjs
|
||||
node dist/report.mjs >> $GITHUB_STEP_SUMMARY
|
||||
env:
|
||||
DISCORD_TOKEN: ${{ secrets.DTOKEN }}
|
||||
DISCORD_WEBHOOK: ${{ secrets.WEBHOOK }}
|
||||
|
||||
- name: Create Report (Canary)
|
||||
timeout-minutes: 10
|
||||
if: success() || failure() # even run if previous one failed
|
||||
run: |
|
||||
export PATH="$PWD/node_modules/.bin:$PATH"
|
||||
export CHROMIUM_BIN=$(which chromium-browser)
|
||||
export USE_CANARY=true
|
||||
- name: Create Report (Canary)
|
||||
timeout-minutes: 10
|
||||
if: success() || failure() # even run if previous one failed
|
||||
run: |
|
||||
export PATH="$PWD/node_modules/.bin:$PATH"
|
||||
export CHROMIUM_BIN=$(which chromium-browser)
|
||||
export USE_CANARY=true
|
||||
|
||||
esbuild scripts/generateReport.ts > dist/report.mjs
|
||||
node dist/report.mjs >> $GITHUB_STEP_SUMMARY
|
||||
env:
|
||||
DISCORD_TOKEN: ${{ secrets.DISCORD_TOKEN }}
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
esbuild scripts/generateReport.ts > dist/report.mjs
|
||||
node dist/report.mjs >> $GITHUB_STEP_SUMMARY
|
||||
env:
|
||||
DISCORD_TOKEN: ${{ secrets.DTOKEN }}
|
||||
DISCORD_WEBHOOK: ${{ secrets.WEBHOOK }}
|
||||
|
|
|
|||
96
.github/workflows/test.yml
vendored
96
.github/workflows/test.yml
vendored
|
|
@ -1,32 +1,82 @@
|
|||
name: test
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
DetermineRunner:
|
||||
name: Determine Runner
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
runner: ${{ steps.set-runner.outputs.runner }}
|
||||
steps:
|
||||
- name: Determine which runner to use
|
||||
id: set-runner
|
||||
uses: benjaminmichaelis/get-soonest-available-runner@v1.1.0
|
||||
with:
|
||||
primary-runner: "self-hosted"
|
||||
fallback-runner: "ubuntu-latest"
|
||||
min-available-runners: 1
|
||||
github-token: ${{ env.GITHUB_TOKEN }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.ETOKEN }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: pnpm/action-setup@v2 # Install pnpm using packageManager key in package.json
|
||||
Test:
|
||||
name: Test For Pushes
|
||||
needs: DetermineRunner
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
runs-on: ${{ needs.DetermineRunner.outputs.runner}}
|
||||
|
||||
- name: Use Node.js 18
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: "pnpm"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3 # Install pnpm using packageManager key in package.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Lint & Test if desktop version compiles
|
||||
run: pnpm test
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Test if web version compiles
|
||||
run: pnpm buildWeb
|
||||
- name: Lint & Test if desktop version compiles
|
||||
run: pnpm test
|
||||
|
||||
- name: Test if plugin structure is valid
|
||||
run: pnpm generatePluginJson
|
||||
- name: Test if web version compiles
|
||||
run: pnpm buildWeb
|
||||
|
||||
- name: Test if plugin structure is valid
|
||||
run: pnpm generatePluginJson
|
||||
|
||||
TestForPRs:
|
||||
name: Test For Pull Requests
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3 # Install pnpm using packageManager key in package.json
|
||||
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Lint & Test if desktop version compiles
|
||||
run: pnpm test
|
||||
|
||||
- name: Test if web version compiles
|
||||
run: pnpm buildWeb
|
||||
|
||||
- name: Test if plugin structure is valid
|
||||
run: pnpm generatePluginJson
|
||||
|
|
|
|||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
|
@ -13,11 +13,10 @@
|
|||
"typescript.format.semicolons": "insert",
|
||||
"typescript.preferences.quoteStyle": "double",
|
||||
"javascript.preferences.quoteStyle": "double",
|
||||
|
||||
"gitlens.remotes": [
|
||||
{
|
||||
"domain": "codeberg.org",
|
||||
"type": "Gitea"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -19,7 +19,7 @@ For a friendly introduction to plugins, see [Megu's Plugin Guide!](docs/2_PLUGIN
|
|||
|
||||
Because plugins modify code directly, incompatibilities are a problem.
|
||||
|
||||
Thus, 3rd party plugins are not supported, instead all plugins are part of Vencord itself.
|
||||
Thus, 3rd party plugins are not supported, instead all plugins are part of Equicord itself.
|
||||
This way we can ensure compatibility and high quality patches.
|
||||
|
||||
Follow the below guide to make your first plugin!
|
||||
|
|
|
|||
98
README.md
98
README.md
|
|
@ -1,67 +1,99 @@
|
|||
# Vencord
|
||||
# Equicord (Vencord+)
|
||||
|
||||
[](https://codeberg.org/Vee/cord)
|
||||
An enhanced version of [Vencord](https://github.com/Vendicated/Vencord) by [Vendicated](https://github.com/Vendicated) called Equicord
|
||||
|
||||
The cutest Discord client mod
|
||||
|
||||
|  |
|
||||
|:--:|
|
||||
| A screenshot of vencord showcasing the [vencord-theme](https://github.com/synqat/vencord-theme) |
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- Super easy to install (Download Installer, open, click install button, done)
|
||||
- 100+ plugins built in: [See a list](https://vencord.dev/plugins)
|
||||
- Some highlights: SpotifyControls, MessageLogger, Experiments, GameActivityToggle, Translate, NoTrack, QuickReply, Free Emotes/Stickers, PermissionsViewer, CustomCommands, ShowHiddenChannels, PronounDB
|
||||
- Third-party plugins implemented into the main build.
|
||||
- 100+ plugins built-in.
|
||||
- Some highlights: SpotifyControls, MessageLogger, Experiments, GameActivityToggle, Translate, NoTrack, QuickReply, Free Emotes/Stickers, PermissionsViewer,
|
||||
CustomCommands, ShowHiddenChannels, PronounDB
|
||||
- Fairly lightweight despite the many inbuilt plugins
|
||||
- Excellent Browser Support: Run Vencord in your Browser via extension or UserScript
|
||||
- Works on any Discord branch: Stable, Canary or PTB all work (though for the best experience I recommend stable!)
|
||||
- Custom CSS and Themes: Inbuilt css editor with support to import any css files (including BetterDiscord themes)
|
||||
- Works on any Discord branch: Stable, Canary or PTB all work (though for the best experience, I recommend stable!)
|
||||
- Custom CSS and Themes: Inbuilt CSS editor with support to import any CSS files (including BetterDiscord themes)
|
||||
- Privacy friendly, blocks Discord analytics & crash reporting out of the box and has no telemetry
|
||||
- Maintained very actively, broken plugins are usually fixed within 12 hours
|
||||
- Settings sync: Keep your plugins and their settings synchronised between devices / apps (optional)
|
||||
- Able to update inside of Equicord through the update tab.
|
||||
- Same supporter badges as on Vencord (Don't lose your benefits)
|
||||
- Easy to install third-party plugins through the plugin page in Discord.
|
||||
- Request for plugins from Discord.
|
||||
|
||||
|
||||
## Installing / Uninstalling
|
||||
|
||||
Visit https://vencord.dev/download
|
||||
### Dependencies
|
||||
[Git](https://git-scm.com/download) and [Node.JS LTS](https://nodejs.dev/en/) are required.
|
||||
|
||||
### Installing Equicord
|
||||
|
||||
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/Equicord instance and you **will** most likely have to reinstall.
|
||||
|
||||
Clone Equicord:
|
||||
|
||||
```shell
|
||||
git clone https://github.com/Equicord/Equicord
|
||||
cd Equicord
|
||||
```
|
||||
|
||||
Install dependencies:
|
||||
|
||||
```shell
|
||||
pnpm install --frozen-lockfile
|
||||
```
|
||||
|
||||
Build Equicord:
|
||||
|
||||
```shell
|
||||
pnpm build
|
||||
```
|
||||
Inject Equicord into your client:
|
||||
|
||||
```shell
|
||||
pnpm inject
|
||||
```
|
||||
After you have done this command, it will look like you are just installing Equicord but it will say it is a development build. If it doesn't say it is a development build, please reach out for support in the [Discord Server](https://discord.gg/5Xh2W87egW)
|
||||
|
||||
## Join our Support/Community Server
|
||||
|
||||
https://discord.gg/D9uwnFnqmd
|
||||
|
||||
## Sponsors
|
||||
|
||||
| **Thanks a lot to all Vencord [sponsors](https://github.com/sponsors/Vendicated)!!** |
|
||||
|:--:|
|
||||
| [](https://github.com/sponsors/Vendicated) |
|
||||
| *generated using [github-sponsor-graph](https://github.com/Vendicated/github-sponsor-graph)* |
|
||||
|
||||
https://discord.gg/5Xh2W87egW
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://star-history.com/#Vendicated/Vencord&Timeline">
|
||||
<a href="https://star-history.com/#Equicord/Equicord&Timeline">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=Vendicated/Vencord&type=Timeline&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=Vendicated/Vencord&type=Timeline" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=Vendicated/Vencord&type=Timeline" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=Equicord/Equicord&type=Timeline&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=Equicord/Equicord&type=Timeline" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=Equicord/Equicord&type=Timeline" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## Disclaimer
|
||||
|
||||
Discord is trademark of Discord Inc. and solely mentioned for the sake of descriptivity.
|
||||
Mention of it does not imply any affiliation with or endorsement by Discord Inc.
|
||||
Mentioning it does not imply any affiliation with or endorsement by Discord Inc.
|
||||
Vencord is not connected to Equicord and as such, all donation links go to Vendicated's donation link.
|
||||
|
||||
<details>
|
||||
<summary>Using Vencord violates Discord's terms of service</summary>
|
||||
<summary>Using Equicord (Vencord+) violates Discord's terms of service</summary>
|
||||
|
||||
Client modifications are against Discord’s Terms of Service.
|
||||
|
||||
However, Discord is pretty indifferent about them and there are no known cases of users getting banned for using client mods! So you should generally be fine as long as you don’t use any plugins that implement abusive behaviour. But no worries, all inbuilt plugins are safe to use!
|
||||
However, Discord is pretty indifferent about them and there are no known cases of users getting banned for using client mods! So you should generally be fine if you don’t use plugins that implement abusive behaviour. But no worries, all inbuilt plugins are safe to use!
|
||||
|
||||
Regardless, if your account is very important to you and it getting disabled would be a disaster for you, you should probably not use any client mods (not exclusive to Vencord), just to be safe
|
||||
Regardless, if your account is essential to you and getting disabled would be a disaster for you, you should probably not use any client mods (not exclusive to Equicord), just to be safe
|
||||
|
||||
Additionally, make sure not to post screenshots with Vencord in a server where you might get banned for it
|
||||
Additionally, make sure not to post screenshots with Equicord in a server where you might get banned for it
|
||||
|
||||
</details>
|
||||
|
||||
Plugins may take time to be added as I am not on all of the time because of school and stuff.
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import * as DataStore from "../src/api/DataStore";
|
|||
import { debounce } from "../src/utils";
|
||||
import { EXTENSION_BASE_URL } from "../src/utils/web-metadata";
|
||||
import { getTheme, Theme } from "../src/utils/discord";
|
||||
import { getThemeInfo } from "../src/main/themes";
|
||||
import { getThemeInfo } from "../src/utils/themes/bd";
|
||||
import { Settings } from "../src/Vencord";
|
||||
|
||||
// Discord deletes this so need to store in variable
|
||||
|
|
@ -47,7 +47,7 @@ window.VencordNative = {
|
|||
deleteTheme: (fileName: string) => DataStore.del(fileName, themeStore),
|
||||
getThemesDir: async () => "",
|
||||
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),
|
||||
getSystemValues: async () => ({}),
|
||||
|
|
@ -59,7 +59,7 @@ window.VencordNative = {
|
|||
},
|
||||
|
||||
updater: {
|
||||
getRepo: async () => ({ ok: true, value: "https://github.com/Vendicated/Vencord" }),
|
||||
getRepo: async () => ({ ok: true, value: "https://github.com/Equicord/Equicord" }),
|
||||
getUpdates: async () => ({ ok: true, value: [] }),
|
||||
update: async () => ({ ok: true, value: false }),
|
||||
rebuild: async () => ({ ok: true, value: true }),
|
||||
|
|
|
|||
BIN
browser/icon.png
BIN
browser/icon.png
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 7.5 KiB |
|
|
@ -1,38 +1,43 @@
|
|||
{
|
||||
"manifest_version": 3,
|
||||
"minimum_chrome_version": "91",
|
||||
|
||||
"name": "Vencord Web",
|
||||
"description": "The cutest Discord mod now in your browser",
|
||||
"author": "Vendicated",
|
||||
"homepage_url": "https://github.com/Vendicated/Vencord",
|
||||
"name": "Equicord Web",
|
||||
"description": "The other cutest Discord mod now in your browser",
|
||||
"author": "Thororen",
|
||||
"homepage_url": "https://github.com/Equicord/Equicord",
|
||||
"icons": {
|
||||
"128": "icon.png"
|
||||
},
|
||||
|
||||
"host_permissions": [
|
||||
"*://*.discord.com/*",
|
||||
"https://raw.githubusercontent.com/*"
|
||||
],
|
||||
|
||||
"permissions": ["declarativeNetRequest"],
|
||||
|
||||
"permissions": [
|
||||
"declarativeNetRequest"
|
||||
],
|
||||
"content_scripts": [
|
||||
{
|
||||
"run_at": "document_start",
|
||||
"matches": ["*://*.discord.com/*"],
|
||||
"js": ["content.js"],
|
||||
"matches": [
|
||||
"*://*.discord.com/*"
|
||||
],
|
||||
"js": [
|
||||
"content.js"
|
||||
],
|
||||
"all_frames": true
|
||||
}
|
||||
],
|
||||
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": ["dist/*", "third-party/*"],
|
||||
"matches": ["*://*.discord.com/*"]
|
||||
"resources": [
|
||||
"dist/*",
|
||||
"third-party/*"
|
||||
],
|
||||
"matches": [
|
||||
"*://*.discord.com/*"
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
"declarative_net_request": {
|
||||
"rule_resources": [
|
||||
{
|
||||
|
|
@ -42,4 +47,4 @@
|
|||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,41 +1,44 @@
|
|||
{
|
||||
"manifest_version": 2,
|
||||
"minimum_chrome_version": "91",
|
||||
|
||||
"name": "Vencord Web",
|
||||
"description": "The cutest Discord mod now in your browser",
|
||||
"author": "Vendicated",
|
||||
"homepage_url": "https://github.com/Vendicated/Vencord",
|
||||
"name": "Equicord Web",
|
||||
"description": "The other cutest Discord mod now in your browser",
|
||||
"author": "Thororen",
|
||||
"homepage_url": "https://github.com/Equicord/Equicord",
|
||||
"icons": {
|
||||
"128": "icon.png"
|
||||
},
|
||||
|
||||
"permissions": [
|
||||
"webRequest",
|
||||
"webRequestBlocking",
|
||||
"*://*.discord.com/*",
|
||||
"https://raw.githubusercontent.com/*"
|
||||
],
|
||||
|
||||
"content_scripts": [
|
||||
{
|
||||
"run_at": "document_start",
|
||||
"matches": ["*://*.discord.com/*"],
|
||||
"js": ["content.js"],
|
||||
"matches": [
|
||||
"*://*.discord.com/*"
|
||||
],
|
||||
"js": [
|
||||
"content.js"
|
||||
],
|
||||
"all_frames": true
|
||||
}
|
||||
],
|
||||
|
||||
"background": {
|
||||
"scripts": ["background.js"]
|
||||
"scripts": [
|
||||
"background.js"
|
||||
]
|
||||
},
|
||||
|
||||
"web_accessible_resources": ["dist/Vencord.js", "dist/Vencord.css"],
|
||||
|
||||
"web_accessible_resources": [
|
||||
"dist/Vencord.js",
|
||||
"dist/Vencord.css"
|
||||
],
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "vencord-firefox@vendicated.dev",
|
||||
"id": "Equicord@equicord.patrickdk.com",
|
||||
"strict_min_version": "91.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,37 +1,39 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Vencord QuickCSS Editor</title>
|
||||
<style>
|
||||
html,
|
||||
body,
|
||||
#container {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="container"></div>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Equicord QuickCSS Editor</title>
|
||||
<style>
|
||||
html,
|
||||
body,
|
||||
#container {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<script>
|
||||
const script = document.createElement("script");
|
||||
script.src = new URL("/dist/monaco/index.js", baseUrl);
|
||||
<body>
|
||||
<div id="container"></div>
|
||||
|
||||
const style = document.createElement("link");
|
||||
style.type = "text/css";
|
||||
style.rel = "stylesheet";
|
||||
style.href = new URL("/dist/monaco/index.css", baseUrl);
|
||||
<script>
|
||||
const script = document.createElement("script");
|
||||
script.src = new URL("/dist/monaco/index.js", baseUrl);
|
||||
|
||||
document.body.append(style, script);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
const style = document.createElement("link");
|
||||
style.type = "text/css";
|
||||
style.rel = "stylesheet";
|
||||
style.href = new URL("/dist/monaco/index.css", baseUrl);
|
||||
|
||||
document.body.append(style, script);
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -1,26 +1,26 @@
|
|||
> [!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.
|
||||
> [!WARNING]
|
||||
> These instructions are only for advanced users. If you're not a Developer, you should use our [graphical installer](https://github.com/Equicord/Installer#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!
|
||||
Welcome to Megu's Installation Guide! In this file, you will learn about how to download, install, and uninstall Equicord!
|
||||
|
||||
## Sections
|
||||
|
||||
- [Installation Guide](#installation-guide)
|
||||
- [Sections](#sections)
|
||||
- [Dependencies](#dependencies)
|
||||
- [Installing Vencord](#installing-vencord)
|
||||
- [Updating Vencord](#updating-vencord)
|
||||
- [Uninstalling Vencord](#uninstalling-vencord)
|
||||
- [Installing Equicord](#installing-equicord)
|
||||
- [Updating Equicord](#updating-equicord)
|
||||
- [Uninstalling Equicord](#uninstalling-equicord)
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Install Git from https://git-scm.com/download
|
||||
- Install Node.JS LTS from here: https://nodejs.dev/en/
|
||||
|
||||
## Installing Vencord
|
||||
## Installing Equicord
|
||||
|
||||
Install `pnpm`:
|
||||
|
||||
|
|
@ -30,13 +30,13 @@ Install `pnpm`:
|
|||
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.
|
||||
> :exclamation: **IMPORTANT** Make sure you aren't using an admin/root terminal from here onwards. It **will** mess up your Discord/Equicord instance and you **will** most likely have to reinstall.
|
||||
|
||||
Clone Vencord:
|
||||
Clone Equicord:
|
||||
|
||||
```shell
|
||||
git clone https://github.com/Vendicated/Vencord
|
||||
cd Vencord
|
||||
git clone https://github.com/Equicord/Equicord
|
||||
cd Equicord
|
||||
```
|
||||
|
||||
Install dependencies:
|
||||
|
|
@ -45,21 +45,19 @@ Install dependencies:
|
|||
pnpm install --frozen-lockfile
|
||||
```
|
||||
|
||||
Build Vencord:
|
||||
Build Equicord:
|
||||
|
||||
```shell
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Inject vencord into your client:
|
||||
Inject Equicord 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
|
||||
Then fully close Discord from your taskbar or task manager, and restart it. Equicord should be injected - you can check this by looking for the Equicord section in Discord settings.
|
||||
|
||||
If you're using Discord already, go into the `Updater` tab in settings.
|
||||
|
||||
|
|
@ -71,9 +69,9 @@ To pull latest changes:
|
|||
git pull
|
||||
```
|
||||
|
||||
If this fails, you likely need to reset your local changes to vencord to resolve merge errors:
|
||||
If this fails, you likely need to reset your local changes to equicord 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!
|
||||
> :exclamation: This command will remove any local changes you've made to equicord. Make sure you back up if you made any code changes you don't want to lose!
|
||||
|
||||
```shell
|
||||
git reset --hard
|
||||
|
|
@ -88,10 +86,12 @@ pnpm build
|
|||
|
||||
Then just refresh your client
|
||||
|
||||
## Uninstalling Vencord
|
||||
## Uninstalling Equicord
|
||||
|
||||
Simply run:
|
||||
|
||||
```shell
|
||||
pnpm uninject
|
||||
```
|
||||
|
||||
If you need more help, ask in the support channel in our [Discord Server](https://discord.gg/D9uwnFnqmd).
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
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.
|
||||
You don't need to run `pnpm build` every time you make a change. Instead, use `pnpm watch` - this will auto-compile Equicord whenever you make a change. If using code patches (recommended), you will need to CTRL+R to load the changes.
|
||||
|
||||
## Plugin Entrypoint
|
||||
|
||||
|
|
@ -37,9 +37,9 @@ 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
|
||||
## How Plugins Work In Equicord
|
||||
|
||||
Vencord uses a different way of making mods than you're used to.
|
||||
Equicord 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.
|
||||
|
|
@ -103,7 +103,7 @@ The match value _can_ be a string, rather than regex, however usually regex will
|
|||
|
||||
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!
|
||||
If you want to publish your plugin into the Equicord 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
|
||||
|
|
|
|||
30
package.json
30
package.json
|
|
@ -2,17 +2,22 @@
|
|||
"name": "vencord",
|
||||
"private": "true",
|
||||
"version": "1.7.7",
|
||||
"description": "The cutest Discord client mod",
|
||||
"homepage": "https://github.com/Vendicated/Vencord#readme",
|
||||
"description": "The cutest Discord client mod with extras",
|
||||
"homepage": "https://github.com/Equicord/Equicord#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/Vendicated/Vencord/issues"
|
||||
"url": "https://github.com/Equicord/Equicord/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/Vendicated/Vencord.git"
|
||||
"url": "git+https://github.com/Equicord/Equicord.git"
|
||||
},
|
||||
"license": "GPL-3.0-or-later",
|
||||
"author": "Vendicated",
|
||||
"contributors": [
|
||||
"Equicord",
|
||||
"Thororen1234",
|
||||
"Yeetov"
|
||||
],
|
||||
"directories": {
|
||||
"doc": "docs"
|
||||
},
|
||||
|
|
@ -24,7 +29,7 @@
|
|||
"lint": "eslint . --ext .js,.jsx,.ts,.tsx --ignore-pattern src/userplugins",
|
||||
"lint-styles": "stylelint \"src/**/*.css\" --ignore-pattern src/userplugins",
|
||||
"lint:fix": "pnpm lint --fix",
|
||||
"test": "pnpm build && pnpm lint && pnpm lint-styles && pnpm testTsc && pnpm generatePluginJson",
|
||||
"test": "pnpm build && pnpm lint:fix && pnpm lint-styles && pnpm testTsc && pnpm generatePluginJson",
|
||||
"testWeb": "pnpm lint && pnpm buildWeb && pnpm testTsc",
|
||||
"testTsc": "tsc --noEmit",
|
||||
"uninject": "node scripts/runInstaller.mjs",
|
||||
|
|
@ -32,16 +37,21 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@sapphi-red/web-noise-suppressor": "0.3.3",
|
||||
"@types/less": "^3.0.6",
|
||||
"@types/stylus": "^0.48.42",
|
||||
"@types/tinycolor2": "^1.4.6",
|
||||
"@vap/core": "0.0.12",
|
||||
"@vap/shiki": "0.10.5",
|
||||
"eslint-plugin-simple-header": "^1.0.2",
|
||||
"axios": "^1.6.8",
|
||||
"fflate": "^0.7.4",
|
||||
"gifenc": "github:mattdesl/gifenc#64842fca317b112a8590f8fef2bf3825da8f6fe3",
|
||||
"monaco-editor": "^0.43.0",
|
||||
"nanoid": "^4.0.2",
|
||||
"usercss-meta": "^0.12.0",
|
||||
"virtual-merge": "^1.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-spring/web": "^9.7.3",
|
||||
"@types/chrome": "^0.0.246",
|
||||
"@types/diff": "^5.0.3",
|
||||
"@types/lodash": "^4.14.194",
|
||||
|
|
@ -56,7 +66,7 @@
|
|||
"esbuild": "^0.15.18",
|
||||
"eslint": "^8.46.0",
|
||||
"eslint-import-resolver-alias": "^1.1.2",
|
||||
"eslint-plugin-path-alias": "^1.0.0",
|
||||
"eslint-plugin-simple-header": "^1.0.2",
|
||||
"eslint-plugin-simple-import-sort": "^10.0.0",
|
||||
"eslint-plugin-unused-imports": "^2.0.0",
|
||||
"highlight.js": "10.6.0",
|
||||
|
|
@ -74,8 +84,8 @@
|
|||
"packageManager": "pnpm@8.10.2",
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"eslint-plugin-path-alias@1.0.0": "patches/eslint-plugin-path-alias@1.0.0.patch",
|
||||
"eslint@8.46.0": "patches/eslint@8.46.0.patch"
|
||||
"eslint@8.46.0": "patches/eslint@8.46.0.patch",
|
||||
"@types/less@3.0.6": "patches/@types__less@3.0.6.patch"
|
||||
},
|
||||
"peerDependencyRules": {
|
||||
"ignoreMissing": [
|
||||
|
|
@ -101,4 +111,4 @@
|
|||
"node": ">=18",
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
}
|
||||
}
|
||||
13
patches/@types__less@3.0.6.patch
Normal file
13
patches/@types__less@3.0.6.patch
Normal 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;
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
||||
701
pnpm-lock.yaml
generated
701
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -67,7 +67,7 @@ const globNativesPlugin = {
|
|||
});
|
||||
|
||||
build.onLoad({ filter, namespace: "import-natives" }, async () => {
|
||||
const pluginDirs = ["plugins", "userplugins"];
|
||||
const pluginDirs = ["plugins", "userplugins", "equicordplugins"];
|
||||
let code = "";
|
||||
let natives = "\n";
|
||||
let i = 0;
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ export const globPlugins = kind => ({
|
|||
});
|
||||
|
||||
build.onLoad({ filter, namespace: "import-plugins" }, async () => {
|
||||
const pluginDirs = ["plugins/_api", "plugins/_core", "plugins", "userplugins"];
|
||||
const pluginDirs = ["plugins/_api", "plugins/_core", "plugins", "userplugins", "equicordplugins"];
|
||||
let code = "";
|
||||
let plugins = "\n";
|
||||
let i = 0;
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ function toCodeBlock(s: string) {
|
|||
async function printReport() {
|
||||
console.log();
|
||||
|
||||
console.log("# Vencord Report" + (CANARY ? " (Canary)" : ""));
|
||||
console.log("# Equicord Report" + (CANARY ? " (Canary)" : ""));
|
||||
|
||||
console.log();
|
||||
|
||||
|
|
@ -136,9 +136,9 @@ async function printReport() {
|
|||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
description: "Here's the latest Vencord Report!",
|
||||
username: "Vencord Reporter" + (CANARY ? " (Canary)" : ""),
|
||||
avatar_url: "https://cdn.discordapp.com/avatars/1017176847865352332/c312b6b44179ae6817de7e4b09e9c6af.webp?size=512",
|
||||
description: "Here's the latest Equicord Report!",
|
||||
username: "Equicord Reporter" + (CANARY ? " (Canary)" : ""),
|
||||
avatar_url: "https://avatars.githubusercontent.com/u/150590884?s=48&v=4",
|
||||
embeds: [
|
||||
{
|
||||
title: "Bad Patches",
|
||||
|
|
@ -269,7 +269,7 @@ page.on("pageerror", e => console.error("[Page Error]", e));
|
|||
|
||||
await page.setBypassCSP(true);
|
||||
|
||||
function runTime(token: string) {
|
||||
async function runtime(token: string) {
|
||||
console.log("[PUP_DEBUG]", "Starting test...");
|
||||
|
||||
try {
|
||||
|
|
@ -282,9 +282,13 @@ function runTime(token: string) {
|
|||
|
||||
// Monkey patch Logger to not log with custom css
|
||||
// @ts-ignore
|
||||
const originalLog = Vencord.Util.Logger.prototype._log;
|
||||
// @ts-ignore
|
||||
Vencord.Util.Logger.prototype._log = function (level, levelColor, args) {
|
||||
if (level === "warn" || level === "error")
|
||||
console[level]("[Vencord]", this.name + ":", ...args);
|
||||
return console[level]("[Vencord]", this.name + ":", ...args);
|
||||
|
||||
return originalLog.call(this, level, levelColor, args);
|
||||
};
|
||||
|
||||
// Force enable all plugins and patches
|
||||
|
|
@ -299,6 +303,9 @@ function runTime(token: string) {
|
|||
delete patch.predicate;
|
||||
delete patch.group;
|
||||
|
||||
if (!Array.isArray(patch.find))
|
||||
patch.find = [patch.find];
|
||||
|
||||
if (!Array.isArray(patch.replacement))
|
||||
patch.replacement = [patch.replacement];
|
||||
|
||||
|
|
@ -310,45 +317,30 @@ function runTime(token: string) {
|
|||
});
|
||||
});
|
||||
|
||||
Vencord.Webpack.waitFor(
|
||||
"loginToken",
|
||||
m => {
|
||||
console.log("[PUP_DEBUG]", "Logging in with token...");
|
||||
m.loginToken(token);
|
||||
}
|
||||
);
|
||||
let wreq: typeof Vencord.Webpack.wreq;
|
||||
|
||||
// Force load all chunks
|
||||
Vencord.Webpack.onceReady.then(() => setTimeout(async () => {
|
||||
console.log("[PUP_DEBUG]", "Webpack is ready!");
|
||||
const { canonicalizeMatch, Logger } = Vencord.Util;
|
||||
|
||||
const { wreq } = Vencord.Webpack;
|
||||
const validChunks = new Set<string>();
|
||||
const invalidChunks = new Set<string>();
|
||||
|
||||
console.log("[PUP_DEBUG]", "Loading all chunks...");
|
||||
let chunksSearchingResolve: (value: void | PromiseLike<void>) => void;
|
||||
const chunksSearchingDone = new Promise<void>(r => chunksSearchingResolve = r);
|
||||
|
||||
let chunks = null as Record<number, string[]> | null;
|
||||
const sym = Symbol("Vencord.chunksExtract");
|
||||
// True if resolved, false otherwise
|
||||
const chunksSearchPromises = [] as Array<() => boolean>;
|
||||
const lazyChunkRegex = canonicalizeMatch(/Promise\.all\((\[\i\.\i\(".+?"\).+?\])\).then\(\i\.bind\(\i,"(.+?)"\)\)/g);
|
||||
const chunkIdsRegex = canonicalizeMatch(/\("(.+?)"\)/g);
|
||||
|
||||
Object.defineProperty(Object.prototype, sym, {
|
||||
get() {
|
||||
chunks = this;
|
||||
},
|
||||
set() { },
|
||||
configurable: true,
|
||||
});
|
||||
async function searchAndLoadLazyChunks(factoryCode: string) {
|
||||
const lazyChunks = factoryCode.matchAll(lazyChunkRegex);
|
||||
const validChunkGroups = new Set<[chunkIds: string[], entryPoint: string]>();
|
||||
|
||||
await (wreq as any).el(sym);
|
||||
delete Object.prototype[sym];
|
||||
await Promise.all(Array.from(lazyChunks).map(async ([, rawChunkIds, entryPoint]) => {
|
||||
const chunkIds = Array.from(rawChunkIds.matchAll(chunkIdsRegex)).map(m => m[1]);
|
||||
if (chunkIds.length === 0) return;
|
||||
|
||||
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;
|
||||
let invalidChunkGroup = false;
|
||||
|
||||
for (const id of chunkIds) {
|
||||
if (wreq.u(id) == null || wreq.u(id) === "undefined.js") continue;
|
||||
|
|
@ -359,56 +351,28 @@ function runTime(token: string) {
|
|||
|
||||
if (isWasm) {
|
||||
invalidChunks.add(id);
|
||||
invalidEntryPoint = true;
|
||||
invalidChunkGroup = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
validChunks.add(id);
|
||||
}
|
||||
|
||||
if (!invalidEntryPoint)
|
||||
validChunksEntryPoints.add(entryPoint);
|
||||
}
|
||||
if (!invalidChunkGroup) {
|
||||
validChunkGroups.add([chunkIds, entryPoint]);
|
||||
}
|
||||
}));
|
||||
|
||||
for (const entryPoint of validChunksEntryPoints) {
|
||||
try {
|
||||
// Loads all chunks required for an entry point
|
||||
await (wreq as any).el(entryPoint);
|
||||
} catch (err) { }
|
||||
}
|
||||
// Loads all found valid chunk groups
|
||||
await Promise.all(
|
||||
Array.from(validChunkGroups)
|
||||
.map(([chunkIds]) =>
|
||||
Promise.all(chunkIds.map(id => wreq.e(id as any).catch(() => { })))
|
||||
)
|
||||
);
|
||||
|
||||
// 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) {
|
||||
// Requires the entry points for all valid chunk groups
|
||||
for (const [, entryPoint] of validChunkGroups) {
|
||||
try {
|
||||
if (wreq.m[entryPoint]) wreq(entryPoint as any);
|
||||
} catch (err) {
|
||||
|
|
@ -416,54 +380,139 @@ function runTime(token: string) {
|
|||
}
|
||||
}
|
||||
|
||||
console.log("[PUP_DEBUG]", "Finished loading all chunks!");
|
||||
// setImmediate to only check if all chunks were loaded after this function resolves
|
||||
// We check if all chunks were loaded every time a factory is loaded
|
||||
// If we are still looking for chunks in the other factories, the array will have that factory's chunk search promise not resolved
|
||||
// But, if all chunk search promises are resolved, this means we found every lazy chunk loaded by Discord code and manually loaded them
|
||||
setTimeout(() => {
|
||||
let allResolved = true;
|
||||
|
||||
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 (let i = 0; i < chunksSearchPromises.length; i++) {
|
||||
const isResolved = chunksSearchPromises[i]();
|
||||
|
||||
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") {
|
||||
if (typeof args[0] === "string") method = "findByProps";
|
||||
else method = "find";
|
||||
}
|
||||
if (searchType === "waitForStore") method = "findStore";
|
||||
|
||||
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));
|
||||
if (isResolved) {
|
||||
// Remove finished promises to avoid having to iterate through a huge array everytime
|
||||
chunksSearchPromises.splice(i--, 1);
|
||||
} else {
|
||||
// @ts-ignore
|
||||
result = Vencord.Webpack[method](...args);
|
||||
allResolved = false;
|
||||
}
|
||||
|
||||
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));
|
||||
if (allResolved) chunksSearchingResolve();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
Vencord.Webpack.waitFor(
|
||||
"loginToken",
|
||||
m => {
|
||||
console.log("[PUP_DEBUG]", "Logging in with token...");
|
||||
m.loginToken(token);
|
||||
}
|
||||
);
|
||||
|
||||
Vencord.Webpack.beforeInitListeners.add(async webpackRequire => {
|
||||
console.log("[PUP_DEBUG]", "Loading all chunks...");
|
||||
|
||||
wreq = webpackRequire;
|
||||
|
||||
Vencord.Webpack.factoryListeners.add(factory => {
|
||||
let isResolved = false;
|
||||
searchAndLoadLazyChunks(factory.toString()).then(() => isResolved = true);
|
||||
|
||||
chunksSearchPromises.push(() => isResolved);
|
||||
});
|
||||
|
||||
// setImmediate to only search the initial factories after Discord initialized the app
|
||||
// our beforeInitListeners are called before Discord initializes the app
|
||||
setTimeout(() => {
|
||||
for (const factoryId in wreq.m) {
|
||||
let isResolved = false;
|
||||
searchAndLoadLazyChunks(wreq.m[factoryId].toString()).then(() => isResolved = true);
|
||||
|
||||
chunksSearchPromises.push(() => isResolved);
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
|
||||
await chunksSearchingDone;
|
||||
|
||||
// All chunks Discord has mapped to asset files, even if they are not used anymore
|
||||
const allChunks = [] as string[];
|
||||
|
||||
// Matches "id" or id:
|
||||
for (const currentMatch of wreq!.u.toString().matchAll(/(?:"(\d+?)")|(?:(\d+?):)/g)) {
|
||||
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");
|
||||
|
||||
// Chunks that are not loaded (not used) by Discord code anymore
|
||||
const chunksLeft = allChunks.filter(id => {
|
||||
return !(validChunks.has(id) || invalidChunks.has(id));
|
||||
});
|
||||
|
||||
await Promise.all(chunksLeft.map(async id => {
|
||||
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 and requires a chunk
|
||||
if (!isWasm) {
|
||||
await wreq.e(id as any);
|
||||
if (wreq.m[id]) wreq(id as any);
|
||||
}
|
||||
}));
|
||||
|
||||
console.log("[PUP_DEBUG]", "Finished loading all chunks!");
|
||||
|
||||
for (const patch of Vencord.Plugins.patches) {
|
||||
if (!patch.all) {
|
||||
new 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") {
|
||||
if (typeof args[0] === "string") method = "findByProps";
|
||||
else method = "find";
|
||||
}
|
||||
if (searchType === "waitForStore") method = "findStore";
|
||||
|
||||
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(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);
|
||||
} catch (e) {
|
||||
console.log("[PUP_DEBUG]", "A fatal error occurred:", e);
|
||||
process.exit(1);
|
||||
|
|
@ -473,7 +522,7 @@ function runTime(token: string) {
|
|||
await page.evaluateOnNewDocument(`
|
||||
${readFileSync("./dist/browser.js", "utf-8")}
|
||||
|
||||
;(${runTime.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");
|
||||
|
|
|
|||
|
|
@ -35,11 +35,11 @@ const ETAG_FILE = join(FILE_DIR, "etag.txt");
|
|||
function getFilename() {
|
||||
switch (process.platform) {
|
||||
case "win32":
|
||||
return "VencordInstallerCli.exe";
|
||||
return "VencordInstaller.exe";
|
||||
case "darwin":
|
||||
return "VencordInstaller.MacOS.zip";
|
||||
case "linux":
|
||||
return "VencordInstallerCli-linux";
|
||||
return "VencordInstaller-" + (process.env.WAYLAND_DISPLAY ? "wayland" : "x11");
|
||||
default:
|
||||
throw new Error("Unsupported platform: " + process.platform);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ async function syncSettings() {
|
|||
|
||||
async function init() {
|
||||
await onceReady;
|
||||
|
||||
startAllPlugins(StartAt.WebpackReady);
|
||||
|
||||
syncSettings();
|
||||
|
|
@ -132,16 +133,13 @@ async function init() {
|
|||
}
|
||||
}
|
||||
|
||||
startAllPlugins(StartAt.Init);
|
||||
init();
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
startAllPlugins(StartAt.DOMContentLoaded);
|
||||
|
||||
if (IS_DISCORD_DESKTOP && Settings.winNativeTitleBar && navigator.platform.toLowerCase().startsWith("win")) {
|
||||
if (IS_DISCORD_DESKTOP && Settings.winNativeTitleBar && navigator.platform.toLowerCase().startsWith("win")) {
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
document.head.append(Object.assign(document.createElement("style"), {
|
||||
id: "vencord-native-titlebar-style",
|
||||
textContent: "[class*=titleBar]{display: none!important}"
|
||||
}));
|
||||
}
|
||||
}, { once: true });
|
||||
}, { once: true });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,12 +4,11 @@
|
|||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { PluginIpcMappings } from "@main/ipcPlugins";
|
||||
import type { UserThemeHeader } from "@main/themes";
|
||||
import { IpcEvents } from "@shared/IpcEvents";
|
||||
import { IpcRes } from "@utils/types";
|
||||
import type { Settings } from "api/Settings";
|
||||
import { ipcRenderer } from "electron";
|
||||
import { PluginIpcMappings } from "main/ipcPlugins";
|
||||
|
||||
function invoke<T = any>(event: IpcEvents, ...args: any[]) {
|
||||
return ipcRenderer.invoke(event, ...args) as Promise<T>;
|
||||
|
|
@ -34,7 +33,7 @@ export default {
|
|||
uploadTheme: (fileName: string, fileData: string) => invoke<void>(IpcEvents.UPLOAD_THEME, fileName, fileData),
|
||||
deleteTheme: (fileName: string) => invoke<void>(IpcEvents.DELETE_THEME, fileName),
|
||||
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),
|
||||
getSystemValues: () => invoke<Record<string, string>>(IpcEvents.GET_THEME_SYSTEM_VALUES),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ export interface MessageReplyOptions {
|
|||
messageReference: Message["messageReference"];
|
||||
allowedMentions?: {
|
||||
parse: Array<string>;
|
||||
users?: Array<string>;
|
||||
roles?: Array<string>;
|
||||
repliedUser: boolean;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export interface Settings {
|
|||
useQuickCss: boolean;
|
||||
enableReactDevtools: boolean;
|
||||
themeLinks: string[];
|
||||
disabledThemeLinks: string[];
|
||||
enabledThemes: string[];
|
||||
frameless: boolean;
|
||||
transparent: boolean;
|
||||
|
|
@ -75,6 +76,12 @@ export interface Settings {
|
|||
settingsSync: boolean;
|
||||
settingsSyncVersion: number;
|
||||
};
|
||||
|
||||
userCssVars: {
|
||||
[themeId: string]: {
|
||||
[varName: string]: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const DefaultSettings: Settings = {
|
||||
|
|
@ -83,6 +90,7 @@ const DefaultSettings: Settings = {
|
|||
autoUpdateNotification: true,
|
||||
useQuickCss: true,
|
||||
themeLinks: [],
|
||||
disabledThemeLinks: [],
|
||||
enabledThemes: [],
|
||||
enableReactDevtools: false,
|
||||
frameless: false,
|
||||
|
|
@ -102,10 +110,12 @@ const DefaultSettings: Settings = {
|
|||
|
||||
cloud: {
|
||||
authenticated: false,
|
||||
url: "https://api.vencord.dev/",
|
||||
url: "https://equicord.patrickdk.com/",
|
||||
settingsSync: false,
|
||||
settingsSyncVersion: 0
|
||||
}
|
||||
},
|
||||
|
||||
userCssVars: {}
|
||||
};
|
||||
|
||||
const settings = VencordNative.settings.get();
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export default function DonateButton(props: any) {
|
|||
{...props}
|
||||
look={Button.Looks.LINK}
|
||||
color={Button.Colors.TRANSPARENT}
|
||||
onClick={() => VencordNative.native.openExternal("https://github.com/sponsors/Vendicated")}
|
||||
onClick={() => VencordNative.native.openExternal("https://github.com/sponsors/vendicated")}
|
||||
>
|
||||
<Heart />
|
||||
Donate
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
.vc-expandableheader-center-flex {
|
||||
display: flex;
|
||||
justify-items: center;
|
||||
align-items: center;
|
||||
place-content: center;
|
||||
}
|
||||
|
||||
.vc-expandableheader-btn {
|
||||
|
|
@ -9,4 +8,4 @@
|
|||
cursor: pointer;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
|
@ -256,6 +256,25 @@ 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) {
|
||||
return (
|
||||
<Icon
|
||||
|
|
|
|||
258
src/components/ThemeSettings/OnlineThemes.tsx
Normal file
258
src/components/ThemeSettings/OnlineThemes.tsx
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { useSettings } from "@api/Settings";
|
||||
import { classNameFactory } from "@api/Styles";
|
||||
import { CopyIcon, DeleteIcon } from "@components/Icons";
|
||||
import { copyWithToast } from "@utils/misc";
|
||||
import { ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModalLazy } from "@utils/modal";
|
||||
import { getThemeInfo, UserThemeHeader } from "@utils/themes/bd";
|
||||
import { Button, Card, Forms, Text, TextInput, useEffect, useState } from "@webpack/common";
|
||||
|
||||
import { AddonCard } from "../VencordSettings/AddonCard";
|
||||
import { ThemeCard } from "./ThemesTab";
|
||||
|
||||
export interface OnlineTheme {
|
||||
link: string;
|
||||
headers?: UserThemeHeader;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const cl = classNameFactory("vc-settings-theme-");
|
||||
|
||||
/**
|
||||
* Trims URLs like so
|
||||
* https://whatever.example.com/whatever/whatever/whatever/whatever/index.html
|
||||
* -> whatever/index.html
|
||||
*/
|
||||
function trimThemeUrl(url: string) {
|
||||
let urlObj: URL;
|
||||
|
||||
try {
|
||||
urlObj = new URL(url);
|
||||
} catch (e) {
|
||||
return url;
|
||||
}
|
||||
|
||||
return urlObj.pathname.split("/").slice(-2).join("/");
|
||||
}
|
||||
|
||||
async function FetchTheme(link: string) {
|
||||
const theme: OnlineTheme = await fetch(link, { redirect: "follow" })
|
||||
.then(res => {
|
||||
if (res.status >= 400) 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 res.text();
|
||||
})
|
||||
.then(text => {
|
||||
const headers = getThemeInfo(text, trimThemeUrl(link));
|
||||
return { link, headers };
|
||||
}).catch(e => {
|
||||
return { link, error: e.toString() };
|
||||
});
|
||||
|
||||
return theme;
|
||||
}
|
||||
|
||||
export function OnlineThemes() {
|
||||
const settings = useSettings(["themeLinks", "disabledThemeLinks"]);
|
||||
const [themes, setThemes] = useState<OnlineTheme[]>([]);
|
||||
|
||||
async function fetchThemes() {
|
||||
const themes = await Promise.all(settings.themeLinks.map(link => FetchTheme(link)));
|
||||
setThemes(themes);
|
||||
}
|
||||
|
||||
async function addTheme(link: string) {
|
||||
settings.disabledThemeLinks = [...settings.disabledThemeLinks, link];
|
||||
settings.themeLinks = [...settings.themeLinks, link];
|
||||
setThemes([...themes, await FetchTheme(link)]);
|
||||
}
|
||||
|
||||
async function removeTheme(link: string) {
|
||||
settings.disabledThemeLinks = settings.disabledThemeLinks.filter(l => l !== link);
|
||||
settings.themeLinks = settings.themeLinks.filter(l => l !== link);
|
||||
setThemes(themes.filter(t => t.link !== link));
|
||||
}
|
||||
|
||||
function setThemeEnabled(link: string, enabled: boolean) {
|
||||
if (enabled) {
|
||||
settings.disabledThemeLinks = settings.disabledThemeLinks.filter(l => l !== link);
|
||||
} else {
|
||||
settings.disabledThemeLinks = [...settings.disabledThemeLinks, link];
|
||||
}
|
||||
settings.themeLinks = [...settings.themeLinks];
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchThemes();
|
||||
}, []);
|
||||
|
||||
function AddThemeModal({ transitionState, onClose }: ModalProps) {
|
||||
const [disabled, setDisabled] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [url, setUrl] = useState("");
|
||||
|
||||
async function checkUrl() {
|
||||
if (!url) {
|
||||
setDisabled(true);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (themes.some(t => t.link === url)) {
|
||||
setError("Theme already added");
|
||||
setDisabled(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let urlObj: URL | undefined = undefined;
|
||||
try {
|
||||
urlObj = new URL(url);
|
||||
} catch (e) {
|
||||
setDisabled(true);
|
||||
setError("Invalid URL");
|
||||
return;
|
||||
}
|
||||
|
||||
if (urlObj.hostname !== "raw.githubusercontent.com" && !urlObj.hostname.endsWith("github.io")) {
|
||||
setError("Only raw.githubusercontent.com and github.io URLs are allowed, otherwise the theme will not work");
|
||||
setDisabled(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!urlObj.pathname.endsWith(".css")) {
|
||||
setError("Not a CSS file. Remember to use the raw link!");
|
||||
setDisabled(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let success = true;
|
||||
await fetch(url).then(res => {
|
||||
if (!res.ok) {
|
||||
setError(`Could not fetch theme: ${res.status} ${res.statusText}`);
|
||||
setDisabled(true);
|
||||
success = false;
|
||||
}
|
||||
}).catch(e => {
|
||||
setError(`Could not fetch theme: ${e}`);
|
||||
setDisabled(true);
|
||||
success = false;
|
||||
});
|
||||
|
||||
if (!success) return;
|
||||
|
||||
setDisabled(false);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
return <ModalRoot transitionState={transitionState} size={ModalSize.SMALL}>
|
||||
<ModalHeader>
|
||||
<Text variant="heading-lg/semibold" style={{ flexGrow: 1 }}>Add Online Theme</Text>
|
||||
<ModalCloseButton onClick={onClose} />
|
||||
</ModalHeader>
|
||||
<ModalContent className={cl("add-theme-content")}>
|
||||
<Forms.FormText>Only raw.githubusercontent.com and github.io URLs will work</Forms.FormText>
|
||||
|
||||
<TextInput placeholder="URL" name="url" onBlur={checkUrl} onChange={setUrl} />
|
||||
<Forms.FormText className={cl("add-theme-error")}>{error}</Forms.FormText>
|
||||
</ModalContent>
|
||||
<ModalFooter className={cl("add-theme-footer")}>
|
||||
<Button onClick={onClose} color={Button.Colors.RED}>Cancel</Button>
|
||||
<Button onClick={() => {
|
||||
addTheme(url);
|
||||
onClose();
|
||||
}} color={Button.Colors.BRAND} disabled={disabled}>
|
||||
Add
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalRoot>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="vc-settings-card">
|
||||
<Forms.FormTitle tag="h5">Find Themes:</Forms.FormTitle>
|
||||
<Forms.FormText>
|
||||
Find a theme you like and press "Add Theme" to add it.
|
||||
To get a raw link to a theme, go to it's GitHub repository,
|
||||
find the CSS file, and press the "Raw" button, then copy the URL.
|
||||
</Forms.FormText>
|
||||
</Card>
|
||||
<Forms.FormSection title="Online Themes" tag="h5">
|
||||
<Card className="vc-settings-quick-actions-card">
|
||||
<Button onClick={() => openModalLazy(async () => {
|
||||
return modalProps => {
|
||||
return <AddThemeModal {...modalProps} />;
|
||||
};
|
||||
})} size={Button.Sizes.SMALL}>
|
||||
Add Theme
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => fetchThemes()}
|
||||
size={Button.Sizes.SMALL}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Card>
|
||||
<div className={cl("grid")}>
|
||||
{themes.length === 0 && (
|
||||
<Forms.FormText>Add themes with the "Add Theme" button above</Forms.FormText>
|
||||
)}
|
||||
{themes.map(theme => (
|
||||
(!theme.error && <ThemeCard
|
||||
key={theme.link}
|
||||
enabled={!settings.disabledThemeLinks.includes(theme.link)}
|
||||
onChange={value => {
|
||||
setThemeEnabled(theme.link, value);
|
||||
}}
|
||||
onDelete={() => removeTheme(theme.link)}
|
||||
theme={theme.headers!}
|
||||
showDelete={true}
|
||||
extraButtons={
|
||||
<div
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => copyWithToast(theme.link, "Link copied to clipboard!")}
|
||||
>
|
||||
<CopyIcon />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) || (
|
||||
<AddonCard
|
||||
key={theme.link}
|
||||
name={theme.error}
|
||||
description={theme.link}
|
||||
enabled={false}
|
||||
setEnabled={() => { }}
|
||||
hideSwitch={true}
|
||||
infoButton={<>
|
||||
<div
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => copyWithToast(theme.link, "Link copied to clipboard!")}
|
||||
>
|
||||
<CopyIcon />
|
||||
</div>
|
||||
<div
|
||||
style={{ cursor: "pointer", color: "var(--status-danger" }}
|
||||
onClick={() => removeTheme(theme.link)}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
</Forms.FormSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
431
src/components/ThemeSettings/ThemesTab.tsx
Normal file
431
src/components/ThemeSettings/ThemesTab.tsx
Normal file
|
|
@ -0,0 +1,431 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
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 { 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 { findByCodeLazy } from "@webpack";
|
||||
import { Button, Card, Forms, React, showToast, TabBar, 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 { OnlineThemes } from "./OnlineThemes";
|
||||
import { UserCSSSettingsModal } from "./UserCSSModal";
|
||||
|
||||
type FileInput = ComponentType<{
|
||||
ref: Ref<HTMLInputElement>;
|
||||
onChange: (e: SyntheticEvent<HTMLInputElement>) => void;
|
||||
multiple?: boolean;
|
||||
filters?: { name?: string; extensions: string[]; }[];
|
||||
}>;
|
||||
|
||||
const FileInput: FileInput = findByCodeLazy("activateUploadDialogue=");
|
||||
|
||||
const cl = classNameFactory("vc-settings-theme-");
|
||||
|
||||
interface ThemeCardProps {
|
||||
theme: UserThemeHeader;
|
||||
enabled: boolean;
|
||||
onChange: (enabled: boolean) => void;
|
||||
onDelete: () => void;
|
||||
showDelete?: boolean;
|
||||
extraButtons?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface OtherThemeCardProps {
|
||||
theme: UserThemeHeader;
|
||||
enabled: boolean;
|
||||
onChange: (enabled: boolean) => void;
|
||||
onDelete: () => void;
|
||||
showDelete?: boolean;
|
||||
extraButtons?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface UserCSSCardProps {
|
||||
theme: UserstyleHeader;
|
||||
enabled: boolean;
|
||||
onChange: (enabled: boolean) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export function ThemeCard({ theme, enabled, onChange, onDelete, showDelete, extraButtons }: ThemeCardProps) {
|
||||
return (
|
||||
<AddonCard
|
||||
name={theme.name}
|
||||
description={theme.description}
|
||||
author={theme.author}
|
||||
enabled={enabled}
|
||||
setEnabled={onChange}
|
||||
infoButton={
|
||||
(IS_WEB || showDelete) && (<>
|
||||
{extraButtons}
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function UserCSSThemeCard({ theme, enabled, onChange, onDelete }: 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}
|
||||
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-warning" }}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<PluginIcon />
|
||||
</div>
|
||||
)}
|
||||
</Tooltip>
|
||||
)}
|
||||
{theme.vars && (
|
||||
<div style={{ cursor: "pointer" }} onClick={
|
||||
() => openModal(modalProps =>
|
||||
<UserCSSSettingsModal modalProps={modalProps} theme={theme} />)
|
||||
}>
|
||||
<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, showDelete, extraButtons }: OtherThemeCardProps) {
|
||||
return (
|
||||
<AddonCard
|
||||
name={theme.name}
|
||||
description={theme.description}
|
||||
author={theme.author}
|
||||
enabled={enabled}
|
||||
setEnabled={onChange}
|
||||
infoButton={
|
||||
(IS_WEB || showDelete) && (<>
|
||||
{extraButtons}
|
||||
<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,
|
||||
REPO
|
||||
}
|
||||
|
||||
function ThemesTab() {
|
||||
const settings = useSettings(["themeLinks", "disabledThemeLinks", "enabledThemes"]);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [currentTab, setCurrentTab] = useState(ThemeTab.LOCAL);
|
||||
const [userThemes, setUserThemes] = useState<ThemeHeader[] | null>(null);
|
||||
const [themeDir, , themeDirPending] = useAwaiter(VencordNative.themes.getThemesDir);
|
||||
|
||||
useEffect(() => {
|
||||
refreshLocalThemes();
|
||||
}, [settings.themeLinks]);
|
||||
|
||||
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":
|
||||
normalizedValue = varInfo.default;
|
||||
break;
|
||||
case "select":
|
||||
normalizedValue = varInfo.options.find(v => v.name === varInfo.default)!.value;
|
||||
break;
|
||||
case "checkbox":
|
||||
normalizedValue = varInfo.default ? "1" : "0";
|
||||
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();
|
||||
}}
|
||||
theme={theme as UserstyleHeader}
|
||||
/>
|
||||
)))}
|
||||
</div>
|
||||
</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 && <OnlineThemes />}
|
||||
</SettingsTab>
|
||||
);
|
||||
}
|
||||
|
||||
export default wrapTab(ThemesTab, "Themes");
|
||||
114
src/components/ThemeSettings/UserCSSModal.tsx
Normal file
114
src/components/ThemeSettings/UserCSSModal.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { useSettings } from "@api/Settings";
|
||||
import { Flex } from "@components/Flex";
|
||||
import { ModalCloseButton, ModalContent, ModalHeader, ModalProps, ModalRoot } from "@utils/modal";
|
||||
import { Text } 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;
|
||||
}
|
||||
|
||||
export function UserCSSSettingsModal({ modalProps, theme }: UserCSSSettingsModalProps) {
|
||||
// @ts-expect-error UseSettings<> can't determine this is a valid key
|
||||
const themeSettings = useSettings(["userCssVars"], false).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={themeSettings}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "checkbox": {
|
||||
controls.push(
|
||||
<SettingBooleanComponent
|
||||
label={varInfo.label}
|
||||
name={name}
|
||||
themeSettings={themeSettings}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "color": {
|
||||
controls.push(
|
||||
<SettingColorComponent
|
||||
label={varInfo.label}
|
||||
name={name}
|
||||
themeSettings={themeSettings}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "number": {
|
||||
controls.push(
|
||||
<SettingNumberComponent
|
||||
label={varInfo.label}
|
||||
name={name}
|
||||
themeSettings={themeSettings}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "select": {
|
||||
controls.push(
|
||||
<SettingSelectComponent
|
||||
label={varInfo.label}
|
||||
name={name}
|
||||
options={varInfo.options}
|
||||
default={varInfo.default}
|
||||
themeSettings={themeSettings}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "range": {
|
||||
controls.push(
|
||||
<SettingRangeComponent
|
||||
label={varInfo.label}
|
||||
name={name}
|
||||
default={varInfo.default}
|
||||
min={varInfo.min}
|
||||
max={varInfo.max}
|
||||
step={varInfo.step}
|
||||
themeSettings={themeSettings}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalRoot {...modalProps}>
|
||||
<ModalHeader separator={false}>
|
||||
<Text variant="heading-lg/semibold" style={{ flexGrow: 1 }}>Settings for {theme.name}</Text>
|
||||
<ModalCloseButton onClick={modalProps.onClose} />
|
||||
</ModalHeader>
|
||||
<ModalContent>
|
||||
<Flex flexDirection="column" style={{ gap: 12, marginBottom: 16 }}>{controls}</Flex>
|
||||
</ModalContent>
|
||||
</ModalRoot>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Forms, Switch, useState } from "@webpack/common";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
name: string;
|
||||
themeSettings: Record<string, string>;
|
||||
}
|
||||
|
||||
export function SettingBooleanComponent({ label, name, themeSettings }: Props) {
|
||||
const [value, setValue] = useState(themeSettings[name]);
|
||||
|
||||
function handleChange(value: boolean) {
|
||||
const corrected = value ? "1" : "0";
|
||||
|
||||
setValue(corrected);
|
||||
|
||||
themeSettings[name] = corrected;
|
||||
}
|
||||
|
||||
return (
|
||||
<Forms.FormSection>
|
||||
<Switch
|
||||
key={name}
|
||||
value={value === "1"}
|
||||
onChange={handleChange}
|
||||
hideBorder
|
||||
style={{ marginBottom: "0.5em" }}
|
||||
>
|
||||
{label}
|
||||
</Switch>
|
||||
</Forms.FormSection>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* 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, useState } 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) {
|
||||
const [value, setValue] = useState(themeSettings[name]);
|
||||
|
||||
function handleChange(value: number) {
|
||||
const corrected = "#" + (value?.toString(16).padStart(6, "0") ?? "000000");
|
||||
|
||||
setValue(corrected);
|
||||
|
||||
themeSettings[name] = corrected;
|
||||
}
|
||||
|
||||
const normalizedValue = useMemo(() => parseInt(TinyColor(value).toHex(), 16), [value]);
|
||||
|
||||
return (
|
||||
<Forms.FormSection>
|
||||
<div className={cl("swatch-row")}>
|
||||
<span>{label}</span>
|
||||
<ColorPicker
|
||||
key={name}
|
||||
color={normalizedValue}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</Forms.FormSection>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Forms, TextInput, useState } from "@webpack/common";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
name: string;
|
||||
themeSettings: Record<string, string>;
|
||||
}
|
||||
|
||||
export function SettingNumberComponent({ label, name, themeSettings }: Props) {
|
||||
const [value, setValue] = useState(themeSettings[name]);
|
||||
|
||||
function handleChange(value: string) {
|
||||
setValue(value);
|
||||
|
||||
themeSettings[name] = value;
|
||||
}
|
||||
|
||||
return (
|
||||
<Forms.FormSection>
|
||||
<Forms.FormTitle tag="h5">{label}</Forms.FormTitle>
|
||||
<TextInput
|
||||
type="number"
|
||||
pattern="-?[0-9]+"
|
||||
key={name}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Forms.FormSection>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Forms, Slider, useMemo, useState } 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) {
|
||||
const [value, setValue] = useState(themeSettings[name]);
|
||||
|
||||
function handleChange(value: number) {
|
||||
const corrected = value.toString();
|
||||
|
||||
setValue(corrected);
|
||||
|
||||
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(value, 10)}
|
||||
defaultValue={def}
|
||||
onValueChange={handleChange}
|
||||
minValue={min}
|
||||
maxValue={max}
|
||||
|
||||
markers={markers}
|
||||
stickToMarkers={true}
|
||||
/>
|
||||
</Forms.FormSection>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
* 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, useState } 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) {
|
||||
const [value, setValue] = useState(themeSettings[name]);
|
||||
|
||||
function handleChange(value: string) {
|
||||
setValue(value);
|
||||
|
||||
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 === value}
|
||||
serialize={identity}
|
||||
/>
|
||||
</Forms.FormSection>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Forms, TextInput, useState } from "@webpack/common";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
name: string;
|
||||
themeSettings: Record<string, string>;
|
||||
}
|
||||
|
||||
export function SettingTextComponent({ label, name, themeSettings }: Props) {
|
||||
const [value, setValue] = useState(themeSettings[name]);
|
||||
|
||||
function handleChange(value: string) {
|
||||
setValue(value);
|
||||
|
||||
themeSettings[name] = value;
|
||||
}
|
||||
|
||||
return (
|
||||
<Forms.FormSection>
|
||||
<Forms.FormTitle tag="h5">{label}</Forms.FormTitle>
|
||||
<TextInput
|
||||
key={name}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Forms.FormSection>
|
||||
);
|
||||
}
|
||||
19
src/components/ThemeSettings/components/colorStyles.css
Normal file
19
src/components/ThemeSettings/components/colorStyles.css
Normal 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;
|
||||
}
|
||||
12
src/components/ThemeSettings/components/index.ts
Normal file
12
src/components/ThemeSettings/components/index.ts
Normal 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";
|
||||
47
src/components/ThemeSettings/themesStyles.css
Normal file
47
src/components/ThemeSettings/themesStyles.css
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
.vc-settings-theme-grid {
|
||||
display: grid;
|
||||
grid-gap: 16px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
}
|
||||
|
||||
.vc-settings-theme-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--background-secondary-alt);
|
||||
color: var(--interactive-active);
|
||||
border-radius: 8px;
|
||||
padding: 1em;
|
||||
width: 100%;
|
||||
transition: 0.1s ease-out;
|
||||
transition-property: box-shadow, transform, background, opacity;
|
||||
}
|
||||
|
||||
.vc-settings-theme-card-text {
|
||||
text-overflow: ellipsis;
|
||||
height: 1.2em;
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vc-settings-theme-author::before {
|
||||
content: "by ";
|
||||
}
|
||||
|
||||
.vc-settings-theme-add-theme-content {
|
||||
margin: 10px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.vc-settings-theme-add-theme-footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.vc-settings-theme-add-theme-error {
|
||||
color: var(--text-danger);
|
||||
}
|
||||
|
|
@ -37,11 +37,12 @@ interface Props {
|
|||
onMouseLeave?: MouseEventHandler<HTMLDivElement>;
|
||||
|
||||
infoButton?: ReactNode;
|
||||
hideSwitch?: boolean;
|
||||
footer?: ReactNode;
|
||||
author?: ReactNode;
|
||||
}
|
||||
|
||||
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, hideSwitch }: Props) {
|
||||
return (
|
||||
<div
|
||||
className={cl("card", { "card-disabled": disabled })}
|
||||
|
|
@ -62,11 +63,11 @@ export function AddonCard({ disabled, isNew, name, infoButton, footer, author, e
|
|||
|
||||
{infoButton}
|
||||
|
||||
<Switch
|
||||
{!hideSwitch && <Switch
|
||||
checked={enabled}
|
||||
onChange={setEnabled}
|
||||
disabled={disabled}
|
||||
/>
|
||||
/>}
|
||||
</div>
|
||||
|
||||
<Text className={cl("note")} variant="text-sm/normal">{description}</Text>
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ function BackupRestoreTab() {
|
|||
</Flex>
|
||||
</Card>
|
||||
<Text variant="text-md/normal" className={Margins.bottom8}>
|
||||
You can import and export your Vencord settings as a JSON file.
|
||||
You can import and export your Equicord settings as a JSON file.
|
||||
This allows you to easily transfer your settings to another device,
|
||||
or recover your settings after reinstalling Vencord or Discord.
|
||||
</Text>
|
||||
|
|
|
|||
|
|
@ -180,7 +180,8 @@ function ReplacementInput({ replacement, setReplacement, replacementError }) {
|
|||
|
||||
return (
|
||||
<>
|
||||
<Forms.FormTitle>replacement</Forms.FormTitle>
|
||||
{/* FormTitle adds a class if className is not set, so we set it to an empty string to prevent that */}
|
||||
<Forms.FormTitle className="">replacement</Forms.FormTitle>
|
||||
<TextInput
|
||||
value={replacement?.toString()}
|
||||
onChange={onChange}
|
||||
|
|
@ -188,7 +189,7 @@ function ReplacementInput({ replacement, setReplacement, replacementError }) {
|
|||
/>
|
||||
{!isFunc && (
|
||||
<div className="vc-text-selectable">
|
||||
<Forms.FormTitle>Cheat Sheet</Forms.FormTitle>
|
||||
<Forms.FormTitle className={Margins.top8}>Cheat Sheet</Forms.FormTitle>
|
||||
{Object.entries({
|
||||
"\\i": "Special regex escape sequence that matches identifiers (varnames, classnames, etc.)",
|
||||
"$$": "Insert a $",
|
||||
|
|
@ -266,7 +267,7 @@ function FullPatchInput({ setFind, setMatch, setReplacement }: FullPatchInputPro
|
|||
}
|
||||
|
||||
return <>
|
||||
<Forms.FormText>Paste your full JSON patch here to fill out the fields</Forms.FormText>
|
||||
<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>}
|
||||
</>;
|
||||
|
|
@ -321,7 +322,7 @@ function PatchHelper() {
|
|||
setReplacement={setReplacement}
|
||||
/>
|
||||
|
||||
<Forms.FormTitle>find</Forms.FormTitle>
|
||||
<Forms.FormTitle className={Margins.top8}>find</Forms.FormTitle>
|
||||
<TextInput
|
||||
type="text"
|
||||
value={find}
|
||||
|
|
@ -329,7 +330,7 @@ function PatchHelper() {
|
|||
error={findError}
|
||||
/>
|
||||
|
||||
<Forms.FormTitle>match</Forms.FormTitle>
|
||||
<Forms.FormTitle className={Margins.top8}>match</Forms.FormTitle>
|
||||
<CheckedTextInput
|
||||
value={match}
|
||||
onChange={onMatchChange}
|
||||
|
|
@ -342,6 +343,7 @@ function PatchHelper() {
|
|||
}}
|
||||
/>
|
||||
|
||||
<div className={Margins.top8} />
|
||||
<ReplacementInput
|
||||
replacement={replacement}
|
||||
setReplacement={setReplacement}
|
||||
|
|
|
|||
|
|
@ -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 { useSettings } from "@api/Settings";
|
||||
import { classNameFactory } from "@api/Styles";
|
||||
import { Flex } from "@components/Flex";
|
||||
import { DeleteIcon } from "@components/Icons";
|
||||
import { Link } from "@components/Link";
|
||||
import PluginModal from "@components/PluginSettings/PluginModal";
|
||||
import type { UserThemeHeader } from "@main/themes";
|
||||
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 { findByPropsLazy, findLazy } from "@webpack";
|
||||
import { Button, Card, Forms, React, showToast, TabBar, TextArea, useEffect, useRef, useState } from "@webpack/common";
|
||||
import type { ComponentType, Ref, SyntheticEvent } from "react";
|
||||
|
||||
import { AddonCard } from "./AddonCard";
|
||||
import { SettingsTab, wrapTab } from "./shared";
|
||||
|
||||
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 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">
|
||||
<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(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>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");
|
||||
|
|
@ -201,7 +201,7 @@ function Updater() {
|
|||
};
|
||||
|
||||
return (
|
||||
<SettingsTab title="Vencord Updater">
|
||||
<SettingsTab title="Equicord Updater">
|
||||
<Forms.FormTitle tag="h5">Updater Settings</Forms.FormTitle>
|
||||
<Switch
|
||||
value={settings.notifyAboutUpdates}
|
||||
|
|
@ -214,14 +214,14 @@ function Updater() {
|
|||
<Switch
|
||||
value={settings.autoUpdate}
|
||||
onChange={(v: boolean) => settings.autoUpdate = v}
|
||||
note="Automatically update Vencord without confirmation prompt"
|
||||
note="Automatically update Equicord without confirmation prompt"
|
||||
>
|
||||
Automatically update
|
||||
</Switch>
|
||||
<Switch
|
||||
value={settings.autoUpdateNotification}
|
||||
onChange={(v: boolean) => settings.autoUpdateNotification = v}
|
||||
note="Shows a notification when Vencord automatically updates"
|
||||
note="Shows a notification when Equicord automatically updates"
|
||||
disabled={!settings.autoUpdate}
|
||||
>
|
||||
Get notified when an automatic update completes
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ import { SettingsTab, wrapTab } from "./shared";
|
|||
const cl = classNameFactory("vc-settings-");
|
||||
|
||||
const DEFAULT_DONATE_IMAGE = "https://cdn.discordapp.com/emojis/1026533090627174460.png";
|
||||
const SHIGGY_DONATE_IMAGE = "https://media.discordapp.net/stickers/1039992459209490513.png";
|
||||
const SHIGGY_DONATE_IMAGE = "https://i.imgur.com/UpcDwX0.png";
|
||||
|
||||
type KeysOfType<Object, Type> = {
|
||||
[K in keyof Object]: Object[K] extends Type ? K : never;
|
||||
|
|
@ -93,7 +93,7 @@ function VencordSettings() {
|
|||
];
|
||||
|
||||
return (
|
||||
<SettingsTab title="Vencord Settings">
|
||||
<SettingsTab title="Equicord Settings">
|
||||
<DonateCard image={donateImage} />
|
||||
<Forms.FormSection title="Quick Actions">
|
||||
<Card className={cl("quick-actions-card")}>
|
||||
|
|
@ -327,4 +327,4 @@ function DonateCard({ image }: DonateCardProps) {
|
|||
);
|
||||
}
|
||||
|
||||
export default wrapTab(VencordSettings, "Vencord Settings");
|
||||
export default wrapTab(VencordSettings, "Equicord Settings");
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@
|
|||
*/
|
||||
|
||||
import "./settingsStyles.css";
|
||||
import "./themesStyles.css";
|
||||
|
||||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
import { handleComponentFailed } from "@components/handleComponentFailed";
|
||||
|
|
|
|||
|
|
@ -27,3 +27,21 @@
|
|||
.vc-settings-theme-author::before {
|
||||
content: "by ";
|
||||
}
|
||||
|
||||
.vc-settings-theme-add-theme-content {
|
||||
margin: 10px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.vc-settings-theme-add-theme-footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.vc-settings-theme-add-theme-error {
|
||||
color: var(--text-danger);
|
||||
}
|
||||
14
src/equicordplugins/VCSupport/index.ts
Normal file
14
src/equicordplugins/VCSupport/index.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Devs } from "@utils/constants";
|
||||
import definePlugin from "@utils/types";
|
||||
|
||||
export default definePlugin({
|
||||
name: "VCSupport",
|
||||
description: "Wumpus Dance + Support Warning",
|
||||
authors: [Devs.thororen],
|
||||
});
|
||||
38
src/equicordplugins/allCallTimers/Timer.tsx
Normal file
38
src/equicordplugins/allCallTimers/Timer.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { useFixedTimer } from "@utils/react";
|
||||
import { formatDurationMs } from "@utils/text";
|
||||
import { Tooltip } from "@webpack/common";
|
||||
|
||||
import { settings } from "./index";
|
||||
import { TimerIcon } from "./TimerIcon";
|
||||
import { TimerText } from "./timerText";
|
||||
|
||||
export function Timer({ time }: Readonly<{ time: number; }>) {
|
||||
const durationMs = useFixedTimer({ initialTime: time });
|
||||
const formatted = formatDurationMs(durationMs, settings.store.format === "human", settings.store.showSeconds);
|
||||
const defaultColorClassName = settings.store.showRoleColor ? "" : "usernameFont__71dd5 username__73ce9";
|
||||
|
||||
if (settings.store.showWithoutHover) {
|
||||
return <TimerText text={formatted} className={defaultColorClassName} />;
|
||||
} else {
|
||||
// show as a tooltip
|
||||
return (
|
||||
<Tooltip text={formatted}>
|
||||
{({ onMouseEnter, onMouseLeave }) => (
|
||||
<div
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
role="tooltip"
|
||||
>
|
||||
<TimerIcon />
|
||||
</div>
|
||||
)}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
}
|
||||
26
src/equicordplugins/allCallTimers/TimerIcon.tsx
Normal file
26
src/equicordplugins/allCallTimers/TimerIcon.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export function TimerIcon({ height = 16, width = 16, className }: Readonly<{
|
||||
height?: number;
|
||||
width?: number;
|
||||
className?: string;
|
||||
}>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 455 455"
|
||||
height={height}
|
||||
width={width}
|
||||
className={className}
|
||||
style={{ color: "var(--channels-default)" }}
|
||||
>
|
||||
<path fill="currentColor" d="M332.229,90.04l14.238-27.159l-26.57-13.93L305.67,76.087c-19.618-8.465-40.875-13.849-63.17-15.523V30h48.269V0H164.231v30
|
||||
H212.5v30.563c-22.295,1.674-43.553,7.059-63.171,15.523L135.103,48.95l-26.57,13.93l14.239,27.16
|
||||
C67.055,124.958,30,186.897,30,257.5C30,366.576,118.424,455,227.5,455S425,366.576,425,257.5
|
||||
C425,186.896,387.944,124.958,332.229,90.04z M355,272.5H212.5V130h30v112.5H355V272.5z"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
267
src/equicordplugins/allCallTimers/index.tsx
Normal file
267
src/equicordplugins/allCallTimers/index.tsx
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
import { Devs } from "@utils/constants";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { FluxDispatcher, GuildStore, UserStore } from "@webpack/common";
|
||||
import { PassiveUpdateState, VoiceState } from "@webpack/types";
|
||||
|
||||
import { Timer } from "./Timer";
|
||||
|
||||
export const settings = definePluginSettings({
|
||||
showWithoutHover: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Always show the timer without needing to hover",
|
||||
restartNeeded: false,
|
||||
default: true
|
||||
},
|
||||
showRoleColor: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Show the user's role color (if this plugin in enabled)",
|
||||
restartNeeded: false,
|
||||
default: true
|
||||
},
|
||||
trackSelf: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Also track yourself",
|
||||
restartNeeded: false,
|
||||
default: true
|
||||
},
|
||||
showSeconds: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Show seconds in the timer",
|
||||
restartNeeded: false,
|
||||
default: true
|
||||
},
|
||||
format: {
|
||||
type: OptionType.SELECT,
|
||||
description: "Compact or human readable format:",
|
||||
options: [
|
||||
{
|
||||
label: "30:23:00:42",
|
||||
value: "stopwatch",
|
||||
default: true
|
||||
},
|
||||
{
|
||||
label: "30d 23h 00m 42s",
|
||||
value: "human"
|
||||
}
|
||||
]
|
||||
},
|
||||
watchLargeGuilds: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Track users in large guilds. This may cause lag if you're in a lot of large guilds with active voice users. Tested with up to 2000 active voice users with no issues.",
|
||||
restartNeeded: true,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Save the join time of all users in a Map
|
||||
type userJoinData = { channelId: string, time: number; guildId: string; };
|
||||
const userJoinTimes = new Map<string, userJoinData>();
|
||||
|
||||
/**
|
||||
* The function `addUserJoinTime` stores the join time of a user in a specific channel within a guild.
|
||||
* @param {string} userId - The `userId` parameter is a string that represents the unique identifier of
|
||||
* the user who is joining a channel in a guild.
|
||||
* @param {string} channelId - The `channelId` parameter represents the unique identifier of the
|
||||
* channel where the user joined.
|
||||
* @param {string} guildId - The `guildId` parameter in the `addUserJoinTime` function represents the
|
||||
* unique identifier of the guild (server) to which the user belongs. It is used to associate the
|
||||
* user's join time with a specific guild within the application or platform.
|
||||
*/
|
||||
function addUserJoinTime(userId: string, channelId: string, guildId: string) {
|
||||
// create a random number
|
||||
userJoinTimes.set(userId, { channelId, time: Date.now(), guildId });
|
||||
}
|
||||
|
||||
/**
|
||||
* The function `removeUserJoinTime` removes the join time of a user identified by their user ID.
|
||||
* @param {string} userId - The `userId` parameter is a string that represents the unique identifier of
|
||||
* a user whose join time needs to be removed.
|
||||
*/
|
||||
function removeUserJoinTime(userId: string) {
|
||||
userJoinTimes.delete(userId);
|
||||
}
|
||||
|
||||
// For every user, channelId and oldChannelId will differ when moving channel.
|
||||
// Only for the local user, channelId and oldChannelId will be the same when moving channel,
|
||||
// for some ungodly reason
|
||||
let myLastChannelId: string | undefined;
|
||||
|
||||
// Allow user updates on discord first load
|
||||
let runOneTime = true;
|
||||
|
||||
export default definePlugin({
|
||||
name: "AllCallTimers",
|
||||
description: "Add call timer to all users in a server voice channel.",
|
||||
authors: [Devs.MaxHerbold, Devs.D3SOX],
|
||||
|
||||
settings,
|
||||
|
||||
patches: [
|
||||
{
|
||||
find: "renderPrioritySpeaker",
|
||||
replacement: [
|
||||
{
|
||||
match: /(render\(\)\{.+\}\),children:)\[(.+renderName\(\),)/,
|
||||
replace: "$&,$self.showClockInjection(this),"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
find: "renderPrioritySpeaker",
|
||||
replacement: [
|
||||
{
|
||||
match: /(renderName\(\)\{.+:"")/,
|
||||
replace: "$&,$self.showTextInjection(this),"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
flux: {
|
||||
VOICE_STATE_UPDATES({ voiceStates }: { voiceStates: VoiceState[]; }) {
|
||||
const myId = UserStore.getCurrentUser().id;
|
||||
|
||||
for (const state of voiceStates) {
|
||||
const { userId, channelId, guildId } = state;
|
||||
const isMe = userId === myId;
|
||||
|
||||
if (!guildId) {
|
||||
// guildId is never undefined here
|
||||
continue;
|
||||
}
|
||||
|
||||
// check if the state does not actually has a `oldChannelId` property
|
||||
if (!("oldChannelId" in state) && !runOneTime && !settings.store.watchLargeGuilds) {
|
||||
// batch update triggered. This is ignored because it
|
||||
// is caused by opening a previously unopened guild
|
||||
continue;
|
||||
}
|
||||
|
||||
let { oldChannelId } = state;
|
||||
if (isMe && channelId !== myLastChannelId) {
|
||||
oldChannelId = myLastChannelId;
|
||||
myLastChannelId = channelId;
|
||||
}
|
||||
|
||||
if (channelId !== oldChannelId) {
|
||||
if (channelId) {
|
||||
// move or join
|
||||
addUserJoinTime(userId, channelId, guildId);
|
||||
} else if (oldChannelId) {
|
||||
// leave
|
||||
removeUserJoinTime(userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
runOneTime = false;
|
||||
},
|
||||
PASSIVE_UPDATE_V1(passiveUpdate: PassiveUpdateState) {
|
||||
if (settings.store.watchLargeGuilds) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { voiceStates } = passiveUpdate;
|
||||
if (!voiceStates) {
|
||||
// if there are no users in a voice call
|
||||
return;
|
||||
}
|
||||
|
||||
// find all users that have the same guildId and if that user is not in the voiceStates, remove them from the map
|
||||
const { guildId } = passiveUpdate;
|
||||
|
||||
// check the guildId in the userJoinTimes map
|
||||
for (const [userId, data] of userJoinTimes) {
|
||||
if (data.guildId === guildId) {
|
||||
// check if the user is in the voiceStates
|
||||
const userInVoiceStates = voiceStates.find(state => state.userId === userId);
|
||||
if (!userInVoiceStates) {
|
||||
// remove the user from the map
|
||||
removeUserJoinTime(userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// since we were gifted this data let's use it to update our join times
|
||||
for (const state of voiceStates) {
|
||||
const { userId, channelId } = state;
|
||||
|
||||
if (!channelId) {
|
||||
// channelId is never undefined here
|
||||
continue;
|
||||
}
|
||||
|
||||
// check if the user is in the map
|
||||
if (userJoinTimes.has(userId)) {
|
||||
// check if the user is in a channel
|
||||
if (channelId !== userJoinTimes.get(userId)?.channelId) {
|
||||
// update the user's join time
|
||||
addUserJoinTime(userId, channelId, guildId);
|
||||
}
|
||||
} else {
|
||||
// user wasn't previously tracked, add the user to the map
|
||||
addUserJoinTime(userId, channelId, guildId);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
subscribeToAllGuilds() {
|
||||
// we need to subscribe to all guilds' events because otherwise we would miss updates on large guilds
|
||||
const guilds = Object.values(GuildStore.getGuilds()).map(guild => guild.id);
|
||||
const subscriptions = guilds.reduce((acc, id) => ({ ...acc, [id]: { typing: true } }), {});
|
||||
FluxDispatcher.dispatch({ type: "GUILD_SUBSCRIPTIONS_FLUSH", subscriptions });
|
||||
},
|
||||
|
||||
start() {
|
||||
if (settings.store.watchLargeGuilds) {
|
||||
this.subscribeToAllGuilds();
|
||||
}
|
||||
},
|
||||
|
||||
showClockInjection(property: { props: { user: { id: string; }; }; }) {
|
||||
if (settings.store.showWithoutHover) {
|
||||
return "";
|
||||
}
|
||||
return this.showInjection(property);
|
||||
},
|
||||
|
||||
showTextInjection(property: { props: { user: { id: string; }; }; }) {
|
||||
if (!settings.store.showWithoutHover) {
|
||||
return "";
|
||||
}
|
||||
return this.showInjection(property);
|
||||
},
|
||||
|
||||
showInjection(property: { props: { user: { id: string; }; }; }) {
|
||||
const userId = property.props.user.id;
|
||||
return this.renderTimer(userId);
|
||||
},
|
||||
|
||||
renderTimer(userId: string) {
|
||||
// get the user join time from the users object
|
||||
const joinTime = userJoinTimes.get(userId);
|
||||
if (!joinTime?.time) {
|
||||
// join time is unknown
|
||||
return;
|
||||
}
|
||||
if (userId === UserStore.getCurrentUser().id && !settings.store.trackSelf) {
|
||||
// don't show for self
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Timer time={joinTime.time} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
},
|
||||
});
|
||||
15
src/equicordplugins/allCallTimers/timerText.tsx
Normal file
15
src/equicordplugins/allCallTimers/timerText.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export function TimerText({ text, className }: Readonly<{ text: string; className: string; }>) {
|
||||
return <div className={`timeCounter ${className}`} style={{
|
||||
marginTop: -6, // this margin value doesn't change the default size of the user container
|
||||
fontWeight: "bold",
|
||||
fontFamily: "monospace",
|
||||
fontSize: 11, // good size that doesn't touch username
|
||||
position: "relative",
|
||||
}}>{text}</div>;
|
||||
}
|
||||
5
src/equicordplugins/allowedMentions/AllowedMentions.css
Normal file
5
src/equicordplugins/allowedMentions/AllowedMentions.css
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
.vc-allowed-mentions-popout-menu {
|
||||
height: 250px;
|
||||
width: 200px;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
386
src/equicordplugins/allowedMentions/AllowedMentions.tsx
Normal file
386
src/equicordplugins/allowedMentions/AllowedMentions.tsx
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import "./AllowedMentions.css";
|
||||
|
||||
import { Flex } from "@components/Flex";
|
||||
import { isNonNullish } from "@utils/guards";
|
||||
import { useForceUpdater } from "@utils/react";
|
||||
import { findByPropsLazy } from "@webpack";
|
||||
import { Clickable, Forms, GuildMemberStore, GuildStore, Menu, Popout as DiscordPopout, RelationshipStore, TextInput, useEffect, UserStore, useState } from "@webpack/common";
|
||||
import { Channel } from "discord-types/general";
|
||||
import { CSSProperties, ReactNode } from "react";
|
||||
|
||||
export type AllowedMentionsParsables = "everyone" | "users" | "roles";
|
||||
|
||||
export interface AllowedMentions {
|
||||
parse: Set<AllowedMentionsParsables>;
|
||||
users?: Set<string>;
|
||||
roles?: Set<string>;
|
||||
meta: {
|
||||
hasEveryone: boolean;
|
||||
userIds: Set<string>;
|
||||
roleIds: Set<string>;
|
||||
tooManyUsers: boolean;
|
||||
tooManyRoles: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EditAttachments {
|
||||
attachments: File[];
|
||||
}
|
||||
|
||||
export interface AllowedMentionsProps {
|
||||
mentions: AllowedMentions,
|
||||
channel: Channel;
|
||||
trailingSeparator?: boolean;
|
||||
}
|
||||
|
||||
const replyClasses = findByPropsLazy("replyBar", "replyLabel", "separator");
|
||||
export const AllowedMentionsStore = {
|
||||
store: new Map<string, AllowedMentions>(),
|
||||
callbacks: new Map<string, (mentions: AllowedMentions) => void>,
|
||||
get(channelId: string) {
|
||||
return this.store.get(channelId);
|
||||
},
|
||||
set(channelId: string, mentions: AllowedMentions, dispatch: boolean) {
|
||||
this.store.set(channelId, mentions);
|
||||
dispatch && this.callbacks.get(channelId)?.(mentions);
|
||||
},
|
||||
delete(channelId: string) {
|
||||
return this.store.delete(channelId);
|
||||
},
|
||||
clear() {
|
||||
return this.store.clear();
|
||||
},
|
||||
subscribe(channelId: string, callback: (mentions: AllowedMentions) => void) {
|
||||
return this.callbacks.set(channelId, callback);
|
||||
},
|
||||
unsubscribe(channelId: string) {
|
||||
return this.callbacks.delete(channelId);
|
||||
}
|
||||
};
|
||||
|
||||
function getDisplayableUserNameParts(userId: string, guildId: string | null) {
|
||||
// @ts-ignore discord-types doesn't have globalName
|
||||
const { globalName, username } = UserStore.getUser(userId) ?? {};
|
||||
const nickname = guildId ? GuildMemberStore.getNick(guildId, userId) : RelationshipStore.getNickname(userId);
|
||||
|
||||
return [nickname, globalName as string, username];
|
||||
}
|
||||
|
||||
function getDisplayableUserName(userId: string, guildId: string | null) {
|
||||
const [nickname, globalName, username] = getDisplayableUserNameParts(userId, guildId);
|
||||
|
||||
// Displayed name priority
|
||||
// Guild/Friend Nickname > Global Name > Username
|
||||
|
||||
// User id if not cached in any stores
|
||||
return nickname ?? globalName ?? username ?? userId;
|
||||
}
|
||||
|
||||
function getDisplayableRoleName(roleId: string, guildId: string | null) {
|
||||
// You *can* mention roles in DMs but is it really worth adding the UI for
|
||||
// it in DMs
|
||||
const role = guildId ? Object.values(GuildStore.getGuild(guildId).roles).find(r => r.id === roleId)?.name : undefined;
|
||||
|
||||
// Role id if not cached or not from current guild
|
||||
return role ?? roleId;
|
||||
}
|
||||
|
||||
function fuzzySearch(searchQuery: string, searchString: string) {
|
||||
let searchIndex = 0;
|
||||
let score = 0;
|
||||
|
||||
for (let i = 0; i < searchString.length; i++) {
|
||||
if (searchString[i] === searchQuery[searchIndex]) {
|
||||
score++;
|
||||
searchIndex++;
|
||||
} else {
|
||||
score--;
|
||||
}
|
||||
|
||||
if (searchIndex === searchQuery.length) {
|
||||
return score;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function AtIcon({ width, height }: { width: number, height: number; }) {
|
||||
return <svg width={width} height={height} viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 2C6.486 2 2 6.486 2 12C2 17.515 6.486 22 12 22C14.039 22 15.993
|
||||
21.398 17.652 20.259L16.521 18.611C15.195 19.519 13.633 20 12 20C7.589
|
||||
20 4 16.411 4 12C4 7.589 7.589 4 12 4C16.411 4 20 7.589 20 12V12.782C20
|
||||
14.17 19.402 15 18.4 15L18.398 15.018C18.338 15.005 18.273 15 18.209
|
||||
15H18C17.437 15 16.6 14.182 16.6 13.631V12C16.6 9.464 14.537 7.4 12
|
||||
7.4C9.463 7.4 7.4 9.463 7.4 12C7.4 14.537 9.463 16.6 12 16.6C13.234 16.6
|
||||
14.35 16.106 15.177 15.313C15.826 16.269 16.93 17 18 17L18.002
|
||||
16.981C18.064 16.994 18.129 17 18.195 17H18.4C20.552 17 22 15.306 22
|
||||
12.782V12C22 6.486 17.514 2 12 2ZM12 14.599C10.566 14.599 9.4 13.433 9.4
|
||||
11.999C9.4 10.565 10.566 9.399 12 9.399C13.434 9.399 14.6 10.565 14.6
|
||||
11.999C14.6 13.433 13.434 14.599 12 14.599Z"
|
||||
/>
|
||||
</svg>;
|
||||
}
|
||||
|
||||
function Title({ children, pointer, style }: { children: ReactNode; pointer?: boolean; style?: CSSProperties; }) {
|
||||
return <Forms.FormTitle style={{ margin: 0, cursor: pointer ? "pointer" : "default", ...style }}>{children}</Forms.FormTitle>;
|
||||
}
|
||||
|
||||
function TitleSwitch({ state, setState, children }: { state: boolean, setState: (value: boolean) => void; children: ReactNode; }) {
|
||||
return <Clickable onClick={() => setState(!state)}>
|
||||
<Title
|
||||
style={{ ...(state ? { color: "var(--text-link)" } : {}), display: "flex", gap: "0.2rem", userSelect: "none" }}
|
||||
pointer
|
||||
>
|
||||
{children}
|
||||
</Title>
|
||||
</Clickable>;
|
||||
}
|
||||
|
||||
function Separator() {
|
||||
return <div className={replyClasses.separator}></div>;
|
||||
}
|
||||
|
||||
function Popout({
|
||||
title,
|
||||
shouldShow,
|
||||
setShouldShow,
|
||||
update,
|
||||
fuzzy,
|
||||
ids,
|
||||
rawIds,
|
||||
guildId,
|
||||
getDisplayableName,
|
||||
all,
|
||||
setAll,
|
||||
}: {
|
||||
title: string,
|
||||
shouldShow: boolean,
|
||||
setShouldShow: (value: boolean) => void,
|
||||
update: () => void,
|
||||
fuzzy: (search: string, id: string) => number | null,
|
||||
ids: Set<string>,
|
||||
rawIds: Set<string>,
|
||||
guildId: string | null,
|
||||
getDisplayableName: (userId: string, guildId: string | null) => string | undefined,
|
||||
all: boolean,
|
||||
setAll: (value: boolean) => void,
|
||||
}) {
|
||||
const [search, setSearch] = useState(undefined as undefined | string);
|
||||
|
||||
return <DiscordPopout
|
||||
animation={DiscordPopout.Animation.SCALE}
|
||||
align="center"
|
||||
position="top"
|
||||
shouldShow={shouldShow}
|
||||
onRequestClose={() => setShouldShow(false)}
|
||||
renderPopout={() => {
|
||||
return <Menu.Menu
|
||||
navId={`vc-allowed-mentions-${title}-popout`}
|
||||
onClose={() => setShouldShow(false)}
|
||||
className="vc-allowed-mentions-popout-menu"
|
||||
>
|
||||
<Menu.MenuCheckboxItem
|
||||
id={`vc-allowed-mentions-${title}-popout-all`}
|
||||
label="All"
|
||||
checked={all}
|
||||
action={() => {
|
||||
// If all are selected, deselect them,
|
||||
// otherwise select the remaining ones.
|
||||
if (ids.size === rawIds.size) {
|
||||
ids.clear();
|
||||
setAll(false);
|
||||
update();
|
||||
} else {
|
||||
rawIds.forEach(id => ids.add(id));
|
||||
setAll(true);
|
||||
update();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Menu.MenuSeparator />
|
||||
<Menu.MenuItem
|
||||
label="Search"
|
||||
id={`vc-allowed-mentions-${title}-popout-search`}
|
||||
render={() => {
|
||||
return <TextInput
|
||||
placeholder={`Search ${title.toLowerCase()}`}
|
||||
type="text"
|
||||
maxLength={32}
|
||||
role="combobox"
|
||||
value={search}
|
||||
onChange={value => setSearch(value.trim())}
|
||||
style={{ margin: "2px 0", padding: "6px 8px" }}
|
||||
onKeyDown={e => {
|
||||
if (e.key === "Escape") {
|
||||
setSearch(undefined);
|
||||
} else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
|
||||
// Some random event listener is blocking
|
||||
// left & right arrow keys so you can't
|
||||
// navigate the text with arrow keys unless
|
||||
// you do e.stopPropogation
|
||||
e.stopPropagation();
|
||||
} else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
// Pressing up/down arrow keys leads to a messy
|
||||
// UI state, blurring the text input fixes it.
|
||||
// (kinda)
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>;
|
||||
}}
|
||||
/>
|
||||
{(isNonNullish(search) ?
|
||||
Array.from(rawIds).map(id => ({
|
||||
score: fuzzy(search, id),
|
||||
name: getDisplayableName(id, guildId),
|
||||
id: id
|
||||
})
|
||||
).filter(o => isNonNullish(o.score))
|
||||
.sort((a, b) => b.score! - a.score!)
|
||||
: Array.from(rawIds).map(id => ({
|
||||
score: 0,
|
||||
name: getDisplayableName(id, guildId),
|
||||
id: id
|
||||
}))
|
||||
)
|
||||
.map(object => {
|
||||
return <Menu.MenuCheckboxItem
|
||||
id={`vc-allowed-mentions-${title}-popout-${object.id}`}
|
||||
label={object.name!}
|
||||
checked={all || ids.has(object.id)}
|
||||
action={() => {
|
||||
all || ids.has(object.id) ? ids.delete(object.id) : ids.add(object.id);
|
||||
setAll(ids.size === rawIds.size);
|
||||
update();
|
||||
}}
|
||||
/>;
|
||||
})}
|
||||
</Menu.Menu>;
|
||||
}}
|
||||
>
|
||||
{
|
||||
(_, { isShown }) => {
|
||||
return <Clickable onClick={() => setShouldShow(!isShown)}>
|
||||
<Title pointer>{title}</Title>
|
||||
</Clickable>;
|
||||
}
|
||||
}
|
||||
</DiscordPopout>;
|
||||
}
|
||||
|
||||
export function AllowedMentionsBar({ mentions, channel, trailingSeparator }: AllowedMentionsProps) {
|
||||
const [users, setUsers] = useState(new Set(mentions.users));
|
||||
const [roles, setRoles] = useState(new Set(mentions.users));
|
||||
const [everyone, setEveryone] = useState(mentions.parse.has("everyone"));
|
||||
const [allUsers, setAllUsers] = useState(users.size === mentions.meta.userIds.size);
|
||||
const [allRoles, setAllRoles] = useState(roles.size === mentions.meta.roleIds.size);
|
||||
|
||||
useEffect(() => {
|
||||
AllowedMentionsStore.subscribe(
|
||||
channel.id,
|
||||
mentions => {
|
||||
allUsers && mentions.users && setUsers(new Set(mentions.users));
|
||||
allRoles && mentions.roles && setRoles(new Set(mentions.roles));
|
||||
}
|
||||
);
|
||||
|
||||
return () => { AllowedMentionsStore.unsubscribe(channel.id); };
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
AllowedMentionsStore.set(
|
||||
channel.id,
|
||||
{
|
||||
parse: new Set(
|
||||
[
|
||||
everyone && "everyone",
|
||||
allUsers && "users",
|
||||
allRoles && "roles"
|
||||
].filter(v => v) as AllowedMentionsParsables[]
|
||||
),
|
||||
users: allUsers || users.size === 0 ? undefined : users,
|
||||
roles: allRoles || roles.size === 0 ? undefined : roles,
|
||||
meta: {
|
||||
...mentions.meta,
|
||||
tooManyUsers: users.size > 100,
|
||||
tooManyRoles: roles.size > 100,
|
||||
}
|
||||
},
|
||||
false
|
||||
);
|
||||
}, [
|
||||
mentions,
|
||||
everyone,
|
||||
allUsers,
|
||||
allRoles,
|
||||
users,
|
||||
roles,
|
||||
]);
|
||||
|
||||
const [shouldShowUsersPopout, setShouldShowUsersPopout] = useState(false);
|
||||
const [shouldShowRolesPopout, setShouldShowRolesPopout] = useState(false);
|
||||
const update = useForceUpdater();
|
||||
|
||||
const displayEveryone = mentions.meta.hasEveryone;
|
||||
const displayUserIds = mentions.meta.userIds.size > 0;
|
||||
const displayRoleIds = mentions.meta.roleIds.size > 0;
|
||||
|
||||
return <Flex style={{ gap: "1rem", alignItems: "center" }}>
|
||||
{displayEveryone && <>
|
||||
<TitleSwitch state={everyone} setState={setEveryone}>
|
||||
<AtIcon width={16} height={16} />
|
||||
everyone /
|
||||
<AtIcon width={16} height={16} />
|
||||
here
|
||||
</TitleSwitch>
|
||||
</>}
|
||||
{displayUserIds && <>
|
||||
{displayEveryone && <Separator />}
|
||||
<Popout
|
||||
title="Users"
|
||||
shouldShow={shouldShowUsersPopout}
|
||||
setShouldShow={setShouldShowUsersPopout}
|
||||
update={update}
|
||||
fuzzy={(search, userId) => {
|
||||
const samples = getDisplayableUserNameParts(userId, channel.guild_id)
|
||||
.filter(isNonNullish)
|
||||
.map(name => fuzzySearch(search, name))
|
||||
.filter(isNonNullish) as number[];
|
||||
return samples.length > 0 ? Math.max(...samples) : null;
|
||||
}}
|
||||
ids={users}
|
||||
rawIds={mentions.meta.userIds}
|
||||
guildId={channel.guild_id}
|
||||
getDisplayableName={getDisplayableUserName}
|
||||
all={allUsers}
|
||||
setAll={setAllUsers}
|
||||
/>
|
||||
</>}
|
||||
{displayRoleIds && <>
|
||||
{(displayEveryone || displayUserIds) && <Separator />}
|
||||
<Popout
|
||||
title="Roles"
|
||||
shouldShow={shouldShowRolesPopout}
|
||||
setShouldShow={setShouldShowRolesPopout}
|
||||
update={update}
|
||||
fuzzy={(search, roleId) => fuzzySearch(search, getDisplayableRoleName(roleId, channel.guild_id).toLowerCase())}
|
||||
ids={roles}
|
||||
rawIds={mentions.meta.roleIds}
|
||||
guildId={channel.guild_id}
|
||||
getDisplayableName={getDisplayableRoleName}
|
||||
all={allRoles}
|
||||
setAll={setAllRoles}
|
||||
/>
|
||||
</>}
|
||||
{trailingSeparator && (displayEveryone || displayUserIds || displayRoleIds) && <Separator />}
|
||||
</Flex>;
|
||||
}
|
||||
274
src/equicordplugins/allowedMentions/index.tsx
Normal file
274
src/equicordplugins/allowedMentions/index.tsx
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { addPreSendListener, MessageExtra, removePreSendListener } from "@api/MessageEvents";
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { Flex } from "@components/Flex";
|
||||
import { Devs } from "@utils/constants";
|
||||
import { isNonNullish } from "@utils/guards";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { Alerts, GuildStore, PermissionsBits, PermissionStore } from "@webpack/common";
|
||||
import { Channel } from "discord-types/general";
|
||||
|
||||
import { AllowedMentions, AllowedMentionsBar, AllowedMentionsProps, AllowedMentionsStore as store } from "./AllowedMentions";
|
||||
|
||||
export default definePlugin({
|
||||
name: "AllowedMentions",
|
||||
authors: [Devs.arHSM, Devs.amia],
|
||||
description: "Fine grained control over whom to ping when sending or editing a message.",
|
||||
dependencies: ["MessageEventsAPI"],
|
||||
settings: definePluginSettings({
|
||||
pingEveryone: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Mention everyone by default",
|
||||
default: false,
|
||||
},
|
||||
pingAllUsers: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Mention all users by default",
|
||||
default: true,
|
||||
},
|
||||
pingAllRoles: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Mention all roles by default",
|
||||
default: true,
|
||||
}
|
||||
}),
|
||||
patches: [
|
||||
{
|
||||
find: ".slateContainer)",
|
||||
replacement: [
|
||||
// Pass type prop to slate wrapper
|
||||
{
|
||||
match: /,children:\(0,\i.jsx\)\(\i.\i,{/,
|
||||
replace: "$& type: arguments[0].type,"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
find: '"chat input type must be set");',
|
||||
replacement: [
|
||||
// Set allowedMentions & populate attachments store
|
||||
{
|
||||
match: /"chat input type must be set"\);/,
|
||||
replace: "$& $self.allowedSlateTypes.includes(arguments[0].type?.analyticsName) && $self.setAllowedMentions(arguments[0]);"
|
||||
},
|
||||
// Add the hasConnectedBar class when AllowedMentionsBar is visible
|
||||
{
|
||||
match: /.hasConnectedBar]:\i/,
|
||||
replace: '$& || $self.getAllowedMentions(arguments[0].channel.id, arguments[0].type?.analyticsName === "edit"),'
|
||||
},
|
||||
// Pass mentions to attached bars component
|
||||
// Would do a simple isEdit but the component is memo'd
|
||||
{
|
||||
match: /activeCommand:\i,pendingReply:\i/,
|
||||
replace: '$&, mentions: $self.getAllowedMentions(arguments[0].channel.id, arguments[0].type?.analyticsName === "edit"),'
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
find: ".stackedAttachedBar]:!",
|
||||
replacement: [
|
||||
// Add AllowedMentionsBar when not replying
|
||||
// Will never render if above patch fails
|
||||
{
|
||||
match: /(?<=pendingReply:\i}=(\i),.+?)null!=(\i)&&(\i).push\(\(0,\i.jsx\)\(\i.\i,{reply:\i,/,
|
||||
replace: "null == $2 && null != $1.mentions && $3.push($self.AllowedMentionsBar({ mentions: $1.mentions, channel: $1.channel })), $&"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
find: ".Messages.REPLYING_TO.format({",
|
||||
replacement: [
|
||||
// Add AllowedMentionsBar to reply bar when replying
|
||||
{
|
||||
match: /(?<="div",\{className:\i.actions,children:\[)(?=\i&&)/,
|
||||
replace: "null != $self.getAllowedMentions(arguments[0].reply.channel.id, false) && $self.AllowedMentionsBarInner({ mentions: $self.getAllowedMentions(arguments[0].reply.channel.id, false), channel: arguments[0].reply.channel, trailingSeparator: true }),",
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
find: ".Messages.EVERYONE_POPOUT_BODY",
|
||||
replacement: [
|
||||
// Remove the warning popout for large server when @everyone mention is off
|
||||
{
|
||||
match: /(?<=shouldShowEveryoneGuard\(\i,(\i)\))/,
|
||||
replace: "|| $self.skipEveryoneContentWarningPopout($1.id)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
find: '"?use_nested_fields=true"',
|
||||
replacement: [
|
||||
// Patch sending allowed_mentions for forum creation
|
||||
{
|
||||
match: /(?<=.Endpoints.CHANNEL_THREADS\((\i.id)\)\+"\?use_nested_fields=true".+?message:\{)/,
|
||||
replace: "allowed_mentions: $self.patchForumAllowedMentions($1),"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
find: ".ComponentActions.FOCUS_COMPOSER_TITLE,",
|
||||
replacement: [
|
||||
// Clear entry on cancelling new forum post
|
||||
{
|
||||
match: /.trackForumNewPostCleared\)\(\{guildId:\i.guild_id,channelId:(\i.id)\}\)/,
|
||||
replace: "$&; $self.onForumCancel($1);"
|
||||
},
|
||||
// Fail creating forum if tooManyUsers or tooManyRoles
|
||||
{
|
||||
match: /applyChatRestrictions\)\(\{.+?channel:(\i)\}\);if\(!\i/,
|
||||
replace: "$& || !$self.validateForum($1.id)"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
allowedSlateTypes: ["normal", "sidebar", "thread_creation", "create_forum_post"],
|
||||
getAllowedMentions(channelId: string, shouldDelete?: boolean) {
|
||||
const mentions = store.get(channelId);
|
||||
|
||||
if (shouldDelete) { store.delete(channelId); }
|
||||
|
||||
return mentions;
|
||||
},
|
||||
setAllowedMentions({ richValue, channel: { id: channelId, guild_id: guildId } }: { richValue: any, channel: Channel; }) {
|
||||
const previous = store.get(channelId);
|
||||
|
||||
const canMentionEveryone = isNonNullish(guildId) ? PermissionStore.can(PermissionsBits.MENTION_EVERYONE, GuildStore.getGuild(guildId)) as boolean : true;
|
||||
|
||||
const mentions: AllowedMentions = {
|
||||
parse: new Set(),
|
||||
users: previous?.users ?? new Set(),
|
||||
roles: previous?.roles ?? new Set(),
|
||||
meta: {
|
||||
hasEveryone: false,
|
||||
userIds: new Set(),
|
||||
roleIds: new Set(),
|
||||
tooManyUsers: false,
|
||||
tooManyRoles: false,
|
||||
}
|
||||
};
|
||||
|
||||
if (!isNonNullish(richValue[0]?.children)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Discord renders the slate wrapper twice
|
||||
// 1. unparsed raw text
|
||||
// 2. parsed text (we need this)
|
||||
// We skip setting allowed mentions for unparsed text cause there can be potential unparsed mentions
|
||||
if (richValue[0]?.children.length === 1 && typeof richValue[0]?.children[0].text === "string") {
|
||||
// This is the case where the input is empty (no potential unparsed mentions)
|
||||
if (richValue[0]?.children[0].text === "") { store.delete(channelId); }
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
for (const node of richValue[0].children) {
|
||||
switch (node.type) {
|
||||
case "userMention":
|
||||
mentions.meta.userIds.add(node.userId);
|
||||
break;
|
||||
case "roleMention":
|
||||
mentions.meta.roleIds.add(node.roleId);
|
||||
break;
|
||||
case "textMention":
|
||||
if (node.name === "@everyone" || node.name === "@here") {
|
||||
mentions.meta.hasEveryone = canMentionEveryone;
|
||||
|
||||
if (canMentionEveryone && (previous?.parse.has?.("everyone") ?? this.settings.store.pingEveryone)) {
|
||||
mentions.parse.add("everyone");
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.settings.store.pingAllUsers) { mentions.users = mentions.meta.userIds; }
|
||||
if (this.settings.store.pingAllRoles) { mentions.roles = mentions.meta.roleIds; }
|
||||
|
||||
if (
|
||||
!mentions.meta.hasEveryone
|
||||
&& mentions.meta.userIds.size === 0
|
||||
&& mentions.meta.roleIds.size === 0
|
||||
) {
|
||||
store.delete(channelId);
|
||||
} else {
|
||||
store.set(channelId, mentions, true);
|
||||
}
|
||||
},
|
||||
skipEveryoneContentWarningPopout(channelId: string) {
|
||||
const mentions = store.get(channelId);
|
||||
return isNonNullish(mentions) && !mentions.parse.has("everyone");
|
||||
},
|
||||
tooManyAlert(tooManyUsers: boolean, tooManyRoles: boolean) {
|
||||
const type = [
|
||||
tooManyUsers && "users",
|
||||
tooManyRoles && "roles"
|
||||
].filter(x => x).join(" and ");
|
||||
|
||||
Alerts.show({
|
||||
title: "Uh oh!",
|
||||
body: `You've selected too many individual ${type} to mention!\nYou may only select all or up to 100 items in each category.`
|
||||
});
|
||||
},
|
||||
validateForum(channelId: string) {
|
||||
const mentions = this.getAllowedMentions(channelId, true);
|
||||
if (!isNonNullish(mentions)) return;
|
||||
|
||||
if (mentions.meta.tooManyUsers || mentions.meta.tooManyRoles) {
|
||||
this.tooManyAlert(mentions.meta.tooManyUsers, mentions.meta.tooManyRoles);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
patchSendAllowedMentions(channelId: string, extra: MessageExtra) {
|
||||
const mentions = this.getAllowedMentions(channelId, true);
|
||||
if (!isNonNullish(mentions)) return;
|
||||
|
||||
if (mentions.meta.tooManyUsers || mentions.meta.tooManyRoles) {
|
||||
this.tooManyAlert(mentions.meta.tooManyUsers, mentions.meta.tooManyRoles);
|
||||
return { cancel: true };
|
||||
}
|
||||
|
||||
extra.replyOptions.allowedMentions = {
|
||||
parse: Array.from(mentions.parse),
|
||||
users: mentions.users ? Array.from(mentions.users) : undefined,
|
||||
roles: mentions.roles ? Array.from(mentions.roles) : undefined,
|
||||
// Don't override this for send! Discord already has a UI for this
|
||||
repliedUser: extra.replyOptions.allowedMentions?.repliedUser ?? false,
|
||||
};
|
||||
},
|
||||
patchForumAllowedMentions(channelId: string) {
|
||||
const mentions = this.getAllowedMentions(channelId, true);
|
||||
if (!isNonNullish(mentions)) return;
|
||||
|
||||
return {
|
||||
parse: Array.from(mentions.parse),
|
||||
users: mentions.users ? Array.from(mentions.users) : undefined,
|
||||
roles: mentions.roles ? Array.from(mentions.roles) : undefined,
|
||||
};
|
||||
},
|
||||
onForumCancel(channelId: string) {
|
||||
store.delete(channelId);
|
||||
},
|
||||
AllowedMentionsBar(props: AllowedMentionsProps) {
|
||||
return <Flex style={{ padding: "0.45rem 1rem", lineHeight: "16px" }}>
|
||||
{<this.AllowedMentionsBarInner {...props} />}
|
||||
</Flex>;
|
||||
},
|
||||
AllowedMentionsBarInner(props: AllowedMentionsProps) {
|
||||
return <AllowedMentionsBar {...props} />;
|
||||
},
|
||||
start() {
|
||||
this.preSend = addPreSendListener((channelId, _, extra) => this.patchSendAllowedMentions(channelId, extra));
|
||||
},
|
||||
stop() {
|
||||
removePreSendListener(this.preSend);
|
||||
store.clear();
|
||||
},
|
||||
});
|
||||
33
src/equicordplugins/altKrispSwitch/index.tsx
Normal file
33
src/equicordplugins/altKrispSwitch/index.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Devs } from "@utils/constants";
|
||||
import definePlugin from "@utils/types";
|
||||
|
||||
export default definePlugin({
|
||||
name: "AltKrispSwitch",
|
||||
description: "Makes the Noise Suppression Popout switch between None and Krisp instead of Krisp and Standard",
|
||||
authors: [Devs.newwares],
|
||||
patches: [
|
||||
{
|
||||
find: ",setNoiseCancellation(",
|
||||
replacement: {
|
||||
match: /(}\),)(.{1,2}\.default\.dispatch\({type:"AUDIO_SET_NOISE_SUPPRESSION",)/,
|
||||
replace: "$1!$self.shouldCancelSuppression(arguments)&&$2"
|
||||
}
|
||||
}
|
||||
],
|
||||
shouldCancelSuppression([enableKrisp, { section: origin }]) {
|
||||
if (origin === "Noise Cancellation Popout") {
|
||||
if (enableKrisp) {
|
||||
return false;
|
||||
} else {
|
||||
// don't enable standard noise suppression if disabling
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
106
src/equicordplugins/annamox/index.ts
Normal file
106
src/equicordplugins/annamox/index.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { Devs } from "@utils/constants";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
|
||||
export const settings = definePluginSettings({
|
||||
dms: {
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
description: "Remove shops above DMs list",
|
||||
restartNeeded: true,
|
||||
},
|
||||
billing: {
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
description: "Remove billing settings",
|
||||
restartNeeded: true,
|
||||
},
|
||||
gift: {
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
description: "Remove gift button",
|
||||
restartNeeded: true,
|
||||
},
|
||||
emojiList: {
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
description: "Remove unavailable categories from the emoji picker",
|
||||
restartNeeded: true,
|
||||
},
|
||||
});
|
||||
|
||||
export default definePlugin({
|
||||
name: "Anammox",
|
||||
description: "A microbial process that plays an important part in the nitrogen cycle",
|
||||
authors: [Devs.Kyuuhachi],
|
||||
settings,
|
||||
|
||||
patches: [
|
||||
{ // Above DMs, mouse nav
|
||||
find: 'tutorialId:"direct-messages"',
|
||||
replacement: [
|
||||
{
|
||||
match: /"premium"\)/,
|
||||
replace: "$&&&undefined",
|
||||
},
|
||||
{
|
||||
match: /"discord-shop"\)/,
|
||||
replace: "$&&&undefined",
|
||||
},
|
||||
],
|
||||
predicate: () => settings.store.dms,
|
||||
},
|
||||
{ // Above DMs, keyboard nav
|
||||
find: ".default.hasLibraryApplication()&&!",
|
||||
replacement: [
|
||||
{
|
||||
match: /\i\.Routes\.APPLICATION_STORE,/,
|
||||
replace: "/*$&*/",
|
||||
},
|
||||
{
|
||||
match: /\i\.Routes\.COLLECTIBLES_SHOP,/,
|
||||
replace: "/*$&*/",
|
||||
},
|
||||
],
|
||||
predicate: () => settings.store.dms,
|
||||
},
|
||||
{ // Settings, sidebar
|
||||
find: "Messages.BILLING_SETTINGS",
|
||||
replacement: {
|
||||
match: /\{section:\i\.SectionTypes\.HEADER,label:\i\.default\.Messages\.BILLING_SETTINGS\},.*?\{section:\i\.SectionTypes\.DIVIDER\},/,
|
||||
replace: "/*$&*/"
|
||||
},
|
||||
predicate: () => settings.store.billing,
|
||||
},
|
||||
{ // Gift button
|
||||
find: 'Messages.PREMIUM_GIFT_BUTTON_LABEL,"aria-haspopup":"dialog",onClick:',
|
||||
replacement: {
|
||||
match: /if\(\w+\)return null;/,
|
||||
replace: "return null;",
|
||||
},
|
||||
predicate: () => settings.store.gift,
|
||||
},
|
||||
{ // Emoji list
|
||||
find: "useEmojiGrid:function()",
|
||||
replacement: {
|
||||
match: /(\w+)=!\w+&&\w+.default.isEmojiCategoryNitroLocked\(\{[^}]*\}\);/,
|
||||
replace: "$&$1||"
|
||||
},
|
||||
predicate: () => settings.store.emojiList,
|
||||
},
|
||||
{ // Emoji category list
|
||||
find: ".Messages.EMOJI_PICKER_SCROLL_TO_UNICODE_A11Y_LABEL",
|
||||
replacement: {
|
||||
match: /(\w+)=\(0,\w+.useCategoryNitroLockedStates\)\(\w+,\w+,(\w+)\)/,
|
||||
replace: "$&,__98=($2=$2.filter((_,$2)=>!$1[$2]))"
|
||||
},
|
||||
predicate: () => settings.store.emojiList,
|
||||
}
|
||||
],
|
||||
});
|
||||
84
src/equicordplugins/betterQuickReact/index.tsx
Normal file
84
src/equicordplugins/betterQuickReact/index.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import "./style.css";
|
||||
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { makeRange } from "@components/PluginSettings/components";
|
||||
import { Devs } from "@utils/constants";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
|
||||
export const settings = definePluginSettings({
|
||||
frequentEmojis: {
|
||||
description: "Use frequently used emojis instead of favourite emojis",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true
|
||||
},
|
||||
rows: {
|
||||
description: "Rows of quick reactions to display",
|
||||
type: OptionType.SLIDER,
|
||||
default: 2,
|
||||
markers: makeRange(1, 16, 1),
|
||||
stickToMarkers: true
|
||||
},
|
||||
columns: {
|
||||
description: "Columns of quick reactions to display",
|
||||
type: OptionType.SLIDER,
|
||||
default: 4,
|
||||
markers: makeRange(1, 12, 1),
|
||||
stickToMarkers: true
|
||||
},
|
||||
compactMode: {
|
||||
description: "Scales the buttons to 75% of their original scale, whilst increasing the inner emoji to 125% scale. Emojis will be 93.75% of the original size. Recommended to have a minimum of 5 columns",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
export default definePlugin({
|
||||
name: "BetterQuickReact",
|
||||
description: "Improves the quick react buttons in the message context menu.",
|
||||
authors: [Devs.Ven, Devs.Sqaaakoi],
|
||||
settings,
|
||||
|
||||
patches: [
|
||||
// Remove favourite emojis from being inserted at the start of the reaction list
|
||||
{
|
||||
find: "this.favoriteEmojisWithoutFetchingLatest.concat",
|
||||
replacement: {
|
||||
match: /(this\.favoriteEmojisWithoutFetchingLatest)\.concat/,
|
||||
replace: "($self.settings.store.frequentEmojis?[]:$1).concat"
|
||||
}
|
||||
},
|
||||
// Override limit of emojis to display
|
||||
{
|
||||
find: "default.Messages.ADD_REACTION_NAMED.format",
|
||||
replacement: {
|
||||
match: /(\i)\.length>4&&\((\i)\.length=4\);/,
|
||||
replace: "$1.length>$self.getMaxQuickReactions()&&($2.length=$self.getMaxQuickReactions());"
|
||||
}
|
||||
},
|
||||
// Add a custom class to identify the quick reactions have been modified and a CSS variable for the number of columns to display
|
||||
{
|
||||
find: "default.Messages.ADD_REACTION_NAMED.format",
|
||||
replacement: {
|
||||
match: /className:(\i)\.wrapper,/,
|
||||
replace: "className:\"vc-better-quick-react \"+($self.settings.store.compactMode?\"vc-better-quick-react-compact \":\"\")+$1.wrapper,style:{\"--vc-better-quick-react-columns\":$self.settings.store.columns},"
|
||||
}
|
||||
},
|
||||
// MenuGroup doesn't accept styles or anything special by default :/
|
||||
{
|
||||
find: "{MenuGroup:function()",
|
||||
replacement: {
|
||||
match: /role:"group",/,
|
||||
replace: "role:\"group\",style:arguments[0].style,"
|
||||
}
|
||||
}
|
||||
],
|
||||
getMaxQuickReactions() {
|
||||
return settings.store.rows * settings.store.columns;
|
||||
}
|
||||
});
|
||||
30
src/equicordplugins/betterQuickReact/style.css
Normal file
30
src/equicordplugins/betterQuickReact/style.css
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/* Fixes to make the quick reactions look better */
|
||||
|
||||
.vc-better-quick-react {
|
||||
/* Discord officially has a 2px right margin and a 0px left margin, which is a hack for their selection border being cut off on the right */
|
||||
padding: 4px 1px 6px;
|
||||
justify-content: center;
|
||||
grid-template-columns: repeat(var(--vc-better-quick-react-columns), 44px);
|
||||
grid-template-rows: unset;
|
||||
grid-auto-rows: 44px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.vc-better-quick-react.vc-better-quick-react-compact {
|
||||
grid-template-columns: repeat(var(--vc-better-quick-react-columns), 33px);
|
||||
grid-auto-rows: 33px;
|
||||
}
|
||||
|
||||
.vc-better-quick-react.vc-better-quick-react-compact > div {
|
||||
scale: 0.75;
|
||||
}
|
||||
|
||||
.vc-better-quick-react.vc-better-quick-react-compact img {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
}
|
||||
|
||||
/* 8 or more columns exceeds the default max width */
|
||||
[class*="menu_"]:has(>div>.vc-better-quick-react) {
|
||||
max-width: fit-content;
|
||||
}
|
||||
23
src/equicordplugins/blockKrisp.discordDesktop/index.ts
Normal file
23
src/equicordplugins/blockKrisp.discordDesktop/index.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Devs } from "@utils/constants";
|
||||
import definePlugin from "@utils/types";
|
||||
|
||||
export default definePlugin({
|
||||
name: "BlockKrisp",
|
||||
description: "Prevent Krisp from loading",
|
||||
authors: [Devs.D3SOX],
|
||||
patches: [
|
||||
{
|
||||
find: "Failed to load Krisp module",
|
||||
replacement: {
|
||||
match: /await (\i).default.ensureModule\("discord_krisp"\)/,
|
||||
replace: "throw new Error();await $1.default.ensureModule(\"discord_krisp\")"
|
||||
}
|
||||
}
|
||||
],
|
||||
});
|
||||
149
src/equicordplugins/bypassDND/index.tsx
Normal file
149
src/equicordplugins/bypassDND/index.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { type NavContextMenuPatchCallback } from "@api/ContextMenu";
|
||||
import { Notifications } from "@api/index";
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { Devs } from "@utils/constants";
|
||||
import { getCurrentChannel } from "@utils/discord";
|
||||
import { Logger } from "@utils/Logger";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { ChannelStore, Menu, MessageStore, NavigationRouter, PresenceStore, PrivateChannelsStore, UserStore, WindowStore } from "@webpack/common";
|
||||
import type { Message, User as DiscordUser } from "discord-types/general";
|
||||
|
||||
interface User extends DiscordUser {
|
||||
globalName: string;
|
||||
}
|
||||
|
||||
interface IMessageCreate {
|
||||
channelId: string;
|
||||
guildId: string;
|
||||
message: Message;
|
||||
}
|
||||
|
||||
function Icon(enabled?: boolean): JSX.Element {
|
||||
return <svg
|
||||
width="18"
|
||||
height="18"
|
||||
>
|
||||
<circle cx="9" cy="9" r="8" fill={!enabled ? "var(--status-danger)" : "currentColor"} />
|
||||
<circle cx="9" cy="9" r="3.75" fill={!enabled ? "white" : "black"} />
|
||||
</svg>;
|
||||
}
|
||||
|
||||
function processIds(value: string): string {
|
||||
return value.replace(/\s/g, "").split(",").filter(id => id.trim() !== "").join(", ");
|
||||
}
|
||||
|
||||
async function showNotification(message: Message, guildId: string | undefined): Promise<void> {
|
||||
const channel = ChannelStore.getChannel(message.channel_id);
|
||||
const channelRegex = /<#(\d{19})>/g;
|
||||
const userRegex = /<@(\d{18})>/g;
|
||||
|
||||
message.content = message.content.replace(channelRegex, (match, channelId: string) => {
|
||||
return `#${ChannelStore.getChannel(channelId)?.name}`;
|
||||
});
|
||||
|
||||
message.content = message.content.replace(userRegex, (match, userId: string) => {
|
||||
return `@${(UserStore.getUser(userId) as User).globalName}`;
|
||||
});
|
||||
|
||||
await Notifications.showNotification({
|
||||
title: `${(message.author as User).globalName} ${guildId ? `(#${channel?.name}, ${ChannelStore.getChannel(channel?.parent_id)?.name})` : ""}`,
|
||||
body: message.content,
|
||||
icon: UserStore.getUser(message.author.id).getAvatarURL(undefined, undefined, false),
|
||||
onClick: function (): void {
|
||||
NavigationRouter.transitionTo(`/channels/${guildId ?? "@me"}/${message.channel_id}/${message.id}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function ContextCallback(name: "guild" | "user" | "channel"): NavContextMenuPatchCallback {
|
||||
return (children, props) => {
|
||||
const type = props[name];
|
||||
if (!type) return;
|
||||
const enabled = settings.store[`${name}s`].split(", ").includes(type.id);
|
||||
if (name === "user" && type.id === UserStore.getCurrentUser().id) return;
|
||||
children.splice(-1, 0, (
|
||||
<Menu.MenuGroup>
|
||||
<Menu.MenuItem
|
||||
id={`dnd-${name}-bypass`}
|
||||
label={`${enabled ? "Remove" : "Add"} DND Bypass`}
|
||||
icon={() => Icon(enabled)}
|
||||
action={() => {
|
||||
let bypasses: string[] = settings.store[`${name}s`].split(", ");
|
||||
if (enabled) bypasses = bypasses.filter(id => id !== type.id);
|
||||
else bypasses.push(type.id);
|
||||
settings.store[`${name}s`] = bypasses.filter(id => id.trim() !== "").join(", ");
|
||||
}}
|
||||
/>
|
||||
</Menu.MenuGroup>
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
const settings = definePluginSettings({
|
||||
guilds: {
|
||||
type: OptionType.STRING,
|
||||
description: "Guilds to let bypass (notified when pinged anywhere in guild)",
|
||||
default: "",
|
||||
placeholder: "Separate with commas",
|
||||
onChange: value => settings.store.guilds = processIds(value)
|
||||
},
|
||||
channels: {
|
||||
type: OptionType.STRING,
|
||||
description: "Channels to let bypass (notified when pinged in that channel)",
|
||||
default: "",
|
||||
placeholder: "Separate with commas",
|
||||
onChange: value => settings.store.channels = processIds(value)
|
||||
},
|
||||
users: {
|
||||
type: OptionType.STRING,
|
||||
description: "Users to let bypass (notified for all messages sent in DMs)",
|
||||
default: "",
|
||||
placeholder: "Separate with commas",
|
||||
onChange: value => settings.store.users = processIds(value)
|
||||
},
|
||||
allowOutsideOfDms: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Allow selected users to bypass DND outside of DMs too (acts like a channel/guild bypass, but it's for all messages sent by the selected users)"
|
||||
}
|
||||
});
|
||||
|
||||
export default definePlugin({
|
||||
name: "BypassDND",
|
||||
description: "Still get notifications from specific sources when in do not disturb mode. Right-click on users/channels/guilds to set them to bypass do not disturb mode.",
|
||||
authors: [Devs.Inbestigator],
|
||||
flux: {
|
||||
async MESSAGE_CREATE({ message, guildId, channelId }: IMessageCreate): Promise<void> {
|
||||
try {
|
||||
const currentUser = UserStore.getCurrentUser();
|
||||
const userStatus = await PresenceStore.getStatus(currentUser.id);
|
||||
const currentChannelId = getCurrentChannel()?.id ?? "0";
|
||||
if (message.state === "SENDING" || message.content === "" || message.author.id === currentUser.id || (channelId === currentChannelId && WindowStore.isFocused()) || userStatus !== "dnd") {
|
||||
return;
|
||||
}
|
||||
const mentioned = MessageStore.getMessage(channelId, message.id)?.mentioned;
|
||||
if ((settings.store.guilds.split(", ").includes(guildId) || settings.store.channels.split(", ").includes(channelId)) && mentioned) {
|
||||
await showNotification(message, guildId);
|
||||
} else if (settings.store.users.split(", ").includes(message.author.id)) {
|
||||
const userChannelId = await PrivateChannelsStore.getOrEnsurePrivateChannel(message.author.id);
|
||||
if (channelId === userChannelId || (mentioned && settings.store.allowOutsideOfDms === true)) {
|
||||
await showNotification(message, guildId);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
new Logger("BypassDND").error("Failed to handle message", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
settings,
|
||||
contextMenus: {
|
||||
"guild-context": ContextCallback("guild"),
|
||||
"channel-context": ContextCallback("channel"),
|
||||
"user-context": ContextCallback("user"),
|
||||
}
|
||||
});
|
||||
31
src/equicordplugins/cleanChannelName/index.ts
Normal file
31
src/equicordplugins/cleanChannelName/index.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Devs } from "@utils/constants";
|
||||
import definePlugin from "@utils/types";
|
||||
import { Channel } from "discord-types/general";
|
||||
|
||||
export default definePlugin({
|
||||
name: "CleanChannelName",
|
||||
authors: [Devs.AutumnVN],
|
||||
description: "remove all emoji and decor shit from channel names",
|
||||
patches: [
|
||||
{
|
||||
find: "loadAllGuildAndPrivateChannelsFromDisk(){",
|
||||
replacement: {
|
||||
match: /(?<=getChannel\(\i\)\{if\(null!=\i\)return )\i\(\i\)/,
|
||||
replace: "$self.cleanChannelName($&)",
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
cleanChannelName(channel?: Channel) {
|
||||
if (channel) {
|
||||
channel.name = channel.name.normalize("NFKC").replace(/[^\u0020-\u007E]?\p{Extended_Pictographic}[^\u0020-\u007E]?/ug, "").replace(/-?[^\p{Letter}\u0020-\u007E]-?/ug, [2, 4].includes(channel.type) ? " " : "-").replace(/(^-|-$)/g, "");
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
});
|
||||
62
src/equicordplugins/colorMessage/index.ts
Normal file
62
src/equicordplugins/colorMessage/index.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import * as Styles from "@api/Styles";
|
||||
import { makeRange } from "@components/PluginSettings/components";
|
||||
import { Devs } from "@utils/constants";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { findByPropsLazy } from "@webpack";
|
||||
|
||||
const AuthorStore = findByPropsLazy("useNullableMessageAuthor", "useNullableMessageAuthor");
|
||||
|
||||
import style from "./style.css?managed";
|
||||
|
||||
export const settings = definePluginSettings({
|
||||
saturation: {
|
||||
type: OptionType.SLIDER,
|
||||
description: "Message color saturation",
|
||||
markers: makeRange(0, 100, 10),
|
||||
default: 20,
|
||||
onChange() {
|
||||
updateStyle();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function updateStyle() {
|
||||
(Styles.requireStyle(style).dom!.sheet!.cssRules[0] as CSSStyleRule)
|
||||
.style.setProperty("--98-message-color-saturation", `${settings.store.saturation}`);
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
name: "ColorMessage",
|
||||
description: "Colors message content with author's role color",
|
||||
authors: [Devs.Kyuuhachi],
|
||||
settings,
|
||||
|
||||
patches: [
|
||||
{
|
||||
find: 'default.Messages.MESSAGE_EDITED,")"',
|
||||
replacement: {
|
||||
match: /id:\(0,\w+.getMessageContentId\)\((\w+)\),/,
|
||||
replace: '$&style:{"--98-message-color":$self.getMessageColor($1)},'
|
||||
}
|
||||
},
|
||||
],
|
||||
|
||||
getMessageColor(messageId: string) {
|
||||
return AuthorStore.default(messageId).colorString;
|
||||
},
|
||||
|
||||
start() {
|
||||
Styles.enableStyle(style);
|
||||
updateStyle();
|
||||
},
|
||||
stop() {
|
||||
Styles.disableStyle(style);
|
||||
},
|
||||
});
|
||||
14
src/equicordplugins/colorMessage/style.css
Normal file
14
src/equicordplugins/colorMessage/style.css
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/* stylelint-disable indentation */
|
||||
/* stylelint-disable custom-property-pattern */
|
||||
:root {
|
||||
--98-message-color-saturation: /*DYNAMIC*/ ;
|
||||
}
|
||||
|
||||
div[class*="messageContent_"] {
|
||||
color: color-mix(
|
||||
in lab,
|
||||
var(--98-message-color, var(--text-normal))
|
||||
calc(var(--98-message-color-saturation) * 1%),
|
||||
var(--text-normal)
|
||||
);
|
||||
}
|
||||
78
src/equicordplugins/copyEmojiAsFormattedString/index.tsx
Normal file
78
src/equicordplugins/copyEmojiAsFormattedString/index.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Devs } from "@utils/constants";
|
||||
import definePlugin from "@utils/types";
|
||||
import { Clipboard, Menu, showToast, Toasts } from "@webpack/common";
|
||||
|
||||
interface Emoji {
|
||||
type: string;
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Target {
|
||||
dataset: Emoji;
|
||||
firstChild: HTMLImageElement;
|
||||
}
|
||||
|
||||
function removeCountingPostfix(name: string): string {
|
||||
return name.replace(/~\d+$/, "");
|
||||
}
|
||||
|
||||
function getEmojiFormattedString(target: Target): string {
|
||||
const { dataset } = target;
|
||||
|
||||
if (!dataset.id) {
|
||||
const fiberKey = Object.keys(target).find(key =>
|
||||
/^__reactFiber\$\S+$/gm.test(key)
|
||||
);
|
||||
|
||||
if (!fiberKey) return `:${dataset.name}:`;
|
||||
|
||||
const emojiUnicode =
|
||||
target[fiberKey]?.child?.memoizedProps?.emoji?.surrogates;
|
||||
|
||||
return emojiUnicode || `:${dataset.name}:`;
|
||||
}
|
||||
|
||||
const extension = target?.firstChild.src.match(
|
||||
/https:\/\/cdn\.discordapp\.com\/emojis\/\d+\.(\w+)/
|
||||
)?.[1];
|
||||
|
||||
const emojiName = removeCountingPostfix(dataset.name);
|
||||
const emojiId = dataset.id;
|
||||
|
||||
return extension === "gif"
|
||||
? `<a:${emojiName}:${emojiId}>`
|
||||
: `<:${emojiName}:${emojiId}>`;
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
name: "CopyEmojiAsString",
|
||||
description: "Add's button to copy emoji as formatted string!",
|
||||
authors: [Devs.HAPPY_ENDERMAN, Devs.VISHNYA_NET_CHERESHNYA],
|
||||
contextMenus: {
|
||||
"expression-picker"(children, { target }: { target: Target; }) {
|
||||
if (target.dataset.type !== "emoji") return;
|
||||
|
||||
children.push(
|
||||
<Menu.MenuItem
|
||||
id="copy-formatted-string"
|
||||
key="copy-formatted-string"
|
||||
label={"Copy as formatted string"}
|
||||
action={() => {
|
||||
Clipboard.copy(getEmojiFormattedString(target));
|
||||
showToast(
|
||||
"Success! Copied to clipboard as formatted string.",
|
||||
Toasts.Type.SUCCESS
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
53
src/equicordplugins/copyUserMention/index.tsx
Normal file
53
src/equicordplugins/copyUserMention/index.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { NavContextMenuPatchCallback } from "@api/ContextMenu";
|
||||
import { Devs } from "@utils/constants";
|
||||
import definePlugin from "@utils/types";
|
||||
import { Clipboard, Menu } from "@webpack/common";
|
||||
import type { Channel, User } from "discord-types/general";
|
||||
|
||||
const MentionIcon = () => (
|
||||
<svg
|
||||
className="mentionicon"
|
||||
height="20"
|
||||
width="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M12 2C6.486 2 2 6.486 2 12C2 17.515 6.486 22 12 22C14.039 22 15.993 21.398 17.652 20.259L16.521 18.611C15.195 19.519 13.633 20 12 20C7.589 20 4 16.411 4 12C4 7.589 7.589 4 12 4C16.411 4 20 7.589 20 12V12.782C20 14.17 19.402 15 18.4 15L18.398 15.018C18.338 15.005 18.273 15 18.209 15H18C17.437 15 16.6 14.182 16.6 13.631V12C16.6 9.464 14.537 7.4 12 7.4C9.463 7.4 7.4 9.463 7.4 12C7.4 14.537 9.463 16.6 12 16.6C13.234 16.6 14.35 16.106 15.177 15.313C15.826 16.269 16.93 17 18 17L18.002 16.981C18.064 16.994 18.129 17 18.195 17H18.4C20.552 17 22 15.306 22 12.782V12C20 6.486 17.514 2 12 2ZM12 14.599C10.566 14.599 9.4 13.433 9.4 11.999C9.4 10.565 10.566 9.399 12 9.399C13.434 9.399 14.6 10.565 14.6 11.999C14.6 13.433 13.434 14.599 12 14.599Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
interface UserContextProps {
|
||||
channel: Channel;
|
||||
guildId?: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
const UserContextMenuPatch: NavContextMenuPatchCallback = (children, { user }: UserContextProps) => {
|
||||
if (!user) return;
|
||||
|
||||
children.push(
|
||||
<Menu.MenuItem
|
||||
id="vc-copy-user-mention"
|
||||
label="Copy User Mention"
|
||||
action={() => Clipboard.copy(`<@${user.id}>`)}
|
||||
icon={MentionIcon}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default definePlugin({
|
||||
name: "CopyUserMention",
|
||||
authors: [Devs.Cortex, Devs.castdrian],
|
||||
description: "Adds a button to copy user's mention on the user context menu, works best with ValidUser.",
|
||||
contextMenus: {
|
||||
"user-context": UserContextMenuPatch
|
||||
}
|
||||
});
|
||||
104
src/equicordplugins/customAppIcons/AppIconModal.tsx
Normal file
104
src/equicordplugins/customAppIcons/AppIconModal.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { localStorage } from "@utils/localStorage";
|
||||
import { ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize } from "@utils/modal";
|
||||
import { findByProps } from "@webpack";
|
||||
import { Button, FluxDispatcher, Forms, React, showToast, Text, TextInput, Toasts, useState } from "@webpack/common";
|
||||
|
||||
|
||||
interface AppIcon {
|
||||
id: string,
|
||||
iconSource: string,
|
||||
name: string,
|
||||
isPremium: boolean;
|
||||
}
|
||||
function AppIconModal(props: ModalProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [imageUrl, setimageUrl] = useState("");
|
||||
|
||||
function addAppIcon(name, url) {
|
||||
const appIcons = JSON.parse(localStorage.getItem("vc_app_icons") ?? "[]");
|
||||
const id = `${name.replaceAll(" ", "")}_${Date.now()}`; // avoid crashing if repeated name
|
||||
const icon = {
|
||||
"id": id,
|
||||
"iconSource": url,
|
||||
"isPremium": false,
|
||||
"name": name
|
||||
} as AppIcon;
|
||||
|
||||
|
||||
appIcons.push(icon);
|
||||
findByProps("ICONS", "ICONS_BY_ID").ICONS.push(icon);
|
||||
findByProps("ICONS", "ICONS_BY_ID").ICONS_BY_ID[icon.id] = icon;
|
||||
showToast("Added custom app icon!", Toasts.Type.SUCCESS);
|
||||
props.onClose();
|
||||
const oldIcon = findByProps("getCurrentDesktopIcon").getCurrentDesktopIcon();
|
||||
|
||||
let random_icon = Object.keys(findByProps("ICONS_BY_ID")).filter(icon => icon !== oldIcon) as [];
|
||||
random_icon = random_icon[Math.floor(Math.random() * random_icon.length)];
|
||||
|
||||
FluxDispatcher.dispatch({
|
||||
type: "APP_ICON_UPDATED",
|
||||
id: random_icon
|
||||
});
|
||||
FluxDispatcher.dispatch({
|
||||
type: "APP_ICON_UPDATED",
|
||||
id: oldIcon
|
||||
});
|
||||
localStorage.setItem("vc_app_icons", JSON.stringify(appIcons));
|
||||
}
|
||||
|
||||
return <ModalRoot {...props} size={ModalSize.MEDIUM}>
|
||||
<ModalHeader>
|
||||
<Text color="header-primary" variant="heading-lg/semibold" tag="h1" style={{ flexGrow: 1 }}>Add a custom app icon:</Text>
|
||||
<ModalCloseButton onClick={props.onClose}></ModalCloseButton>
|
||||
</ModalHeader>
|
||||
<ModalContent>
|
||||
<br />
|
||||
<Forms.FormSection title="Name">
|
||||
<Forms.FormText type="description">
|
||||
This name will be shown in the App Icons list.
|
||||
</Forms.FormText>
|
||||
<TextInput
|
||||
placeholder="My awesome Custom App Icon!"
|
||||
value={name}
|
||||
onChange={setName}
|
||||
/>
|
||||
</Forms.FormSection>
|
||||
<br />
|
||||
<Forms.FormSection title="Image URL">
|
||||
<Forms.FormText type="description">
|
||||
Paste here your image URL to upload your icon (.webp, .jpg, .jpeg, .png, .gif, .ico and Discord Icons, Emojis, PFPs, etc.).
|
||||
</Forms.FormText>
|
||||
<TextInput
|
||||
placeholder="https://cdn.discordapp.com/emojis/1098881040900173895.gif"
|
||||
value={imageUrl}
|
||||
onChange={setimageUrl}
|
||||
/>
|
||||
</Forms.FormSection>
|
||||
</ModalContent>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
onClick={() => {
|
||||
addAppIcon(name, imageUrl);
|
||||
}}
|
||||
disabled={!name || !imageUrl && imageUrl.match(/(https:\/\/www\.|http:\/\/www\.|https:\/\/|http:\/\/)?[a-zA-Z]{2,}(\.[a-zA-Z]{2,})(\.[a-zA-Z]{2,})?\/[a-zA-Z0-9]{2,}|((https:\/\/www\.|http:\/\/www\.|https:\/\/|http:\/\/)?[a-zA-Z]{2,}(\.[a-zA-Z]{2,})(\.[a-zA-Z]{2,})?)|(https:\/\/www\.|http:\/\/www\.|https:\/\/|http:\/\/)?[a-zA-Z0-9]{2,}\.[a-zA-Z0-9]{2,}\.[a-zA-Z0-9]{2,}(\.[a-zA-Z0-9]{2,})?/) !== undefined}
|
||||
>
|
||||
Add Icon
|
||||
</Button>
|
||||
<Button
|
||||
onClick={props.onClose}
|
||||
color={Button.Colors.PRIMARY}
|
||||
look={Button.Looks.LINK}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalRoot>;
|
||||
}
|
||||
|
||||
export default AppIconModal;
|
||||
110
src/equicordplugins/customAppIcons/index.tsx
Normal file
110
src/equicordplugins/customAppIcons/index.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
/*
|
||||
* 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 { Link } from "@components/Link";
|
||||
import { Devs } from "@utils/constants";
|
||||
import { localStorage } from "@utils/localStorage";
|
||||
import { closeAllModals, openModal } from "@utils/modal";
|
||||
import definePlugin from "@utils/types";
|
||||
import { findByProps } from "@webpack";
|
||||
import { Button, FluxDispatcher, Forms, React, showToast, Toasts } from "@webpack/common";
|
||||
|
||||
import AppIconModal from "./AppIconModal";
|
||||
|
||||
function removeAppIcon() {
|
||||
const current_icon = findByProps("getCurrentDesktopIcon").getCurrentDesktopIcon();
|
||||
let icons = JSON.parse(localStorage.getItem("vc_app_icons") || "[]");
|
||||
const index = icons.findIndex(icon => current_icon === icon.id);
|
||||
if (index !== -1) {
|
||||
icons = icons.filter(e => e.id !== current_icon);
|
||||
delete findByProps("ICONS", "ICONS_BY_ID").ICONS_BY_ID[current_icon];
|
||||
delete findByProps("ICONS", "ICONS_BY_ID").ICONS[findByProps("ICONS", "ICONS_BY_ID").ICONS.findIndex((icon => current_icon === icon?.id))];
|
||||
localStorage.setItem("vc_app_icons", JSON.stringify(icons));
|
||||
showToast("Icon successfully deleted!", Toasts.Type.SUCCESS);
|
||||
FluxDispatcher.dispatch({
|
||||
type: "APP_ICON_UPDATED",
|
||||
id: "AppIcon"
|
||||
});
|
||||
} else {
|
||||
showToast("Cannot delete native App Icons!", Toasts.Type.FAILURE);
|
||||
return;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
export default definePlugin({
|
||||
name: "CustomAppIcons",
|
||||
description: "Add/upload custom (In-)App Icons.",
|
||||
authors: [Devs.HAPPY_ENDERMAN, Devs.SerStars],
|
||||
patches: [
|
||||
{
|
||||
find: ".PremiumUpsellTypes.APP_ICON_UPSELL",
|
||||
replacement: [
|
||||
{
|
||||
match: /\w+\.jsx\)\(\w+,{markAsDismissed:\w+,isCoachmark:\w+}\)/,
|
||||
replace(str) {
|
||||
return str + ",$self.addButtons()";
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
|
||||
start() {
|
||||
const appIcons = JSON.parse(localStorage.getItem("vc_app_icons") ?? "[]");
|
||||
for (const icon of appIcons) {
|
||||
findByProps("ICONS", "ICONS_BY_ID").ICONS.push(icon);
|
||||
findByProps("ICONS", "ICONS_BY_ID").ICONS_BY_ID[icon.id] = icon;
|
||||
}
|
||||
},
|
||||
stop() {
|
||||
|
||||
},
|
||||
addButtons() {
|
||||
|
||||
const { editorFooter } = findByProps("editorFooter");
|
||||
return (
|
||||
<>
|
||||
<Button color={Button.Colors.BRAND_NEW} size={Button.Sizes.MEDIUM} className={editorFooter} onClick={() => {
|
||||
openModal(props => <AppIconModal {...props} />);
|
||||
}}>
|
||||
Add Custom App Icon
|
||||
</Button>
|
||||
<Button color={Button.Colors.RED} size={Button.Sizes.MEDIUM} className={editorFooter} onClick={removeAppIcon}>
|
||||
Remove Custom selected App Icon
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
},
|
||||
|
||||
settingsAboutComponent: () => {
|
||||
return (
|
||||
<><Forms.FormTitle>
|
||||
<Forms.FormTitle>How to use?</Forms.FormTitle>
|
||||
</Forms.FormTitle>
|
||||
<Forms.FormText>
|
||||
<Forms.FormText>Go to <Link href="/settings/appearance" onClick={e => { e.preventDefault(); closeAllModals(); FluxDispatcher.dispatch({ type: "USER_SETTINGS_MODAL_SET_SECTION", section: "Appearance" }); }}>Appearance Settings</Link> tab.</Forms.FormText>
|
||||
<Forms.FormText>Scroll down to "In-app Icons" and click on "Preview App Icon".</Forms.FormText>
|
||||
<Forms.FormText>And upload your own custom icon!</Forms.FormText>
|
||||
<Forms.FormText>You can only use links when you are uploading your Custom Icon.</Forms.FormText>
|
||||
</Forms.FormText></>
|
||||
);
|
||||
}
|
||||
});
|
||||
45
src/equicordplugins/customScreenShare.desktop/goofs.tsx
Normal file
45
src/equicordplugins/customScreenShare.desktop/goofs.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export function goofs() {
|
||||
setTimeout(() => {
|
||||
(document.querySelector('[class^="wordmarkWindows"]')?.firstElementChild as any).style.display = "none";
|
||||
document.querySelector('[class^="wordmarkWindows"]')?.prepend(Object.assign(document.createElement("div"), {
|
||||
style: `height: 16px;
|
||||
width: 51px;
|
||||
color: var(--text-muted);
|
||||
font-size: xx-small;
|
||||
text-align: center;
|
||||
margin-top: 5px;
|
||||
font-weight: 600;
|
||||
transform: scaleX(2.5);
|
||||
letter-spacing: -1px;`,
|
||||
innerText: "Skype"
|
||||
}
|
||||
));
|
||||
document.onmousedown = e => {
|
||||
const hit = Object.assign(document.createElement("div"), {
|
||||
style: `left: ${e.clientX}px;
|
||||
top: ${e.clientY}px;
|
||||
color: white;
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
z-index: 9999999;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 2rem;`,
|
||||
innerText: "×"
|
||||
});
|
||||
const hitSound = Object.assign(document.createElement("audio"), { src: "https://cdn.discordapp.com/attachments/545600181744173068/1090841136458899516/hit.wav", autoplay: true, volume: 0.15 });
|
||||
hitSound.onended = () => {
|
||||
hit.removeChild(hitSound);
|
||||
document.body.removeChild(hit);
|
||||
};
|
||||
hit.appendChild(hitSound);
|
||||
document.body.appendChild(hit);
|
||||
};
|
||||
}, 1000);
|
||||
}
|
||||
221
src/equicordplugins/customScreenShare.desktop/index.tsx
Normal file
221
src/equicordplugins/customScreenShare.desktop/index.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
/*
|
||||
* 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 { definePluginSettings } from "@api/Settings";
|
||||
import { Devs } from "@utils/constants";
|
||||
import definePlugin, { OptionType, StartAt } from "@utils/types";
|
||||
import { Forms, Menu, TextInput, useState } from "@webpack/common";
|
||||
|
||||
import { goofs } from "./goofs";
|
||||
import { cooldown, denormalize, normalize } from "./utils";
|
||||
|
||||
const settings = definePluginSettings({
|
||||
maxFPS: {
|
||||
description: "Max FPS for the range slider",
|
||||
default: 144,
|
||||
type: OptionType.COMPONENT,
|
||||
component: (props: any) => {
|
||||
const [value, setValue] = useState(settings.store.maxFPS);
|
||||
return <Forms.FormSection>
|
||||
<Forms.FormTitle>Max FPS for the range slider</Forms.FormTitle>
|
||||
<TextInput type="number" pattern="-?[0-9]+" onChange={value => { props.setValue(Math.max(Number.parseInt(value), 1)); setValue(value); }} value={value} />
|
||||
</Forms.FormSection>;
|
||||
}
|
||||
},
|
||||
maxResolution: {
|
||||
description: "Max Resolution for the range slider",
|
||||
default: 1080,
|
||||
type: OptionType.COMPONENT,
|
||||
component: (props: any) => {
|
||||
const [value, setValue] = useState(settings.store.maxResolution);
|
||||
return <Forms.FormSection>
|
||||
<Forms.FormTitle>Max Resolution for the range slider</Forms.FormTitle>
|
||||
<TextInput type="number" pattern="-?[0-9]+" onChange={value => { props.setValue(Math.max(Number.parseInt(value), 22)); setValue(value); }} value={value} />
|
||||
</Forms.FormSection>;
|
||||
}
|
||||
},
|
||||
roundValues: {
|
||||
description: "Round Resolution and FPS values to the nearest whole number",
|
||||
default: true,
|
||||
type: OptionType.BOOLEAN,
|
||||
},
|
||||
goofs: {
|
||||
description: "Goofs and gags :^)",
|
||||
default: false,
|
||||
type: OptionType.BOOLEAN,
|
||||
restartNeeded: true,
|
||||
},
|
||||
bitrates: {
|
||||
description: "ADVANCED: ONLY USE FOR TESTING PURPOSES!",
|
||||
default: false,
|
||||
type: OptionType.BOOLEAN,
|
||||
restartNeeded: false,
|
||||
},
|
||||
targetBitrate: {
|
||||
description: "Bitrate",
|
||||
default: 600000,
|
||||
type: OptionType.NUMBER,
|
||||
hidden: true
|
||||
},
|
||||
minBitrate: {
|
||||
description: "Bitrate",
|
||||
default: 500000,
|
||||
type: OptionType.NUMBER,
|
||||
hidden: true
|
||||
},
|
||||
maxBitrate: {
|
||||
description: "Bitrate",
|
||||
default: 8000000,
|
||||
type: OptionType.NUMBER,
|
||||
hidden: true
|
||||
},
|
||||
});
|
||||
|
||||
export default definePlugin({
|
||||
name: "CustomScreenShare",
|
||||
description: "Stream any resolution and any FPS!",
|
||||
authors: [Devs.KawaiianPizza],
|
||||
settingsAboutComponent: () => (
|
||||
<Forms.FormSection>
|
||||
<Forms.FormTitle tag="h3">Usage</Forms.FormTitle>
|
||||
<Forms.FormText>
|
||||
Adds a slider for the quality and fps options submenu
|
||||
</Forms.FormText>
|
||||
</Forms.FormSection>),
|
||||
settings,
|
||||
patches: [
|
||||
{
|
||||
find: "ApplicationStreamSettingRequirements)",
|
||||
replacement: {
|
||||
match: /for\(let . of ..ApplicationStreamSettingRequirements\).+?!1/,
|
||||
replace: "return !0"
|
||||
}
|
||||
},
|
||||
{
|
||||
find: "ApplicationStreamFPSButtonsWithSuffixLabel.map",
|
||||
replacement: {
|
||||
match: /(.=)(.{19}FPS.+?([A-z]{1,2}).{11}>([A-z]{1,2}).[A-z]{1,2},([A-z]{1,2}),[A-z]{1,2},([A-z.]+).+?\}\)),/,
|
||||
replace: "$1[$self.CustomRange($4,$5,$3,$6,'fps'),...$2],"
|
||||
}
|
||||
},
|
||||
{
|
||||
find: "ApplicationStreamResolutionButtonsWithSuffixLabel.map",
|
||||
replacement: {
|
||||
match: /(.=)(.{19}Resolution.+?([A-z]{1,2}).{11}>([A-z]{1,2}).[A-z]{1,2},[A-z]{1,2},([A-z]{1,2}),([A-z.]+).+?\}\));/,
|
||||
replace: "$1[$self.CustomRange($4,$3,$5,$6,'resolution'),...$2];"
|
||||
}
|
||||
},
|
||||
{
|
||||
find: "=4e6",
|
||||
replacement: {
|
||||
match: /=4e6/,
|
||||
replace: "=8e6"
|
||||
}
|
||||
},
|
||||
{
|
||||
find: "\"Discord_Clip_\".concat",
|
||||
replacement: {
|
||||
match: /(=15e)3/, // disable discord idle fps reduction
|
||||
replace: "$18"
|
||||
}
|
||||
},
|
||||
{
|
||||
find: "updateRemoteWantsFramerate(){",
|
||||
replacement: {
|
||||
match: /updateRemoteWantsFramerate..\{/, // disable discord mute fps reduction
|
||||
replace: "$&return;"
|
||||
}
|
||||
},
|
||||
{
|
||||
find: "{getQuality(",
|
||||
replacement: {
|
||||
match: /(bitrateMin:).+?(,bitrateMax:).+?(,bitrateTarget:).+?,/,
|
||||
replace: "$1$self.getMinBitrate()$2$self.getMaxBitrate()$3$self.getTargetBitrate(),"
|
||||
}
|
||||
},
|
||||
{
|
||||
find: "ApplicationStreamResolutionButtonsWithSuffixLabel.map",
|
||||
replacement: {
|
||||
match: /(stream-settings-resolution-.+?children:.)/,
|
||||
replace: "$1$self.settings.store.bitrates?$self.BitrateGroup():null,"
|
||||
}
|
||||
}
|
||||
],
|
||||
CustomRange(changeRes: Function, res: number, fps: number, analytics: string, group: "fps" | "resolution") {
|
||||
const [value, setValue] = useState(group === "fps" ? fps : res);
|
||||
const { maxFPS, maxResolution, roundValues } = settings.store;
|
||||
|
||||
const maxValue = group === "fps" ? maxFPS : maxResolution,
|
||||
minValue = group === "fps" ? 1 : 22; // 0 FPS freezes (obviously) and anything less than 22p doesn't work
|
||||
|
||||
const onChange = (number: number) => {
|
||||
let tmp = denormalize(number, minValue, maxValue);
|
||||
if (roundValues)
|
||||
tmp = Math.round(tmp);
|
||||
setValue(tmp);
|
||||
cooldown(() => changeRes(true, group === "resolution" ? tmp : res, group === "fps" ? tmp : fps, analytics));
|
||||
};
|
||||
return (<Menu.MenuControlItem group={`stream-settings-${group}`} id={`stream-settings-${group}-custom`}>
|
||||
<Menu.MenuSliderControl
|
||||
onChange={onChange}
|
||||
renderValue={() => value + (group === "fps" ? " FPS" : "p")}
|
||||
value={normalize((group === "fps" ? fps : res), minValue, maxValue)}>
|
||||
</Menu.MenuSliderControl>
|
||||
</Menu.MenuControlItem>);
|
||||
},
|
||||
BitrateGroup() {
|
||||
const bitrates: Array<"target" | "min" | "max"> = ["min", "target", "max"];
|
||||
|
||||
return (<Menu.MenuGroup label="Bitrate (Min/Target/Max)">
|
||||
{bitrates.map(e => this.BitrateSlider(e))}
|
||||
</Menu.MenuGroup>);
|
||||
},
|
||||
BitrateSlider(name: "target" | "min" | "max") {
|
||||
const [bitrate, setBitrate] = useState(this.settings.store[name + "Bitrate"]);
|
||||
const { minBitrate, maxBitrate } = settings.store;
|
||||
const onChange = (number: number) => {
|
||||
const tmp = denormalize(number, name === "min" ? 1000 : minBitrate, name === "max" ? 20000000 : maxBitrate);
|
||||
setBitrate(tmp);
|
||||
this.settings.store[name + "Bitrate"] = Math.round(tmp);
|
||||
};
|
||||
return (<Menu.MenuControlItem group={`stream-settings-bitrate-${name}`} id={`stream-settings-bitrate-${name}-custom`}>
|
||||
<Menu.MenuSliderControl
|
||||
onChange={onChange}
|
||||
renderValue={() => Math.round(bitrate / 1000) + "kbps"}
|
||||
value={normalize(bitrate, name === "min" ? 1000 : minBitrate, name === "max" ? 20000000 : maxBitrate)}>
|
||||
</Menu.MenuSliderControl>
|
||||
</Menu.MenuControlItem>);
|
||||
},
|
||||
getMinBitrate() {
|
||||
const { minBitrate } = settings.store;
|
||||
return minBitrate;
|
||||
},
|
||||
getTargetBitrate() {
|
||||
const { targetBitrate } = settings.store;
|
||||
return targetBitrate;
|
||||
},
|
||||
getMaxBitrate() {
|
||||
const { maxBitrate } = settings.store;
|
||||
return maxBitrate;
|
||||
},
|
||||
start() {
|
||||
if (settings.store.goofs)
|
||||
goofs();
|
||||
},
|
||||
startAt: StartAt.DOMContentLoaded
|
||||
});
|
||||
34
src/equicordplugins/customScreenShare.desktop/utils.ts
Normal file
34
src/equicordplugins/customScreenShare.desktop/utils.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
let isOnCooldown = false;
|
||||
let nextFunction: Function | undefined = undefined;
|
||||
|
||||
export function cooldown(func: Function | undefined) {
|
||||
if (isOnCooldown) {
|
||||
nextFunction = func;
|
||||
return;
|
||||
}
|
||||
isOnCooldown = true;
|
||||
nextFunction = undefined;
|
||||
|
||||
if (func && typeof func === "function")
|
||||
func();
|
||||
|
||||
setTimeout(() => {
|
||||
isOnCooldown = false;
|
||||
if (nextFunction)
|
||||
cooldown(nextFunction);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
export function normalize(value: number, minValue: number, maxValue: any): number | undefined {
|
||||
return (value - minValue) / (maxValue - minValue) * 100;
|
||||
}
|
||||
|
||||
export function denormalize(number: number, minValue: number, maxValue: any) {
|
||||
return number * (maxValue - minValue) / 100 + minValue;
|
||||
}
|
||||
62
src/equicordplugins/deadMembers/index.tsx
Normal file
62
src/equicordplugins/deadMembers/index.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Devs } from "@utils/constants";
|
||||
import definePlugin from "@utils/types";
|
||||
import { ChannelStore, GuildMemberStore, useStateFromStores } from "@webpack/common";
|
||||
|
||||
export default definePlugin({
|
||||
name: "DeadMembers",
|
||||
description: "Shows when the sender of a message has left the guild",
|
||||
authors: [Devs.Kyuuhachi],
|
||||
|
||||
patches: [
|
||||
{
|
||||
find: "UsernameDecorationTypes:function()",
|
||||
replacement: {
|
||||
match: /(\i)=\{className:\i.username,style:.*?onContextMenu:\i,children:.*?\}/,
|
||||
replace: "$&,{}=$1.children=$self.wrapMessageAuthor(arguments[0],$1.children)"
|
||||
}
|
||||
},
|
||||
{
|
||||
find: "Messages.FORUM_POST_AUTHOR_A11Y_LABEL",
|
||||
replacement: {
|
||||
match: /(?<=\}=(\i),\{(user:\i,author:\i)\}=.{0,400}?\(\i\.Fragment,{children:)\i(?=}\),)/,
|
||||
replace: "$self.wrapForumAuthor({...$1,$2},$&)"
|
||||
}
|
||||
},
|
||||
],
|
||||
|
||||
wrapMessageAuthor({ message }, text) {
|
||||
const channel = ChannelStore.getChannel(message.channel_id);
|
||||
return message.webhookId
|
||||
? text
|
||||
: <DeadIndicator
|
||||
channel={channel}
|
||||
userId={message.author.id}
|
||||
text={text}
|
||||
/>;
|
||||
},
|
||||
|
||||
wrapForumAuthor({ channel, user }, text) {
|
||||
return !user
|
||||
? text
|
||||
: <DeadIndicator
|
||||
channel={channel}
|
||||
userId={user.id}
|
||||
text={text}
|
||||
/>;
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
function DeadIndicator({ channel, userId, text }) {
|
||||
const isMember = useStateFromStores(
|
||||
[GuildMemberStore],
|
||||
() => GuildMemberStore.isMember(channel?.guild_id, userId),
|
||||
);
|
||||
return channel?.guild_id && !isMember ? <s className="c98-author-dead">{text}</s> : text;
|
||||
}
|
||||
89
src/equicordplugins/doNotLeak/index.ts
Normal file
89
src/equicordplugins/doNotLeak/index.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
/*
|
||||
* 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 { definePluginSettings, migratePluginSettings } from "@api/Settings";
|
||||
import { disableStyle, enableStyle } from "@api/Styles";
|
||||
import { Devs } from "@utils/constants";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
|
||||
import styles from "./style.css?managed";
|
||||
|
||||
const settings = definePluginSettings({
|
||||
hoverToView: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "When hovering over a message, show the contents.",
|
||||
default: false,
|
||||
restartNeeded: false,
|
||||
onChange: () => {
|
||||
updateClassList("hoverToView", settings.store.hoverToView);
|
||||
},
|
||||
},
|
||||
keybind: {
|
||||
type: OptionType.STRING,
|
||||
description: "The keybind to show the contents of a message.",
|
||||
default: "Insert",
|
||||
restartNeeded: false,
|
||||
},
|
||||
enableForStream: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Blur all messages in streamer mode.",
|
||||
default: false,
|
||||
restartNeeded: false,
|
||||
onChange: () => {
|
||||
updateClassList("hideinstreamermode", settings.store.enableForStream);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
migratePluginSettings("DoNotLeak", "Do Not Leak!");
|
||||
export default definePlugin({
|
||||
name: "DoNotLeak",
|
||||
description: "Hide all message contents and attachments when you're streaming or sharing your screen.",
|
||||
authors: [Devs.Perny],
|
||||
settings,
|
||||
start() {
|
||||
document.addEventListener("keyup", keyUpHandler);
|
||||
document.addEventListener("keydown", keyDownHandler);
|
||||
updateClassList("hoverToView", settings.store.hoverToView);
|
||||
updateClassList("hideinstreamermode", settings.store.enableForStream);
|
||||
enableStyle(styles);
|
||||
},
|
||||
stop() {
|
||||
document.removeEventListener("keyup", keyUpHandler);
|
||||
document.removeEventListener("keydown", keyDownHandler);
|
||||
disableStyle(styles);
|
||||
},
|
||||
});
|
||||
|
||||
function updateClassList(className, condition) {
|
||||
if (condition) {
|
||||
document.body.classList.add(className);
|
||||
return;
|
||||
}
|
||||
document.body.classList.remove(className);
|
||||
}
|
||||
|
||||
function keyUpHandler(e: KeyboardEvent) {
|
||||
if (e.key !== settings.store.keybind) return;
|
||||
document.body.classList.remove("youcanleaknow");
|
||||
}
|
||||
|
||||
function keyDownHandler(e: KeyboardEvent) {
|
||||
if (e.key !== settings.store.keybind) return;
|
||||
document.body.classList.add("youcanleaknow");
|
||||
}
|
||||
50
src/equicordplugins/doNotLeak/style.css
Normal file
50
src/equicordplugins/doNotLeak/style.css
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/* stylelint-disable length-zero-no-unit */
|
||||
/* stylelint-disable selector-class-pattern */
|
||||
|
||||
body.youcanleaknow .attachmentContentItem_ef9fc2 {
|
||||
filter: blur(0px) brightness(1)!important;
|
||||
}
|
||||
|
||||
body.youcanleaknow .messageContent__21e69 {
|
||||
filter: blur(0px)!important;
|
||||
}
|
||||
|
||||
body.youcanleaknow .embed_d3cbe3 {
|
||||
filter: blur(0px)!important;
|
||||
}
|
||||
|
||||
body:has([aria-label="Share Your Screen"].buttonActive__407a7) .attachmentContentItem_ef9fc2 {
|
||||
filter: blur(30px) brightness(0.1);
|
||||
}
|
||||
|
||||
body:has([aria-label="Share Your Screen"].buttonActive__407a7) .messageContent__21e69 {
|
||||
filter: blur(6px)
|
||||
}
|
||||
|
||||
body:has([aria-label="Share Your Screen"].buttonActive__407a7) .embed_d3cbe3 {
|
||||
filter: blur(6px)
|
||||
}
|
||||
|
||||
body.hideinstreamermode:has(.colorStreamerMode_e927c0) .attachmentContentItem_ef9fc2 {
|
||||
filter: blur(30px) brightness(0.1);
|
||||
}
|
||||
|
||||
body.hideinstreamermode:has(.colorStreamerMode_e927c0) .messageContent__21e69 {
|
||||
filter: blur(6px)
|
||||
}
|
||||
|
||||
body.hideinstreamermode:has(.colorStreamerMode_e927c0) .embed_d3cbe3 {
|
||||
filter: blur(6px)
|
||||
}
|
||||
|
||||
body.hoverToView .messageContent__21e69:hover {
|
||||
filter: blur(0px) brightness(1)!important;
|
||||
}
|
||||
|
||||
body.hoverToView .embed_d3cbe3:hover {
|
||||
filter: blur(0px) brightness(1)!important;
|
||||
}
|
||||
|
||||
body.hoverToView .attachmentContentItem_ef9fc2:hover {
|
||||
filter: blur(0px) brightness(1)!important;
|
||||
}
|
||||
82
src/equicordplugins/doubleCounterVerifyBypass/index.tsx
Normal file
82
src/equicordplugins/doubleCounterVerifyBypass/index.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { NavContextMenuPatchCallback } from "@api/ContextMenu";
|
||||
import { Devs } from "@utils/constants";
|
||||
import definePlugin from "@utils/types";
|
||||
import { Alerts, Menu } from "@webpack/common";
|
||||
import { Message } from "discord-types/general";
|
||||
|
||||
const DOUBLECOUNTER_APP_ID = "703886990948565003";
|
||||
const VERIFICATION_LINK_REGEX = /https:\/\/verify.doublecounter.gg\/v\/[0-9a-z]{8,16}/g;
|
||||
|
||||
const patchMessageContextMenu: NavContextMenuPatchCallback = (children, { message }) => {
|
||||
const { components } = message;
|
||||
|
||||
if (message.author.id === DOUBLECOUNTER_APP_ID && components?.length === 0 && message.embeds?.map(embed => embed)[0].fields.length === 4) {
|
||||
children.push((
|
||||
<Menu.MenuItem
|
||||
id="ml-dcvp"
|
||||
key="ml-dcvp-verify"
|
||||
label="Bypass Double Counter"
|
||||
color="brand"
|
||||
action={() => {
|
||||
const regex_link = VERIFICATION_LINK_REGEX.exec(message.embeds.map(embed => embed.fields.map(field => field))[0][1].rawValue);
|
||||
if (regex_link) {
|
||||
verify(regex_link[0]).then(() => {
|
||||
Alerts.show({
|
||||
title: "Verified",
|
||||
body: "You have been verified successfully, please wait a little bit for DoubleCounter to update your roles.",
|
||||
confirmText: "Okay",
|
||||
});
|
||||
});
|
||||
} else {
|
||||
Alerts.show({
|
||||
title: "Link not found",
|
||||
body: "The link has not been found in this message.",
|
||||
confirmText: "Okay"
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
async function verify(link) {
|
||||
try {
|
||||
const res = await fetch(link);
|
||||
console.log(res.ok);
|
||||
} catch { }
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
name: "DoubleCounterBypass",
|
||||
description: "Bypass Double Counter verifications easily.",
|
||||
authors: [Devs.nyx],
|
||||
|
||||
contextMenus: {
|
||||
"message": patchMessageContextMenu,
|
||||
},
|
||||
|
||||
flux: {
|
||||
async MESSAGE_CREATE({ message }: { message: Message; }) {
|
||||
if (message.author.id !== DOUBLECOUNTER_APP_ID || message.type !== 19 || message.embeds.length === 0) return;
|
||||
|
||||
// @ts-ignore
|
||||
const link = VERIFICATION_LINK_REGEX.exec(message.embeds.map(embed => embed.fields.map(field => field))[0][1].value);
|
||||
console.log(link);
|
||||
await verify(link).then(() => {
|
||||
Alerts.show({
|
||||
title: "Verified",
|
||||
body: "You have been verified successfully, please wait a little bit for DoubleCounter to update your roles.",
|
||||
confirmText: "Okay",
|
||||
onConfirm: () => { }
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
68
src/equicordplugins/emojiDumper/index.tsx
Normal file
68
src/equicordplugins/emojiDumper/index.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { addContextMenuPatch, findGroupChildrenByChildId, NavContextMenuPatchCallback, removeContextMenuPatch } from "@api/ContextMenu";
|
||||
import { Devs } from "@utils/constants";
|
||||
import definePlugin from "@utils/types";
|
||||
import { Menu } from "@webpack/common";
|
||||
import type { Guild } from "discord-types/general";
|
||||
import { zipSync } from "fflate";
|
||||
|
||||
const Patch: NavContextMenuPatchCallback = (children, { guild }: { guild: Guild; }) => {
|
||||
// Assuming "privacy" is the correct ID for the group you want to modify.
|
||||
const group = findGroupChildrenByChildId("privacy", children);
|
||||
|
||||
if (group) {
|
||||
group.push(
|
||||
<Menu.MenuItem id="emoji.download" label="Download Emojis" action={() => zipServerEmojis(guild)}></Menu.MenuItem>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default definePlugin({
|
||||
name: "emojiDumper",
|
||||
description: "Context menu to dump and download a server's emojis.",
|
||||
authors: [
|
||||
Devs.Cortex,
|
||||
Devs.Samwich,
|
||||
Devs.Woosh,
|
||||
],
|
||||
start() {
|
||||
addContextMenuPatch(["guild-context", "guild-header-popout"], Patch);
|
||||
},
|
||||
stop() {
|
||||
removeContextMenuPatch(["guild-context", "guild-header-popout"], Patch);
|
||||
}
|
||||
});
|
||||
|
||||
async function zipServerEmojis(guild: Guild) {
|
||||
|
||||
const emojis = Vencord.Webpack.Common.EmojiStore.getGuilds()[guild.id]?.emojis;
|
||||
if (!emojis) {
|
||||
return console.log("Server not found!");
|
||||
}
|
||||
|
||||
const fetchEmojis = async e => {
|
||||
const filename = e.id + (e.animated ? ".gif" : ".png");
|
||||
const emoji = await fetch("https://cdn.discordapp.com/emojis/" + filename + "?size=512&quality=lossless").then(res => res.blob());
|
||||
return { file: new Uint8Array(await emoji.arrayBuffer()), filename };
|
||||
};
|
||||
const emojiPromises = emojis.map(e => fetchEmojis(e));
|
||||
|
||||
Promise.all(emojiPromises)
|
||||
.then(results => {
|
||||
const emojis = zipSync(Object.fromEntries(results.map(({ file, filename }) => [filename, file])));
|
||||
const blob = new Blob([emojis], { type: "application/zip" });
|
||||
const link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = `${guild.name}-emojis.zip`;
|
||||
link.click();
|
||||
link.remove();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
457
src/equicordplugins/encryptcord/index.tsx
Normal file
457
src/equicordplugins/encryptcord/index.tsx
Normal file
|
|
@ -0,0 +1,457 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { addChatBarButton, ChatBarButton } from "@api/ChatButtons";
|
||||
import {
|
||||
ApplicationCommandInputType,
|
||||
ApplicationCommandOptionType,
|
||||
sendBotMessage,
|
||||
} from "@api/Commands";
|
||||
import * as DataStore from "@api/DataStore";
|
||||
import { addPreSendListener, removePreSendListener, SendListener } from "@api/MessageEvents";
|
||||
import { removeButton } from "@api/MessagePopover";
|
||||
import { Devs } from "@utils/constants";
|
||||
import { sleep } from "@utils/misc";
|
||||
import definePlugin from "@utils/types";
|
||||
import { findByPropsLazy } from "@webpack";
|
||||
import {
|
||||
FluxDispatcher, MessageActions,
|
||||
PrivateChannelsStore, RestAPI,
|
||||
SnowflakeUtils,
|
||||
useEffect, UserStore,
|
||||
UserUtils, useState,
|
||||
} from "@webpack/common";
|
||||
import { Message } from "discord-types/general";
|
||||
|
||||
import { decryptData, encryptData, formatPemKey, generateKeys } from "./rsa-utils";
|
||||
const MessageCreator = findByPropsLazy("createBotMessage");
|
||||
const CloudUtils = findByPropsLazy("CloudUpload");
|
||||
import { getCurrentChannel } from "@utils/discord";
|
||||
|
||||
let enabled;
|
||||
let setEnabled;
|
||||
|
||||
// Interface for Message Create
|
||||
interface IMessageCreate {
|
||||
type: "MESSAGE_CREATE";
|
||||
optimistic: boolean;
|
||||
isPushNotification: boolean;
|
||||
channelId: string;
|
||||
message: Message;
|
||||
}
|
||||
|
||||
// Chat Bar Icon Component
|
||||
const ChatBarIcon: ChatBarButton = ({ isMainChat }) => {
|
||||
[enabled, setEnabled] = useState(false);
|
||||
const [buttonDisabled, setButtonDisabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const listener: SendListener = async (_, message) => {
|
||||
if (enabled) {
|
||||
const groupChannel = await DataStore.get("encryptcordChannelId");
|
||||
if (getCurrentChannel().id !== groupChannel) {
|
||||
sendBotMessage(getCurrentChannel().id, { content: `You must be in <#${groupChannel}> to send an encrypted message!\n> If you wish to send an unencrypted message, please click the button in the chatbar.` });
|
||||
message.content = "";
|
||||
return;
|
||||
}
|
||||
const trimmedMessage = message.content.trim();
|
||||
await MessageActions.receiveMessage(groupChannel, await createMessage(trimmedMessage, UserStore.getCurrentUser().id, groupChannel, 0));
|
||||
const encryptcordGroupMembers = await DataStore.get("encryptcordGroupMembers");
|
||||
const dmPromises = Object.keys(encryptcordGroupMembers).map(async memberId => {
|
||||
const groupMember = await UserUtils.getUser(memberId).catch(() => null);
|
||||
if (!groupMember) return;
|
||||
const encryptedMessage = await encryptData(encryptcordGroupMembers[memberId].key, trimmedMessage);
|
||||
const encryptedMessageString = JSON.stringify(encryptedMessage);
|
||||
await sendTempMessage(groupMember.id, encryptedMessageString, "message");
|
||||
});
|
||||
|
||||
await Promise.all(dmPromises);
|
||||
message.content = "";
|
||||
}
|
||||
};
|
||||
|
||||
addPreSendListener(listener);
|
||||
return () => void removePreSendListener(listener);
|
||||
}, [enabled]);
|
||||
|
||||
if (!isMainChat) return null;
|
||||
|
||||
return (
|
||||
<ChatBarButton
|
||||
tooltip={enabled ? "Send Unencrypted Messages" : "Send Encrypted Messages"}
|
||||
onClick={async () => {
|
||||
if (await DataStore.get("encryptcordGroup") === false || (await DataStore.get("encryptcordChannelId") !== getCurrentChannel().id)) {
|
||||
setButtonDisabled(true);
|
||||
await sendTempMessage(getCurrentChannel().id, "", `join\`\`\`\n${await DataStore.get("encryptcordPublicKey")}\`\`\``, false);
|
||||
sendBotMessage(getCurrentChannel().id, { content: `*Checking for any groups in this channel...*\n> If none is found, a new one will be created <t:${Math.floor(Date.now() / 1000) + 5}:R>\n> [Tip] You can do \`/encryptcord leave\` to leave a group` });
|
||||
await sleep(5000);
|
||||
if (await DataStore.get("encryptcordGroup") === true && (await DataStore.get("encryptcordChannelId") !== getCurrentChannel().id)) {
|
||||
sendBotMessage(getCurrentChannel().id, { content: "*Leaving current group...*" });
|
||||
await leave("", { channel: { id: await DataStore.get("encryptcordChannelId") } });
|
||||
} else if (await DataStore.get("encryptcordGroup") === true) {
|
||||
setButtonDisabled(false);
|
||||
return;
|
||||
}
|
||||
await startGroup("", { channel: { id: getCurrentChannel().id } });
|
||||
}
|
||||
setEnabled(!enabled);
|
||||
setButtonDisabled(false);
|
||||
}}
|
||||
buttonProps={{
|
||||
style: {
|
||||
transition: "transform 0.3s ease-in-out",
|
||||
transform: `rotate(${enabled ? 0 : 15}deg)`,
|
||||
},
|
||||
disabled: buttonDisabled
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 129 171"
|
||||
>
|
||||
{!enabled && <>
|
||||
<mask id="encordBarIcon">
|
||||
</mask>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M128.93 149.231V74.907a21.142 21.142 0 00-6.195-15.1 21.165 21.165 0 00-15.101-6.195h-1.085V40.918A40.604 40.604 0 0042.214 8.065 40.602 40.602 0 0026.28 32.318h15.972a25.164 25.164 0 0128.043-15.94 25.166 25.166 0 0120.691 24.745v12.694H22.184A21.276 21.276 0 00.89 75.111v74.325a21.27 21.27 0 0013.143 19.679 21.273 21.273 0 008.152 1.615h85.388a21.455 21.455 0 0015.083-6.357 21.453 21.453 0 006.213-15.142h.062zm-63.888-15.765a21.296 21.296 0 01-15.058-36.352 21.296 21.296 0 0136.354 15.057 21.151 21.151 0 01-21.296 21.295z"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
<path
|
||||
mask="url(#encordBarIcon)"
|
||||
fill="currentColor"
|
||||
d="M129.497 149.264V75.001a21.27 21.27 0 00-21.295-21.294h-3.072V41.012a41.079 41.079 0 00-1.024-8.6A40.62 40.62 0 0070.729 1.087 5.673 5.673 0 0168.886.88h-.204c-.615 0-1.23-.205-1.844-.205h-4.095A5.672 5.672 0 0060.9.881h-.204a5.672 5.672 0 00-1.843.205A40.627 40.627 0 0025.27 32.413h.205a41.092 41.092 0 00-1.024 8.6v12.694h-3.133A21.153 21.153 0 00.023 75v74.325a21.415 21.415 0 0021.296 21.294h87.231a21.336 21.336 0 0020.886-21.294l.061-.062zm-64.91-15.97a21.317 21.317 0 01-22.069-24.804 21.316 21.316 0 0142.34 3.509 21.355 21.355 0 01-20.272 21.295zm25.185-79.649H39.604V40.951a24.283 24.283 0 016.963-17.2 25.351 25.351 0 0116.79-7.78h2.663a25.31 25.31 0 0123.752 25.184v12.49z"
|
||||
/>
|
||||
</svg>
|
||||
</ChatBarButton>
|
||||
);
|
||||
};
|
||||
|
||||
// Export Plugin
|
||||
export default definePlugin({
|
||||
name: "Encryptcord",
|
||||
description: "End-to-end encryption in Discord!",
|
||||
authors: [Devs.Inbestigator],
|
||||
dependencies: ["CommandsAPI"],
|
||||
patches: [
|
||||
{
|
||||
find: "executeMessageComponentInteraction:",
|
||||
replacement: {
|
||||
match: /await \i\.HTTP\.post\({url:\i\.Endpoints\.INTERACTIONS,body:\i,timeout:/,
|
||||
replace: "await $self.joinGroup(C);$&"
|
||||
}
|
||||
}
|
||||
],
|
||||
async joinGroup(interaction) {
|
||||
const sender = await UserUtils.getUser(interaction.application_id).catch(() => null);
|
||||
if (!sender || (sender.bot === true && sender.id !== "1")) return;
|
||||
if (interaction.data.component_type !== 2) return;
|
||||
switch (interaction.data.custom_id) {
|
||||
case "removeFromSelf":
|
||||
await handleLeaving(sender.id, await DataStore.get("encryptcordGroupMembers") ?? {}, interaction.channel_id);
|
||||
await sendTempMessage(sender.id, "", "leaving");
|
||||
FluxDispatcher.dispatch({
|
||||
type: "MESSAGE_DELETE",
|
||||
channelId: interaction.channel_id,
|
||||
id: interaction.message_id,
|
||||
mlDeleted: true
|
||||
});
|
||||
break;
|
||||
case "createGroup":
|
||||
await leave("", { channel: { id: interaction.channel_id } });
|
||||
await startGroup("", { channel: { id: interaction.channel_id } });
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
},
|
||||
flux: {
|
||||
async MESSAGE_CREATE({ optimistic, type, message, channelId }: IMessageCreate) {
|
||||
if (optimistic || type !== "MESSAGE_CREATE") return;
|
||||
if (message.state === "SENDING") return;
|
||||
if (message.author.id === UserStore.getCurrentUser().id) return;
|
||||
if (!message.content) return;
|
||||
const encryptcordGroupMembers = await DataStore.get("encryptcordGroupMembers");
|
||||
if (!Object.keys(encryptcordGroupMembers).some(key => key === message.author.id)) {
|
||||
switch (message.content.toLowerCase().split("```")[0]) {
|
||||
case "groupdata":
|
||||
const response = await fetch(message.attachments[0].url);
|
||||
const groupdata = await response.json();
|
||||
await handleGroupData(groupdata);
|
||||
break;
|
||||
case "join":
|
||||
if (encryptcordGroupMembers[UserStore.getCurrentUser().id].child) return;
|
||||
if (!await DataStore.get("encryptcordGroup")) return;
|
||||
const sender = await UserUtils.getUser(message.author.id).catch(() => null);
|
||||
if (!sender) return;
|
||||
const userKey = message.content.split("```")[1];
|
||||
await handleJoin(sender.id, userKey, encryptcordGroupMembers);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const dmChannelId = await PrivateChannelsStore.getOrEnsurePrivateChannel(message.author.id);
|
||||
if (channelId !== dmChannelId) return;
|
||||
const sender = await UserUtils.getUser(message.author.id).catch(() => null);
|
||||
if (!sender) return;
|
||||
const groupChannel = await DataStore.get("encryptcordChannelId");
|
||||
switch (message.content.toLowerCase()) {
|
||||
case "leaving":
|
||||
handleLeaving(sender.id, encryptcordGroupMembers, groupChannel);
|
||||
break;
|
||||
case "message":
|
||||
const msgResponse = await fetch(message.attachments[0].url);
|
||||
const messagedata = await msgResponse.json();
|
||||
await handleMessage(messagedata, sender.id, groupChannel);
|
||||
break;
|
||||
case "groupdata":
|
||||
const response = await fetch(message.attachments[0].url);
|
||||
const groupdata = await response.json();
|
||||
await handleGroupData(groupdata);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
},
|
||||
commands: [
|
||||
{
|
||||
name: "encryptcord",
|
||||
description: "End-to-end encryption in Discord!",
|
||||
options: [
|
||||
{
|
||||
name: "leave",
|
||||
description: "Leave current group",
|
||||
options: [],
|
||||
type: ApplicationCommandOptionType.SUB_COMMAND,
|
||||
},
|
||||
{
|
||||
name: "data",
|
||||
description: "View your keys and current group members",
|
||||
options: [],
|
||||
type: ApplicationCommandOptionType.SUB_COMMAND,
|
||||
},
|
||||
],
|
||||
inputType: ApplicationCommandInputType.BOT,
|
||||
execute: (opts, ctx) => {
|
||||
switch (opts[0].name) {
|
||||
case "start":
|
||||
startGroup(opts[0].options, ctx);
|
||||
break;
|
||||
case "leave":
|
||||
leave(opts[0].options, ctx);
|
||||
break;
|
||||
case "data":
|
||||
data(opts[0].options, ctx);
|
||||
break;
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
async start() {
|
||||
addChatBarButton("Encryptcord", ChatBarIcon);
|
||||
const pair = await generateKeys();
|
||||
await DataStore.set("encryptcordPublicKey", pair.publicKey);
|
||||
await DataStore.set("encryptcordPrivateKey", pair.privateKey);
|
||||
if (await DataStore.get("encryptcordGroup") === true) {
|
||||
await leave("", { channel: { id: await DataStore.get("encryptcordChannelId") } });
|
||||
}
|
||||
await DataStore.set("encryptcordGroup", false);
|
||||
await DataStore.set("encryptcordChannelId", "");
|
||||
await DataStore.set("encryptcordGroupMembers", {});
|
||||
},
|
||||
async stop() {
|
||||
removeButton("Encryptcord");
|
||||
if (await DataStore.get("encryptcordGroup") === true) {
|
||||
await leave("", { channel: { id: await DataStore.get("encryptcordChannelId") } });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Send Temporary Message
|
||||
async function sendTempMessage(recipientId: string, attachment: string, content: string, dm: boolean = true) {
|
||||
if (recipientId === UserStore.getCurrentUser().id) return;
|
||||
const channelId = dm ? await PrivateChannelsStore.getOrEnsurePrivateChannel(recipientId) : recipientId;
|
||||
if (attachment && attachment !== "") {
|
||||
const upload = await new CloudUtils.CloudUpload({
|
||||
file: new File([new Blob([attachment])], "file.text", { type: "text/plain; charset=utf-8" }),
|
||||
isClip: false,
|
||||
isThumbnail: false,
|
||||
platform: 1,
|
||||
}, channelId, false, 0);
|
||||
upload.on("complete", async () => {
|
||||
const messageId = await RestAPI.post({
|
||||
url: `/channels/${channelId}/messages`,
|
||||
body: {
|
||||
content,
|
||||
attachments: [{
|
||||
id: "0",
|
||||
filename: upload.filename,
|
||||
uploaded_filename: upload.uploadedFilename,
|
||||
}],
|
||||
nonce: SnowflakeUtils.fromTimestamp(Date.now()),
|
||||
},
|
||||
}).then(response => response.body.id);
|
||||
|
||||
await sleep(500);
|
||||
MessageActions.deleteMessage(channelId, messageId);
|
||||
});
|
||||
await upload.upload();
|
||||
return;
|
||||
}
|
||||
|
||||
const messageId = await RestAPI.post({
|
||||
url: `/channels/${channelId}/messages`,
|
||||
body: {
|
||||
content,
|
||||
nonce: SnowflakeUtils.fromTimestamp(Date.now()),
|
||||
},
|
||||
}).then(response => response.body.id);
|
||||
|
||||
await sleep(500);
|
||||
MessageActions.deleteMessage(channelId, messageId);
|
||||
}
|
||||
|
||||
// Handle leaving group
|
||||
async function handleLeaving(senderId: string, encryptcordGroupMembers: object, groupChannel: string) {
|
||||
const updatedMembers = Object.keys(encryptcordGroupMembers).reduce((result, memberId) => {
|
||||
if (memberId !== senderId) {
|
||||
result[memberId] = encryptcordGroupMembers[memberId];
|
||||
if (result[memberId].child === senderId) {
|
||||
result[memberId].child = encryptcordGroupMembers[senderId].child;
|
||||
}
|
||||
if (result[memberId].parent === senderId) {
|
||||
result[memberId].parent = encryptcordGroupMembers[senderId].parent;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, {});
|
||||
|
||||
await DataStore.set("encryptcordGroupMembers", updatedMembers);
|
||||
|
||||
await MessageActions.receiveMessage(groupChannel, await createMessage("", senderId, groupChannel, 2));
|
||||
}
|
||||
|
||||
// Handle receiving message
|
||||
async function handleMessage(message, senderId: string, groupChannel: string) {
|
||||
const decryptedMessage = await decryptData(await DataStore.get("encryptcordPrivateKey"), message);
|
||||
await MessageActions.receiveMessage(groupChannel, await createMessage(decryptedMessage, senderId, groupChannel, 0));
|
||||
}
|
||||
|
||||
// Handle receiving group data
|
||||
async function handleGroupData(groupData) {
|
||||
await DataStore.set("encryptcordChannelId", groupData.channel);
|
||||
await DataStore.set("encryptcordGroupMembers", groupData.members);
|
||||
await DataStore.set("encryptcordGroup", true);
|
||||
await MessageActions.receiveMessage(groupData.channel, await createMessage("", UserStore.getCurrentUser().id, groupData.channel, 7));
|
||||
setEnabled(true);
|
||||
}
|
||||
|
||||
// Handle joining group
|
||||
async function handleJoin(senderId: string, senderKey: string, encryptcordGroupMembers: object) {
|
||||
encryptcordGroupMembers[senderId] = { key: senderKey, parent: UserStore.getCurrentUser().id, child: null };
|
||||
encryptcordGroupMembers[UserStore.getCurrentUser().id].child = senderId;
|
||||
await DataStore.set("encryptcordGroupMembers", encryptcordGroupMembers);
|
||||
const groupChannel = await DataStore.get("encryptcordChannelId");
|
||||
const newMember = await UserUtils.getUser(senderId).catch(() => null);
|
||||
if (!newMember) return;
|
||||
|
||||
const membersData = {};
|
||||
Object.entries(encryptcordGroupMembers)
|
||||
.forEach(([memberId, value]) => {
|
||||
membersData[memberId] = value;
|
||||
});
|
||||
|
||||
const membersDataString = JSON.stringify({ members: membersData, channel: groupChannel });
|
||||
|
||||
const dmPromises = Object.keys(encryptcordGroupMembers).map(async memberId => {
|
||||
const groupMember = await UserUtils.getUser(memberId).catch(() => null);
|
||||
if (!groupMember) return;
|
||||
await sendTempMessage(groupMember.id, membersDataString, "groupdata");
|
||||
});
|
||||
|
||||
await Promise.all(dmPromises);
|
||||
await MessageActions.receiveMessage(groupChannel, {
|
||||
...await createMessage("", senderId, groupChannel, 7), components: [{
|
||||
type: 1,
|
||||
components: [{
|
||||
type: 2,
|
||||
style: 4,
|
||||
label: "I don't want to talk to you!",
|
||||
custom_id: "removeFromSelf"
|
||||
},
|
||||
{
|
||||
type: 2,
|
||||
style: 2,
|
||||
label: "(Other users can still send/receive messages to/from them)",
|
||||
disabled: true,
|
||||
custom_id: "encryptcord"
|
||||
}]
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
// Create message for group
|
||||
async function createMessage(message: string, senderId: string, channelId: string, type: number) {
|
||||
const messageStart = MessageCreator.createBotMessage({ channelId, content: "", embeds: [] });
|
||||
const sender = await UserUtils.getUser(senderId).catch(() => null);
|
||||
if (!sender) return;
|
||||
return { ...messageStart, content: message, author: sender, type, flags: 0 };
|
||||
}
|
||||
|
||||
// Start E2EE Group
|
||||
async function startGroup(opts, ctx) {
|
||||
const channelId = ctx.channel.id;
|
||||
await DataStore.set("encryptcordChannelId", channelId);
|
||||
await DataStore.set("encryptcordGroupMembers", {
|
||||
[UserStore.getCurrentUser().id]: { key: await DataStore.get("encryptcordPublicKey"), parent: null, child: null }
|
||||
});
|
||||
await DataStore.set("encryptcordGroup", true);
|
||||
sendBotMessage(channelId, { content: "Group created!\n> Other users can click the lock icon to join." });
|
||||
await MessageActions.receiveMessage(channelId, await createMessage("", UserStore.getCurrentUser().id, channelId, 7));
|
||||
setEnabled(true);
|
||||
}
|
||||
|
||||
// Leave the Group;
|
||||
async function leave(opts, ctx) {
|
||||
const channelId = ctx.channel.id;
|
||||
if (!(await DataStore.get("encryptcordGroup"))) {
|
||||
sendBotMessage(channelId, { content: "You're not in a group!" });
|
||||
return;
|
||||
}
|
||||
const user = UserStore.getCurrentUser();
|
||||
const encryptcordGroupMembers = await DataStore.get("encryptcordGroupMembers");
|
||||
|
||||
const dmPromises = Object.keys(encryptcordGroupMembers).map(async memberId => {
|
||||
const groupMember = await UserUtils.getUser(memberId).catch(() => null);
|
||||
if (!groupMember) return;
|
||||
await sendTempMessage(groupMember.id, "", "leaving");
|
||||
});
|
||||
|
||||
await Promise.all(dmPromises);
|
||||
await DataStore.set("encryptcordGroup", false);
|
||||
await DataStore.set("encryptcordChannelId", "");
|
||||
await DataStore.set("encryptcordGroupMembers", {});
|
||||
await MessageActions.receiveMessage(channelId, await createMessage("", user.id, channelId, 2));
|
||||
setEnabled(false);
|
||||
}
|
||||
|
||||
// View user data
|
||||
async function data(opts, ctx) {
|
||||
const channelId = ctx.channel.id;
|
||||
const encryptcordGroupMembers = await DataStore.get("encryptcordGroupMembers");
|
||||
const encryptcordPublicKey = await DataStore.get("encryptcordPublicKey");
|
||||
const encryptcordPrivateKey = await DataStore.get("encryptcordPrivateKey");
|
||||
const exportedPrivateKey = await crypto.subtle.exportKey("pkcs8", encryptcordPrivateKey);
|
||||
const groupMembers = Object.keys(encryptcordGroupMembers);
|
||||
sendBotMessage(channelId, {
|
||||
content: `## Public key:\n\`\`\`${encryptcordPublicKey}\`\`\`\n## Private key:\n||\`\`\`${formatPemKey(exportedPrivateKey, "private")}\`\`\`||*(DO **NOT** SHARE THIS)*\n## Group members:\n\`\`\`json\n${JSON.stringify(groupMembers)}\`\`\``
|
||||
});
|
||||
}
|
||||
121
src/equicordplugins/encryptcord/rsa-utils.tsx
Normal file
121
src/equicordplugins/encryptcord/rsa-utils.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export const generateKeys = async () => {
|
||||
const keyPair = await crypto.subtle.generateKey(
|
||||
{
|
||||
name: "RSA-OAEP",
|
||||
modulusLength: 4096,
|
||||
publicExponent: new Uint8Array([1, 0, 1]),
|
||||
hash: "SHA-256",
|
||||
},
|
||||
true,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
|
||||
const exportedPublicKey = await crypto.subtle.exportKey("spki", keyPair.publicKey);
|
||||
const publicKey = formatPemKey(exportedPublicKey, "public");
|
||||
|
||||
return { privateKey: keyPair.privateKey, publicKey };
|
||||
};
|
||||
|
||||
export const encryptData = async (pemPublicKey, data) => {
|
||||
const publicKey = await importPemPublicKey(pemPublicKey);
|
||||
|
||||
const chunkSize = 446;
|
||||
|
||||
const encryptedChunks: any[] = [];
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
for (let i = 0; i < data.length; i += chunkSize) {
|
||||
const chunk = await data.substring(i, i + chunkSize);
|
||||
const encryptedChunk = await crypto.subtle.encrypt(
|
||||
{
|
||||
name: "RSA-OAEP",
|
||||
},
|
||||
publicKey,
|
||||
encoder.encode(chunk)
|
||||
);
|
||||
encryptedChunks.push(arrayBufferToBase64(encryptedChunk));
|
||||
}
|
||||
|
||||
return encryptedChunks;
|
||||
};
|
||||
|
||||
export const decryptData = async (privateKey, encArray) => {
|
||||
const decryptionPromises = encArray.map(async encStr => {
|
||||
const encBuffer = base64ToArrayBuffer(encStr);
|
||||
|
||||
const dec = await crypto.subtle.decrypt(
|
||||
{
|
||||
name: "RSA-OAEP",
|
||||
},
|
||||
privateKey,
|
||||
encBuffer
|
||||
);
|
||||
|
||||
return new TextDecoder().decode(dec);
|
||||
});
|
||||
|
||||
const decryptedMessages = await Promise.all(decryptionPromises);
|
||||
|
||||
return decryptedMessages.join("");
|
||||
};
|
||||
|
||||
// Helper functions
|
||||
const arrayBufferToBase64 = buffer => {
|
||||
const binary = String.fromCharCode(...new Uint8Array(buffer));
|
||||
return btoa(binary);
|
||||
};
|
||||
|
||||
const base64ToArrayBuffer = base64String => {
|
||||
const binaryString = atob(base64String);
|
||||
const { length } = binaryString;
|
||||
const buffer = new ArrayBuffer(length);
|
||||
const view = new Uint8Array(buffer);
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
view[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
|
||||
return buffer;
|
||||
};
|
||||
|
||||
export const formatPemKey = (keyData, type) => {
|
||||
const base64Key = arrayBufferToBase64(keyData);
|
||||
return `-----BEGIN ${type.toUpperCase()} KEY-----\n` + base64Key + `\n-----END ${type.toUpperCase()} KEY----- `;
|
||||
};
|
||||
|
||||
const importPemPublicKey = async pemKey => {
|
||||
try {
|
||||
const trimmedPemKey = pemKey.trim();
|
||||
|
||||
const keyBody = trimmedPemKey
|
||||
.replace("-----BEGIN PUBLIC KEY-----", "")
|
||||
.replace("-----END PUBLIC KEY-----", "");
|
||||
|
||||
const binaryDer = atob(keyBody);
|
||||
|
||||
const arrayBuffer = new Uint8Array(binaryDer.length);
|
||||
for (let i = 0; i < binaryDer.length; i++) {
|
||||
arrayBuffer[i] = binaryDer.charCodeAt(i);
|
||||
}
|
||||
|
||||
return await crypto.subtle.importKey(
|
||||
"spki",
|
||||
arrayBuffer,
|
||||
{
|
||||
name: "RSA-OAEP",
|
||||
hash: { name: "SHA-256" },
|
||||
},
|
||||
true,
|
||||
["encrypt"]
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error importing PEM public key:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
127
src/equicordplugins/exportContacts/index.tsx
Normal file
127
src/equicordplugins/exportContacts/index.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
/*
|
||||
* Vencord, a modification for Discord's desktop app
|
||||
* Copyright (c) 2024 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 "./styles.css";
|
||||
|
||||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
import { Devs } from "@utils/constants";
|
||||
import definePlugin from "@utils/types";
|
||||
import { Clipboard, Toasts } from "@webpack/common";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar: string;
|
||||
discriminator: string;
|
||||
publicFlags: number;
|
||||
avatarDecorationData?: any;
|
||||
globalName: string;
|
||||
}
|
||||
|
||||
interface ContactsList {
|
||||
id: string;
|
||||
type: number;
|
||||
nickname?: any;
|
||||
user: User;
|
||||
since: string;
|
||||
}
|
||||
|
||||
// for type parameter, it takes in a number that determines the type of the contact
|
||||
// 1 is friends added
|
||||
// 2 is blocked users
|
||||
// 3 is incoming friend requests
|
||||
// 4 is outgoing friend requests
|
||||
function getUsernames(contacts: ContactsList[], type: number): string[] {
|
||||
return contacts
|
||||
// only select contacts that are the specified type
|
||||
.filter(e => e.type === type)
|
||||
// return the username, and discriminator if necessary
|
||||
.map(e => e.user.discriminator === "0" ? e.user.username : e.user.username + "#" + e.user.discriminator);
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
name: "ExportContacts",
|
||||
description: "Export a list of friends to your clipboard. Adds a new button to the menu bar for the friends tab.",
|
||||
authors: [Devs.dat_insanity],
|
||||
patches: [
|
||||
{
|
||||
find: "fetchRelationships(){",
|
||||
replacement: {
|
||||
match: /\.then\(e=>o\.default\.dispatch\({type:"LOAD_RELATIONSHIPS_SUCCESS",relationships:e\.body}\)/,
|
||||
replace: ".then(e=>{o.default.dispatch({type:\"LOAD_RELATIONSHIPS_SUCCESS\",relationships:e.body}); $self.getContacts(e.body)}"
|
||||
}
|
||||
},
|
||||
{
|
||||
find: "[role=\"tab\"][aria-disabled=\"false\"]",
|
||||
replacement: {
|
||||
match: /this\.props;return\(/,
|
||||
replace: "this.props;console.log(a.Children);return("
|
||||
}
|
||||
},
|
||||
{
|
||||
find: "[role=\"tab\"][aria-disabled=\"false\"]",
|
||||
replacement: {
|
||||
match: /(\w+)\.Children\.map\((\w+),\s*this\.renderChildren\)/,
|
||||
replace: "[...$1.Children.map($2,this.renderChildren),$self.addExportButton()]"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
getContacts(contacts: ContactsList[]) {
|
||||
this.contactList = {
|
||||
friendsAdded: [...getUsernames(contacts, 1)],
|
||||
blockedUsers: [...getUsernames(contacts, 2)],
|
||||
incomingFriendRequests: [...getUsernames(contacts, 3)],
|
||||
outgoingFriendRequests: [...getUsernames(contacts, 4)]
|
||||
};
|
||||
},
|
||||
|
||||
addExportButton() {
|
||||
return <ErrorBoundary noop key=".2">
|
||||
<button className="export-contacts-button" onClick={() => { this.copyContactToClipboard(); console.log("clicked"); }}>Export</button>
|
||||
</ErrorBoundary>;
|
||||
},
|
||||
|
||||
copyContactToClipboard() {
|
||||
if (this.contactList) {
|
||||
Clipboard.copy(JSON.stringify(this.contactList));
|
||||
Toasts.show({
|
||||
message: "Contacts copied to clipboard successfully.",
|
||||
type: Toasts.Type.SUCCESS,
|
||||
id: Toasts.genId(),
|
||||
options: {
|
||||
duration: 3000,
|
||||
position: Toasts.Position.BOTTOM
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
// reason why you need to click the all tab is because the data is extracted during
|
||||
// the request itself when you fetch all your friends. this is done to avoid sending a
|
||||
// manual request to discord, which may raise suspicion and might even get you terminated.
|
||||
Toasts.show({
|
||||
message: "Contact list is undefined. Click on the \"All\" tab before exporting.",
|
||||
type: Toasts.Type.FAILURE,
|
||||
id: Toasts.genId(),
|
||||
options: {
|
||||
duration: 3000,
|
||||
position: Toasts.Position.BOTTOM
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
27
src/equicordplugins/exportContacts/styles.css
Normal file
27
src/equicordplugins/exportContacts/styles.css
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
.export-contacts-button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
min-width: 40px;
|
||||
-webkit-app-region: no-drag;
|
||||
border-radius: 4px;
|
||||
margin: 0 8px;
|
||||
padding: 2px 8px;
|
||||
position: relative;
|
||||
font-size: 16px;
|
||||
line-height: 20px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
outline: 0;
|
||||
background-color: #5865f2;
|
||||
color: var(--status-positive-text);
|
||||
}
|
||||
|
||||
.export-contacts-button:hover {
|
||||
background-color: #454fbf;
|
||||
}
|
||||
73
src/equicordplugins/findReply/ReplyNavigator.tsx
Normal file
73
src/equicordplugins/findReply/ReplyNavigator.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
import { ModalCloseButton } from "@utils/modal";
|
||||
import { findByPropsLazy } from "@webpack";
|
||||
import { Paginator, React, useRef, useState } from "@webpack/common";
|
||||
import { Message } from "discord-types/general";
|
||||
import { MutableRefObject } from "react";
|
||||
|
||||
import { jumper } from "./index";
|
||||
|
||||
const containerStyles = findByPropsLazy("containerBottom", "containerTop");
|
||||
|
||||
export default function ReplyNavigator({ replies }: { replies: Message[]; }) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [visible, setVisible] = useState(true);
|
||||
const ref: MutableRefObject<HTMLDivElement | null> = useRef(null);
|
||||
React.useEffect(() => {
|
||||
setPage(1);
|
||||
setVisible(true);
|
||||
}, [replies]);
|
||||
React.useEffect(() => {
|
||||
function onMouseDown(event: MouseEvent) {
|
||||
if (ref.current && event.target instanceof Element && !ref.current.contains(event.target)) {
|
||||
setVisible(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", onMouseDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onMouseDown);
|
||||
};
|
||||
}, [ref]);
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div ref={ref} className={containerStyles.containerBottom + " vc-findreply-div"} style={{
|
||||
display: visible ? "flex" : "none",
|
||||
backgroundColor: "var(--background-primary)",
|
||||
borderRadius: "3vmin",
|
||||
zIndex: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingLeft: "1em",
|
||||
paddingRight: "1em",
|
||||
opacity: "80%"
|
||||
}}>
|
||||
<Paginator
|
||||
className={"vc-findreply-paginator"}
|
||||
currentPage={page}
|
||||
maxVisiblePages={5}
|
||||
pageSize={1}
|
||||
totalCount={replies.length}
|
||||
onPageChange={processPageChange}
|
||||
/>
|
||||
<ModalCloseButton className={"vc-findreply-close"} onClick={() => setVisible(false)} />
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
function processPageChange(page: number) {
|
||||
setPage(page);
|
||||
jumper.jumpToMessage({
|
||||
channelId: replies[page - 1].channel_id,
|
||||
messageId: replies[page - 1].id,
|
||||
flash: true,
|
||||
jumpType: "INSTANT"
|
||||
});
|
||||
}
|
||||
}
|
||||
133
src/equicordplugins/findReply/index.tsx
Normal file
133
src/equicordplugins/findReply/index.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { addButton, removeButton } from "@api/MessagePopover";
|
||||
import { disableStyle, enableStyle } from "@api/Styles";
|
||||
import { Devs } from "@utils/constants";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { findByPropsLazy } from "@webpack";
|
||||
import { ChannelStore, MessageStore, ReactDOM, Toasts } from "@webpack/common";
|
||||
import Message from "discord-types/general/Message";
|
||||
import { Root } from "react-dom/client";
|
||||
|
||||
import ReplyNavigator from "./ReplyNavigator";
|
||||
import styles from "./styles.css?managed";
|
||||
|
||||
|
||||
export const jumper: any = findByPropsLazy("jumpToMessage");
|
||||
const FindReplyIcon = () => {
|
||||
return <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" width="18" height="18">
|
||||
<path
|
||||
d="M 7 3 L 7 11 C 7 11 7 12 6 12 L 5 12 C 4 12 4 12 4.983 13.115 L 8.164 17.036 C 9 18 9 18 9.844 17.018 L 12.991 13.277 C 14 12 14 12 13.006 11.985 L 12 12 C 12 12 11 12 11 11 L 11 3 C 11 2 11 2 10 2 L 8 2 C 7 2 7 2 7 3" />
|
||||
</svg>;
|
||||
};
|
||||
let root: Root | null = null;
|
||||
let element: HTMLDivElement | null = null;
|
||||
let madeComponent = false;
|
||||
|
||||
function findReplies(message: Message) {
|
||||
const messages: Array<Message & {
|
||||
deleted?: boolean;
|
||||
}> = [...MessageStore.getMessages(message.channel_id)?._array ?? []].filter(m => !m.deleted).sort((a, b) => {
|
||||
return a.timestamp.toString().localeCompare(b.timestamp.toString());
|
||||
}); // Need to deep copy Message array when sorting
|
||||
const found: Message[] = [];
|
||||
for (const other of messages) {
|
||||
if (other.timestamp.toString().localeCompare(message.timestamp.toString()) <= 0) continue;
|
||||
if (other.messageReference?.message_id === message.id) {
|
||||
found.push(other);
|
||||
}
|
||||
if (Vencord.Settings.plugins.FindReply.includePings) {
|
||||
if (other.content?.includes(`<@${message.author.id}>`)) {
|
||||
found.push(other);
|
||||
}
|
||||
}
|
||||
if (Vencord.Settings.plugins.FindReply.includeAuthor) {
|
||||
if (messages.find(m => m.id === other.messageReference?.message_id)?.author.id === message.author.id) {
|
||||
found.push(other);
|
||||
}
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
name: "FindReply",
|
||||
description: "Jumps to the earliest reply to a message in a channel (lets you follow past conversations more easily).",
|
||||
authors: [Devs.newwares],
|
||||
start() {
|
||||
enableStyle(styles);
|
||||
addButton("vc-findreply", message => {
|
||||
if (!message.id) return null;
|
||||
const replies = findReplies(message);
|
||||
if (Vencord.Settings.plugins.FindReply.hideButtonIfNoReply && !replies.length) return null;
|
||||
return {
|
||||
label: "Jump to Reply",
|
||||
icon: FindReplyIcon,
|
||||
message,
|
||||
channel: ChannelStore.getChannel(message.channel_id),
|
||||
onClick: async () => {
|
||||
if (replies.length) {
|
||||
const channelId = replies[0].channel_id;
|
||||
const messageId = replies[0].id;
|
||||
jumper.jumpToMessage({
|
||||
channelId,
|
||||
messageId,
|
||||
flash: true,
|
||||
jumpType: "INSTANT"
|
||||
});
|
||||
if (replies.length > 1) {
|
||||
Toasts.show({
|
||||
id: Toasts.genId(),
|
||||
message: "Use the bottom panel to navigate between replies.",
|
||||
type: Toasts.Type.MESSAGE
|
||||
});
|
||||
if (!madeComponent) {
|
||||
madeComponent = true;
|
||||
element = document.createElement("div");
|
||||
document.querySelector("[class^=base__]")!.appendChild(element);
|
||||
root = ReactDOM.createRoot(element);
|
||||
}
|
||||
root!.render(<ReplyNavigator replies={replies} />);
|
||||
}
|
||||
} else {
|
||||
Toasts.show({
|
||||
id: Toasts.genId(),
|
||||
message: "Couldn't find a reply.",
|
||||
type: Toasts.Type.FAILURE
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
},
|
||||
stop() {
|
||||
removeButton("vc-findreply");
|
||||
root && root.unmount();
|
||||
element?.remove();
|
||||
disableStyle(styles);
|
||||
},
|
||||
options: {
|
||||
includePings: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Will also search for messages that @ the author directly",
|
||||
default: false,
|
||||
restartNeeded: false
|
||||
},
|
||||
includeAuthor: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Will also search for messages that reply to the author in general, not just that exact message",
|
||||
default: false,
|
||||
restartNeeded: false
|
||||
},
|
||||
hideButtonIfNoReply: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Hides the button if there are no replies to the message",
|
||||
default: true,
|
||||
restartNeeded: true
|
||||
}
|
||||
}
|
||||
});
|
||||
8
src/equicordplugins/findReply/styles.css
Normal file
8
src/equicordplugins/findReply/styles.css
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
.vc-findreply-paginator {
|
||||
margin-top: inherit !important;
|
||||
}
|
||||
|
||||
.vc-findreply-close {
|
||||
align-items: inherit !important;
|
||||
line-height: 0 !important;
|
||||
}
|
||||
112
src/equicordplugins/gifCollections/CollectionManager.ts
Normal file
112
src/equicordplugins/gifCollections/CollectionManager.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
/*
|
||||
* 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 { DataStore } from "@api/index";
|
||||
import { Toasts } from "@webpack/common";
|
||||
|
||||
import { settings } from "./index";
|
||||
import { Collection, Gif } from "./types";
|
||||
import { getFormat } from "./utils/getFormat";
|
||||
|
||||
export const DATA_COLLECTION_NAME = "gif-collections-collections";
|
||||
|
||||
// this is here bec async + react class component dont play nice and stutters happen. IF theres a better way of doing it pls let me know
|
||||
export let cache_collections: Collection[] = [];
|
||||
|
||||
export const getCollections = async (): Promise<Collection[]> => (await DataStore.get<Collection[]>(DATA_COLLECTION_NAME)) ?? [];
|
||||
|
||||
export const getCollection = async (name: string): Promise<Collection | undefined> => {
|
||||
const collections = await getCollections();
|
||||
return collections.find(c => c.name === name);
|
||||
};
|
||||
|
||||
export const getCachedCollection = (name: string): Collection | undefined => cache_collections.find(c => c.name === name);
|
||||
|
||||
export const createCollection = async (name: string, gifs: Gif[]): Promise<void> => {
|
||||
|
||||
const collections = await getCollections();
|
||||
const duplicateCollection = collections.find(c => c.name === `gc:${name}`);
|
||||
if (duplicateCollection)
|
||||
return Toasts.show({
|
||||
message: "That collection already exists",
|
||||
type: Toasts.Type.FAILURE,
|
||||
id: Toasts.genId(),
|
||||
options: {
|
||||
duration: 3000,
|
||||
position: Toasts.Position.BOTTOM
|
||||
}
|
||||
});
|
||||
|
||||
// gifs shouldnt be empty because to create a collection you need to right click an image / gif and then create it yk. but cant hurt to have a null-conditional check RIGHT?
|
||||
const latestGifSrc = gifs[gifs.length - 1]?.src ?? settings.store.defaultEmptyCollectionImage;
|
||||
const collection = {
|
||||
name: `gc:${name}`,
|
||||
src: latestGifSrc,
|
||||
format: getFormat(latestGifSrc),
|
||||
type: "Category",
|
||||
gifs
|
||||
};
|
||||
|
||||
await DataStore.set(DATA_COLLECTION_NAME, [...collections, collection]);
|
||||
return await refreshCacheCollection();
|
||||
};
|
||||
|
||||
export const addToCollection = async (name: string, gif: Gif): Promise<void> => {
|
||||
const collections = await getCollections();
|
||||
const collectionIndex = collections.findIndex(c => c.name === name);
|
||||
if (collectionIndex === -1) return console.warn("collection not found");
|
||||
|
||||
collections[collectionIndex].gifs.push(gif);
|
||||
collections[collectionIndex].src = gif.src;
|
||||
collections[collectionIndex].format = getFormat(gif.src);
|
||||
|
||||
await DataStore.set(DATA_COLLECTION_NAME, collections);
|
||||
return await refreshCacheCollection();
|
||||
|
||||
};
|
||||
|
||||
export const removeFromCollection = async (id: string): Promise<void> => {
|
||||
const collections = await getCollections();
|
||||
const collectionIndex = collections.findIndex(c => c.gifs.some(g => g.id === id));
|
||||
if (collectionIndex === -1) return console.warn("collection not found");
|
||||
|
||||
// Remove The Gif
|
||||
collections[collectionIndex].gifs = collections[collectionIndex].gifs.filter(g => g.id !== id);
|
||||
|
||||
const collection = collections[collectionIndex];
|
||||
const latestGifSrc = collection.gifs.length ? collection.gifs[collection.gifs.length - 1].src : settings.store.defaultEmptyCollectionImage;
|
||||
collections[collectionIndex].src = latestGifSrc;
|
||||
collections[collectionIndex].format = getFormat(latestGifSrc);
|
||||
|
||||
await DataStore.set(DATA_COLLECTION_NAME, collections);
|
||||
return await refreshCacheCollection();
|
||||
};
|
||||
|
||||
export const deleteCollection = async (name: string): Promise<void> => {
|
||||
|
||||
const collections = await getCollections();
|
||||
const col = collections.filter(c => c.name !== name);
|
||||
await DataStore.set(DATA_COLLECTION_NAME, col);
|
||||
await refreshCacheCollection();
|
||||
};
|
||||
|
||||
|
||||
export const refreshCacheCollection = async (): Promise<void> => {
|
||||
cache_collections = await getCollections();
|
||||
};
|
||||
|
||||
21
src/equicordplugins/gifCollections/constants.ts
Normal file
21
src/equicordplugins/gifCollections/constants.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* Vencord, a modification for Discord's desktop app
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// cant change them now eh. My friend uses this plugin. LOVE YOU FREEZER
|
||||
export const GIF_ITEM_PREFIX = "gc-moment:";
|
||||
export const GIF_COLLECTION_PREFIX = "gc:";
|
||||
365
src/equicordplugins/gifCollections/index.tsx
Normal file
365
src/equicordplugins/gifCollections/index.tsx
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
// Plugin idea by brainfreeze (668137937333911553) 😎
|
||||
|
||||
import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu";
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { Devs } from "@utils/constants";
|
||||
import { ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, openModal } from "@utils/modal";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { Alerts, Button, ContextMenuApi, FluxDispatcher, Forms, Menu, React, TextInput, useCallback, useState } from "@webpack/common";
|
||||
|
||||
import * as CollectionManager from "./CollectionManager";
|
||||
import { GIF_COLLECTION_PREFIX, GIF_ITEM_PREFIX } from "./constants";
|
||||
import { Category, Collection, Gif, Props } from "./types";
|
||||
import { getFormat } from "./utils/getFormat";
|
||||
import { getGif } from "./utils/getGif";
|
||||
import { downloadCollections, uploadGifCollections } from "./utils/settingsUtils";
|
||||
import { uuidv4 } from "./utils/uuidv4";
|
||||
|
||||
export const settings = definePluginSettings({
|
||||
defaultEmptyCollectionImage: {
|
||||
description: "The image / gif that will be shown when a collection has no images / gifs",
|
||||
type: OptionType.STRING,
|
||||
default: "https://i.imgur.com/TFatP8r.png"
|
||||
},
|
||||
importGifs: {
|
||||
type: OptionType.COMPONENT,
|
||||
description: "Import Collections",
|
||||
component: () =>
|
||||
<Button onClick={async () =>
|
||||
// if they have collections show the warning
|
||||
(await CollectionManager.getCollections()).length ? Alerts.show({
|
||||
title: "Are you sure?",
|
||||
body: "Importing collections will overwrite your current collections.",
|
||||
confirmText: "Import",
|
||||
// wow this works?
|
||||
confirmColor: Button.Colors.RED,
|
||||
cancelText: "Nevermind",
|
||||
onConfirm: async () => uploadGifCollections()
|
||||
|
||||
}) : uploadGifCollections()}>
|
||||
Import Collections
|
||||
</Button>,
|
||||
},
|
||||
exportGifs: {
|
||||
type: OptionType.COMPONENT,
|
||||
description: "Export Collections",
|
||||
component: () =>
|
||||
<Button onClick={downloadCollections}>
|
||||
Export Collections
|
||||
</Button>
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const addCollectionContextMenuPatch: NavContextMenuPatchCallback = (children, props) => {
|
||||
if (!props) return;
|
||||
|
||||
const { message, itemSrc, itemHref, target } = props;
|
||||
|
||||
const gif = getGif(message, itemSrc ?? itemHref, target);
|
||||
|
||||
if (!gif) return;
|
||||
|
||||
const group = findGroupChildrenByChildId("open-native-link", children) ?? findGroupChildrenByChildId("copy-link", children);
|
||||
if (group && !group.some(child => child?.props?.id === "add-to-collection")) {
|
||||
group.push(
|
||||
// if i do it the normal way i get a invalid context menu thingy error -> Menu API only allows Items and groups of Items as children.
|
||||
MenuThingy({ gif })
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
export default definePlugin({
|
||||
name: "Gif Collection",
|
||||
// need better description eh
|
||||
description: "Allows you to have collections of gifs",
|
||||
authors: [Devs.Aria],
|
||||
contextMenus: {
|
||||
"message": addCollectionContextMenuPatch
|
||||
},
|
||||
patches: [
|
||||
{
|
||||
find: "renderCategoryExtras",
|
||||
replacement: [
|
||||
// This patch adds the collections to the gif part yk
|
||||
{
|
||||
match: /(render\(\){)(.{1,50}getItemGrid)/,
|
||||
replace: "$1;$self.insertCollections(this);$2"
|
||||
},
|
||||
// Hides the gc: from the name gc:monkeh -> monkeh
|
||||
// https://regex101.com/r/uEjLFq/1
|
||||
{
|
||||
match: /(className:\w\.categoryName,children:)(\i)/,
|
||||
replace: "$1$self.hidePrefix($2),"
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
find: "renderEmptyFavorite",
|
||||
replacement: {
|
||||
match: /render\(\){.{1,500}onClick:this\.handleClick,/,
|
||||
replace: "$&onContextMenu: (e) => $self.collectionContextMenu(e, this),"
|
||||
}
|
||||
},
|
||||
{
|
||||
find: "renderHeaderContent()",
|
||||
replacement: [
|
||||
// Replaces this.props.resultItems with the collection.gifs
|
||||
{
|
||||
match: /(renderContent\(\){)(.{1,50}resultItems)/,
|
||||
replace: "$1$self.renderContent(this);$2"
|
||||
},
|
||||
]
|
||||
},
|
||||
/*
|
||||
problem:
|
||||
when you click your collection in the gifs picker discord enters the collection name into the search bar
|
||||
which causes discord to fetch the gifs from their api. This causes a tiny flash when the gifs have fetched successfully
|
||||
solution:
|
||||
if query starts with gc: and collection is not null then return early and prevent the fetch
|
||||
*/
|
||||
{
|
||||
find: "type:\"GIF_PICKER_QUERY\"",
|
||||
replacement: {
|
||||
match: /(function \i\(.{1,10}\){)(.{1,100}.GIFS_SEARCH,query:)/,
|
||||
replace:
|
||||
"$1if($self.shouldStopFetch(arguments[0])) return;$2"
|
||||
}
|
||||
},
|
||||
],
|
||||
|
||||
settings,
|
||||
|
||||
|
||||
start() {
|
||||
CollectionManager.refreshCacheCollection();
|
||||
},
|
||||
|
||||
CollectionManager,
|
||||
|
||||
oldTrendingCat: null as Category[] | null,
|
||||
sillyInstance: null as any,
|
||||
sillyContentInstance: null as any,
|
||||
|
||||
get collections(): Collection[] {
|
||||
CollectionManager.refreshCacheCollection();
|
||||
return CollectionManager.cache_collections;
|
||||
},
|
||||
|
||||
renderContent(instance) {
|
||||
if (instance.props.query.startsWith(GIF_COLLECTION_PREFIX)) {
|
||||
this.sillyContentInstance = instance;
|
||||
const collection = this.collections.find(c => c.name === instance.props.query);
|
||||
if (!collection) return;
|
||||
instance.props.resultItems = collection.gifs.map(g => ({
|
||||
id: g.id,
|
||||
format: getFormat(g.src),
|
||||
src: g.src,
|
||||
url: g.url,
|
||||
width: g.width,
|
||||
height: g.height
|
||||
})).reverse();
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
hidePrefix(name: string) {
|
||||
return name.split(":").length > 1 ? name.replace(/.+?:/, "") : name;
|
||||
},
|
||||
|
||||
insertCollections(instance: { props: Props; }) {
|
||||
try {
|
||||
this.sillyInstance = instance;
|
||||
if (instance.props.trendingCategories.length && instance.props.trendingCategories[0].type === "Trending")
|
||||
this.oldTrendingCat = instance.props.trendingCategories;
|
||||
|
||||
|
||||
if (this.oldTrendingCat != null)
|
||||
instance.props.trendingCategories = this.collections.reverse().concat(this.oldTrendingCat as Collection[]);
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
},
|
||||
|
||||
shouldStopFetch(query: string) {
|
||||
if (query.startsWith(GIF_COLLECTION_PREFIX)) {
|
||||
const collection = this.collections.find(c => c.name === query);
|
||||
if (collection != null) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
collectionContextMenu(e: React.UIEvent, instance) {
|
||||
const { item } = instance.props;
|
||||
if (item?.name?.startsWith(GIF_COLLECTION_PREFIX))
|
||||
return ContextMenuApi.openContextMenu(e, () =>
|
||||
<RemoveItemContextMenu
|
||||
type="collection"
|
||||
onConfirm={() => { this.sillyInstance && this.sillyInstance.forceUpdate(); }}
|
||||
nameOrId={instance.props.item.name} />
|
||||
);
|
||||
if (item?.id?.startsWith(GIF_ITEM_PREFIX)) {
|
||||
ContextMenuApi.openContextMenu(e, () =>
|
||||
<RemoveItemContextMenu
|
||||
type="gif"
|
||||
onConfirm={() => { this.sillyContentInstance && this.sillyContentInstance.forceUpdate(); }}
|
||||
nameOrId={instance.props.item.id}
|
||||
/>);
|
||||
instance.props.focused = false;
|
||||
instance.forceUpdate();
|
||||
this.sillyContentInstance && this.sillyContentInstance.forceUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
const { src, url, height, width } = item;
|
||||
if (src && url && height != null && width != null && !item.id?.startsWith(GIF_ITEM_PREFIX))
|
||||
return ContextMenuApi.openContextMenu(e, () =>
|
||||
<Menu.Menu
|
||||
navId="gif-collection-id"
|
||||
onClose={() => FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
|
||||
aria-label="Gif Collections"
|
||||
>
|
||||
|
||||
{/* if i do it the normal way i get a invalid context menu thingy error -> Menu API only allows Items and groups of Items as children.*/}
|
||||
{MenuThingy({ gif: { ...item, id: uuidv4() } })}
|
||||
|
||||
|
||||
</Menu.Menu>
|
||||
);
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
|
||||
// stolen from spotify controls
|
||||
const RemoveItemContextMenu = ({ type, nameOrId, onConfirm }: { type: "gif" | "collection", nameOrId: string, onConfirm: () => void; }) => (
|
||||
<Menu.Menu
|
||||
navId="gif-collection-id"
|
||||
onClose={() => FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
|
||||
aria-label={type === "collection" ? "Delete Collection" : "Remove"}
|
||||
>
|
||||
<Menu.MenuItem
|
||||
key="delete-collection"
|
||||
id="delete-collection"
|
||||
label={type === "collection" ? "Delete Collection" : "Remove"}
|
||||
action={() =>
|
||||
// Stolen from Review components
|
||||
type === "collection" ? Alerts.show({
|
||||
title: "Are you sure?",
|
||||
body: "Do you really want to delete this collection?",
|
||||
confirmText: "Delete",
|
||||
confirmColor: Button.Colors.RED,
|
||||
cancelText: "Nevermind",
|
||||
onConfirm: async () => {
|
||||
await CollectionManager.deleteCollection(nameOrId);
|
||||
onConfirm();
|
||||
}
|
||||
}) : CollectionManager.removeFromCollection(nameOrId).then(() => onConfirm())}
|
||||
>
|
||||
|
||||
</Menu.MenuItem>
|
||||
</Menu.Menu>
|
||||
);
|
||||
|
||||
|
||||
|
||||
const MenuThingy: React.FC<{ gif: Gif; }> = ({ gif }) => {
|
||||
CollectionManager.refreshCacheCollection();
|
||||
const collections = CollectionManager.cache_collections;
|
||||
|
||||
return (
|
||||
<Menu.MenuItem
|
||||
label="Add To Collection"
|
||||
key="add-to-collection"
|
||||
id="add-to-collection"
|
||||
>
|
||||
{collections.map(col => (
|
||||
<Menu.MenuItem
|
||||
key={col.name}
|
||||
id={col.name}
|
||||
label={col.name.replace(/.+?:/, "")}
|
||||
action={() => CollectionManager.addToCollection(col.name, gif)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Menu.MenuSeparator />
|
||||
<Menu.MenuItem
|
||||
key="create-collection"
|
||||
id="create-collection"
|
||||
label="Create Collection"
|
||||
action={() => {
|
||||
openModal(modalProps => (
|
||||
<CreateCollectionModal onClose={modalProps.onClose} gif={gif} modalProps={modalProps} />
|
||||
));
|
||||
}}
|
||||
/>
|
||||
</Menu.MenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
interface CreateCollectionModalProps {
|
||||
gif: Gif;
|
||||
onClose: () => void,
|
||||
modalProps: ModalProps;
|
||||
}
|
||||
|
||||
function CreateCollectionModal({ gif, onClose, modalProps }: CreateCollectionModalProps) {
|
||||
|
||||
const [name, setName] = useState("");
|
||||
|
||||
const onSubmit = useCallback((e: React.FormEvent<HTMLFormElement> | React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
e.preventDefault();
|
||||
if (!name.length) return;
|
||||
CollectionManager.createCollection(name, [gif]);
|
||||
onClose();
|
||||
}, [name]);
|
||||
|
||||
return (
|
||||
<ModalRoot {...modalProps}>
|
||||
<form onSubmit={onSubmit}>
|
||||
<ModalHeader>
|
||||
<Forms.FormText>Create Collection</Forms.FormText>
|
||||
</ModalHeader>
|
||||
<ModalContent>
|
||||
<Forms.FormTitle tag="h5" style={{ marginTop: "10px" }}>Collection Name</Forms.FormTitle>
|
||||
<TextInput onChange={(e: string) => setName(e)} />
|
||||
</ModalContent>
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
color={Button.Colors.GREEN}
|
||||
disabled={!name.length}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</div>
|
||||
</form>
|
||||
</ModalRoot>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
44
src/equicordplugins/gifCollections/types.ts
Normal file
44
src/equicordplugins/gifCollections/types.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
export enum Format { NONE = 0, IMAGE = 1, VIDEO = 2 }
|
||||
|
||||
export interface Category {
|
||||
type: "Trending" | "Category";
|
||||
name: string;
|
||||
src: string;
|
||||
format: Format;
|
||||
gifs?: Gif[];
|
||||
}
|
||||
|
||||
export interface Gif {
|
||||
id: string,
|
||||
src: string;
|
||||
url: string;
|
||||
height: number,
|
||||
width: number;
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
favorites: { [src: string]: any; };
|
||||
trendingCategories: Category[];
|
||||
}
|
||||
|
||||
type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] };
|
||||
|
||||
export type Collection = WithRequired<Category, "gifs">;
|
||||
23
src/equicordplugins/gifCollections/utils/cleanUrl.ts
Normal file
23
src/equicordplugins/gifCollections/utils/cleanUrl.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
export const cleanUrl = (url: string) => {
|
||||
const urlObject = new URL(url);
|
||||
urlObject.search = "";
|
||||
return urlObject.href;
|
||||
};
|
||||
27
src/equicordplugins/gifCollections/utils/getFormat.ts
Normal file
27
src/equicordplugins/gifCollections/utils/getFormat.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* 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 { Format } from "../types";
|
||||
import { getUrlExtension } from "./getUrlExtension";
|
||||
|
||||
const videoExtensions = ["mp4", "ogg", "webm", "avi", "wmv", "flv", "mov", "mkv", "m4v"];
|
||||
|
||||
export function getFormat(url: string) {
|
||||
const extension = getUrlExtension(url);
|
||||
return url.startsWith("https://media.tenor") || extension == null || videoExtensions.includes(extension) ? Format.VIDEO : Format.IMAGE;
|
||||
}
|
||||
137
src/equicordplugins/gifCollections/utils/getGif.ts
Normal file
137
src/equicordplugins/gifCollections/utils/getGif.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
/*
|
||||
* 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 { MessageStore, SnowflakeUtils } from "@webpack/common";
|
||||
import { Message } from "discord-types/general";
|
||||
|
||||
import { Gif } from "../types";
|
||||
import { cleanUrl } from "./cleanUrl";
|
||||
import { isAudio } from "./isAudio";
|
||||
import { uuidv4 } from "./uuidv4";
|
||||
|
||||
export function getGifByTarget(url: string, target?: HTMLDivElement | null): Gif | null {
|
||||
const liElement = target?.closest("li");
|
||||
if (!target || !liElement || !liElement.id) return null;
|
||||
|
||||
const [channelId, messageId] = liElement.id.split("-").slice(2);
|
||||
// the isValidSnowFlake part may not be nessesery cuse either way (valid or not) message will be undefined if it doenst find a message /shrug
|
||||
if (!channelId || !messageId || !isValidSnowFlake(channelId) || !isValidSnowFlake(messageId)) return null;
|
||||
|
||||
const message = MessageStore.getMessage(channelId, messageId);
|
||||
if (!message || !message.embeds.length && !message.attachments.length) return null;
|
||||
|
||||
return getGifByMessageAndUrl(url, message);
|
||||
}
|
||||
|
||||
|
||||
export function getGifByMessageAndTarget(target: HTMLDivElement, message: Message) {
|
||||
const url = target.closest('[class^="imageWrapper"]')?.querySelector("video")?.src ?? target.closest('[class^="imageWrapper"]')?.querySelector("img")?.src;
|
||||
|
||||
if (!url) return null;
|
||||
|
||||
return getGifByMessageAndUrl(url, message);
|
||||
}
|
||||
|
||||
export function getGifByMessageAndUrl(url: string, message: Message): Gif | null {
|
||||
if (!message.embeds.length && !message.attachments.length || isAudio(url))
|
||||
return null;
|
||||
|
||||
const cleanedUrl = cleanUrl(url);
|
||||
|
||||
// find embed with matching url or image/thumbnail url
|
||||
const embed = message.embeds.find(e => {
|
||||
const hasMatchingUrl = e.url && cleanUrl(e.url) === cleanedUrl;
|
||||
const hasMatchingImage = e.image && cleanUrl(e.image.url) === cleanedUrl;
|
||||
const hasMatchingImageProxy = e.image?.proxyURL === cleanedUrl;
|
||||
const hasMatchingVideoProxy = e.video?.proxyURL === cleanedUrl;
|
||||
const hasMatchingThumbnailProxy = e.thumbnail?.proxyURL === cleanedUrl;
|
||||
|
||||
return (
|
||||
hasMatchingUrl ||
|
||||
hasMatchingImage ||
|
||||
hasMatchingImageProxy ||
|
||||
hasMatchingVideoProxy ||
|
||||
hasMatchingThumbnailProxy
|
||||
);
|
||||
});
|
||||
if (embed) {
|
||||
if (embed.image)
|
||||
return {
|
||||
id: uuidv4(),
|
||||
height: embed.image.height,
|
||||
width: embed.image.width,
|
||||
src: embed.image.proxyURL,
|
||||
url: embed.image.url,
|
||||
};
|
||||
// Tennor
|
||||
if (embed.video && embed.video.proxyURL) return {
|
||||
id: uuidv4(),
|
||||
height: embed.video.height,
|
||||
width: embed.video.width,
|
||||
src: embed.video.proxyURL,
|
||||
url: embed.provider?.name === "Tenor" ? embed.url ?? embed.video.url : embed.video.url,
|
||||
};
|
||||
|
||||
// Youtube thumbnails and other stuff idk
|
||||
if (embed.thumbnail && embed.thumbnail.proxyURL) return {
|
||||
id: uuidv4(),
|
||||
height: embed.thumbnail.height,
|
||||
width: embed.thumbnail.width,
|
||||
src: embed.thumbnail.proxyURL,
|
||||
url: embed.thumbnail.url,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
const attachment = message.attachments.find(a => cleanUrl(a.url) === cleanedUrl || a.proxy_url === cleanedUrl);
|
||||
if (attachment) return {
|
||||
id: uuidv4(),
|
||||
height: attachment.height ?? 50,
|
||||
width: attachment.width ?? 50,
|
||||
src: attachment.proxy_url,
|
||||
url: attachment.url
|
||||
};
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const getGif = (message: Message | null, url: string | null, target: HTMLDivElement | null) => {
|
||||
if (message && url) {
|
||||
const gif = getGifByMessageAndUrl(url, message);
|
||||
if (!gif) return null;
|
||||
|
||||
return gif;
|
||||
}
|
||||
if (message && target && !url) {
|
||||
const gif = getGifByMessageAndTarget(target, message);
|
||||
if (!gif) return null;
|
||||
|
||||
return gif;
|
||||
}
|
||||
if (url && target && !message) {
|
||||
// youtube thumbnail url is message link for some reason eh
|
||||
const gif = getGifByTarget(url.startsWith("https://discord.com/") ? target.parentElement?.querySelector("img")?.src ?? url : url, target);
|
||||
if (!gif) return null;
|
||||
|
||||
return gif;
|
||||
}
|
||||
};
|
||||
|
||||
function isValidSnowFlake(snowflake: string) {
|
||||
return !Number.isNaN(SnowflakeUtils.extractTimestamp(snowflake));
|
||||
}
|
||||
23
src/equicordplugins/gifCollections/utils/getUrlExtension.ts
Normal file
23
src/equicordplugins/gifCollections/utils/getUrlExtension.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
export function getUrlExtension(url: string) {
|
||||
// tennor stuff is like //media.tenor/blah/blah
|
||||
if (!url.startsWith("https:")) url = "https:" + url;
|
||||
return new URL(url).pathname.split(".").pop();
|
||||
}
|
||||
27
src/equicordplugins/gifCollections/utils/isAudio.ts
Normal file
27
src/equicordplugins/gifCollections/utils/isAudio.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* 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 { getUrlExtension } from "./getUrlExtension";
|
||||
|
||||
const audioExtensions = ["mp3", "wav", "ogg", "aac", "m4a", "wma", "flac"];
|
||||
|
||||
|
||||
export function isAudio(url: string) {
|
||||
const extension = getUrlExtension(url);
|
||||
return extension && audioExtensions.includes(extension);
|
||||
}
|
||||
131
src/equicordplugins/gifCollections/utils/settingsUtils.ts
Normal file
131
src/equicordplugins/gifCollections/utils/settingsUtils.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
/*
|
||||
* 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 { DataStore } from "@api/index";
|
||||
import { Toasts } from "@webpack/common";
|
||||
|
||||
import { DATA_COLLECTION_NAME, getCollections, refreshCacheCollection } from "../CollectionManager";
|
||||
|
||||
// 99% of this is coppied from src\utils\settingsSync.ts
|
||||
|
||||
export async function downloadCollections() {
|
||||
const filename = "gif-collections.json";
|
||||
const exportData = await exportCollections();
|
||||
const data = new TextEncoder().encode(exportData);
|
||||
|
||||
if (IS_WEB) {
|
||||
const file = new File([data], filename, { type: "application/json" });
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(file);
|
||||
a.download = filename;
|
||||
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
setImmediate(() => {
|
||||
URL.revokeObjectURL(a.href);
|
||||
document.body.removeChild(a);
|
||||
});
|
||||
} else {
|
||||
DiscordNative.fileManager.saveWithDialog(data, filename);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export async function exportCollections() {
|
||||
const collections = await getCollections();
|
||||
return JSON.stringify({ collections }, null, 4);
|
||||
}
|
||||
|
||||
|
||||
export async function importCollections(data: string) {
|
||||
try {
|
||||
var parsed = JSON.parse(data);
|
||||
} catch (err) {
|
||||
console.log(data);
|
||||
throw new Error("Failed to parse JSON: " + String(err));
|
||||
}
|
||||
|
||||
if ("collections" in parsed) {
|
||||
await DataStore.set(DATA_COLLECTION_NAME, parsed.collections);
|
||||
await refreshCacheCollection();
|
||||
} else
|
||||
throw new Error("Invalid Collections");
|
||||
}
|
||||
|
||||
|
||||
export async function uploadGifCollections(showToast = true): Promise<void> {
|
||||
if (IS_WEB) {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.style.display = "none";
|
||||
input.accept = "application/json";
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
try {
|
||||
await importCollections(reader.result as string);
|
||||
if (showToast) toastSuccess();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
// new Logger("SettingsSync").error(err);
|
||||
if (showToast) toastFailure(err);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
document.body.appendChild(input);
|
||||
input.click();
|
||||
setImmediate(() => document.body.removeChild(input));
|
||||
} else {
|
||||
const [file] = await DiscordNative.fileManager.openFiles({
|
||||
filters: [
|
||||
{ name: "Gif Collections", extensions: ["json"] },
|
||||
{ name: "all", extensions: ["*"] }
|
||||
]
|
||||
});
|
||||
|
||||
if (file) {
|
||||
try {
|
||||
await importCollections(new TextDecoder().decode(file.data));
|
||||
if (showToast) toastSuccess();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
// new Logger("SettingsSync").error(err);
|
||||
if (showToast) toastFailure(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
const toastSuccess = () => Toasts.show({
|
||||
type: Toasts.Type.SUCCESS,
|
||||
message: "Settings successfully imported.",
|
||||
id: Toasts.genId()
|
||||
});
|
||||
|
||||
const toastFailure = (err: any) => Toasts.show({
|
||||
type: Toasts.Type.FAILURE,
|
||||
message: `Failed to import settings: ${String(err)}`,
|
||||
id: Toasts.genId()
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue