This commit is contained in:
thororen 2024-04-17 14:29:47 -04:00
parent 538b87062a
commit ea7451bcdc
326 changed files with 24876 additions and 2280 deletions

View file

@ -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"
}
}
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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

View file

@ -13,11 +13,10 @@
"typescript.format.semicolons": "insert",
"typescript.preferences.quoteStyle": "double",
"javascript.preferences.quoteStyle": "double",
"gitlens.remotes": [
{
"domain": "codeberg.org",
"type": "Gitea"
}
]
}
}

View file

@ -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!

View file

@ -1,67 +1,99 @@
# Vencord
# Equicord (Vencord+)
[![Codeberg Mirror](https://img.shields.io/static/v1?style=for-the-badge&label=Codeberg%20Mirror&message=codeberg.org/Vee/cord&color=2185D0&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAABmJLR0QA/wD/AP+gvaeTAAAKbUlEQVR4nNVae3AV5RX/nW/3Pva+b24e5HHzIICQKGoiYiW8NFBFgohaa6ctglpbFSujSGurzUinohWsOij/gGX6R2fqOK0d1FYTEZXaTrWCBbEikJCEyCvkeXNvkrunf+zdkJDkPnex/c3cmd29+53v/M6e73znnF2Cydj4Tntldzi6qrN/qKqzf2jy6b7BnL4B1dI7oMp9AyoRAIdVsNMqhlxWMZjtspzyK/Jhr036OMsm//bh2vzPzNSPzBD6xFutd7R0Dq758ky4orkjYuc05RCAkixbeEq2/UCJ1/LczxcX/c5IPfU5DMHmxpbCpu7o1k/b+xc1n43YjJI7EqV+W2RmvuPt0oDjB2vn5bQbITNjAzzdeKK8qTO0bU9T77zucNQUjzofHrvENWWu3aUBZfW6+ZOOZiIrbYXrmUXo9daX3v6i667O/iGRiRLpwqtIvKDc+0efJ3hb/UIaSkdGWgZ4sqGt9r2m3lc/P9HvSWe80ZiRp3TPL/UsX1+bvyvVsSkb4NE3WjbuPNj5SM8Fcvdk4bAKrqvwv7DxhuCPUxmXNIn6XSy3nWr6R8OhrqrU1btwqJ3m/bgwu/SqZJdEUgbYsuuka09b9/4Pm3tLMlPvwuAbpe6m+RcplfdcURBKdG9CA2zZddLV2Nx1+JO2vlxj1LswqCpynlxc6SxLZIS40bueWfy9vXvv/xt5APhXa1/u7v+EPqvfxXK8++IaoO2Vpn9+cLS33FjVLhw+bOotOX7q6N/i3TOhAX7y+rHN/+sBLxm8fah71k93tjw/0f/jGuDJxtZrdh7setA8tS4sdn7eef+v3mmfP95/Ywxw6x9Yev9I35/6Iubv83WVfl5a6Uu3VkoavZEo7TnS/Vo98xi+Yy6UKC3bDp7sd5ut1OWFDjyzNMib6oq5Oug0ezp8dqLfG3r92Nbzr48ywNONJ8obDnV/z2xlAk4ZW1aUqhaJIAvCb5YVqwFn3GBtCBoO9dz5TOPxUbnMKAM0dYa2d5lc2AgCNi8r5klui3aBgWynjE11QZbI3FV3NjQkjnYNbB+lj36wubGlcE9T71xTNQDw0Px8nlvmHl73GmfCrKCL19Tkmh4P9jT1LHz2vVP5+vmwAZq71a1m1/PXTPXwD68eS5KIEVUZd1yZwwumeEw1Qld/lJrPhF7Sz4cNsO+rUK2ZExd6rfj10iCPZ2GJCCoAZuCJxQUc9FvNVAX72kPX6ccC0Hp4zR0Ru1kT2mTCSzeXqn5l/EAniMAqoDLDYZWwqa5EVSzmhaKmsxHbLxvbbgdiBmjpHFxj2mwANlxXxBdPUib8nwgQgqAyEFUZxT4L1i/MN3UpHDsTWQvEDHDoTLjCrIluuyzAt8zMSkhGFhp5hrYUFk3z8IqZftOMcKRj4GIAEM80tFccM8n9Z+Qq+MXigqRIWCQCMzQvYIbKwH1X53FFnjkr88iZsLKpoXWa6BiIrjbDzF67hK23lKp2Obm1LAstPEZVjTwDkAio/2ZQ9dolw/VjAB0DfKfoCg9WGy2cADy1NMhBX2rR3CIRGICq8rAhAg4Jj9UWsDBhg+4MR6vF2VC0zGjB99fk8eJp3pQdyyrRMHF9KURVxswCB6+alWO4o3b2RyeLU32D2UYKnVPm5gfm5qWlrF0Wo4hzbCmoDNw0089XlboNNcLpvsFc0RtRDXuNle+x4Lkbi9PO6WWJIBFGEY+qjGjswtq5eVzosRilLnoiUavoH1INiTCyIDy/vETNcmRW1dl0L4gRVxmx3YFhlwnrry1QrZIxASE0yJIIDaiGSHt8UQFXF2Ve1zusYgzxkXGhyGvFvePUE+mgfyAqhGqAqKWVPv5udbYhSjmtkpYWq6OJqzFjqCpjTpmbl1Rk3klSGRBWmTISNC3Hjo1LgoYFJ0GA1aIVR+cTVxlQoS2Pb18a4PLszMKXzSJYuCySmq4Al03CiytKVYfBhYvLKk1IXE+XLRLhwZp81WlNf26HTFHhd0jhdAYTgKduCPLkgPHfQjitYkLiAIEZBDBlu2R6aF7euCV2Mgg45bDw2qWOdAavnp3D109PPdlJBvpTnYg4kVY3MDMuylVw62WJi63x4LHLZ0TAIR9OdWBVodPUclUQwWmT4hLXfgCIUDfDi6oiR8rzBJzyl8LnkD9KZVCOU8aLN5eoshnJ+Qh4bFJC4gztmEjgrtk5anaKnWWfXfpIuBTLjmSpSILw/E0laq7LuGxsIngVCYmIa96hLRG3TaZ1C/KTfjAEQLFIO8TPFk7aH/RZI8kMWrdgEs8udqXLKSUoMkEW4ETEQTRsoHyPlVZfmVw+Uuy3hR9bVHBQAMD0XPu/Ew24dqqH777K/La1DiKCxyYlRRzQymgG4+oyDxZOTdxZnp5r3wvEWmJ5btuL8W4uzbJh87LitLebdOFVpKSJx4IlwIzbL81CcYLO8iSX/IImGQCYae6Wg/2tXQNjNnW7LPDKyilqZd7ETU2zEBlifNTSS4i9PNFIx44x4jh2nZlBsUr0dN8QP/6XVhEaHJvnlfhtkXd/NF0BUextKRFXFznfGk+JDdcX8tdBHtDa6YpFsB4I9ac88omf8wbEgqa2XAIOme6bM35foqrQ+QZIKwGG80ifVbrXZZNGDfhOVYBvviS9JMMoaP3AEcQpPnHdOxiMGXkKbrx4dGfZY5c4T8H9+vmwAeqXFLXOKXW9r59fWuDA44sKv1byAOBzyCkTH+kdS2f4MLPgXJI0p9T17vrFxcf181GVxEUB+0qfIqt+RcKWFSWGNR4ygd4RTpW4HiCJgFWzstmnSPA7ZLU827pypPwxDB/687GXl1X6Vs6bbGz/LRN80hZCT+yLFZ0cgHED4egACeiXm89GsP9EePuzy4rvGil7jAGYmQDsBjDHUBYZ4GhHBMfORigd4rpnyIS9u6d4rqgnGrUtjCmmSYuOqwB0GcwjbWh9xviurpNnxnDA1IspMPe6bOL755MHJvhKjIgOA7jbJD4pw22Thj+kSIW47h2KRaydVezeP57sCdspRPQqgGeNJJIuBAE+ReJUiOv32mXaXjPZs21C2QnmXgdghyEsMoRfkVMiDgCywF/by9z3xJMb1wCxeHAPgDczZpAh/Iq+HSYmDjCsstgThmf5t4ii8eQm7CgS0SCA5QBezoRApnBaBSyCEhIHCLJEb4ZUd+2SqZSwzE+qpUpEQ9CC4qb01M8cRIQsh8zxiKsMtsn08nvlnrpkyAPj5AGJwMw3AtgGwJ/q2ExxvHsQB74KxfKBMblAyGmTHq4pc4/5GjQeUm6qE9FrAK4E8H6ie41GlkN/jTk6F5Ak2ueUpNmpkgfSMAAAENERAAsB3AHgZDoy0oFdFnBYpXPEBfU4beLRD6Z4qmumug+kIzPjaoeZfQDWAHgAQFam8hLh4MkwWjsHemyS2OF08IYrCjynzZ4zKTCzi5nXMvOnzBw16bevIxR95JOj7DNKb1PqXWa+HMDtAGoBXII0lxq0N2OfAmgA8Hsi2muMhudgesHPzNkA5gKoADADwFRoS8UHQO+x9wLoBNAB4AsAnwM4AOADIjLVxf8L9kdXUOE0IskAAAAASUVORK5CYII=)](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
| ![image](https://github.com/Vendicated/Vencord/assets/45497981/706722b1-32de-4d99-bee9-93993b504334) |
|:--:|
| A screenshot of vencord showcasing the [vencord-theme](https://github.com/synqat/vencord-theme) |
![image](https://i.ibb.co/xgNr2gq/image-2023-11-24-122019133.png)
## 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://meow.vendicated.dev/sponsors.png)](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.

View file

@ -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 }),

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

View file

@ -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 @@
}
]
}
}
}

View file

@ -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"
}
}
}
}

View file

@ -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>

View file

@ -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).

View file

@ -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

View file

@ -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"
}
}
}

View file

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

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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;

View file

@ -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;

View file

@ -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");

View file

@ -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);
}

View file

@ -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 });
}

View file

@ -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),
},

View file

@ -59,6 +59,8 @@ export interface MessageReplyOptions {
messageReference: Message["messageReference"];
allowedMentions?: {
parse: Array<string>;
users?: Array<string>;
roles?: Array<string>;
repliedUser: boolean;
};
}

View file

@ -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();

View file

@ -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

View file

@ -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;
}
}

View file

@ -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

View 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>
</>
);
}

View 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");

View 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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

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

View file

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

View file

@ -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);
}

View file

@ -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>

View file

@ -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>

View file

@ -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}

View file

@ -1,356 +0,0 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2022 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { 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");

View file

@ -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

View file

@ -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");

View file

@ -17,7 +17,6 @@
*/
import "./settingsStyles.css";
import "./themesStyles.css";
import ErrorBoundary from "@components/ErrorBoundary";
import { handleComponentFailed } from "@components/handleComponentFailed";

View file

@ -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);
}

View 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],
});

View 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>
);
}
}

View 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>
);
}

View 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>
);
},
});

View 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>;
}

View file

@ -0,0 +1,5 @@
.vc-allowed-mentions-popout-menu {
height: 250px;
width: 200px;
text-overflow: ellipsis;
}

View 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>;
}

View 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();
},
});

View 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;
}
}
}
});

View 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,
}
],
});

View 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;
}
});

View 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;
}

View 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\")"
}
}
],
});

View 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"),
}
});

View 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;
}
});

View 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);
},
});

View 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)
);
}

View 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
);
}}
/>
);
},
},
});

View 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
}
});

View 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;

View 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></>
);
}
});

View 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);
}

View 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
});

View 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;
}

View 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;
}

View 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");
}

View 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;
}

View 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: () => { }
});
});
}
}
});

View 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);
});
}

View 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)}\`\`\``
});
}

View 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;
}
};

View 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
}
});
}
});

View 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;
}

View 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"
});
}
}

View 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
}
}
});

View file

@ -0,0 +1,8 @@
.vc-findreply-paginator {
margin-top: inherit !important;
}
.vc-findreply-close {
align-items: inherit !important;
line-height: 0 !important;
}

View 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();
};

View 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:";

View 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>
);
}

View 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">;

View 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;
};

View 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;
}

View 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));
}

View 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();
}

View 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);
}

View 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