Merge branch 'Vendicated:main' into main

This commit is contained in:
Manti 2023-02-18 14:17:50 +03:00 committed by GitHub
commit b098286197
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
168 changed files with 6653 additions and 2460 deletions

View file

@ -37,7 +37,7 @@
" * Vencord, a modification for Discord's desktop app", " * Vencord, a modification for Discord's desktop app",
{ {
"pattern": " \\* Copyright \\(c\\) \\d{4}", "pattern": " \\* Copyright \\(c\\) \\d{4}",
"template": " * Copyright (c) 2022 Vendicated and contributors" "template": " * Copyright (c) 2023 Vendicated and contributors"
}, },
" *", " *",
" * This program is free software: you can redistribute it and/or modify", " * This program is free software: you can redistribute it and/or modify",
@ -82,11 +82,13 @@
"no-constant-condition": ["error", { "checkLoops": false }], "no-constant-condition": ["error", { "checkLoops": false }],
"no-duplicate-imports": "error", "no-duplicate-imports": "error",
"no-extra-semi": "error", "no-extra-semi": "error",
"consistent-return": ["warn", { "treatUndefinedAsUnspecified": true }],
"dot-notation": "error", "dot-notation": "error",
"no-useless-escape": ["error", { "no-useless-escape": [
"extra": "i" "error",
}], {
"extra": "i"
}
],
"no-fallthrough": "error", "no-fallthrough": "error",
"for-direction": "error", "for-direction": "error",
"no-async-promise-executor": "error", "no-async-promise-executor": "error",

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
* text=auto eol=lf

22
.github/ISSUE_TEMPLATE/blank.yml vendored Normal file
View file

@ -0,0 +1,22 @@
name: Blank Template
description: Use this only if your issue does not fit into another template. **DO NOT ASK FOR SUPPORT OR REQUEST PLUGINS**
labels: []
body:
- type: textarea
id: info-sec
attributes:
label: Tell us all about it.
description: Go nuts, let us know what you're wanting to bring attention to.
placeholder: ...
validations:
required: true
- type: checkboxes
id: agreement-check
attributes:
label: Request Agreement
description: DO NOT USE THIS TEMPLATE FOR SUPPORT OR PLUGIN REQUESTS!!! For Support, **join our Discord**. For plugin requests, **use discussions**
options:
- label: This is not a support or plugin request
required: true

66
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View file

@ -0,0 +1,66 @@
name: Bug/Crash Report
description: Create a bug or crash report for Vencord
labels: [bug]
title: "[Bug] <title>"
body:
- type: input
id: discord
attributes:
label: Discord Account
description: Who on Discord is making this request? Not required but encouraged for easier follow-up
placeholder: username#0000
validations:
required: false
- type: textarea
id: bug-description
attributes:
label: What happens when the bug or crash occurs?
description: Where does this bug or crash occur, when does it occur, etc.
placeholder: The bug/crash happens sometimes when I do ..., causing this to not work/the app to crash. I think it happens because of ...
validations:
required: true
- type: textarea
id: expected-behaviour
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 ...
validations:
required: true
- type: textarea
id: steps-to-take
attributes:
label: How do you recreate this bug or crash?
description: Give us a list of steps in order to recreate the bug or crash.
placeholder: |
1. Do ...
2. Then ...
3. Do this ..., ... and then ...
4. Observe "the bug" or "the crash"
validations:
required: true
- type: textarea
id: crash-log
attributes:
label: Errors
description: Open the Developer Console with Ctrl/Cmd + Shift + i. Then look for any red errors (Ignore network errors like Failed to load resource) and paste them between the "```".
value: |
```
Replace this text with your crash-log.
```
validations:
required: false
- type: checkboxes
id: agreement-check
attributes:
label: Request Agreement
description: We only accept reports for bugs that happen on Discord Stable. Canary and PTB are Development branches and may be unstable
options:
- label: I am using Discord Stable or tried on Stable and this bug happens there as well
required: true

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View file

@ -0,0 +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

View file

@ -0,0 +1,32 @@
name: Feature Request
description: Create a feature request for Vencord. To request new plugins, please use the Discussions tab
labels: [enhancement]
title: "[Feature Request] <title>"
body:
- type: input
id: discord
attributes:
label: Discord Account
description: Who on Discord is making this request? Not required but encouraged for easier follow-up
placeholder: username#0000
validations:
required: false
- type: textarea
id: feature-basic-description
attributes:
label: What is it that you'd like to see?
description: Describe the feature you want added as detailed as possible
placeholder: I think ... would be a cool feature to add. This would be awesome, thanks!
validations:
required: true
- type: checkboxes
id: agreement-check
attributes:
label: Request Agreement
description: DO NOT USE THIS TEMPLATE FOR PLUGIN REQUESTS!!! For plugin requests, **use discussions**
options:
- label: This is not a plugin request
required: true

View file

@ -34,28 +34,19 @@ jobs:
- name: Build web - name: Build web
run: pnpm buildWeb --standalone run: pnpm buildWeb --standalone
- name: Sign firefox extension
run: |
pnpx web-ext sign --api-key $WEBEXT_USER --api-secret $WEBEXT_SECRET --channel=unlisted
env:
WEBEXT_USER: ${{ secrets.WEBEXT_USER }}
WEBEXT_SECRET: ${{ secrets.WEBEXT_SECRET }}
- name: Build - name: Build
run: pnpm build --standalone run: pnpm build --standalone
- name: Rename extensions for more user friendliness - name: Clean up obsolete files
run: | run: |
mv dist/*.xpi dist/Vencord-for-Firefox.xpi rm -rf dist/extension* Vencord.user.css
mv dist/extension-v3.zip dist/Vencord-for-Chrome-and-Edge.zip
rm -rf dist/extension-v2-unpacked dist/extension-v2.zip
- name: Get some values needed for the release - name: Get some values needed for the release
id: release_values id: release_values
run: | run: |
echo "release_tag=$(git rev-parse --short HEAD)" >> $GITHUB_ENV echo "release_tag=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
- name: Upload Devbuild as release - name: Upload DevBuild as release
run: | run: |
gh release upload devbuild --clobber dist/* gh release upload devbuild --clobber dist/*
gh release edit devbuild --title "DevBuild $RELEASE_TAG" gh release edit devbuild --title "DevBuild $RELEASE_TAG"
@ -63,13 +54,15 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ env.release_tag }} RELEASE_TAG: ${{ env.release_tag }}
- name: Upload Devbuild to builds repo - name: Upload DevBuild to builds repo
run: | run: |
git config --global user.name "$USERNAME" git config --global user.name "$USERNAME"
git config --global user.email actions@github.com git config --global user.email actions@github.com
git clone https://$USERNAME:$API_TOKEN@github.com/$GH_REPO.git upload git clone https://$USERNAME:$API_TOKEN@github.com/$GH_REPO.git upload
cd upload cd upload
GLOBIGNORE=.git:.gitignore:README.md:LICENSE
rm -rf * rm -rf *
cp -r ../dist/* . cp -r ../dist/* .
@ -78,6 +71,5 @@ jobs:
git push --force https://$USERNAME:$API_TOKEN@github.com/$GH_REPO.git git push --force https://$USERNAME:$API_TOKEN@github.com/$GH_REPO.git
env: env:
API_TOKEN: ${{ secrets.BUILDS_TOKEN }} API_TOKEN: ${{ secrets.BUILDS_TOKEN }}
GLOBIGNORE: .git:.gitignore:README.md:LICENSE
GH_REPO: Vencord/builds GH_REPO: Vencord/builds
USERNAME: GitHub-Actions USERNAME: GitHub-Actions

61
.github/workflows/publish.yml vendored Normal file
View file

@ -0,0 +1,61 @@
name: Release Browser Extension
on:
push:
tags:
- v*
jobs:
Publish:
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/extension-unpacked
# Do not fail so that even if chrome fails, firefox gets a shot. But also store exit code to fail workflow later
EXIT_CODE=0
# Chrome
pnpx chrome-webstore-upload-cli@2.1.0 upload --auto-publish || EXIT_CODE=$?
# Firefox
npm i -g web-ext@7.4.0 web-ext-submit@7.4.0
web-ext-submit || EXIT_CODE=$?
exit $EXIT_CODE
env:
# Chrome
EXTENSION_ID: ${{ secrets.CHROME_EXTENSION_ID }}
CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }}
CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }}
REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }}
# Firefox
WEB_EXT_API_KEY: ${{ secrets.WEBEXT_USER }}
WEB_EXT_API_SECRET: ${{ secrets.WEBEXT_SECRET }}

1
.gitignore vendored
View file

@ -5,6 +5,7 @@ node_modules
vencord_installer vencord_installer
.idea .idea
.DS_Store
yarn.lock yarn.lock
package-lock.json package-lock.json

6
.stylelintrc.json Normal file
View file

@ -0,0 +1,6 @@
{
"extends": "stylelint-config-standard",
"rules": {
"indentation": 4
}
}

View file

@ -1,11 +1,11 @@
{ {
"recommendations": [ "recommendations": [
"EditorConfig.EditorConfig",
"pmneo.tsimporter",
"dbaeumer.vscode-eslint", "dbaeumer.vscode-eslint",
"eamodio.gitlens",
"EditorConfig.EditorConfig",
"ExodiusStudios.comment-anchors",
"formulahendry.auto-rename-tag", "formulahendry.auto-rename-tag",
"GregorBiswanger.json2ts", "GregorBiswanger.json2ts",
"eamodio.gitlens", "stylelint.vscode-stylelint"
"kamikillerto.vscode-colorize"
] ]
} }

View file

@ -1,51 +1,30 @@
# Vencord # Vencord
A Discord client mod that does things differently The cutest Discord client mod
## Features ## Features
- Super easy to install, no git or node or anything else required - Super easy to install (one click installer)
- Many plugins built in: [See a list](https://gist.github.com/Vendicated/8696cde7b92548064a3ae92ead84d033) - 90+ plugins built in: [See a list](https://gist.github.com/Vendicated/8696cde7b92548064a3ae92ead84d033)
- Some highlights: SpotifyControls, Experiments, NoTrack, MessageLogger, QuickReply, Free Emotes/Stickers, custom slash commands, ShowHiddenChannels - Some highlights: SpotifyControls, Experiments, NoTrack, MessageLogger, QuickReply, Free Emotes/Stickers, CustomCommands, ShowHiddenChannels, PronounDB
- Browser Support: Run Vencord in your Browser via extension or UserScript - Excellent Browser Support: Run Vencord in your Browser via extension or UserScript
- Custom CSS and Themes: Inbuilt css editor with support to import any css files (including BetterDiscord themes) - Custom CSS and Themes: Inbuilt css editor with support to import any css files (including BetterDiscord themes)
- Works in all Electron versions (Confirmed working on versions 13-23) - Works in all Electron versions (Confirmed working on versions 13-23)
- Maintained very actively, broken plugins are usually fixed within 12 hours
## Installing / Uninstalling ## Installing / Uninstalling
If you're just a normal user, use [our simple gui installer!](https://github.com/Vendicated/VencordInstaller#usage) [![Download and run the Installer ](https://img.shields.io/github/v/release/Vencord/Installer?label=Download%20Vencord%20Installer&style=for-the-badge)](https://github.com/Vencord/Installer#usage)
If you're a power user who wants to contribute and make plugins or just want to build from source and install manually, read [Megu's Installation Guide!](docs/1_INSTALLING.md)
## Installing on Browser ## Installing on Browser
[![Get the Firefox extension](https://blog.mozilla.org/addons/files/2015/11/get-the-addon-small.png)](https://addons.mozilla.org/en-GB/firefox/addon/vencord-web/) [![Get it on the Firefox Webstore](https://blog.mozilla.org/addons/files/2015/11/get-the-addon.png)](https://addons.mozilla.org/en-GB/firefox/addon/vencord-web/) [![Get it on the Chrome Webstore](https://storage.googleapis.com/web-dev-uploads/image/WlD8wC6g8khYWPJUsQceQkhXSlv1/UV4C4ybeBTsZt43U4xis.png)](https://chrome.google.com/webstore/detail/vencord-web/cbghhgpcnddeihccjmnadmkaejncjndb)
Or install the browser extension for Or use the [UserScript](https://raw.githubusercontent.com/Vencord/builds/main/Vencord.user.js) - Please note that QuickCSS and plugins making use of external resources will not work with the UserScript.
- [![Chrome](https://img.shields.io/badge/chrome-ext-brightgreen)](https://github.com/Vendicated/Vencord/releases/latest/download/Vencord-for-Chrome-and-Edge.zip)
- [UserScript](https://raw.githubusercontent.com/Vencord/builds/main/Vencord.user.js) - Please note that QuickCSS, shiki and other plugins making use of external resources will not work with the UserScript.
## Building from Source
You may also build them from source, to do that do the same steps as in the manual regular install method, See the docs folder
except run `pnpm buildWeb` instead of `pnpm build`, and your outputs will be in the dist folder
```sh
pnpm buildWeb
```
You will find the built extension at dist/extension.zip. Now just install this extension in your Browser
## Installing Plugins
> **Note**
> You can only use 3rd party plugins in the manual Vencord install for now.
Vencord comes with a bunch of plugins out of the box!
However, if you want to install your own ones, create a `userplugins` folder in the `src` directory and create or clone your plugins in there.
Don't forget to rebuild!
Want to learn how to create your own plugin, and maybe PR it into Vencord? See the [Contributing](#contributing) section below!
## Contributing ## Contributing

View file

@ -1,48 +0,0 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2022 Linnea Gräf
*
* 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/>.
*/
function setContentTypeOnStylesheets(details) {
if (details.type === "stylesheet") {
details.responseHeaders = details.responseHeaders.filter(it => it.name.toLowerCase() !== 'content-type');
details.responseHeaders.push({ name: "Content-Type", value: "text/css" });
}
return { responseHeaders: details.responseHeaders };
}
var cspHeaders = [
"content-security-policy",
"content-security-policy-report-only",
];
function removeCSPHeaders(details) {
return {
responseHeaders: details.responseHeaders.filter(header =>
!cspHeaders.includes(header.name.toLowerCase()))
};
}
browser.webRequest.onHeadersReceived.addListener(
setContentTypeOnStylesheets, { urls: ["https://raw.githubusercontent.com/*"] }, ["blocking", "responseHeaders"]
);
browser.webRequest.onHeadersReceived.addListener(
removeCSPHeaders, { urls: ["https://raw.githubusercontent.com/*", "*://*.discord.com/*"] }, ["blocking", "responseHeaders"]
);

BIN
browser/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View file

@ -1,10 +1,14 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"minimum_chrome_version": "91",
"name": "Vencord Web", "name": "Vencord Web",
"description": "Yeee", "description": "The cutest Discord mod now in your browser",
"version": "1.0.0",
"author": "Vendicated", "author": "Vendicated",
"homepage_url": "https://github.com/Vendicated/Vencord", "homepage_url": "https://github.com/Vendicated/Vencord",
"icons": {
"128": "icon.png"
},
"host_permissions": [ "host_permissions": [
"*://*.discord.com/*", "*://*.discord.com/*",
@ -36,5 +40,12 @@
"path": "modifyResponseHeaders.json" "path": "modifyResponseHeaders.json"
} }
] ]
},
"browser_specific_settings": {
"gecko": {
"id": "vencord-firefox@vendicated.dev",
"strict_min_version": "109.0"
}
} }
} }

View file

@ -1,25 +0,0 @@
{
"manifest_version": 2,
"name": "Vencord Web",
"description": "The Vencord Client Mod for Discord Web.",
"version": "1.0.0",
"author": "Vendicated",
"homepage_url": "https://github.com/Vendicated/Vencord",
"permissions": [
"webRequest",
"webRequestBlocking",
"*://*.discord.com/*",
"https://raw.githubusercontent.com/*"
],
"content_scripts": [
{
"run_at": "document_start",
"matches": ["*://*.discord.com/*"],
"js": ["content.js"]
}
],
"web_accessible_resources": ["dist/Vencord.js", "dist/Vencord.css"],
"background": {
"scripts": ["background.js"]
}
}

View file

@ -1,3 +0,0 @@
// FIXME: Delete this soon, for now it is needed so people can update
import("./scripts/build/build.mjs");

View file

@ -1,5 +1,5 @@
> **Warning** > **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. > 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.
# Installation Guide # Installation Guide
@ -183,7 +183,6 @@ In `index.js`:
```js ```js
require("C:/Users/<your user>/path/to/vencord/dist/patcher.js"); require("C:/Users/<your user>/path/to/vencord/dist/patcher.js");
require("../app.asar");
``` ```
And in `package.json`: And in `package.json`:

View file

@ -1,8 +1,8 @@
{ {
"name": "vencord", "name": "vencord",
"private": "true", "private": "true",
"version": "1.0.1", "version": "1.0.6",
"description": "A Discord client mod that does things differently", "description": "The cutest Discord client mod",
"keywords": [], "keywords": [],
"homepage": "https://github.com/Vendicated/Vencord#readme", "homepage": "https://github.com/Vendicated/Vencord#readme",
"bugs": { "bugs": {
@ -20,33 +20,33 @@
"scripts": { "scripts": {
"build": "node scripts/build/build.mjs", "build": "node scripts/build/build.mjs",
"buildWeb": "node --require=./scripts/suppressExperimentalWarnings.js scripts/build/buildWeb.mjs", "buildWeb": "node --require=./scripts/suppressExperimentalWarnings.js scripts/build/buildWeb.mjs",
"inject": "node scripts/patcher/install.js", "inject": "node scripts/runInstaller.mjs",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx", "lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"lint-styles": "stylelint \"src/**/*.css\"",
"lint:fix": "pnpm lint --fix", "lint:fix": "pnpm lint --fix",
"test": "pnpm lint && pnpm build && pnpm testTsc", "test": "pnpm build && pnpm lint && pnpm lint-styles && pnpm testTsc",
"testWeb": "pnpm lint && pnpm buildWeb && pnpm testTsc", "testWeb": "pnpm lint && pnpm buildWeb && pnpm testTsc",
"testTsc": "tsc --noEmit", "testTsc": "tsc --noEmit",
"uninject": "node scripts/patcher/uninstall.js", "uninject": "node scripts/runInstaller.mjs",
"watch": "node scripts/build/build.mjs --watch" "watch": "node scripts/build/build.mjs --watch"
}, },
"dependencies": { "dependencies": {
"@vap/core": "0.0.12",
"@vap/shiki": "0.10.3",
"fflate": "^0.7.4" "fflate": "^0.7.4"
}, },
"devDependencies": { "devDependencies": {
"@types/diff": "^5.0.2", "@types/diff": "^5.0.2",
"@types/lodash": "^4.14.0", "@types/lodash": "^4.14.191",
"@types/node": "^18.11.9", "@types/node": "^18.11.18",
"@types/react": "^18.0.25", "@types/react": "^18.0.27",
"@types/react-dom": "^18.0.9", "@types/react-dom": "^18.0.10",
"@types/yazl": "^2.4.2", "@types/yazl": "^2.4.2",
"@typescript-eslint/eslint-plugin": "^5.44.0", "@typescript-eslint/eslint-plugin": "^5.49.0",
"@typescript-eslint/parser": "^5.44.0", "@typescript-eslint/parser": "^5.49.0",
"@vap/core": "0.0.12",
"@vap/shiki": "0.10.3",
"console-menu": "^0.1.0",
"diff": "^5.1.0", "diff": "^5.1.0",
"discord-types": "^1.3.26", "discord-types": "^1.3.26",
"esbuild": "^0.15.16", "esbuild": "^0.15.18",
"eslint": "^8.28.0", "eslint": "^8.28.0",
"eslint-import-resolver-alias": "^1.1.2", "eslint-import-resolver-alias": "^1.1.2",
"eslint-plugin-header": "^3.1.1", "eslint-plugin-header": "^3.1.1",
@ -55,10 +55,12 @@
"eslint-plugin-unused-imports": "^2.0.0", "eslint-plugin-unused-imports": "^2.0.0",
"highlight.js": "10.6.0", "highlight.js": "10.6.0",
"moment": "^2.29.4", "moment": "^2.29.4",
"puppeteer-core": "^19.3.0", "puppeteer-core": "^19.6.0",
"standalone-electron-types": "^1.0.0", "standalone-electron-types": "^1.0.0",
"type-fest": "^3.3.0", "stylelint": "^14.16.1",
"typescript": "^4.9.3" "stylelint-config-standard": "^29.0.0",
"type-fest": "^3.5.3",
"typescript": "^4.9.4"
}, },
"packageManager": "pnpm@7.13.4", "packageManager": "pnpm@7.13.4",
"pnpm": { "pnpm": {
@ -68,7 +70,8 @@
}, },
"peerDependencyRules": { "peerDependencyRules": {
"ignoreMissing": [ "ignoreMissing": [
"eslint-plugin-import" "eslint-plugin-import",
"eslint"
] ]
}, },
"allowedDeprecatedVersions": { "allowedDeprecatedVersions": {
@ -84,5 +87,8 @@
"overwriteDest": true "overwriteDest": true
}, },
"sourceDir": "./dist/extension-v2-unpacked" "sourceDir": "./dist/extension-v2-unpacked"
},
"engines": {
"node": ">=18"
} }
} }

1125
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -82,10 +82,19 @@ async function buildPluginZip(target, files, shouldZip) {
const entries = { const entries = {
"dist/Vencord.js": await readFile("dist/browser.js"), "dist/Vencord.js": await readFile("dist/browser.js"),
"dist/Vencord.css": await readFile("dist/browser.css"), "dist/Vencord.css": await readFile("dist/browser.css"),
...Object.fromEntries(await Promise.all(files.map(async f => [ ...Object.fromEntries(await Promise.all(files.map(async f => {
(f.startsWith("manifest") ? "manifest.json" : f), let content = await readFile(join("browser", f));
await readFile(join("browser", f)) if (f.startsWith("manifest")) {
]))), const json = JSON.parse(content.toString("utf-8"));
json.version = PackageJSON.version;
content = new TextEncoder().encode(JSON.stringify(json));
}
return [
f.startsWith("manifest") ? "manifest.json" : f,
content
];
}))),
}; };
if (shouldZip) { if (shouldZip) {
@ -115,21 +124,22 @@ async function buildPluginZip(target, files, shouldZip) {
} }
} }
const cssText = "`" + readFileSync("dist/Vencord.user.css", "utf-8").replaceAll("`", "\\`") + "`"; const appendCssRuntime = readFile("dist/Vencord.user.css", "utf-8").then(content => {
const cssRuntime = ` const cssRuntime = `
;document.addEventListener("DOMContentLoaded", () => document.documentElement.appendChild( ;document.addEventListener("DOMContentLoaded", () => document.documentElement.appendChild(
Object.assign(document.createElement("style"), { Object.assign(document.createElement("style"), {
textContent: ${cssText}, textContent: \`${content.replaceAll("`", "\\`")}\`,
id: "vencord-css-core" id: "vencord-css-core"
}), })
{ once: true } ), { once: true });
));
`; `;
return appendFile("dist/Vencord.user.js", cssRuntime);
});
await Promise.all([ await Promise.all([
appendFile("dist/Vencord.user.js", cssRuntime), appendCssRuntime,
buildPluginZip("extension-v3.zip", ["modifyResponseHeaders.json", "content.js", "manifestv3.json"], true), buildPluginZip("extension.zip", ["modifyResponseHeaders.json", "content.js", "manifest.json", "icon.png"], true),
buildPluginZip("extension-v2.zip", ["background.js", "content.js", "manifestv2.json"], true), buildPluginZip("extension-unpacked", ["modifyResponseHeaders.json", "content.js", "manifest.json", "icon.png"], false),
buildPluginZip("extension-v2-unpacked", ["background.js", "content.js", "manifestv2.json"], false),
]); ]);

View file

@ -68,11 +68,12 @@ export const globPlugins = {
if (!existsSync(`./src/${dir}`)) continue; if (!existsSync(`./src/${dir}`)) continue;
const files = await readdir(`./src/${dir}`); const files = await readdir(`./src/${dir}`);
for (const file of files) { for (const file of files) {
if (file.startsWith(".")) continue;
if (file === "index.ts") { if (file === "index.ts") {
continue; continue;
} }
const mod = `p${i}`; const mod = `p${i}`;
code += `import ${mod} from "./${dir}/${file.replace(/.tsx?$/, "")}";\n`; code += `import ${mod} from "./${dir}/${file.replace(/\.tsx?$/, "")}";\n`;
plugins += `[${mod}.name]:${mod},\n`; plugins += `[${mod}.name]:${mod},\n`;
i++; i++;
} }

View file

@ -0,0 +1,20 @@
/*
* 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/>.
*/
if (Number(process.versions.node.split(".")[0]) < 18)
throw `Your node version (${process.version}) is too old, please update to v18 or higher https://nodejs.org/en/download/`;

View file

@ -1,357 +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/>.
*/
const path = require("path");
const readline = require("readline");
const fs = require("fs");
const menu = require("console-menu");
function pathToBranch(dir) {
dir = dir.toLowerCase();
if (dir.endsWith("development")) {
return "development";
}
if (dir.endsWith("canary")) {
return "canary";
}
if (dir.endsWith("ptb")) {
return "ptb";
}
return "stable";
}
const BRANCH_NAMES = [
"Discord",
"DiscordPTB",
"DiscordCanary",
"DiscordDevelopment",
"discord",
"discordptb",
"discordcanary",
"discorddevelopment",
"discord-ptb",
"discord-canary",
"discord-development",
// Flatpak
"com.discordapp.Discord",
"com.discordapp.DiscordPTB",
"com.discordapp.DiscordCanary",
"com.discordapp.DiscordDevelopment",
];
const MACOS_DISCORD_DIRS = [
"Discord.app",
"Discord PTB.app",
"Discord Canary.app",
"Discord Development.app",
];
if (process.platform === "linux" && process.env.SUDO_USER) {
process.env.HOME = fs
.readFileSync("/etc/passwd", "utf-8")
.match(new RegExp(`^${process.env.SUDO_USER}.+$`, "m"))[0]
.split(":")[5];
}
const LINUX_DISCORD_DIRS = [
"/usr/share",
"/usr/lib64",
"/opt",
`${process.env.HOME}/.local/share`,
`${process.env.HOME}/.dvm`,
"/var/lib/flatpak/app",
`${process.env.HOME}/.local/share/flatpak/app`,
];
const FLATPAK_NAME_MAPPING = {
DiscordCanary: "discord-canary",
DiscordPTB: "discord-ptb",
DiscordDevelopment: "discord-development",
Discord: "discord",
};
const ENTRYPOINT = path
.join(process.cwd(), "dist", "patcher.js")
.replace(/\\/g, "/");
function question(question) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: false,
});
return new Promise(resolve => {
rl.question(question, answer => {
rl.close();
resolve(answer);
});
});
}
async function getMenuItem(installations) {
const menuItems = installations.map(info => ({
title: info.patched ? "[MODIFIED] " + info.location : info.location,
info,
}));
const result = await menu(
[
...menuItems,
{ title: "Specify custom path", info: "custom" },
{ title: "Exit without patching", exit: true }
],
{
header: "Select a Discord installation to patch:",
border: true,
helpMessage:
"Use the up/down arrow keys to select an option. " +
"Press ENTER to confirm.",
}
);
if (!result || !result.info || result.exit) {
console.log("No installation selected.");
process.exit(0);
}
if (result.info === "custom") {
const customPath = await question("Please enter the path: ");
if (!customPath || !fs.existsSync(customPath)) {
console.log("No such Path or not specifed.");
process.exit();
}
const resourceDir = path.join(customPath, "resources");
if (!fs.existsSync(path.join(resourceDir, "app.asar"))) {
console.log("Unsupported Install. resources/app.asar not found");
process.exit();
}
const appDir = path.join(resourceDir, "app");
result.info = {
branch: "unknown",
patched: fs.existsSync(appDir),
location: customPath,
versions: [{
path: appDir,
name: null
}],
arch: process.platform === "linux" ? "linux" : "win32",
isFlatpak: false,
};
}
if (result.info.patched) {
const answer = await question(
"This installation has already been modified. Overwrite? [Y/n]: "
);
if (!["y", "yes", "yeah", ""].includes(answer.toLowerCase())) {
console.log("Not patching.");
process.exit(0);
}
}
return result.info;
}
function getWindowsDirs() {
const dirs = [];
for (const dir of fs.readdirSync(process.env.LOCALAPPDATA)) {
if (!BRANCH_NAMES.includes(dir)) continue;
const location = path.join(process.env.LOCALAPPDATA, dir);
if (!fs.statSync(location).isDirectory()) continue;
const appDirs = fs
.readdirSync(location, { withFileTypes: true })
.filter(file => file.isDirectory())
.filter(file => file.name.startsWith("app-"))
.map(file => path.join(location, file.name));
const versions = [];
let patched = false;
for (const fqAppDir of appDirs) {
const resourceDir = path.join(fqAppDir, "resources");
if (!fs.existsSync(path.join(resourceDir, "app.asar"))) {
continue;
}
const appDir = path.join(resourceDir, "app");
if (fs.existsSync(appDir)) {
patched = true;
}
versions.push({
path: appDir,
name: /app-([0-9.]+)/.exec(fqAppDir)[1],
});
}
if (appDirs.length) {
dirs.push({
branch: dir,
patched,
location,
versions,
arch: "win32",
flatpak: false,
});
}
}
return dirs;
}
function getDarwinDirs() {
const dirs = [];
for (const dir of fs.readdirSync("/Applications")) {
if (!MACOS_DISCORD_DIRS.includes(dir)) continue;
const location = path.join("/Applications", dir, "Contents");
if (!fs.existsSync(location)) continue;
if (!fs.statSync(location).isDirectory()) continue;
const appDirs = fs
.readdirSync(location, { withFileTypes: true })
.filter(file => file.isDirectory())
.filter(file => file.name.startsWith("Resources"))
.map(file => path.join(location, file.name));
const versions = [];
let patched = false;
for (const resourceDir of appDirs) {
if (!fs.existsSync(path.join(resourceDir, "app.asar"))) {
continue;
}
const appDir = path.join(resourceDir, "app");
if (fs.existsSync(appDir)) {
patched = true;
}
versions.push({
path: appDir,
name: null, // MacOS installs have no version number
});
}
if (appDirs.length) {
dirs.push({
branch: dir,
patched,
location,
versions,
arch: "win32",
});
}
}
return dirs;
}
function getLinuxDirs() {
const dirs = [];
for (const dir of LINUX_DISCORD_DIRS) {
if (!fs.existsSync(dir)) continue;
for (const branch of fs.readdirSync(dir)) {
if (!BRANCH_NAMES.includes(branch)) continue;
const location = path.join(dir, branch);
if (!fs.statSync(location).isDirectory()) continue;
const isFlatpak = location.includes("/flatpak/");
let appDirs = [];
if (isFlatpak) {
const fqDir = path.join(location, "current", "active", "files");
if (!/com\.discordapp\.(\w+)\//.test(fqDir)) continue;
const branchName = /com\.discordapp\.(\w+)\//.exec(fqDir)[1];
if (!Object.keys(FLATPAK_NAME_MAPPING).includes(branchName)) {
continue;
}
const appDir = path.join(
fqDir,
FLATPAK_NAME_MAPPING[branchName]
);
if (!fs.existsSync(appDir)) continue;
if (!fs.statSync(appDir).isDirectory()) continue;
const resourceDir = path.join(appDir, "resources");
appDirs.push(resourceDir);
} else {
appDirs = fs
.readdirSync(location, { withFileTypes: true })
.filter(file => file.isDirectory())
.filter(
file =>
file.name.startsWith("app-") ||
file.name === "resources"
)
.map(file => path.join(location, file.name));
}
const versions = [];
let patched = false;
for (const resourceDir of appDirs) {
if (!fs.existsSync(path.join(resourceDir, "app.asar"))) {
continue;
}
const appDir = path.join(resourceDir, "app");
if (fs.existsSync(appDir)) {
patched = true;
}
const version = /app-([0-9.]+)/.exec(resourceDir);
versions.push({
path: appDir,
name: version && version.length > 1 ? version[1] : null,
});
}
if (appDirs.length) {
dirs.push({
branch,
patched,
location,
versions,
arch: "linux",
isFlatpak,
});
}
}
}
return dirs;
}
module.exports = {
pathToBranch,
BRANCH_NAMES,
MACOS_DISCORD_DIRS,
LINUX_DISCORD_DIRS,
FLATPAK_NAME_MAPPING,
ENTRYPOINT,
question,
getMenuItem,
getWindowsDirs,
getDarwinDirs,
getLinuxDirs,
};

View file

@ -1,219 +0,0 @@
#!/usr/bin/node
/*
* 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/>.
*/
const path = require("path");
const fs = require("fs");
const { execSync } = require("child_process");
console.log("\nVencord Installer\n");
if (!fs.existsSync(path.join(process.cwd(), "node_modules"))) {
console.log("You need to install dependencies first. Run:", "pnpm install --frozen-lockfile");
process.exit(1);
}
if (!fs.existsSync(path.join(process.cwd(), "dist", "patcher.js"))) {
console.log("You need to build the project first. Run:", "pnpm build");
process.exit(1);
}
const {
getMenuItem,
getWindowsDirs,
getDarwinDirs,
getLinuxDirs,
ENTRYPOINT,
question,
pathToBranch
} = require("./common");
switch (process.platform) {
case "win32":
install(getWindowsDirs());
break;
case "darwin":
install(getDarwinDirs());
break;
case "linux":
install(getLinuxDirs());
break;
default:
console.log("Unknown OS");
break;
}
async function install(installations) {
const selected = await getMenuItem(installations);
// Attempt to give flatpak perms
if (selected.isFlatpak) {
try {
const cwd = process.cwd();
const globalCmd = `flatpak override ${selected.branch} --filesystem=${cwd}`;
const userCmd = `flatpak override --user ${selected.branch} --filesystem=${cwd}`;
const cmd = selected.location.startsWith("/home")
? userCmd
: globalCmd;
execSync(cmd);
console.log("Gave write perms to Discord Flatpak.");
} catch (e) {
console.log("Failed to give write perms to Discord Flatpak.");
console.log(
"Try running this script as an administrator:",
"sudo pnpm inject"
);
process.exit(1);
}
const answer = await question(
`Would you like to allow ${selected.branch} to talk to org.freedesktop.Flatpak?\n` +
"This is essentially full host access but necessary to spawn git. Without it, the updater will not work\n" +
"Consider using the http based updater (using the gui installer) instead if you want to maintain the sandbox.\n" +
"[y/N]: "
);
if (["y", "yes", "yeah"].includes(answer.toLowerCase())) {
try {
const globalCmd = `flatpak override ${selected.branch} --talk-name=org.freedesktop.Flatpak`;
const userCmd = `flatpak override --user ${selected.branch} --talk-name=org.freedesktop.Flatpak`;
const cmd = selected.location.startsWith("/home")
? userCmd
: globalCmd;
execSync(cmd);
console.log("Sucessfully gave talk permission");
} catch (err) {
console.error("Failed to give talk permission\n", err);
}
} else {
console.log(`Not giving full host access. If you change your mind later, you can run:\nflatpak override ${selected.branch} --talk-name=org.freedesktop.Flatpak`);
}
}
const useNewMethod = pathToBranch(selected.branch) !== "stable";
for (const version of selected.versions) {
const dir = useNewMethod ? path.join(version.path, "..") : version.path;
// Check if we have write perms to the install directory...
try {
fs.accessSync(selected.location, fs.constants.W_OK);
} catch (e) {
console.error("No write access to", selected.location);
console.error(
"Make sure Discord isn't running. If that doesn't work,",
"try running this script as an administrator:",
"sudo pnpm inject"
);
process.exit(1);
}
if (useNewMethod) {
const appAsar = path.join(dir, "app.asar");
const _appAsar = path.join(dir, "_app.asar");
if (fs.existsSync(_appAsar) && fs.existsSync(appAsar)) {
console.log("This copy of Discord already seems to be patched...");
console.log("Try running `pnpm uninject` first.");
process.exit(1);
}
try {
fs.renameSync(appAsar, _appAsar);
} catch (err) {
if (err.code === "EBUSY") {
console.error(selected.branch, "is still running. Make sure you fully close it before running this script.");
process.exit(1);
}
console.error("Failed to rename app.asar to _app.asar");
throw err;
}
try {
fs.mkdirSync(appAsar);
} catch (err) {
if (err.code === "EBUSY") {
console.error(selected.branch, "is still running. Make sure you fully close it before running this script.");
process.exit(1);
}
console.error("Failed to create app.asar folder");
throw err;
}
fs.writeFileSync(
path.join(appAsar, "index.js"),
`require("${ENTRYPOINT}");`
);
fs.writeFileSync(
path.join(appAsar, "package.json"),
JSON.stringify({
name: "discord",
main: "index.js",
})
);
const requiredFiles = ["index.js", "package.json"];
if (requiredFiles.every(f => fs.existsSync(path.join(appAsar, f)))) {
console.log(
"Successfully patched",
version.name
? `${selected.branch} ${version.name}`
: selected.branch
);
} else {
console.log("Failed to patch", dir);
console.log("Files in directory:", fs.readdirSync(appAsar));
}
return;
}
if (fs.existsSync(dir) && fs.lstatSync(dir).isDirectory()) {
fs.rmSync(dir, { recursive: true });
}
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(
path.join(dir, "index.js"),
`require("${ENTRYPOINT}");`
);
fs.writeFileSync(
path.join(dir, "package.json"),
JSON.stringify({
name: "discord",
main: "index.js",
})
);
const requiredFiles = ["index.js", "package.json"];
if (requiredFiles.every(f => fs.existsSync(path.join(dir, f)))) {
console.log(
"Successfully patched",
version.name
? `${selected.branch} ${version.name}`
: selected.branch
);
} else {
console.log("Failed to patch", dir);
console.log("Files in directory:", fs.readdirSync(dir));
}
}
}

View file

@ -1,116 +0,0 @@
#!/usr/bin/node
/*
* 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/>.
*/
const path = require("path");
const fs = require("fs");
console.log("\nVencord Uninstaller\n");
if (!fs.existsSync(path.join(process.cwd(), "node_modules"))) {
console.log("You need to install dependencies first. Run:", "pnpm install --frozen-lockfile");
process.exit(1);
}
const {
getMenuItem,
getWindowsDirs,
getDarwinDirs,
getLinuxDirs,
pathToBranch,
} = require("./common");
switch (process.platform) {
case "win32":
uninstall(getWindowsDirs());
break;
case "darwin":
uninstall(getDarwinDirs());
break;
case "linux":
uninstall(getLinuxDirs());
break;
default:
console.log("Unknown OS");
break;
}
async function uninstall(installations) {
const selected = await getMenuItem(installations);
const useNewMethod = pathToBranch(selected.branch) !== "stable";
for (const version of selected.versions) {
const dir = useNewMethod ? path.join(version.path, "..") : version.path;
// Check if we have write perms to the install directory...
try {
fs.accessSync(selected.location, fs.constants.W_OK);
} catch (e) {
console.error("No write access to", selected.location);
console.error(
"Make sure Discord isn't running. If that doesn't work,",
"try running this script as an administrator:",
"sudo pnpm uninject"
);
process.exit(1);
}
if (useNewMethod) {
if (!fs.existsSync(path.join(dir, "_app.asar"))) {
console.error(
"Original app.asar (_app.asar) doesn't exist.",
"Is your Discord installation corrupt? Try reinstalling Discord."
);
process.exit(1);
}
if (fs.existsSync(path.join(dir, "app.asar"))) {
try {
fs.rmSync(path.join(dir, "app.asar"), { force: true, recursive: true });
} catch (err) {
console.error("Failed to delete app.asar folder");
throw err;
}
}
try {
fs.renameSync(
path.join(dir, "_app.asar"),
path.join(dir, "app.asar")
);
} catch (err) {
console.error("Failed to rename _app.asar to app.asar");
throw err;
}
console.log(
"Successfully unpatched",
version.name
? `${selected.branch} ${version.name}`
: selected.branch
);
return;
}
if (fs.existsSync(dir)) {
fs.rmSync(dir, { recursive: true });
}
console.log(
"Successfully unpatched",
version.name
? `${selected.branch} ${version.name}`
: selected.branch
);
}
}

128
scripts/runInstaller.mjs Normal file
View file

@ -0,0 +1,128 @@
/*
* 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 "./checkNodeVersion.js";
import { execFileSync, execSync } from "child_process";
import { createWriteStream, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { dirname, join } from "path";
import { Readable } from "stream";
import { finished } from "stream/promises";
import { fileURLToPath } from "url";
const BASE_URL = "https://github.com/Vencord/Installer/releases/latest/download/";
const INSTALLER_PATH_DARWIN = "VencordInstaller.app/Contents/MacOS/VencordInstaller";
const BASE_DIR = join(dirname(fileURLToPath(import.meta.url)), "..");
const FILE_DIR = join(BASE_DIR, "dist", "Installer");
const ETAG_FILE = join(FILE_DIR, "etag.txt");
function getFilename() {
switch (process.platform) {
case "win32":
return "VencordInstaller.exe";
case "darwin":
return "VencordInstaller.MacOS.zip";
case "linux":
return "VencordInstaller-" + (process.env.WAYLAND_DISPLAY ? "wayland" : "x11");
default:
throw new Error("Unsupported platform: " + process.platform);
}
}
async function ensureBinary() {
const filename = getFilename();
console.log("Downloading " + filename);
mkdirSync(FILE_DIR, { recursive: true });
const downloadName = join(FILE_DIR, filename);
const outputFile = process.platform === "darwin"
? join(FILE_DIR, "VencordInstaller")
: downloadName;
const etag = existsSync(outputFile) && existsSync(ETAG_FILE)
? readFileSync(ETAG_FILE, "utf-8")
: null;
const res = await fetch(BASE_URL + filename, {
headers: {
"User-Agent": "Vencord (https://github.com/Vendicated/Vencord)",
"If-None-Match": etag
}
});
if (res.status === 304) {
console.log("Up to date, not redownloading!");
return outputFile;
}
if (!res.ok)
throw new Error(`Failed to download installer: ${res.status} ${res.statusText}`);
writeFileSync(ETAG_FILE, res.headers.get("etag"));
if (process.platform === "darwin") {
console.log("Unzipping...");
const zip = new Uint8Array(await res.arrayBuffer());
const ff = await import("fflate");
const bytes = ff.unzipSync(zip, {
filter: f => f.name === INSTALLER_PATH_DARWIN
})[INSTALLER_PATH_DARWIN];
writeFileSync(outputFile, bytes, { mode: 0o755 });
console.log("Overriding security policy for installer binary (this is required to run it)");
console.log("xattr might error, that's okay");
const logAndRun = cmd => {
console.log("Running", cmd);
try {
execSync(cmd);
} catch { }
};
logAndRun(`sudo spctl --add '${outputFile}' --label "Vencord Installer"`);
logAndRun(`sudo xattr -d com.apple.quarantine '${outputFile}'`);
} else {
// WHY DOES NODE FETCH RETURN A WEB STREAM OH MY GOD
const body = Readable.fromWeb(res.body);
await finished(body.pipe(createWriteStream(outputFile, {
mode: 0o755,
autoClose: true
})));
}
console.log("Finished downloading!");
return outputFile;
}
const installerBin = await ensureBinary();
console.log("Now running Installer...");
execFileSync(installerBin, {
stdio: "inherit",
env: {
...process.env,
VENCORD_USER_DATA_DIR: BASE_DIR,
VENCORD_DEV_INSTALL: "1"
}
});

View file

@ -30,9 +30,9 @@ import "./webpack/patchWebpack";
import { popNotice, showNotice } from "./api/Notices"; import { popNotice, showNotice } from "./api/Notices";
import { PlainSettings, Settings } from "./api/settings"; import { PlainSettings, Settings } from "./api/settings";
import { patches, PMLogger, startAllPlugins } from "./plugins"; import { patches, PMLogger, startAllPlugins } from "./plugins";
import { checkForUpdates, UpdateLogger } from "./utils/updater"; import { checkForUpdates, rebuild, update, UpdateLogger } from "./utils/updater";
import { onceReady } from "./webpack"; import { onceReady } from "./webpack";
import { Router } from "./webpack/common"; import { SettingsRouter } from "./webpack/common";
export let Components: any; export let Components: any;
@ -44,17 +44,37 @@ async function init() {
if (!IS_WEB) { if (!IS_WEB) {
try { try {
const isOutdated = await checkForUpdates(); const isOutdated = await checkForUpdates();
if (isOutdated && Settings.notifyAboutUpdates) if (!isOutdated) return;
if (Settings.autoUpdate) {
await update();
const needsFullRestart = await rebuild();
setTimeout(() => {
showNotice(
"Vencord has been updated!",
"Restart",
() => {
if (needsFullRestart)
window.DiscordNative.app.relaunch();
else
location.reload();
}
);
}, 10_000);
return;
}
if (Settings.notifyAboutUpdates)
setTimeout(() => { setTimeout(() => {
showNotice( showNotice(
"A Vencord update is available!", "A Vencord update is available!",
"View Update", "View Update",
() => { () => {
popNotice(); popNotice();
Router.open("VencordUpdater"); SettingsRouter.open("VencordUpdater");
} }
); );
}, 10000); }, 10_000);
} catch (err) { } catch (err) {
UpdateLogger.error("Failed to check for updates", err); UpdateLogger.error("Failed to check for updates", err);
} }

View file

@ -16,6 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import ErrorBoundary from "@components/ErrorBoundary";
import { User } from "discord-types/general"; import { User } from "discord-types/general";
import { ComponentType, HTMLProps } from "react"; import { ComponentType, HTMLProps } from "react";
@ -52,6 +53,7 @@ const Badges = new Set<ProfileBadge>();
* @param badge The badge to register * @param badge The badge to register
*/ */
export function addBadge(badge: ProfileBadge) { export function addBadge(badge: ProfileBadge) {
badge.component &&= ErrorBoundary.wrap(badge.component, { noop: true });
Badges.add(badge); Badges.add(badge);
} }

View file

@ -0,0 +1,92 @@
/*
* 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 "./styles.css";
import { useSettings } from "@api/settings";
import ErrorBoundary from "@components/ErrorBoundary";
import { Forms, React, useEffect, useMemo, useState, useStateFromStores, WindowStore } from "@webpack/common";
import { NotificationData } from "./Notifications";
export default ErrorBoundary.wrap(function NotificationComponent({
title,
body,
richBody,
color,
icon,
onClick,
onClose,
image
}: NotificationData) {
const { timeout, position } = useSettings(["notifications.timeout", "notifications.position"]).notifications;
const hasFocus = useStateFromStores([WindowStore], () => WindowStore.isFocused());
const [isHover, setIsHover] = useState(false);
const [elapsed, setElapsed] = useState(0);
const start = useMemo(() => Date.now(), [timeout, isHover, hasFocus]);
useEffect(() => {
if (isHover || !hasFocus || timeout === 0) return void setElapsed(0);
const intervalId = setInterval(() => {
const elapsed = Date.now() - start;
if (elapsed >= timeout)
onClose!();
else
setElapsed(elapsed);
}, 10);
return () => clearInterval(intervalId);
}, [timeout, isHover, hasFocus]);
const timeoutProgress = elapsed / timeout;
return (
<button
className="vc-notification-root"
style={position === "bottom-right" ? { bottom: "1rem" } : { top: "3rem" }}
onClick={onClick}
onContextMenu={e => {
e.preventDefault();
e.stopPropagation();
onClose!();
}}
onMouseEnter={() => setIsHover(true)}
onMouseLeave={() => setIsHover(false)}
>
<div className="vc-notification">
{icon && <img className="vc-notification-icon" src={icon} alt="" />}
<div className="vc-notification-content">
<Forms.FormTitle tag="h2">{title}</Forms.FormTitle>
<div>
{richBody ?? <p className="vc-notification-p">{body}</p>}
</div>
</div>
</div>
{image && <img className="vc-notification-img" src={image} alt="" />}
{timeout !== 0 && (
<div
className="vc-notification-progressbar"
style={{ width: `${(1 - timeoutProgress) * 100}%`, backgroundColor: color || "var(--brand-experiment)" }}
/>
)}
</button>
);
});

View file

@ -0,0 +1,99 @@
/*
* 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 { Settings } from "@api/settings";
import { Queue } from "@utils/Queue";
import { ReactDOM } from "@webpack/common";
import type { ReactNode } from "react";
import type { Root } from "react-dom/client";
import NotificationComponent from "./NotificationComponent";
const NotificationQueue = new Queue();
let reactRoot: Root;
let id = 42;
function getRoot() {
if (!reactRoot) {
const container = document.createElement("div");
container.id = "vc-notification-container";
document.body.append(container);
reactRoot = ReactDOM.createRoot(container);
}
return reactRoot;
}
export interface NotificationData {
title: string;
body: string;
/**
* Same as body but can be a custom component.
* Will be used over body if present.
* Not supported on desktop notifications, those will fall back to body */
richBody?: ReactNode;
/** Small icon. This is for things like profile pictures and should be square */
icon?: string;
/** Large image. Optimally, this should be around 16x9 but it doesn't matter much. Desktop Notifications might not support this */
image?: string;
onClick?(): void;
onClose?(): void;
color?: string;
}
function _showNotification(notification: NotificationData, id: number) {
const root = getRoot();
return new Promise<void>(resolve => {
root.render(
<NotificationComponent key={id} {...notification} onClose={() => {
notification.onClose?.();
root.render(null);
resolve();
}} />,
);
});
}
function shouldBeNative() {
const { useNative } = Settings.notifications;
if (useNative === "always") return true;
if (useNative === "not-focused") return !document.hasFocus();
return false;
}
export async function requestPermission() {
return (
Notification.permission === "granted" ||
(Notification.permission !== "denied" && (await Notification.requestPermission()) === "granted")
);
}
export async function showNotification(data: NotificationData) {
if (shouldBeNative() && await requestPermission()) {
const { title, body, icon, image, onClick = null, onClose = null } = data;
const n = new Notification(title, {
body,
icon,
image
});
n.onclick = onClick;
n.onclose = onClose;
} else {
NotificationQueue.push(() => _showNotification(data, id++));
}
}

View file

@ -0,0 +1,19 @@
/*
* 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 * from "./Notifications";

View file

@ -0,0 +1,49 @@
.vc-notification-root {
/* clear default button styles */
all: unset;
display: flex;
flex-direction: column;
width: 25vw;
min-height: 10vh;
color: var(--text-normal);
background-color: var(--background-secondary-alt);
position: absolute;
z-index: 2147483647;
right: 1rem;
border-radius: 6px;
overflow: hidden;
cursor: pointer;
}
.vc-notification {
display: flex;
flex-direction: row;
padding: 1.25rem;
gap: 1.25rem;
}
.vc-notification-icon {
height: 4rem;
width: 4rem;
border-radius: 6px;
}
/* Discord adding 3km margin to generic tags */
.vc-notification h2 {
margin: unset;
}
.vc-notification-progressbar {
height: 0.25rem;
border-radius: 5px;
margin-top: auto;
}
.vc-notification-p {
margin: 0.5rem 0 0;
line-height: 140%;
}
.vc-notification-img {
width: 100%;
}

View file

@ -25,6 +25,7 @@ import * as $MessageDecorations from "./MessageDecorations";
import * as $MessageEventsAPI from "./MessageEvents"; import * as $MessageEventsAPI from "./MessageEvents";
import * as $MessagePopover from "./MessagePopover"; import * as $MessagePopover from "./MessagePopover";
import * as $Notices from "./Notices"; import * as $Notices from "./Notices";
import * as $Notifications from "./Notifications";
import * as $ServerList from "./ServerList"; import * as $ServerList from "./ServerList";
import * as $Styles from "./Styles"; import * as $Styles from "./Styles";
@ -88,3 +89,7 @@ export const MemberListDecorators = $MemberListDecorators;
* a * a
*/ */
export const Styles = $Styles; export const Styles = $Styles;
/**
* An API allowing you to display notifications
*/
export const Notifications = $Notifications;

View file

@ -19,7 +19,7 @@
import IpcEvents from "@utils/IpcEvents"; import IpcEvents from "@utils/IpcEvents";
import Logger from "@utils/Logger"; import Logger from "@utils/Logger";
import { mergeDefaults } from "@utils/misc"; import { mergeDefaults } from "@utils/misc";
import { OptionType } from "@utils/types"; import { DefinedSettings, OptionType, SettingsChecks, SettingsDefinition } from "@utils/types";
import { React } from "@webpack/common"; import { React } from "@webpack/common";
import plugins from "~plugins"; import plugins from "~plugins";
@ -27,23 +27,43 @@ import plugins from "~plugins";
const logger = new Logger("Settings"); const logger = new Logger("Settings");
export interface Settings { export interface Settings {
notifyAboutUpdates: boolean; notifyAboutUpdates: boolean;
autoUpdate: boolean;
useQuickCss: boolean; useQuickCss: boolean;
enableReactDevtools: boolean; enableReactDevtools: boolean;
themeLinks: string[]; themeLinks: string[];
frameless: boolean;
transparent: boolean;
winCtrlQ: boolean;
plugins: { plugins: {
[plugin: string]: { [plugin: string]: {
enabled: boolean; enabled: boolean;
[setting: string]: any; [setting: string]: any;
}; };
}; };
notifications: {
timeout: number;
position: "top-right" | "bottom-right";
useNative: "always" | "never" | "not-focused";
};
} }
const DefaultSettings: Settings = { const DefaultSettings: Settings = {
notifyAboutUpdates: true, notifyAboutUpdates: true,
autoUpdate: false,
useQuickCss: true, useQuickCss: true,
themeLinks: [], themeLinks: [],
enableReactDevtools: false, enableReactDevtools: false,
plugins: {} frameless: false,
transparent: false,
winCtrlQ: false,
plugins: {},
notifications: {
timeout: 5000,
position: "bottom-right",
useNative: "not-focused"
}
}; };
try { try {
@ -144,6 +164,7 @@ export const Settings = makeProxy(settings);
* @param paths An optional list of paths to whitelist for rerenders * @param paths An optional list of paths to whitelist for rerenders
* @returns Settings * @returns Settings
*/ */
// TODO: Representing paths as essentially "string[].join('.')" wont allow dots in paths, change to "paths?: string[][]" later
export function useSettings(paths?: string[]) { export function useSettings(paths?: string[]) {
const [, forceUpdate] = React.useReducer(() => ({}), {}); const [, forceUpdate] = React.useReducer(() => ({}), {});
@ -198,3 +219,19 @@ export function migratePluginSettings(name: string, ...oldNames: string[]) {
} }
} }
} }
export function definePluginSettings<D extends SettingsDefinition, C extends SettingsChecks<D>>(def: D, checks?: C) {
const definedSettings: DefinedSettings<D> = {
get store() {
if (!definedSettings.pluginName) throw new Error("Cannot access settings before plugin is initialized");
return Settings.plugins[definedSettings.pluginName] as any;
},
use: settings => useSettings(
settings?.map(name => `plugins.${definedSettings.pluginName}.${name}`)
).plugins[definedSettings.pluginName] as any,
def,
checks: checks ?? {},
pluginName: "",
};
return definedSettings;
}

View file

@ -16,15 +16,14 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { BadgeStyle } from "@components/PluginSettings/styles";
export function Badge({ text, color }): JSX.Element { export function Badge({ text, color }): JSX.Element {
return ( return (
<div style={{ <div className="vc-plugins-badge" style={{
backgroundColor: color, backgroundColor: color,
justifySelf: "flex-end", justifySelf: "flex-end",
marginLeft: "auto", marginLeft: "auto"
...BadgeStyle }}>
}}>{text}</div> {text}
</div>
); );
} }

View file

@ -103,7 +103,7 @@ const ErrorBoundary = LazyComponent(() => {
}; };
}) as }) as
React.ComponentType<React.PropsWithChildren<Props>> & { React.ComponentType<React.PropsWithChildren<Props>> & {
wrap<T extends JSX.IntrinsicAttributes = any>(Component: React.ComponentType<T>, errorBoundaryProps?: Props): React.ComponentType<T>; wrap<T extends object = any>(Component: React.ComponentType<T>, errorBoundaryProps?: Props): React.ComponentType<T>;
}; };
ErrorBoundary.wrap = (Component, errorBoundaryProps) => props => ( ErrorBoundary.wrap = (Component, errorBoundaryProps) => props => (

View file

@ -144,6 +144,7 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti
onChange={onChange} onChange={onChange}
onError={onError} onError={onError}
pluginSettings={pluginSettings} pluginSettings={pluginSettings}
definedSettings={plugin.settings}
/> />
); );
}); });

View file

@ -21,7 +21,7 @@ import { Forms, React, Select } from "@webpack/common";
import { ISettingElementProps } from "."; import { ISettingElementProps } from ".";
export function SettingBooleanComponent({ option, pluginSettings, id, onChange, onError }: ISettingElementProps<PluginOptionBoolean>) { export function SettingBooleanComponent({ option, pluginSettings, definedSettings, id, onChange, onError }: ISettingElementProps<PluginOptionBoolean>) {
const def = pluginSettings[id] ?? option.default; const def = pluginSettings[id] ?? option.default;
const [state, setState] = React.useState(def ?? false); const [state, setState] = React.useState(def ?? false);
@ -37,7 +37,7 @@ export function SettingBooleanComponent({ option, pluginSettings, id, onChange,
]; ];
function handleChange(newValue: boolean): void { function handleChange(newValue: boolean): void {
const isValid = (option.isValid && option.isValid(newValue)) ?? true; const isValid = option.isValid?.call(definedSettings, newValue) ?? true;
if (typeof isValid === "string") setError(isValid); if (typeof isValid === "string") setError(isValid);
else if (!isValid) setError("Invalid input provided."); else if (!isValid) setError("Invalid input provided.");
else { else {
@ -51,7 +51,7 @@ export function SettingBooleanComponent({ option, pluginSettings, id, onChange,
<Forms.FormSection> <Forms.FormSection>
<Forms.FormTitle>{option.description}</Forms.FormTitle> <Forms.FormTitle>{option.description}</Forms.FormTitle>
<Select <Select
isDisabled={option.disabled?.() ?? false} isDisabled={option.disabled?.call(definedSettings) ?? false}
options={options} options={options}
placeholder={option.placeholder ?? "Select an option"} placeholder={option.placeholder ?? "Select an option"}
maxVisibleItems={5} maxVisibleItems={5}

View file

@ -23,7 +23,7 @@ import { ISettingElementProps } from ".";
const MAX_SAFE_NUMBER = BigInt(Number.MAX_SAFE_INTEGER); const MAX_SAFE_NUMBER = BigInt(Number.MAX_SAFE_INTEGER);
export function SettingNumericComponent({ option, pluginSettings, id, onChange, onError }: ISettingElementProps<PluginOptionNumber>) { export function SettingNumericComponent({ option, pluginSettings, definedSettings, id, onChange, onError }: ISettingElementProps<PluginOptionNumber>) {
function serialize(value: any) { function serialize(value: any) {
if (option.type === OptionType.BIGINT) return BigInt(value); if (option.type === OptionType.BIGINT) return BigInt(value);
return Number(value); return Number(value);
@ -37,7 +37,7 @@ export function SettingNumericComponent({ option, pluginSettings, id, onChange,
}, [error]); }, [error]);
function handleChange(newValue) { function handleChange(newValue) {
const isValid = (option.isValid && option.isValid(newValue)) ?? true; const isValid = option.isValid?.call(definedSettings, newValue) ?? true;
if (typeof isValid === "string") setError(isValid); if (typeof isValid === "string") setError(isValid);
else if (!isValid) setError("Invalid input provided."); else if (!isValid) setError("Invalid input provided.");
else if (option.type === OptionType.NUMBER && BigInt(newValue) >= MAX_SAFE_NUMBER) { else if (option.type === OptionType.NUMBER && BigInt(newValue) >= MAX_SAFE_NUMBER) {
@ -58,7 +58,7 @@ export function SettingNumericComponent({ option, pluginSettings, id, onChange,
value={state} value={state}
onChange={handleChange} onChange={handleChange}
placeholder={option.placeholder ?? "Enter a number"} placeholder={option.placeholder ?? "Enter a number"}
disabled={option.disabled?.() ?? false} disabled={option.disabled?.call(definedSettings) ?? false}
{...option.componentProps} {...option.componentProps}
/> />
{error && <Forms.FormText style={{ color: "var(--text-danger)" }}>{error}</Forms.FormText>} {error && <Forms.FormText style={{ color: "var(--text-danger)" }}>{error}</Forms.FormText>}

View file

@ -21,7 +21,7 @@ import { Forms, React, Select } from "@webpack/common";
import { ISettingElementProps } from "."; import { ISettingElementProps } from ".";
export function SettingSelectComponent({ option, pluginSettings, onChange, onError, id }: ISettingElementProps<PluginOptionSelect>) { export function SettingSelectComponent({ option, pluginSettings, definedSettings, onChange, onError, id }: ISettingElementProps<PluginOptionSelect>) {
const def = pluginSettings[id] ?? option.options?.find(o => o.default)?.value; const def = pluginSettings[id] ?? option.options?.find(o => o.default)?.value;
const [state, setState] = React.useState<any>(def ?? null); const [state, setState] = React.useState<any>(def ?? null);
@ -32,7 +32,7 @@ export function SettingSelectComponent({ option, pluginSettings, onChange, onErr
}, [error]); }, [error]);
function handleChange(newValue) { function handleChange(newValue) {
const isValid = (option.isValid && option.isValid(newValue)) ?? true; const isValid = option.isValid?.call(definedSettings, newValue) ?? true;
if (typeof isValid === "string") setError(isValid); if (typeof isValid === "string") setError(isValid);
else if (!isValid) setError("Invalid input provided."); else if (!isValid) setError("Invalid input provided.");
else { else {
@ -45,7 +45,7 @@ export function SettingSelectComponent({ option, pluginSettings, onChange, onErr
<Forms.FormSection> <Forms.FormSection>
<Forms.FormTitle>{option.description}</Forms.FormTitle> <Forms.FormTitle>{option.description}</Forms.FormTitle>
<Select <Select
isDisabled={option.disabled?.() ?? false} isDisabled={option.disabled?.call(definedSettings) ?? false}
options={option.options} options={option.options}
placeholder={option.placeholder ?? "Select an option"} placeholder={option.placeholder ?? "Select an option"}
maxVisibleItems={5} maxVisibleItems={5}

View file

@ -29,7 +29,7 @@ export function makeRange(start: number, end: number, step = 1) {
return ranges; return ranges;
} }
export function SettingSliderComponent({ option, pluginSettings, id, onChange, onError }: ISettingElementProps<PluginOptionSlider>) { export function SettingSliderComponent({ option, pluginSettings, definedSettings, id, onChange, onError }: ISettingElementProps<PluginOptionSlider>) {
const def = pluginSettings[id] ?? option.default; const def = pluginSettings[id] ?? option.default;
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
@ -39,7 +39,7 @@ export function SettingSliderComponent({ option, pluginSettings, id, onChange, o
}, [error]); }, [error]);
function handleChange(newValue: number): void { function handleChange(newValue: number): void {
const isValid = (option.isValid && option.isValid(newValue)) ?? true; const isValid = option.isValid?.call(definedSettings, newValue) ?? true;
if (typeof isValid === "string") setError(isValid); if (typeof isValid === "string") setError(isValid);
else if (!isValid) setError("Invalid input provided."); else if (!isValid) setError("Invalid input provided.");
else { else {
@ -52,7 +52,7 @@ export function SettingSliderComponent({ option, pluginSettings, id, onChange, o
<Forms.FormSection> <Forms.FormSection>
<Forms.FormTitle>{option.description}</Forms.FormTitle> <Forms.FormTitle>{option.description}</Forms.FormTitle>
<Slider <Slider
disabled={option.disabled?.() ?? false} disabled={option.disabled?.call(definedSettings) ?? false}
markers={option.markers} markers={option.markers}
minValue={option.markers[0]} minValue={option.markers[0]}
maxValue={option.markers[option.markers.length - 1]} maxValue={option.markers[option.markers.length - 1]}

View file

@ -21,7 +21,7 @@ import { Forms, React, TextInput } from "@webpack/common";
import { ISettingElementProps } from "."; import { ISettingElementProps } from ".";
export function SettingTextComponent({ option, pluginSettings, id, onChange, onError }: ISettingElementProps<PluginOptionString>) { export function SettingTextComponent({ option, pluginSettings, definedSettings, id, onChange, onError }: ISettingElementProps<PluginOptionString>) {
const [state, setState] = React.useState(pluginSettings[id] ?? option.default ?? null); const [state, setState] = React.useState(pluginSettings[id] ?? option.default ?? null);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
@ -30,7 +30,7 @@ export function SettingTextComponent({ option, pluginSettings, id, onChange, onE
}, [error]); }, [error]);
function handleChange(newValue) { function handleChange(newValue) {
const isValid = (option.isValid && option.isValid(newValue)) ?? true; const isValid = option.isValid?.call(definedSettings, newValue) ?? true;
if (typeof isValid === "string") setError(isValid); if (typeof isValid === "string") setError(isValid);
else if (!isValid) setError("Invalid input provided."); else if (!isValid) setError("Invalid input provided.");
else { else {
@ -47,7 +47,7 @@ export function SettingTextComponent({ option, pluginSettings, id, onChange, onE
value={state} value={state}
onChange={handleChange} onChange={handleChange}
placeholder={option.placeholder ?? "Enter a value"} placeholder={option.placeholder ?? "Enter a value"}
disabled={option.disabled?.() ?? false} disabled={option.disabled?.call(definedSettings) ?? false}
{...option.componentProps} {...option.componentProps}
/> />
{error && <Forms.FormText style={{ color: "var(--text-danger)" }}>{error}</Forms.FormText>} {error && <Forms.FormText style={{ color: "var(--text-danger)" }}>{error}</Forms.FormText>}

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { PluginOptionBase } from "@utils/types"; import { DefinedSettings, PluginOptionBase } from "@utils/types";
export interface ISettingElementProps<T extends PluginOptionBase> { export interface ISettingElementProps<T extends PluginOptionBase> {
option: T; option: T;
@ -27,9 +27,10 @@ export interface ISettingElementProps<T extends PluginOptionBase> {
}; };
id: string; id: string;
onError(hasError: boolean): void; onError(hasError: boolean): void;
definedSettings?: DefinedSettings;
} }
export * from "./BadgeComponent"; export * from "../../Badge";
export * from "./SettingBooleanComponent"; export * from "./SettingBooleanComponent";
export * from "./SettingCustomComponent"; export * from "./SettingCustomComponent";
export * from "./SettingNumericComponent"; export * from "./SettingNumericComponent";

View file

@ -16,28 +16,32 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import "./styles.css";
import * as DataStore from "@api/DataStore"; import * as DataStore from "@api/DataStore";
import { showNotice } from "@api/Notices"; import { showNotice } from "@api/Notices";
import { Settings, useSettings } from "@api/settings"; import { useSettings } from "@api/settings";
import { classNameFactory } from "@api/Styles";
import ErrorBoundary from "@components/ErrorBoundary"; import ErrorBoundary from "@components/ErrorBoundary";
import { ErrorCard } from "@components/ErrorCard";
import { Flex } from "@components/Flex"; import { Flex } from "@components/Flex";
import { handleComponentFailed } from "@components/handleComponentFailed"; import { handleComponentFailed } from "@components/handleComponentFailed";
import { Badge } from "@components/PluginSettings/components"; import { Badge } from "@components/PluginSettings/components";
import PluginModal from "@components/PluginSettings/PluginModal"; import PluginModal from "@components/PluginSettings/PluginModal";
import * as styles from "@components/PluginSettings/styles"; import { Switch } from "@components/Switch";
import { ChangeList } from "@utils/ChangeList"; import { ChangeList } from "@utils/ChangeList";
import Logger from "@utils/Logger"; import Logger from "@utils/Logger";
import { classes, LazyComponent, useAwaiter } from "@utils/misc"; import { classes, LazyComponent, useAwaiter } from "@utils/misc";
import { openModalLazy } from "@utils/modal"; import { openModalLazy } from "@utils/modal";
import { Plugin } from "@utils/types"; import { Plugin } from "@utils/types";
import { findByCode, findByPropsLazy } from "@webpack"; import { findByCode, findByPropsLazy } from "@webpack";
import { Alerts, Button, Forms, Margins, Parser, React, Select, Switch, Text, TextInput, Toasts, Tooltip } from "@webpack/common"; import { Alerts, Button, Card, Forms, Margins, Parser, React, Select, Text, TextInput, Toasts, Tooltip } from "@webpack/common";
import Plugins from "~plugins"; import Plugins from "~plugins";
import { startDependenciesRecursive, startPlugin, stopPlugin } from "../../plugins"; import { startDependenciesRecursive, startPlugin, stopPlugin } from "../../plugins";
const cl = classNameFactory("vc-plugins-");
const logger = new Logger("PluginSettings", "#a6d189"); const logger = new Logger("PluginSettings", "#a6d189");
const InputStyles = findByPropsLazy("inputDefault", "inputWrapper"); const InputStyles = findByPropsLazy("inputDefault", "inputWrapper");
@ -56,23 +60,27 @@ function showErrorToast(message: string) {
}); });
} }
interface ReloadRequiredCardProps extends React.HTMLProps<HTMLDivElement> { function ReloadRequiredCard({ required }: { required: boolean; }) {
plugins: string[];
}
function ReloadRequiredCard({ plugins, ...props }: ReloadRequiredCardProps) {
if (plugins.length === 0) return null;
const pluginPrefix = plugins.length === 1 ? "The plugin" : "The following plugins require a reload to apply changes:";
const pluginSuffix = plugins.length === 1 ? " requires a reload to apply changes." : ".";
return ( return (
<ErrorCard {...props} style={{ padding: "1em", display: "grid", gridTemplateColumns: "1fr auto", gap: 8, ...props.style }}> <Card className={cl("info-card", { "restart-card": required })}>
<span style={{ margin: "auto 0" }}> {required ? (
{pluginPrefix} <code>{plugins.join(", ")}</code>{pluginSuffix} <>
</span> <Forms.FormTitle tag="h5">Restart required!</Forms.FormTitle>
<Button look={Button.Looks.INVERTED} onClick={() => location.reload()}>Reload</Button> <Forms.FormText className={cl("dep-text")}>
</ErrorCard> Restart now to apply new plugins and their settings
</Forms.FormText>
<Button color={Button.Colors.YELLOW} onClick={() => location.reload()}>
Restart
</Button>
</>
) : (
<>
<Forms.FormTitle tag="h5">Plugin Management</Forms.FormTitle>
<Forms.FormText>Press the cog wheel or info icon to get more info on a plugin</Forms.FormText>
<Forms.FormText>Plugins with a cog wheel have settings you can modify!</Forms.FormText>
</>
)}
</Card>
); );
} }
@ -84,14 +92,9 @@ interface PluginCardProps extends React.HTMLProps<HTMLDivElement> {
} }
function PluginCard({ plugin, disabled, onRestartNeeded, onMouseEnter, onMouseLeave, isNew }: PluginCardProps) { function PluginCard({ plugin, disabled, onRestartNeeded, onMouseEnter, onMouseLeave, isNew }: PluginCardProps) {
const settings = useSettings(); const settings = useSettings([`plugins.${plugin.name}`]).plugins[plugin.name];
const pluginSettings = settings.plugins[plugin.name];
const [iconHover, setIconHover] = React.useState(false); const isEnabled = () => settings.enabled ?? false;
function isEnabled() {
return pluginSettings?.enabled || plugin.started;
}
function openModal() { function openModal() {
openModalLazy(async () => { openModalLazy(async () => {
@ -113,7 +116,7 @@ function PluginCard({ plugin, disabled, onRestartNeeded, onMouseEnter, onMouseLe
return; return;
} else if (restartNeeded) { } else if (restartNeeded) {
// If any dependencies have patches, don't start the plugin yet. // If any dependencies have patches, don't start the plugin yet.
pluginSettings.enabled = true; settings.enabled = true;
onRestartNeeded(plugin.name); onRestartNeeded(plugin.name);
return; return;
} }
@ -121,14 +124,14 @@ function PluginCard({ plugin, disabled, onRestartNeeded, onMouseEnter, onMouseLe
// if the plugin has patches, dont use stopPlugin/startPlugin. Wait for restart to apply changes. // if the plugin has patches, dont use stopPlugin/startPlugin. Wait for restart to apply changes.
if (plugin.patches) { if (plugin.patches) {
pluginSettings.enabled = !wasEnabled; settings.enabled = !wasEnabled;
onRestartNeeded(plugin.name); onRestartNeeded(plugin.name);
return; return;
} }
// If the plugin is enabled, but hasn't been started, then we can just toggle it off. // If the plugin is enabled, but hasn't been started, then we can just toggle it off.
if (wasEnabled && !plugin.started) { if (wasEnabled && !plugin.started) {
pluginSettings.enabled = !wasEnabled; settings.enabled = !wasEnabled;
return; return;
} }
@ -141,60 +144,38 @@ function PluginCard({ plugin, disabled, onRestartNeeded, onMouseEnter, onMouseLe
return; return;
} }
pluginSettings.enabled = !wasEnabled; settings.enabled = !wasEnabled;
} }
return ( return (
<Flex style={styles.PluginsGridItem} flexDirection="column" onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}> <Flex className={cl("card", { "card-disabled": disabled })} flexDirection="column" onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
<Switch <div className={cl("card-header")}>
onChange={toggleEnabled} <Text variant="text-md/bold" className={cl("name")}>
disabled={disabled} {plugin.name}{isNew && <Badge text="NEW" color="#ED4245" />}
value={isEnabled()} </Text>
note={<Text variant="text-md/normal" style={{ <button role="switch" onClick={() => openModal()} className={classes("button-12Fmur", cl("info-button"))}>
height: 40, {plugin.options
overflow: "hidden", ? <CogWheel />
// mfw css is so bad you need whatever this is to get multi line overflow ellipsis to work : <InfoIcon width="24" height="24" />}
textOverflow: "ellipsis", </button>
display: "-webkit-box", // firefox users will cope (it doesn't support it) <Switch
WebkitLineClamp: 2, checked={isEnabled()}
lineClamp: 2, onChange={toggleEnabled}
WebkitBoxOrient: "vertical", disabled={disabled}
boxOrient: "vertical" />
}}> </div>
{plugin.description} <Text className={cl("note")} variant="text-sm/normal">{plugin.description}</Text>
</Text>} </Flex >
hideBorder={true}
>
<Flex style={{ marginTop: "auto", width: "100%", height: "100%", alignItems: "center", gap: "8px" }}>
<Text
variant="text-md/bold"
style={{
display: "flex", width: "100%", alignItems: "center", flexGrow: "1", gap: "8px"
}}
>
{plugin.name}{(isNew) && <Badge text="NEW" color="#ED4245" />}
</Text>
<button role="switch" onClick={() => openModal()} style={styles.SettingsIcon} className="button-12Fmur">
{plugin.options
? <CogWheel
style={{ color: iconHover ? "" : "var(--text-muted)" }}
onMouseEnter={() => setIconHover(true)}
onMouseLeave={() => setIconHover(false)}
/>
: <InfoIcon
width="24" height="24"
style={{ color: iconHover ? "" : "var(--text-muted)" }}
onMouseEnter={() => setIconHover(true)}
onMouseLeave={() => setIconHover(false)}
/>}
</button>
</Flex>
</Switch>
</Flex>
); );
} }
export default ErrorBoundary.wrap(function Settings() { enum SearchStatus {
ALL,
ENABLED,
DISABLED
}
export default ErrorBoundary.wrap(function PluginSettings() {
const settings = useSettings(); const settings = useSettings();
const changes = React.useMemo(() => new ChangeList<string>(), []); const changes = React.useMemo(() => new ChangeList<string>(), []);
@ -235,21 +216,19 @@ export default ErrorBoundary.wrap(function Settings() {
const sortedPlugins = React.useMemo(() => Object.values(Plugins) const sortedPlugins = React.useMemo(() => Object.values(Plugins)
.sort((a, b) => a.name.localeCompare(b.name)), []); .sort((a, b) => a.name.localeCompare(b.name)), []);
const [searchValue, setSearchValue] = React.useState({ value: "", status: "all" }); const [searchValue, setSearchValue] = React.useState({ value: "", status: SearchStatus.ALL });
const onSearch = (query: string) => setSearchValue(prev => ({ ...prev, value: query })); const onSearch = (query: string) => setSearchValue(prev => ({ ...prev, value: query }));
const onStatusChange = (status: string) => setSearchValue(prev => ({ ...prev, status })); const onStatusChange = (status: SearchStatus) => setSearchValue(prev => ({ ...prev, status }));
const pluginFilter = (plugin: typeof Plugins[keyof typeof Plugins]) => { const pluginFilter = (plugin: typeof Plugins[keyof typeof Plugins]) => {
const showEnabled = searchValue.status === "enabled" || searchValue.status === "all"; const enabled = settings.plugins[plugin.name]?.enabled;
const showDisabled = searchValue.status === "disabled" || searchValue.status === "all"; if (enabled && searchValue.status === SearchStatus.DISABLED) return false;
const enabled = settings.plugins[plugin.name]?.enabled || plugin.started; if (!enabled && searchValue.status === SearchStatus.ENABLED) return false;
if (!searchValue.value.length) return true;
return ( return (
((showEnabled && enabled) || (showDisabled && !enabled)) && plugin.name.toLowerCase().includes(searchValue.value.toLowerCase()) ||
( plugin.description.toLowerCase().includes(searchValue.value.toLowerCase())
plugin.name.toLowerCase().includes(searchValue.value.toLowerCase()) ||
plugin.description.toLowerCase().includes(searchValue.value.toLowerCase())
)
); );
}; };
@ -270,23 +249,69 @@ export default ErrorBoundary.wrap(function Settings() {
return window._.isEqual(newPlugins, sortedPluginNames) ? [] : newPlugins; return window._.isEqual(newPlugins, sortedPluginNames) ? [] : newPlugins;
})); }));
type P = JSX.Element | JSX.Element[];
let plugins: P, requiredPlugins: P;
if (sortedPlugins?.length) {
plugins = [];
requiredPlugins = [];
for (const p of sortedPlugins) {
if (!pluginFilter(p)) continue;
const isRequired = p.required || depMap[p.name]?.some(d => settings.plugins[d].enabled);
if (isRequired) {
const tooltipText = p.required
? "This plugin is required for Vencord to function."
: makeDependencyList(depMap[p.name]?.filter(d => settings.plugins[d].enabled));
requiredPlugins.push(
<Tooltip text={tooltipText} key={p.name}>
{({ onMouseLeave, onMouseEnter }) => (
<PluginCard
onMouseLeave={onMouseLeave}
onMouseEnter={onMouseEnter}
onRestartNeeded={name => changes.handleChange(name)}
disabled={true}
plugin={p}
/>
)}
</Tooltip>
);
} else {
plugins.push(
<PluginCard
onRestartNeeded={name => changes.handleChange(name)}
disabled={false}
plugin={p}
isNew={newPlugins?.includes(p.name)}
key={p.name}
/>
);
}
}
} else {
plugins = requiredPlugins = <Text variant="text-md/normal">No plugins meet search criteria.</Text>;
}
return ( return (
<Forms.FormSection> <Forms.FormSection className={Margins.marginTop16}>
<ReloadRequiredCard required={changes.hasChanges} />
<Forms.FormTitle tag="h5" className={classes(Margins.marginTop20, Margins.marginBottom8)}> <Forms.FormTitle tag="h5" className={classes(Margins.marginTop20, Margins.marginBottom8)}>
Filters Filters
</Forms.FormTitle> </Forms.FormTitle>
<ReloadRequiredCard plugins={[...changes.getChanges()]} style={{ marginBottom: 16 }} /> <div className={cl("filter-controls")}>
<TextInput autoFocus value={searchValue.value} placeholder="Search for a plugin..." onChange={onSearch} className={Margins.marginBottom20} />
<div style={styles.FiltersBar}>
<TextInput value={searchValue.value} placeholder={"Search for a plugin..."} onChange={onSearch} style={{ marginBottom: 24 }} />
<div className={InputStyles.inputWrapper}> <div className={InputStyles.inputWrapper}>
<Select <Select
className={InputStyles.inputDefault} className={InputStyles.inputDefault}
options={[ options={[
{ label: "Show All", value: "all", default: true }, { label: "Show All", value: SearchStatus.ALL, default: true },
{ label: "Show Enabled", value: "enabled" }, { label: "Show Enabled", value: SearchStatus.ENABLED },
{ label: "Show Disabled", value: "disabled" } { label: "Show Disabled", value: SearchStatus.DISABLED }
]} ]}
serialize={String} serialize={String}
select={onStatusChange} select={onStatusChange}
@ -298,50 +323,17 @@ export default ErrorBoundary.wrap(function Settings() {
<Forms.FormTitle className={Margins.marginTop20}>Plugins</Forms.FormTitle> <Forms.FormTitle className={Margins.marginTop20}>Plugins</Forms.FormTitle>
<div style={styles.PluginsGrid}> <div className={cl("grid")}>
{sortedPlugins?.length ? sortedPlugins {plugins}
.filter(a => !a.required && !dependencyCheck(a.name, depMap).length && pluginFilter(a))
.map(plugin => {
const enabledDependants = depMap[plugin.name]?.filter(d => settings.plugins[d].enabled);
const dependency = enabledDependants?.length;
return <PluginCard
onRestartNeeded={name => changes.add(name)}
disabled={plugin.required || !!dependency}
plugin={plugin}
isNew={newPlugins?.includes(plugin.name)}
key={plugin.name}
/>;
})
: <Text variant="text-md/normal">No plugins meet search criteria.</Text>
}
</div> </div>
<Forms.FormDivider />
<Forms.FormDivider className={Margins.marginTop20} />
<Forms.FormTitle tag="h5" className={classes(Margins.marginTop20, Margins.marginBottom8)}> <Forms.FormTitle tag="h5" className={classes(Margins.marginTop20, Margins.marginBottom8)}>
Required Plugins Required Plugins
</Forms.FormTitle> </Forms.FormTitle>
<div style={styles.PluginsGrid}> <div className={cl("grid")}>
{sortedPlugins?.length ? sortedPlugins {requiredPlugins}
.filter(a => a.required || dependencyCheck(a.name, depMap).length && pluginFilter(a))
.map(plugin => {
const enabledDependants = depMap[plugin.name]?.filter(d => settings.plugins[d].enabled);
const dependency = enabledDependants?.length;
const tooltipText = plugin.required
? "This plugin is required for Vencord to function."
: makeDependencyList(dependencyCheck(plugin.name, depMap));
return <Tooltip text={tooltipText} key={plugin.name}>
{({ onMouseLeave, onMouseEnter }) => (
<PluginCard
onMouseLeave={onMouseLeave}
onMouseEnter={onMouseEnter}
onRestartNeeded={name => changes.handleChange(name)}
disabled={plugin.required || !!dependency}
plugin={plugin}
/>
)}
</Tooltip>;
})
: <Text variant="text-md/normal">No plugins meet search criteria.</Text>
}
</div> </div>
</Forms.FormSection > </Forms.FormSection >
); );
@ -354,11 +346,7 @@ function makeDependencyList(deps: string[]) {
return ( return (
<React.Fragment> <React.Fragment>
<Forms.FormText>This plugin is required by:</Forms.FormText> <Forms.FormText>This plugin is required by:</Forms.FormText>
{deps.map((dep: string) => <Forms.FormText style={{ margin: "0 auto" }}>{dep}</Forms.FormText>)} {deps.map((dep: string) => <Forms.FormText className={cl("dep-text")}>{dep}</Forms.FormText>)}
</React.Fragment> </React.Fragment>
); );
} }
function dependencyCheck(pluginName: string, depMap: Record<string, string[]>): string[] {
return depMap[pluginName]?.filter(d => Settings.plugins[d].enabled) || [];
}

View file

@ -0,0 +1,138 @@
/*
* 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/>.
*/
.vc-plugins-grid {
margin-top: 16px;
display: grid;
grid-gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
}
.vc-plugins-card {
background-color: var(--background-secondary-alt);
color: var(--interactive-active);
border-radius: 8px;
display: block;
height: 100%;
padding: 12px;
width: 100%;
transition: 0.1s ease-out;
transition-property: box-shadow, transform, background, opacity;
}
.vc-plugins-card-disabled {
opacity: 0.6;
}
.vc-plugins-card:hover {
background-color: var(--background-tertiary);
transform: translateY(-1px);
box-shadow: var(--elevation-high);
}
.vc-plugins-card-header {
margin-top: auto;
display: flex;
width: 100%;
justify-content: flex-end;
height: 1.5rem;
align-items: center;
gap: 8px;
}
.vc-plugins-info-button {
height: 24px;
width: 24px;
padding: 0;
background: transparent;
margin-right: 8px;
}
.vc-plugins-settings-button:hover {
color: var(--interactive-hover);
}
.vc-plugins-filter-controls {
display: grid;
height: 40px;
gap: 10px;
grid-template-columns: 1fr 150px;
}
.vc-plugins-badge {
padding: 0 6px;
font-family: var(--font-display);
font-weight: 500;
border-radius: 8px;
height: 16px;
font-size: 12px;
line-height: 16px;
color: var(--white-500);
text-align: center;
}
.vc-plugins-note {
height: 36px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
/* stylelint-disable-next-line property-no-unknown */
box-orient: vertical;
}
.vc-plugins-name {
display: flex;
width: 100%;
align-items: center;
flex-grow: 1;
gap: 8px;
cursor: "default";
}
.vc-plugins-dep-name {
margin: 0 auto;
}
.vc-plugins-info-card {
padding: 1em;
height: 8em;
display: flex;
flex-direction: column;
}
.vc-plugins-info-card div {
line-height: 32px;
}
.vc-plugins-restart-card {
padding: 1em;
background: var(--info-warning-background);
border: 1px solid var(--info-warning-foreground);
color: var(--info-warning-text);
}
.vc-plugins-restart-card button {
margin-top: 0.5em;
}
.vc-plugins-info-button svg:not(:hover, :focus) {
color: var(--text-muted);
}

View file

@ -1,61 +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/>.
*/
export const PluginsGrid: React.CSSProperties = {
marginTop: 16,
display: "grid",
gridGap: 16,
gridTemplateColumns: "repeat(auto-fill, minmax(250px, 1fr))",
};
export const PluginsGridItem: React.CSSProperties = {
backgroundColor: "var(--background-modifier-selected)",
color: "var(--interactive-active)",
borderRadius: 3,
cursor: "pointer",
display: "block",
height: "100%",
padding: 10,
width: "100%",
};
export const FiltersBar: React.CSSProperties = {
gap: 10,
height: 40,
gridTemplateColumns: "1fr 150px",
display: "grid"
};
export const SettingsIcon: React.CSSProperties = {
height: "24px",
width: "24px",
padding: "0",
background: "transparent",
marginRight: 8
};
export const BadgeStyle: React.CSSProperties = {
padding: "0 6px",
fontFamily: "var(--font-display)",
fontWeight: "500",
borderRadius: "8px",
height: "16px",
fontSize: "12px",
lineHeight: "16px",
color: "var(--white-500)",
};

View file

@ -0,0 +1,3 @@
.vc-switch-slider {
transition: 100ms transform ease-in-out;
}

76
src/components/Switch.tsx Normal file
View file

@ -0,0 +1,76 @@
/*
* 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 "./Switch.css";
import { findByPropsLazy } from "@webpack";
interface SwitchProps {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
}
const SWITCH_ON = "var(--green-360)";
const SWITCH_OFF = "var(--primary-400)";
const SwitchClasses = findByPropsLazy("slider", "input", "container");
export function Switch({ checked, onChange, disabled }: SwitchProps) {
return (
<div>
<div className={`${SwitchClasses.container} default-colors`} style={{
backgroundColor: checked ? SWITCH_ON : SWITCH_OFF,
opacity: disabled ? 0.3 : 1
}}>
<svg
className={SwitchClasses.slider + " vc-switch-slider"}
viewBox="0 0 28 20"
preserveAspectRatio="xMinYMid meet"
aria-hidden="true"
style={{
transform: checked ? "translateX(12px)" : "translateX(-3px)",
}}
>
<rect fill="white" x="4" y="0" height="20" width="20" rx="10" />
<svg viewBox="0 0 20 20" fill="none">
{checked ? (
<>
<path fill={SWITCH_ON} d="M7.89561 14.8538L6.30462 13.2629L14.3099 5.25755L15.9009 6.84854L7.89561 14.8538Z" />
<path fill={SWITCH_ON} d="M4.08643 11.0903L5.67742 9.49929L9.4485 13.2704L7.85751 14.8614L4.08643 11.0903Z" />
</>
) : (
<>
<path fill={SWITCH_OFF} d="M5.13231 6.72963L6.7233 5.13864L14.855 13.2704L13.264 14.8614L5.13231 6.72963Z" />
<path fill={SWITCH_OFF} d="M13.2704 5.13864L14.8614 6.72963L6.72963 14.8614L5.13864 13.2704L13.2704 5.13864Z" />
</>
)}
</svg>
</svg>
<input
disabled={disabled}
type="checkbox"
className={SwitchClasses.input}
tabIndex={0}
checked={checked}
onChange={e => onChange(e.currentTarget.checked)}
/>
</div>
</div>
);
}

View file

@ -18,19 +18,14 @@
import ErrorBoundary from "@components/ErrorBoundary"; import ErrorBoundary from "@components/ErrorBoundary";
import { Flex } from "@components/Flex"; import { Flex } from "@components/Flex";
import { classes } from "@utils/misc";
import { downloadSettingsBackup, uploadSettingsBackup } from "@utils/settingsSync"; import { downloadSettingsBackup, uploadSettingsBackup } from "@utils/settingsSync";
import { Button, Card, Forms, Margins, Text } from "@webpack/common"; import { Button, Card, Forms, Margins, Text } from "@webpack/common";
function BackupRestoreTab() { function BackupRestoreTab() {
return ( return (
<Forms.FormSection title="Settings Sync"> <Forms.FormSection title="Settings Sync" className={Margins.marginTop16}>
<Card style={{ <Card className={classes("vc-settings-card", "vc-backup-restore-card")}>
backgroundColor: "var(--info-warning-background)",
borderColor: "var(--info-warning-foreground)",
color: "var(--info-warning-text)",
padding: "1em",
marginBottom: "0.5em",
}}>
<Flex flexDirection="column"> <Flex flexDirection="column">
<strong>Warning</strong> <strong>Warning</strong>
<span>Importing a settings file will overwrite your current settings.</span> <span>Importing a settings file will overwrite your current settings.</span>
@ -50,7 +45,7 @@ function BackupRestoreTab() {
</Text> </Text>
<Flex> <Flex>
<Button <Button
onClick={uploadSettingsBackup} onClick={() => uploadSettingsBackup()}
size={Button.Sizes.SMALL} size={Button.Sizes.SMALL}
> >
Import Settings Import Settings

View file

@ -75,11 +75,11 @@ function Validators({ themeLinks }: { themeLinks: string[]; }) {
export default ErrorBoundary.wrap(function () { export default ErrorBoundary.wrap(function () {
const settings = useSettings(); const settings = useSettings();
const ref = React.useRef<HTMLTextAreaElement>(); const [themeText, setThemeText] = React.useState(settings.themeLinks.join("\n"));
function onBlur() { function onBlur() {
settings.themeLinks = [...new Set( settings.themeLinks = [...new Set(
ref.current!.value themeText
.trim() .trim()
.split(/\n+/) .split(/\n+/)
.map(s => s.trim()) .map(s => s.trim())
@ -89,15 +89,11 @@ export default ErrorBoundary.wrap(function () {
return ( return (
<> <>
<Card style={{ <Card className="vc-settings-card">
padding: "1em",
marginBottom: "1em",
marginTop: "1em"
}}>
<Forms.FormTitle tag="h5">Paste links to .css / .theme.css files here</Forms.FormTitle> <Forms.FormTitle tag="h5">Paste links to .css / .theme.css files here</Forms.FormTitle>
<Forms.FormText>One link per line</Forms.FormText> <Forms.FormText>One link per line</Forms.FormText>
<Forms.FormText>Make sure to use the raw links or github.io links!</Forms.FormText> <Forms.FormText>Make sure to use the raw links or github.io links!</Forms.FormText>
<Forms.FormDivider /> <Forms.FormDivider className={Margins.marginTop8 + " " + Margins.marginBottom8} />
<Forms.FormTitle tag="h5">Find Themes:</Forms.FormTitle> <Forms.FormTitle tag="h5">Find Themes:</Forms.FormTitle>
<div style={{ marginBottom: ".5em" }}> <div style={{ marginBottom: ".5em" }}>
<Link style={{ marginRight: ".5em" }} href="https://betterdiscord.app/themes"> <Link style={{ marginRight: ".5em" }} href="https://betterdiscord.app/themes">
@ -123,8 +119,8 @@ export default ErrorBoundary.wrap(function () {
padding: ".5em", padding: ".5em",
border: "1px solid var(--background-modifier-accent)" border: "1px solid var(--background-modifier-accent)"
}} }}
ref={ref} value={themeText}
defaultValue={settings.themeLinks.join("\n")} onChange={e => setThemeText(e.currentTarget.value)}
className={TextAreaProps.textarea} className={TextAreaProps.textarea}
placeholder="Theme Links" placeholder="Theme Links"
spellCheck={false} spellCheck={false}

View file

@ -16,6 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { useSettings } from "@api/settings";
import ErrorBoundary from "@components/ErrorBoundary"; import ErrorBoundary from "@components/ErrorBoundary";
import { ErrorCard } from "@components/ErrorCard"; import { ErrorCard } from "@components/ErrorCard";
import { Flex } from "@components/Flex"; import { Flex } from "@components/Flex";
@ -23,7 +24,7 @@ import { handleComponentFailed } from "@components/handleComponentFailed";
import { Link } from "@components/Link"; import { Link } from "@components/Link";
import { classes, useAwaiter } from "@utils/misc"; import { classes, useAwaiter } from "@utils/misc";
import { changes, checkForUpdates, getRepo, isNewer, rebuild, update, updateError, UpdateLogger } from "@utils/updater"; import { changes, checkForUpdates, getRepo, isNewer, rebuild, update, updateError, UpdateLogger } from "@utils/updater";
import { Alerts, Button, Card, Forms, Margins, Parser, React, Toasts } from "@webpack/common"; import { Alerts, Button, Card, Forms, Margins, Parser, React, Switch, Toasts } from "@webpack/common";
import gitHash from "~git-hash"; import gitHash from "~git-hash";
@ -69,14 +70,18 @@ interface CommonProps {
repoPending: boolean; repoPending: boolean;
} }
function HashLink({ repo, hash, disabled = false }: { repo: string, hash: string, disabled?: boolean; }) {
return <Link href={`${repo}/commit/${hash}`} disabled={disabled}>
{hash}
</Link>;
}
function Changes({ updates, repo, repoPending }: CommonProps & { updates: typeof changes; }) { function Changes({ updates, repo, repoPending }: CommonProps & { updates: typeof changes; }) {
return ( return (
<Card style={{ padding: ".5em" }}> <Card style={{ padding: ".5em" }}>
{updates.map(({ hash, author, message }) => ( {updates.map(({ hash, author, message }) => (
<div> <div>
<Link href={`${repo}/commit/${hash}`} disabled={repoPending}> <code><HashLink {...{ repo, hash }} disabled={repoPending} /></code>
<code>{hash}</code>
</Link>
<span style={{ <span style={{
marginLeft: "0.5em", marginLeft: "0.5em",
color: "var(--text-normal)" color: "var(--text-normal)"
@ -179,6 +184,8 @@ function Newer(props: CommonProps) {
} }
function Updater() { function Updater() {
const settings = useSettings(["notifyAboutUpdates", "autoUpdate"]);
const [repo, err, repoPending] = useAwaiter(getRepo, { fallbackValue: "Loading..." }); const [repo, err, repoPending] = useAwaiter(getRepo, { fallbackValue: "Loading..." });
React.useEffect(() => { React.useEffect(() => {
@ -192,16 +199,33 @@ function Updater() {
}; };
return ( return (
<Forms.FormSection> <Forms.FormSection className={Margins.marginTop16}>
<Forms.FormTitle tag="h5">Updater Settings</Forms.FormTitle>
<Switch
value={settings.notifyAboutUpdates}
onChange={(v: boolean) => settings.notifyAboutUpdates = v}
note="Shows a toast on startup"
disabled={settings.autoUpdate}
>
Get notified about new updates
</Switch>
<Switch
value={settings.autoUpdate}
onChange={(v: boolean) => settings.autoUpdate = v}
note="Automatically update Vencord without confirmation prompt"
>
Automatically update
</Switch>
<Forms.FormTitle tag="h5">Repo</Forms.FormTitle> <Forms.FormTitle tag="h5">Repo</Forms.FormTitle>
<Forms.FormText>{repoPending ? repo : err ? "Failed to retrieve - check console" : ( <Forms.FormText>{repoPending ? repo : err ? "Failed to retrieve - check console" : (
<Link href={repo}> <Link href={repo}>
{repo.split("/").slice(-2).join("/")} {repo.split("/").slice(-2).join("/")}
</Link> </Link>
)} ({gitHash})</Forms.FormText> )} (<HashLink hash={gitHash} repo={repo} disabled={repoPending} />)</Forms.FormText>
<Forms.FormDivider /> <Forms.FormDivider className={Margins.marginTop8 + " " + Margins.marginBottom8} />
<Forms.FormTitle tag="h5">Updates</Forms.FormTitle> <Forms.FormTitle tag="h5">Updates</Forms.FormTitle>

View file

@ -18,31 +18,73 @@
import { useSettings } from "@api/settings"; import { useSettings } from "@api/settings";
import { classNameFactory } from "@api/Styles";
import DonateButton from "@components/DonateButton"; import DonateButton from "@components/DonateButton";
import ErrorBoundary from "@components/ErrorBoundary"; import ErrorBoundary from "@components/ErrorBoundary";
import { ErrorCard } from "@components/ErrorCard";
import IpcEvents from "@utils/IpcEvents"; import IpcEvents from "@utils/IpcEvents";
import { useAwaiter } from "@utils/misc"; import { Margins } from "@utils/margins";
import { Button, Card, Forms, Margins, React, Switch } from "@webpack/common"; import { identity, useAwaiter } from "@utils/misc";
import { Button, Card, Forms, React, Select, Slider, Switch } from "@webpack/common";
const st = (style: string) => `vcSettings${style}`; 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";
type KeysOfType<Object, Type> = {
[K in keyof Object]: Object[K] extends Type ? K : never;
}[keyof Object];
function VencordSettings() { function VencordSettings() {
const [settingsDir, , settingsDirPending] = useAwaiter(() => VencordNative.ipc.invoke<string>(IpcEvents.GET_SETTINGS_DIR), { const [settingsDir, , settingsDirPending] = useAwaiter(() => VencordNative.ipc.invoke<string>(IpcEvents.GET_SETTINGS_DIR), {
fallbackValue: "Loading..." fallbackValue: "Loading..."
}); });
const settings = useSettings(); const settings = useSettings();
const notifSettings = settings.notifications;
const [donateImage] = React.useState( const donateImage = React.useMemo(() => Math.random() > 0.5 ? DEFAULT_DONATE_IMAGE : SHIGGY_DONATE_IMAGE, []);
Math.random() > 0.5
? "https://cdn.discordapp.com/emojis/1026533090627174460.png" const isWindows = navigator.platform.toLowerCase().startsWith("win");
: "https://media.discordapp.net/stickers/1039992459209490513.png"
); const Switches: Array<false | {
key: KeysOfType<typeof settings, boolean>;
title: string;
note: string;
}> =
[
{
key: "useQuickCss",
title: "Enable Custom CSS",
note: "Loads your Custom CSS"
},
!IS_WEB && {
key: "enableReactDevtools",
title: "Enable React Developer Tools",
note: "Requires a full restart"
},
!IS_WEB && !isWindows && {
key: "frameless",
title: "Disable the window frame",
note: "Requires a full restart"
},
!IS_WEB && {
key: "transparent",
title: "Enable window transparency",
note: "Requires a full restart"
},
!IS_WEB && isWindows && {
key: "winCtrlQ",
title: "Register Ctrl+Q as shortcut to close Discord (Alternative to Alt+F4)",
note: "Requires a full restart"
}
];
return ( return (
<React.Fragment> <React.Fragment>
<DonateCard image={donateImage} /> <DonateCard image={donateImage} />
<Forms.FormSection title="Quick Actions"> <Forms.FormSection title="Quick Actions">
<Card className={st("QuickActionCard")}> <Card className={cl("quick-actions-card")}>
{IS_WEB ? ( {IS_WEB ? (
<Button <Button
onClick={() => require("../Monaco").launchMonacoEditor()} onClick={() => require("../Monaco").launchMonacoEditor()}
@ -82,34 +124,76 @@ function VencordSettings() {
<Forms.FormDivider /> <Forms.FormDivider />
<Forms.FormSection title="Settings"> <Forms.FormSection className={Margins.top16} title="Settings" tag="h5">
<Forms.FormText className={Margins.marginBottom20}> <Forms.FormText className={Margins.bottom20}>
Hint: You can change the position of this settings section in the settings of the "Settings" plugin! Hint: You can change the position of this settings section in the settings of the "Settings" plugin!
</Forms.FormText> </Forms.FormText>
<Switch {Switches.map(s => s && (
value={settings.useQuickCss} <Switch
onChange={(v: boolean) => settings.useQuickCss = v} key={s.key}
note="Loads styles from your QuickCSS file"> value={settings[s.key]}
Use QuickCSS onChange={v => settings[s.key] = v}
</Switch> note={s.note}
{!IS_WEB && ( >
<React.Fragment> {s.title}
<Switch </Switch>
value={settings.enableReactDevtools} ))}
onChange={(v: boolean) => settings.enableReactDevtools = v}
note="Requires a full restart">
Enable React Developer Tools
</Switch>
<Switch
value={settings.notifyAboutUpdates}
onChange={(v: boolean) => settings.notifyAboutUpdates = v}
note="Shows a toast on startup">
Get notified about new updates
</Switch>
</React.Fragment>
)}
</Forms.FormSection> </Forms.FormSection>
<Forms.FormTitle tag="h5">Notification Style</Forms.FormTitle>
{notifSettings.useNative !== "never" && Notification.permission === "denied" && (
<ErrorCard style={{ padding: "1em" }} className={Margins.bottom8}>
<Forms.FormTitle tag="h5">Desktop Notification Permission denied</Forms.FormTitle>
<Forms.FormText>You have denied Notification Permissions. Thus, Desktop notifications will not work!</Forms.FormText>
</ErrorCard>
)}
<Forms.FormText className={Margins.bottom8}>
Some plugins may show you notifications. These come in two styles:
<ul>
<li><strong>Vencord Notifications</strong>: These are in-app notifications</li>
<li><strong>Desktop Notifications</strong>: Native Desktop notifications (like when you get a ping)</li>
</ul>
</Forms.FormText>
<Select
placeholder="Notification Style"
options={[
{ label: "Only use Desktop notifications when Discord is not focused", value: "not-focused", default: true },
{ label: "Always use Desktop notifications", value: "always" },
{ label: "Always use Vencord notifications", value: "never" },
]satisfies Array<{ value: typeof settings["notifications"]["useNative"]; } & Record<string, any>>}
closeOnSelect={true}
select={v => notifSettings.useNative = v}
isSelected={v => v === notifSettings.useNative}
serialize={identity}
/>
<Forms.FormTitle tag="h5" className={Margins.top16 + " " + Margins.bottom8}>Notification Position</Forms.FormTitle>
<Select
isDisabled={notifSettings.useNative === "always"}
placeholder="Notification Position"
options={[
{ label: "Bottom Right", value: "bottom-right", default: true },
{ label: "Top Right", value: "top-right" },
]satisfies Array<{ value: typeof settings["notifications"]["position"]; } & Record<string, any>>}
select={v => notifSettings.position = v}
isSelected={v => v === notifSettings.position}
serialize={identity}
/>
<Forms.FormTitle tag="h5" className={Margins.top16 + " " + Margins.bottom8}>Notification Timeout</Forms.FormTitle>
<Forms.FormText className={Margins.bottom16}>Set to 0s to never automatically time out</Forms.FormText>
<Slider
disabled={notifSettings.useNative === "always"}
markers={[0, 1000, 2500, 5000, 10_000, 20_000]}
minValue={0}
maxValue={20_000}
initialValue={notifSettings.timeout}
onValueChange={v => notifSettings.timeout = v}
onValueRender={v => (v / 1000).toFixed(2) + "s"}
onMarkerRender={v => (v / 1000) + "s"}
stickToMarkers={false}
/>
</React.Fragment> </React.Fragment>
); );
} }
@ -121,18 +205,10 @@ interface DonateCardProps {
function DonateCard({ image }: DonateCardProps) { function DonateCard({ image }: DonateCardProps) {
return ( return (
<Card style={{ <Card className={cl("card", "donate")}>
padding: "1em",
display: "flex",
flexDirection: "row",
marginBottom: "1em",
marginTop: "1em"
}}>
<div> <div>
<Forms.FormTitle tag="h5">Support the Project</Forms.FormTitle> <Forms.FormTitle tag="h5">Support the Project</Forms.FormTitle>
<Forms.FormText> <Forms.FormText>Please consider supporting the development of Vencord by donating!</Forms.FormText>
Please consider supporting the development of Vencord by donating!
</Forms.FormText>
<DonateButton style={{ transform: "translateX(-1em)" }} /> <DonateButton style={{ transform: "translateX(-1em)" }} />
</div> </div>
<img <img
@ -140,7 +216,7 @@ function DonateCard({ image }: DonateCardProps) {
src={image} src={image}
alt="" alt=""
height={128} height={128}
style={{ marginLeft: "auto", transform: "rotate(10deg)" }} style={{ marginLeft: "auto", transform: image === DEFAULT_DONATE_IMAGE ? "rotate(10deg)" : "" }}
/> />
</Card> </Card>
); );

View file

@ -18,9 +18,10 @@
import "./settingsStyles.css"; import "./settingsStyles.css";
import { classNameFactory } from "@api/Styles";
import ErrorBoundary from "@components/ErrorBoundary"; import ErrorBoundary from "@components/ErrorBoundary";
import { findByCodeLazy } from "@webpack"; import { findByCodeLazy } from "@webpack";
import { Forms, Router, Text } from "@webpack/common"; import { Forms, SettingsRouter, Text } from "@webpack/common";
import BackupRestoreTab from "./BackupRestoreTab"; import BackupRestoreTab from "./BackupRestoreTab";
import PluginsTab from "./PluginsTab"; import PluginsTab from "./PluginsTab";
@ -28,7 +29,7 @@ import ThemesTab from "./ThemesTab";
import Updater from "./Updater"; import Updater from "./Updater";
import VencordSettings from "./VencordTab"; import VencordSettings from "./VencordTab";
const st = (style: string) => `vcSettings${style}`; const cl = classNameFactory("vc-settings-");
const TabBar = findByCodeLazy('[role="tab"][aria-disabled="false"]'); const TabBar = findByCodeLazy('[role="tab"][aria-disabled="false"]');
@ -62,15 +63,15 @@ function Settings(props: SettingsProps) {
<TabBar <TabBar
type={TabBar.Types.TOP} type={TabBar.Types.TOP}
look={TabBar.Looks.BRAND} look={TabBar.Looks.BRAND}
className={st("TabBar")} className={cl("tab-bar")}
selectedItem={tab} selectedItem={tab}
onItemSelect={Router.open} onItemSelect={SettingsRouter.open}
> >
{Object.entries(SettingsTabs).map(([key, { name, component }]) => { {Object.entries(SettingsTabs).map(([key, { name, component }]) => {
if (!component) return null; if (!component) return null;
return <TabBar.Item return <TabBar.Item
id={key} id={key}
className={st("TabBarItem")} className={cl("tab-bar-item")}
key={key}> key={key}>
{name} {name}
</TabBar.Item>; </TabBar.Item>;

View file

@ -1,23 +1,40 @@
.vcSettingsTabBar { .vc-settings-tab-bar {
margin-top: 20px; margin-top: 20px;
margin-bottom: -2px; margin-bottom: -2px;
border-bottom: 2px solid var(--background-modifier-accent); border-bottom: 2px solid var(--background-modifier-accent);
} }
.vcSettingsTabBarItem { .vc-settings-tab-bar-item {
margin-right: 32px; margin-right: 32px;
padding-bottom: 16px; padding-bottom: 16px;
margin-bottom: -2px; margin-bottom: -2px;
} }
.vcSettingsQuickActionCard { .vc-settings-quick-actions-card {
padding: 1em; padding: 1em;
display: flex; display: flex;
gap: 1em; gap: 1em;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
flex-wrap: wrap;
flex-grow: 1; flex-grow: 1;
flex-direction: row; flex-flow: row wrap;
margin-bottom: 1em; margin-bottom: 1em;
} }
.vc-settings-donate {
display: flex;
flex-direction: row;
}
.vc-settings-card {
padding: 1em;
margin-bottom: 1em;
margin-top: 1em;
}
.vc-backup-restore-card {
background-color: var(--info-warning-background);
border-color: var(--info-warning-foreground);
color: var(--info-warning-text);
margin-top: 0;
}

View file

@ -67,9 +67,18 @@ export async function installExt(id: string) {
try { try {
await access(extDir, fsConstants.F_OK); await access(extDir, fsConstants.F_OK);
} catch (err) { } catch (err) {
const url = `https://clients2.google.com/service/update2/crx?response=redirect&acceptformat=crx2,crx3&x=id%3D${id}%26uc&prodversion=32`; const url = id === "fmkadmapgofadopljbjfkapdkoienihi"
const buf = await get(url); // React Devtools v4.25
await extract(crxToZip(buf), extDir); // v4.27 is broken in Electron, see https://github.com/facebook/react/issues/25843
// Unfortunately, Google does not serve old versions, so this is the only way
? "https://raw.githubusercontent.com/Vendicated/random-files/f6f550e4c58ac5f2012095a130406c2ab25b984d/fmkadmapgofadopljbjfkapdkoienihi.zip"
: `https://clients2.google.com/service/update2/crx?response=redirect&acceptformat=crx2,crx3&x=id%3D${id}%26uc&prodversion=32`;
const buf = await get(url, {
headers: {
"User-Agent": "Vencord (https://github.com/Vendicated/Vencord)"
}
});
await extract(crxToZip(buf), extDir).catch(console.error);
} }
session.defaultSession.loadExtension(extDir); session.defaultSession.loadExtension(extDir);

View file

@ -16,13 +16,12 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import "./legacy";
import "./updater"; import "./updater";
import { debounce } from "@utils/debounce"; import { debounce } from "@utils/debounce";
import IpcEvents from "@utils/IpcEvents"; import IpcEvents from "@utils/IpcEvents";
import { Queue } from "@utils/Queue"; import { Queue } from "@utils/Queue";
import { BrowserWindow, desktopCapturer, ipcMain, shell } from "electron"; import { BrowserWindow, ipcMain, shell } from "electron";
import { mkdirSync, readFileSync, watch } from "fs"; import { mkdirSync, readFileSync, watch } from "fs";
import { open, readFile, writeFile } from "fs/promises"; import { open, readFile, writeFile } from "fs/promises";
import { join } from "path"; import { join } from "path";
@ -45,9 +44,6 @@ export function readSettings() {
} }
} }
// Fix for screensharing in Electron >= 17
ipcMain.handle(IpcEvents.GET_DESKTOP_CAPTURE_SOURCES, (_, opts) => desktopCapturer.getSources(opts));
ipcMain.handle(IpcEvents.OPEN_QUICKCSS, () => shell.openPath(QUICKCSS_PATH)); ipcMain.handle(IpcEvents.OPEN_QUICKCSS, () => shell.openPath(QUICKCSS_PATH));
ipcMain.handle(IpcEvents.OPEN_EXTERNAL, (_, url) => { ipcMain.handle(IpcEvents.OPEN_EXTERNAL, (_, url) => {
@ -81,7 +77,7 @@ ipcMain.handle(IpcEvents.SET_SETTINGS, (_, s) => {
export function initIpc(mainWindow: BrowserWindow) { export function initIpc(mainWindow: BrowserWindow) {
open(QUICKCSS_PATH, "a+").then(fd => { open(QUICKCSS_PATH, "a+").then(fd => {
fd.close(); fd.close();
watch(QUICKCSS_PATH, debounce(async () => { watch(QUICKCSS_PATH, { persistent: false }, debounce(async () => {
mainWindow.webContents.postMessage(IpcEvents.QUICK_CSS_UPDATE, await readCss()); mainWindow.webContents.postMessage(IpcEvents.QUICK_CSS_UPDATE, await readCss());
}, 50)); }, 50));
}); });
@ -95,7 +91,8 @@ ipcMain.handle(IpcEvents.OPEN_MONACO_EDITOR, async () => {
webPreferences: { webPreferences: {
preload: join(__dirname, "preload.js"), preload: join(__dirname, "preload.js"),
contextIsolation: true, contextIsolation: true,
nodeIntegration: false nodeIntegration: false,
sandbox: false
} }
}); });
await win.loadURL(`data:text/html;base64,${monacoHtml}`); await win.loadURL(`data:text/html;base64,${monacoHtml}`);

View file

@ -28,7 +28,9 @@ const VENCORD_SRC_DIR = join(__dirname, "..");
const execFile = promisify(cpExecFile); const execFile = promisify(cpExecFile);
const isFlatpak = Boolean(process.env.FLATPAK_ID?.includes("discordapp") || process.env.FLATPAK_ID?.includes("Discord")); const isFlatpak = process.platform === "linux" && Boolean(process.env.FLATPAK_ID?.includes("discordapp") || process.env.FLATPAK_ID?.includes("Discord"));
if (process.platform === "darwin") process.env.PATH = `/usr/local/bin:${process.env.PATH}`;
function git(...args: string[]) { function git(...args: string[]) {
const opts = { cwd: VENCORD_SRC_DIR }; const opts = { cwd: VENCORD_SRC_DIR };
@ -66,10 +68,10 @@ async function pull() {
async function build() { async function build() {
const opts = { cwd: VENCORD_SRC_DIR }; const opts = { cwd: VENCORD_SRC_DIR };
let res; const command = isFlatpak ? "flatpak-spawn" : "node";
const args = isFlatpak ? ["--host", "node", "scripts/build/build.mjs"] : ["scripts/build/build.mjs"];
if (isFlatpak) res = await execFile("flatpak-spawn", ["--host", "node", "scripts/build/build.mjs"], opts); const res = await execFile(command, args, opts);
else res = await execFile("node", ["scripts/build/build.mjs"], opts);
return !res.stderr.includes("Build failed"); return !res.stderr.includes("Build failed");
} }

View file

@ -37,10 +37,7 @@ async function githubGet(endpoint: string) {
Accept: "application/vnd.github+json", Accept: "application/vnd.github+json",
// "All API requests MUST include a valid User-Agent header. // "All API requests MUST include a valid User-Agent header.
// Requests with no User-Agent header will be rejected." // Requests with no User-Agent header will be rejected."
"User-Agent": VENCORD_USER_AGENT, "User-Agent": VENCORD_USER_AGENT
// todo: perhaps add support for (optional) api token?
// unauthorised rate limit is 60 reqs/h
// https://github.com/settings/tokens/new?description=Vencord%20Updater
} }
}); });
} }
@ -52,7 +49,7 @@ async function calculateGitChanges() {
const res = await githubGet(`/compare/${gitHash}...HEAD`); const res = await githubGet(`/compare/${gitHash}...HEAD`);
const data = JSON.parse(res.toString("utf-8")); const data = JSON.parse(res.toString("utf-8"));
return data.commits.map(c => ({ return data.commits.map((c: any) => ({
// github api only sends the long sha // github api only sends the long sha
hash: c.sha.slice(0, 7), hash: c.sha.slice(0, 7),
author: c.author.login, author: c.author.login,

3
src/modules.d.ts vendored
View file

@ -38,7 +38,8 @@ declare module "~fileContent/*" {
export default content; export default content;
} }
declare module "*.css" { } declare module "*.css";
declare module "*.css?managed" { declare module "*.css?managed" {
const name: string; const name: string;
export default name; export default name;

View file

@ -17,7 +17,7 @@
*/ */
import { app, autoUpdater } from "electron"; import { app, autoUpdater } from "electron";
import { existsSync, mkdirSync, readdirSync, writeFileSync } from "fs"; import { existsSync, mkdirSync, readdirSync, renameSync, statSync, writeFileSync } from "fs";
import { basename, dirname, join } from "path"; import { basename, dirname, join } from "path";
const { setAppUserModelId } = app; const { setAppUserModelId } = app;
@ -44,58 +44,50 @@ function isNewer($new: string, old: string) {
} }
function patchLatest() { function patchLatest() {
const currentAppPath = dirname(process.execPath); try {
const currentVersion = basename(currentAppPath); const currentAppPath = dirname(process.execPath);
const discordPath = join(currentAppPath, ".."); const currentVersion = basename(currentAppPath);
const discordPath = join(currentAppPath, "..");
const latestVersion = readdirSync(discordPath).reduce((prev, curr) => { const latestVersion = readdirSync(discordPath).reduce((prev, curr) => {
return (curr.startsWith("app-") && isNewer(curr, prev)) return (curr.startsWith("app-") && isNewer(curr, prev))
? curr ? curr
: prev; : prev;
}, currentVersion as string); }, currentVersion as string);
if (latestVersion === currentVersion) return; if (latestVersion === currentVersion) return;
const app = join(discordPath, latestVersion, "resources", "app"); const resources = join(discordPath, latestVersion, "resources");
if (existsSync(app)) return; const app = join(resources, "app.asar");
const _app = join(resources, "_app.asar");
console.info("[Vencord] Detected Host Update. Repatching..."); if (!existsSync(app) || statSync(app).isDirectory()) return;
const patcherPath = join(__dirname, "patcher.js"); console.info("[Vencord] Detected Host Update. Repatching...");
mkdirSync(app);
writeFileSync(join(app, "package.json"), JSON.stringify({ renameSync(app, _app);
name: "discord", mkdirSync(app);
main: "index.js" writeFileSync(join(app, "package.json"), JSON.stringify({
})); name: "discord",
writeFileSync(join(app, "index.js"), `require(${JSON.stringify(patcherPath)});`); main: "index.js"
}));
writeFileSync(join(app, "index.js"), `require(${JSON.stringify(join(__dirname, "patcher.js"))});`);
} catch (err) {
console.error("[Vencord] Failed to repatch latest host update", err);
}
} }
// Windows Host Updates install to a new folder app-{HOST_VERSION}, so we // Windows Host Updates install to a new folder app-{HOST_VERSION}, so we
// need to reinject // need to reinject
function patchUpdater() { function patchUpdater() {
const main = require.main!;
const buildInfo = require(join(process.resourcesPath, "build_info.json"));
try { try {
if (buildInfo?.newUpdater) { const autoStartScript = join(require.main!.filename, "..", "autoStart", "win32.js");
const autoStartScript = join(main.filename, "..", "autoStart", "win32.js"); const { update } = require(autoStartScript);
const { update } = require(autoStartScript);
// New Updater Injection require.cache[autoStartScript]!.exports.update = function () {
require.cache[autoStartScript]!.exports.update = function () { update.apply(this, arguments);
patchLatest(); patchLatest();
update.apply(this, arguments); };
};
} else {
const hostUpdaterScript = join(main.filename, "..", "hostUpdater.js");
const { quitAndInstall } = require(hostUpdaterScript);
// Old Updater Injection
require.cache[hostUpdaterScript]!.exports.quitAndInstall = function () {
patchLatest();
quitAndInstall.apply(this, arguments);
};
}
} catch { } catch {
// OpenAsar uses electrons autoUpdater on Windows // OpenAsar uses electrons autoUpdater on Windows
const { quitAndInstall } = autoUpdater; const { quitAndInstall } = autoUpdater;

View file

@ -17,8 +17,7 @@
*/ */
import { onceDefined } from "@utils/onceDefined"; import { onceDefined } from "@utils/onceDefined";
import electron, { app, BrowserWindowConstructorOptions } from "electron"; import electron, { app, BrowserWindowConstructorOptions, Menu } from "electron";
import { readFileSync } from "fs";
import { dirname, join } from "path"; import { dirname, join } from "path";
import { initIpc } from "./ipcMain"; import { initIpc } from "./ipcMain";
@ -43,16 +42,48 @@ require.main!.filename = join(asarPath, discordPkg.main);
app.setAppPath(asarPath); app.setAppPath(asarPath);
if (!process.argv.includes("--vanilla")) { if (!process.argv.includes("--vanilla")) {
let settings: typeof import("@api/settings").Settings = {} as any;
try {
settings = JSON.parse(readSettings());
} catch { }
// Repatch after host updates on Windows // Repatch after host updates on Windows
if (process.platform === "win32") if (process.platform === "win32") {
require("./patchWin32Updater"); require("./patchWin32Updater");
if (settings.winCtrlQ) {
const originalBuild = Menu.buildFromTemplate;
Menu.buildFromTemplate = function (template) {
if (template[0]?.label === "&File") {
const { submenu } = template[0];
if (Array.isArray(submenu)) {
submenu.push({
label: "Quit (Hidden)",
visible: false,
acceleratorWorksWhenHidden: true,
accelerator: "Control+Q",
click: () => app.quit()
});
}
}
return originalBuild.call(this, template);
};
}
}
class BrowserWindow extends electron.BrowserWindow { class BrowserWindow extends electron.BrowserWindow {
constructor(options: BrowserWindowConstructorOptions) { constructor(options: BrowserWindowConstructorOptions) {
if (options?.webPreferences?.preload && options.title) { if (options?.webPreferences?.preload && options.title) {
const original = options.webPreferences.preload; const original = options.webPreferences.preload;
options.webPreferences.preload = join(__dirname, "preload.js"); options.webPreferences.preload = join(__dirname, "preload.js");
options.webPreferences.sandbox = false; options.webPreferences.sandbox = false;
if (settings.frameless) {
options.frame = false;
}
if (settings.transparent) {
options.transparent = true;
options.backgroundColor = "#00000000";
}
process.env.DISCORD_PRELOAD = original; process.env.DISCORD_PRELOAD = original;
@ -100,8 +131,7 @@ if (!process.argv.includes("--vanilla")) {
}); });
try { try {
const settings = JSON.parse(readSettings()); if (settings?.enableReactDevtools)
if (settings.enableReactDevtools)
installExt("fmkadmapgofadopljbjfkapdkoienihi") installExt("fmkadmapgofadopljbjfkapdkoienihi")
.then(() => console.info("[Vencord] Installed React Developer Tools")) .then(() => console.info("[Vencord] Installed React Developer Tools"))
.catch(err => console.error("[Vencord] Failed to install React Developer Tools", err)); .catch(err => console.error("[Vencord] Failed to install React Developer Tools", err));
@ -160,21 +190,4 @@ if (!process.argv.includes("--vanilla")) {
} }
console.log("[Vencord] Loading original Discord app.asar"); console.log("[Vencord] Loading original Discord app.asar");
// Legacy Vencord Injector requires "../app.asar". However, because we require(require.main!.filename);
// restore the require.main above this is messed up, so monkey patch Module._load to
// redirect such requires
// FIXME: remove this eventually
if (readFileSync(injectorPath, "utf-8").includes('require("../app.asar")')) {
console.warn("[Vencord] [--> WARNING <--] You have a legacy Vencord install. Please reinject");
const Module = require("module");
const loadModule = Module._load;
Module._load = function (path: string) {
if (path === "../app.asar") {
Module._load = loadModule;
arguments[0] = require.main!.filename;
}
return loadModule.apply(this, arguments);
};
} else {
require(require.main!.filename);
}

View file

@ -0,0 +1,42 @@
/*
* 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 { Devs } from "@utils/constants";
import definePlugin from "@utils/types";
export default definePlugin({
name: "AlwaysTrust",
description: "Removes the annoying untrusted domain and suspicious file popup",
authors: [Devs.zt],
patches: [
{
find: ".displayName=\"MaskedLinkStore\"",
replacement: {
match: /\.isTrustedDomain=function\(.\){return.+?};/,
replace: ".isTrustedDomain=function(){return true};"
}
},
{
find: "\"github.com\":new RegExp(\"\\\\/releases\\\\S*\\\\/download\"),",
replacement: {
match: /const o=JSON.parse\('\[.+?'\)/,
replace: "const o=[]"
}
}
]
});

View file

@ -36,7 +36,7 @@ export default definePlugin({
replacement: { replacement: {
match: /uploadFiles:(.{1,2}),/, match: /uploadFiles:(.{1,2}),/,
replace: replace:
"uploadFiles:(...args)=>(args[0].uploads.forEach(f=>f.filename=Vencord.Plugins.plugins.AnonymiseFileNames.anonymise(f.filename)),$1(...args)),", "uploadFiles:(...args)=>(args[0].uploads.forEach(f=>f.filename=$self.anonymise(f.filename)),$1(...args)),",
}, },
}, },
], ],

View file

@ -73,7 +73,7 @@ export default definePlugin({
replace: (_, imageMap, badge) => `src: ${badge}.image ?? ${imageMap}[${badge}.key], ...${badge}.props,` replace: (_, imageMap, badge) => `src: ${badge}.image ?? ${imageMap}[${badge}.key], ...${badge}.props,`
}, },
{ {
match: /spacing:(\d{1,2}),children:(.{1,40}(.{1,2})\.jsx.+(.{1,2})\.onClick.+\)})},/, match: /spacing:(\d{1,2}),children:(.{1,40}(\i)\.jsx.+?(\i)\.onClick.+?\)})},/,
// if the badge provides it's own component, render that instead of an image // if the badge provides it's own component, render that instead of an image
// the badge also includes info about the user that has it (type BadgeUserArgs), which is why it's passed as props // the badge also includes info about the user that has it (type BadgeUserArgs), which is why it's passed as props
replace: (_, s, origBadgeComponent, React, badge) => replace: (_, s, origBadgeComponent, React, badge) =>

View file

@ -50,10 +50,10 @@ export default definePlugin({
}, },
// Show plugin name instead of "Built-In" // Show plugin name instead of "Built-In"
{ {
find: "().source,children", find: ".source,children",
replacement: { replacement: {
// ...children: p?.name // ...children: p?.name
match: /(?<=:(.{1,3})\.displayDescription\}.{0,200}\(\)\.source,children:)[^}]+/, match: /(?<=:(.{1,3})\.displayDescription\}.{0,200}\.source,children:)[^}]+/,
replace: "$1.plugin||($&)" replace: "$1.plugin||($&)"
} }
} }

View file

@ -25,9 +25,9 @@ export default definePlugin({
authors: [Devs.Cyn], authors: [Devs.Cyn],
patches: [ patches: [
{ {
find: "_messageAttachmentToEmbedMedia", find: ".Messages.REMOVE_ATTACHMENT_BODY",
replacement: { replacement: {
match: /(\(\)\.container\)?,children:)(\[[^\]]+\])(}\)\};return)/, match: /(.container\)?,children:)(\[[^\]]+\])(}\)\};return)/,
replace: (_, pre, accessories, post) => replace: (_, pre, accessories, post) =>
`${pre}Vencord.Api.MessageAccessories._modifyAccessories(${accessories},this.props)${post}`, `${pre}Vencord.Api.MessageAccessories._modifyAccessories(${accessories},this.props)${post}`,
}, },

View file

@ -27,7 +27,7 @@ export default definePlugin({
{ {
find: ".withMentionPrefix", find: ".withMentionPrefix",
replacement: { replacement: {
match: /(\(\).roleDot.{10,50}{children:.{1,2})}\)/, match: /(.roleDot.{10,50}{children:.{1,2})}\)/,
replace: "$1.concat(Vencord.Api.MessageDecorations.__addDecorationsToMessage(arguments[0]))})" replace: "$1.concat(Vencord.Api.MessageDecorations.__addDecorationsToMessage(arguments[0]))})"
} }
} }

View file

@ -22,12 +22,17 @@ import definePlugin from "@utils/types";
export default definePlugin({ export default definePlugin({
name: "MessagePopoverAPI", name: "MessagePopoverAPI",
description: "API to add buttons to message popovers.", description: "API to add buttons to message popovers.",
authors: [Devs.KingFish], authors: [Devs.KingFish, Devs.Ven],
patches: [{ patches: [{
find: "Messages.MESSAGE_UTILITIES_A11Y_LABEL", find: "Messages.MESSAGE_UTILITIES_A11Y_LABEL",
replacement: { replacement: {
match: /(message:(.).{0,100}Fragment,\{children:\[)(.{0,90}renderPopout:.{0,200}message_reaction_emoji_picker.+?return (.{1,3})\(.{0,30}"add-reaction")/, // foo && !bar ? createElement(blah,...makeElement(addReactionData))
replace: "$1...Vencord.Api.MessagePopover._buildPopoverElements($2,$4),$3" match: /(\i&&!\i)\?\(0,\i\.jsxs?\)\(.{0,20}renderPopout:.{0,300}?(\i)\(.{3,20}\{key:"add-reaction".+?\}/,
replace: (m, bools, makeElement) => {
const msg = m.match(/message:(.{1,3}),/)?.[1];
if (!msg) throw new Error("Could not find message variable");
return `...(${bools}?Vencord.Api.MessagePopover._buildPopoverElements(${msg},${makeElement}):[]),${m}`;
}
} }
}], }],
}); });

View file

@ -34,8 +34,8 @@ export default definePlugin({
";if(Vencord.Api.Notices.currentNotice)return false$&" ";if(Vencord.Api.Notices.currentNotice)return false$&"
}, },
{ {
match: /(?<=NOTICE_DISMISS:function.+?){(?=if\(null==(.+?)\))/, match: /(?<=,NOTICE_DISMISS:function\(\i\){)(?=if\(null==(\i)\))/,
replace: '{if($1?.id=="VencordNotice")return ($1=null,Vencord.Api.Notices.nextNotice(),true);' replace: 'if($1?.id=="VencordNotice")return($1=null,Vencord.Api.Notices.nextNotice(),true);'
} }
] ]
} }

View file

@ -31,7 +31,7 @@ export default definePlugin({
replacement: { replacement: {
match: /(return.{0,10}\.jsx.{0,50}isWindowFocused)/, match: /(return.{0,10}\.jsx.{0,50}isWindowFocused)/,
replace: replace:
"Vencord.Plugins.plugins.BetterGifAltText.altify(e);$1", "$self.altify(e);$1",
}, },
}, },
{ {
@ -39,7 +39,7 @@ export default definePlugin({
replacement: { replacement: {
match: /(?<==(.{1,3})\.alt.{0,20})\?.{0,5}\.Messages\.GIF/, match: /(?<==(.{1,3})\.alt.{0,20})\?.{0,5}\.Messages\.GIF/,
replace: replace:
"?($1.alt='GIF',Vencord.Plugins.plugins.BetterGifAltText.altify($1))", "?($1.alt='GIF',$self.altify($1))",
}, },
}, },
], ],

View file

@ -33,7 +33,7 @@ export default definePlugin({
find: "M0 4C0 1.79086 1.79086 0 4 0H16C18.2091 0 20 1.79086 20 4V16C20 18.2091 18.2091 20 16 20H4C1.79086 20 0 18.2091 0 16V4Z", find: "M0 4C0 1.79086 1.79086 0 4 0H16C18.2091 0 20 1.79086 20 4V16C20 18.2091 18.2091 20 16 20H4C1.79086 20 0 18.2091 0 16V4Z",
replacement: { replacement: {
match: /viewBox:"0 0 20 20"/, match: /viewBox:"0 0 20 20"/,
replace: "$&,onClick:()=>Vencord.Plugins.plugins.BetterRoleDot.copyToClipBoard(e.color),style:{cursor:'pointer'}", replace: "$&,onClick:()=>$self.copyToClipBoard(e.color),style:{cursor:'pointer'}",
}, },
}, },
{ {
@ -41,7 +41,7 @@ export default definePlugin({
all: true, all: true,
predicate: () => Settings.plugins.BetterRoleDot.bothStyles, predicate: () => Settings.plugins.BetterRoleDot.bothStyles,
replacement: { replacement: {
match: /"(?:username|dot)"===\w\b/g, match: /"(?:username|dot)"===\w(?!\.\w)/g,
replace: "true", replace: "true",
}, },
}, },

View file

@ -43,12 +43,12 @@ export default definePlugin({
patches: [ patches: [
{ {
find: "().embedWrapper,embed", find: ".embedWrapper,embed",
replacement: [{ replacement: [{
match: /(\.renderEmbed=.+?(.)=.\.props)(.+?\(\)\.embedWrapper)/g, match: /(\.renderEmbed=.+?(.)=.\.props)(.+?\.embedWrapper)/g,
replace: "$1,vcProps=$2$3+(vcProps.channel.nsfw?' vc-nsfw-img':'')" replace: "$1,vcProps=$2$3+(vcProps.channel.nsfw?' vc-nsfw-img':'')"
}, { }, {
match: /(\.renderAttachments=.+?(.)=this\.props)(.+?\(\)\.embedWrapper)/g, match: /(\.renderAttachments=.+?(.)=this\.props)(.+?\.embedWrapper)/g,
replace: "$1,vcProps=$2$3+(vcProps.channel.nsfw?' vc-nsfw-img':'')" replace: "$1,vcProps=$2$3+(vcProps.channel.nsfw?' vc-nsfw-img':'')"
}] }]
} }

View file

@ -74,8 +74,8 @@ export default definePlugin({
patches: [{ patches: [{
find: ".renderConnectionStatus=", find: ".renderConnectionStatus=",
replacement: { replacement: {
match: /(?<=renderConnectionStatus=.+\(\)\.channel,children:)\w/, match: /(?<=renderConnectionStatus=.+\.channel,children:)\w/,
replace: "[$&, Vencord.Plugins.plugins.CallTimer.renderTimer(this.props.channel.id)]" replace: "[$&, $self.renderTimer(this.props.channel.id)]"
} }
}], }],
renderTimer(channelId: string) { renderTimer(channelId: string) {

View file

@ -0,0 +1,37 @@
/*
* 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 { Devs } from "@utils/constants";
import definePlugin from "@utils/types";
export default definePlugin({
name: "ColorSighted",
description: "Removes the colorblind-friendly icons from statuses, just like 2015-2017 Discord",
authors: [Devs.lewisakura],
patches: [
{
find: "Masks.STATUS_ONLINE",
replacement: {
// we can use global replacement here - these are specific to the status icons and are used nowhere else,
// so it keeps the patch and plugin small and simple
match: /Masks\.STATUS_(?:IDLE|DND|STREAMING|OFFLINE)/g,
replace: "Masks.STATUS_ONLINE"
}
}
]
});

View file

@ -99,7 +99,7 @@ export default definePlugin({
const newName = video.name.replace(/\.mp4$/i, ".corrupt.mp4"); const newName = video.name.replace(/\.mp4$/i, ".corrupt.mp4");
const promptToUpload = findByCode("UPLOAD_FILE_LIMIT_ERROR"); const promptToUpload = findByCode("UPLOAD_FILE_LIMIT_ERROR");
const file = new File([buf], newName, { type: "video/mp4" }); const file = new File([buf], newName, { type: "video/mp4" });
setImmediate(() => promptToUpload([file], ctx.channel, DRAFT_TYPE)); setTimeout(() => promptToUpload([file], ctx.channel, DRAFT_TYPE), 10);
} }
}] }]
}); });

251
src/plugins/customRPC.tsx Normal file
View file

@ -0,0 +1,251 @@
/*
* 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 { Link } from "@components/Link";
import { Devs } from "@utils/constants";
import { useAwaiter } from "@utils/misc";
import definePlugin, { OptionType } from "@utils/types";
import { filters, findByCodeLazy, findByPropsLazy, mapMangledModuleLazy } from "@webpack";
import {
FluxDispatcher,
Forms,
GuildStore,
React,
SelectedChannelStore,
SelectedGuildStore,
UserStore
} from "@webpack/common";
const ActivityComponent = findByCodeLazy("onOpenGameProfile");
const ActivityClassName = findByPropsLazy("activity", "buttonColor");
const Colors = findByPropsLazy("profileColors");
// START yoinked from lastfm.tsx
const assetManager = mapMangledModuleLazy(
"getAssetImage: size must === [number, number] for Twitch",
{
getAsset: filters.byCode("apply("),
}
);
async function getApplicationAsset(key: string): Promise<string> {
return (await assetManager.getAsset(settings.store.appID, [key, undefined]))[0];
}
interface ActivityAssets {
large_image?: string;
large_text?: string;
small_image?: string;
small_text?: string;
}
interface Activity {
state: string;
details?: string;
timestamps?: {
start?: Number;
end?: Number;
};
assets?: ActivityAssets;
buttons?: Array<string>;
name: string;
application_id: string;
metadata?: {
button_urls?: Array<string>;
};
type: ActivityType;
flags: Number;
}
enum ActivityType {
PLAYING = 0,
LISTENING = 2,
WATCHING = 3,
COMPETING = 5
}
// END
const strOpt = (description: string) => ({
type: OptionType.STRING,
description,
onChange: setRpc
}) as const;
const numOpt = (description: string) => ({
type: OptionType.NUMBER,
description,
onChange: setRpc
}) as const;
const choice = (label: string, value: any, _default?: Boolean) => ({
label,
value,
default: _default
}) as const;
const choiceOpt = (description: string, options) => ({
type: OptionType.SELECT,
description,
onChange: setRpc,
options
}) as const;
const settings = definePluginSettings({
appID: strOpt("The ID of the application for the rich presence."),
appName: strOpt("The name of the presence."),
details: strOpt("Line 1 of rich presence."),
state: strOpt("Line 2 of rich presence."),
type: choiceOpt("Type of presence", [
choice("Playing", ActivityType.PLAYING, true),
choice("Listening", ActivityType.LISTENING),
choice("Watching", ActivityType.WATCHING),
choice("Competing", ActivityType.COMPETING)
]),
startTime: numOpt("Unix Timestamp for beginning of activity."),
endTime: numOpt("Unix Timestamp for end of activity."),
imageBig: strOpt("Sets the big image to the specified image."),
imageBigTooltip: strOpt("Sets the tooltip text for the big image."),
imageSmall: strOpt("Sets the small image to the specified image."),
imageSmallTooltip: strOpt("Sets the tooltip text for the small image."),
buttonOneText: strOpt("The text for the first button"),
buttonOneURL: strOpt("The URL for the first button"),
buttonTwoText: strOpt("The text for the second button"),
buttonTwoURL: strOpt("The URL for the second button")
});
async function createActivity(): Promise<Activity | undefined> {
const {
appID,
appName,
details,
state,
type,
startTime,
endTime,
imageBig,
imageBigTooltip,
imageSmall,
imageSmallTooltip,
buttonOneText,
buttonOneURL,
buttonTwoText,
buttonTwoURL
} = settings.store;
if (!appName) return;
const activity: Activity = {
application_id: appID || "0",
name: appName,
state,
details,
type,
flags: 1 << 0,
};
if (startTime) {
activity.timestamps = {
start: startTime,
};
if (endTime) {
activity.timestamps.end = endTime;
}
}
if (buttonOneText) {
activity.buttons = [
buttonOneText,
buttonTwoText
].filter(Boolean);
activity.metadata = {
button_urls: [
buttonOneURL,
buttonTwoURL
].filter(Boolean)
};
}
if (imageBig) {
activity.assets = {
large_image: await getApplicationAsset(imageBig),
large_text: imageBigTooltip
};
}
if (imageSmall) {
activity.assets = {
...activity.assets,
small_image: await getApplicationAsset(imageSmall),
small_text: imageSmallTooltip
};
}
for (const k in activity) {
if (k === "type") continue; // without type, the presence is considered invalid.
const v = activity[k];
if (!v || v.length === 0)
delete activity[k];
}
// WHAT DO YOU WANT FROM ME
// eslint-disable-next-line consistent-return
return activity;
}
async function setRpc(disable?: Boolean) {
const activity: Activity | undefined = await createActivity();
FluxDispatcher.dispatch({
type: "LOCAL_ACTIVITY_UPDATE",
activity: !disable ? activity : {}
});
}
export default definePlugin({
name: "CustomRPC",
description: "Allows you to set a custom rich presence.",
authors: [Devs.captain],
start: setRpc,
stop: () => setRpc(true),
settings,
settingsAboutComponent: () => {
const activity = useAwaiter(createActivity);
return (
<>
<Forms.FormTitle tag="h2">NOTE:</Forms.FormTitle>
<Forms.FormText>
You will need to <Link href="https://discord.com/developers/applications">create an
application</Link> and
get its ID to use this plugin.
</Forms.FormText>
<Forms.FormDivider />
<div style={{ width: "284px" }} className={Colors.profileColors}>
{activity[0] && <ActivityComponent activity={activity[0]} className={ActivityClassName.activity} channelId={SelectedChannelStore.getChannelId()}
guild={GuildStore.getGuild(SelectedGuildStore.getLastSelectedGuildId())}
application={{ id: settings.store.appID }}
user={UserStore.getCurrentUser()} />}
</div>
</>
);
}
});

View file

@ -57,7 +57,7 @@ async function doClone(guildId: string, id: string, name: string, isAnimated: bo
reader.onload = () => { reader.onload = () => {
uploadEmoji({ uploadEmoji({
guildId, guildId,
name, name: name.split("~")[0],
image: reader.result image: reader.result
}).then(() => { }).then(() => {
Toasts.show({ Toasts.show({
@ -187,7 +187,7 @@ export default definePlugin({
find: "open-native-link", find: "open-native-link",
replacement: { replacement: {
match: /id:"open-native-link".{0,200}\(\{href:(.{0,3}),.{0,200}\},"open-native-link"\)/, match: /id:"open-native-link".{0,200}\(\{href:(.{0,3}),.{0,200}\},"open-native-link"\)/,
replace: "$&,Vencord.Plugins.plugins.EmoteCloner.makeMenu(arguments[2])" replace: "$&,$self.makeMenu(arguments[2])"
}, },
}, },

View file

@ -22,11 +22,25 @@ import { Devs } from "@utils/constants";
import { ApngDisposeOp, getGifEncoder, importApngJs } from "@utils/dependencies"; import { ApngDisposeOp, getGifEncoder, importApngJs } from "@utils/dependencies";
import definePlugin, { OptionType } from "@utils/types"; import definePlugin, { OptionType } from "@utils/types";
import { findByCodeLazy, findByPropsLazy } from "@webpack"; import { findByCodeLazy, findByPropsLazy } from "@webpack";
import { ChannelStore, UserStore } from "@webpack/common"; import { ChannelStore, PermissionStore, UserStore } from "@webpack/common";
const DRAFT_TYPE = 0; const DRAFT_TYPE = 0;
const promptToUpload = findByCodeLazy("UPLOAD_FILE_LIMIT_ERROR"); const promptToUpload = findByCodeLazy("UPLOAD_FILE_LIMIT_ERROR");
const USE_EXTERNAL_EMOJIS = 1n << 18n;
const USE_EXTERNAL_STICKERS = 1n << 37n;
enum EmojiIntentions {
REACTION = 0,
STATUS = 1,
COMMUNITY_CONTENT = 2,
CHAT = 3,
GUILD_STICKER_RELATED_EMOJI = 4,
GUILD_ROLE_BENEFIT_EMOJI = 5,
COMMUNITY_CONTENT_ONLY = 6,
SOUNDBOARD = 7
}
interface BaseSticker { interface BaseSticker {
available: boolean; available: boolean;
description: string; description: string;
@ -58,26 +72,39 @@ migratePluginSettings("FakeNitro", "NitroBypass");
export default definePlugin({ export default definePlugin({
name: "FakeNitro", name: "FakeNitro",
authors: [Devs.Arjix, Devs.D3SOX, Devs.Ven, Devs.obscurity], authors: [Devs.Arjix, Devs.D3SOX, Devs.Ven, Devs.obscurity, Devs.captain],
description: "Allows you to stream in nitro quality and send fake emojis/stickers.", description: "Allows you to stream in nitro quality, send fake emojis/stickers and use client themes.",
dependencies: ["MessageEventsAPI"], dependencies: ["MessageEventsAPI"],
patches: [ patches: [
{ {
find: "canUseAnimatedEmojis:function", find: ".PREMIUM_LOCKED;",
predicate: () => Settings.plugins.FakeNitro.enableEmojiBypass === true, predicate: () => Settings.plugins.FakeNitro.enableEmojiBypass === true,
replacement: [ replacement: [
"canUseAnimatedEmojis", {
"canUseEmojisEverywhere" match: /(?<=(?<intention>\i)=\i\.intention)/,
].map(func => { replace: ",fakeNitroIntention=$<intention>"
return { },
match: new RegExp(`${func}:function\\(.+?\\{`), {
replace: "$&return true;" match: /(?<=\.(?:canUseEmojisEverywhere|canUseAnimatedEmojis)\(\i)(?=\))/g,
}; replace: ",fakeNitroIntention"
}) },
{
match: /(?<=&&!\i&&)!(?<canUseExternal>\i)(?=\)return \i\.\i\.DISALLOW_EXTERNAL;)/,
replace: `(!$<canUseExternal>&&![${EmojiIntentions.CHAT},${EmojiIntentions.GUILD_STICKER_RELATED_EMOJI}].includes(fakeNitroIntention))`
}
]
}, },
{ {
find: "canUseAnimatedEmojis:function", find: "canUseAnimatedEmojis:function",
predicate: () => Settings.plugins.FakeNitro.enableEmojiBypass === true,
replacement: {
match: /(?<=(?:canUseEmojisEverywhere|canUseAnimatedEmojis):function\((?<user>\i))\){(?<premiumCheck>.+?\))/g,
replace: `,fakeNitroIntention){$<premiumCheck>||fakeNitroIntention===undefined||[${EmojiIntentions.CHAT},${EmojiIntentions.GUILD_STICKER_RELATED_EMOJI}].includes(fakeNitroIntention)`
}
},
{
find: "canUseStickersEverywhere:function",
predicate: () => Settings.plugins.FakeNitro.enableStickerBypass === true, predicate: () => Settings.plugins.FakeNitro.enableStickerBypass === true,
replacement: { replacement: {
match: /canUseStickersEverywhere:function\(.+?\{/, match: /canUseStickersEverywhere:function\(.+?\{/,
@ -93,7 +120,7 @@ export default definePlugin({
} }
}, },
{ {
find: "canUseAnimatedEmojis:function", find: "canStreamHighQuality:function",
predicate: () => Settings.plugins.FakeNitro.enableStreamQualityBypass === true, predicate: () => Settings.plugins.FakeNitro.enableStreamQualityBypass === true,
replacement: [ replacement: [
"canUseHighVideoUploadQuality", "canUseHighVideoUploadQuality",
@ -114,6 +141,13 @@ export default definePlugin({
replace: "" replace: ""
} }
}, },
{
find: "canUseClientThemes:function",
replacement: {
match: /(?<=canUseClientThemes:function\(\i\){)/,
replace: "return true;"
}
}
], ],
options: { options: {
@ -161,6 +195,22 @@ export default definePlugin({
return (UserStore.getCurrentUser().premiumType ?? 0) > 1; return (UserStore.getCurrentUser().premiumType ?? 0) > 1;
}, },
hasPermissionToUseExternalEmojis(channelId: string) {
const channel = ChannelStore.getChannel(channelId);
if (!channel || channel.isDM() || channel.isGroupDM() || channel.isMultiUserDM()) return true;
return PermissionStore.can(USE_EXTERNAL_EMOJIS, channel);
},
hasPermissionToUseExternalStickers(channelId: string) {
const channel = ChannelStore.getChannel(channelId);
if (!channel || channel.isDM() || channel.isGroupDM() || channel.isMultiUserDM()) return true;
return PermissionStore.can(USE_EXTERNAL_STICKERS, channel);
},
getStickerLink(stickerId: string) { getStickerLink(stickerId: string) {
return `https://media.discordapp.net/stickers/${stickerId}.png?size=${Settings.plugins.FakeNitro.stickerSize}`; return `https://media.discordapp.net/stickers/${stickerId}.png?size=${Settings.plugins.FakeNitro.stickerSize}`;
}, },
@ -245,7 +295,7 @@ export default definePlugin({
if (!sticker) if (!sticker)
break stickerBypass; break stickerBypass;
if (sticker.available !== false && (this.canUseStickers || (sticker as GuildSticker)?.guild_id === guildId)) if (sticker.available !== false && ((this.canUseStickers && this.hasPermissionToUseExternalStickers(channelId)) || (sticker as GuildSticker)?.guild_id === guildId))
break stickerBypass; break stickerBypass;
let link = this.getStickerLink(sticker.id); let link = this.getStickerLink(sticker.id);
@ -268,7 +318,7 @@ export default definePlugin({
} }
} }
if (!this.canUseEmotes && settings.enableEmojiBypass) { if ((!this.canUseEmotes || !this.hasPermissionToUseExternalEmojis(channelId)) && settings.enableEmojiBypass) {
for (const emoji of messageObj.validNonShortcutEmojis) { for (const emoji of messageObj.validNonShortcutEmojis) {
if (!emoji.require_colons) continue; if (!emoji.require_colons) continue;
if (emoji.guildId === guildId && !emoji.animated) continue; if (emoji.guildId === guildId && !emoji.animated) continue;
@ -284,22 +334,22 @@ export default definePlugin({
return { cancel: false }; return { cancel: false };
}); });
if (!this.canUseEmotes && settings.enableEmojiBypass) { this.preEdit = addPreEditListener((channelId, __, messageObj) => {
this.preEdit = addPreEditListener((_, __, messageObj) => { if (this.canUseEmotes && this.hasPermissionToUseExternalEmojis(channelId)) return;
const { guildId } = this;
for (const [emojiStr, _, emojiId] of messageObj.content.matchAll(/(?<!\\)<a?:(\w+):(\d+)>/ig)) { const { guildId } = this;
const emoji = EmojiStore.getCustomEmojiById(emojiId);
if (emoji == null || (emoji.guildId === guildId && !emoji.animated)) continue;
if (!emoji.require_colons) continue;
const url = emoji.url.replace(/\?size=\d+/, `?size=${Settings.plugins.FakeNitro.emojiSize}`); for (const [emojiStr, _, emojiId] of messageObj.content.matchAll(/(?<!\\)<a?:(\w+):(\d+)>/ig)) {
messageObj.content = messageObj.content.replace(emojiStr, (match, offset, origStr) => { const emoji = EmojiStore.getCustomEmojiById(emojiId);
return `${getWordBoundary(origStr, offset - 1)}${url}${getWordBoundary(origStr, offset + match.length)}`; if (emoji == null || (emoji.guildId === guildId && !emoji.animated)) continue;
}); if (!emoji.require_colons) continue;
}
}); const url = emoji.url.replace(/\?size=\d+/, `?size=${Settings.plugins.FakeNitro.emojiSize}`);
} messageObj.content = messageObj.content.replace(emojiStr, (match, offset, origStr) => {
return `${getWordBoundary(origStr, offset - 1)}${url}${getWordBoundary(origStr, offset + match.length)}`;
});
}
});
}, },
stop() { stop() {

View file

@ -18,10 +18,7 @@
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import definePlugin from "@utils/types"; import definePlugin from "@utils/types";
import { waitFor } from "@webpack"; import { GuildStore } from "@webpack/common";
let GuildStore;
waitFor(["getGuild"], m => GuildStore = m);
export default definePlugin({ export default definePlugin({
name: "ForceOwnerCrown", name: "ForceOwnerCrown",
@ -33,7 +30,7 @@ export default definePlugin({
find: ".renderOwner=", find: ".renderOwner=",
replacement: { replacement: {
match: /isOwner;return null!=(\w+)?&&/g, match: /isOwner;return null!=(\w+)?&&/g,
replace: "isOwner;if(Vencord.Plugins.plugins.ForceOwnerCrown.isGuildOwner(this.props)){$1=true;}return null!=$1&&" replace: "isOwner;if($self.isGuildOwner(this.props)){$1=true;}return null!=$1&&"
} }
}, },
], ],

View file

@ -35,7 +35,7 @@ interface IgnoredActivity {
} }
const RegisteredGamesClasses = findByPropsLazy("overlayToggleIconOff", "overlayToggleIconOn"); const RegisteredGamesClasses = findByPropsLazy("overlayToggleIconOff", "overlayToggleIconOn");
const PreviewBadgeClasses = findByPropsLazy("previewBadge", "previewBadgeIcon"); const TryItOutClasses = findByPropsLazy("tryItOutBadge", "tryItOutBadgeIcon");
const BaseShapeRoundClasses = findByPropsLazy("baseShapeRound", "baseShapeRoundLeft", "baseShapeRoundRight"); const BaseShapeRoundClasses = findByPropsLazy("baseShapeRound", "baseShapeRoundLeft", "baseShapeRoundRight");
const RunningGameStore = findByPropsLazy("getRunningGames", "getGamesSeen"); const RunningGameStore = findByPropsLazy("getRunningGames", "getGamesSeen");
@ -116,7 +116,7 @@ function ToggleActivityComponent({ activity }: { activity: IgnoredActivity; }) {
function ToggleActivityComponentWithBackground({ activity }: { activity: IgnoredActivity; }) { function ToggleActivityComponentWithBackground({ activity }: { activity: IgnoredActivity; }) {
return ( return (
<div <div
className={`${PreviewBadgeClasses.previewBadge} ${BaseShapeRoundClasses.baseShapeRound}`} className={`${TryItOutClasses.tryItOutBadge} ${BaseShapeRoundClasses.baseShapeRound}`}
style={{ padding: "0 2px" }} style={{ padding: "0 2px" }}
> >
<ToggleActivityComponent activity={activity} /> <ToggleActivityComponent activity={activity} />
@ -143,22 +143,22 @@ export default definePlugin({
authors: [Devs.Nuckyz], authors: [Devs.Nuckyz],
description: "Ignore certain activities (like games and actual activities) from showing up on your status. You can configure which ones are ignored from the Registered Games and Activities tabs.", description: "Ignore certain activities (like games and actual activities) from showing up on your status. You can configure which ones are ignored from the Registered Games and Activities tabs.",
patches: [{ patches: [{
find: ".Messages.SETTINGS_GAMES_OVERLAY_ON", find: ".Messages.SETTINGS_GAMES_TOGGLE_OVERLAY",
replacement: { replacement: {
match: /(this.renderLastPlayed\(\)]}\),this.renderOverlayToggle\(\))/, match: /var .=(?<props>.)\.overlay.+?"aria-label":.\..\.Messages\.SETTINGS_GAMES_TOGGLE_OVERLAY.+?}}\)/,
replace: "$1,Vencord.Plugins.plugins.IgnoreActivities.renderToggleGameActivityButton(this.props)" replace: "$&,$self.renderToggleGameActivityButton($<props>)"
} }
}, { }, {
find: ".Messages.NEW,name", find: ".overlayBadge",
replacement: { replacement: {
match: /\(\)\.badgeContainer.+?.\?\(0,.\.jsx\)\(.{1,2},{name:(?<props>.)\.name}\):null/, match: /.badgeContainer.+?.\?\(0,.\.jsx\)\(.{1,2},{name:(?<props>.)\.name}\):null/,
replace: "$&,Vencord.Plugins.plugins.IgnoreActivities.renderToggleActivityButton($<props>)" replace: "$&,$self.renderToggleActivityButton($<props>)"
} }
}, { }, {
find: '.displayName="LocalActivityStore"', find: '.displayName="LocalActivityStore"',
replacement: { replacement: {
match: /((.)\.push\(.\({type:.\..{1,3}\.LISTENING.+?;)/, match: /(?<activities>.)\.push\(.\({type:.\..{1,3}\.LISTENING.+?\)\)/,
replace: "$1$2=$2.filter(Vencord.Plugins.plugins.IgnoreActivities.isActivityEnabled);" replace: "$&;$<activities>=$<activities>.filter($self.isActivityNotIgnored);"
} }
}], }],
@ -189,12 +189,10 @@ export default definePlugin({
} }
}, },
renderToggleGameActivityButton(props: { game: { id?: string; exePath: string; } | null; }) { renderToggleGameActivityButton(props: { id?: string; exePath: string; }) {
if (!props.game) return (null);
return ( return (
<ErrorBoundary noop> <ErrorBoundary noop>
<ToggleActivityComponent activity={{ id: props.game.id ?? props.game.exePath, type: ActivitiesTypes.Game }} /> <ToggleActivityComponent activity={{ id: props.id ?? props.exePath, type: ActivitiesTypes.Game }} />
</ErrorBoundary> </ErrorBoundary>
); );
}, },
@ -207,7 +205,7 @@ export default definePlugin({
); );
}, },
isActivityEnabled(props: { type: number; application_id?: string; name?: string; }) { isActivityNotIgnored(props: { type: number; application_id?: string; name?: string; }) {
if (props.type === 0) { if (props.type === 0) {
if (props.application_id !== undefined) return !ignoredActivitiesCache.has(props.application_id); if (props.application_id !== undefined) return !ignoredActivitiesCache.has(props.application_id);
else { else {

View file

@ -60,7 +60,16 @@ for (const p of pluginsValues) {
}); });
} }
for (const p of pluginsValues) for (const p of pluginsValues) {
if (p.settings) {
p.settings.pluginName = p.name;
p.options ??= {};
for (const [name, def] of Object.entries(p.settings.def)) {
const checks = p.settings.checks?.[name];
p.options[name] = { ...def, ...checks };
}
}
if (p.patches && isPluginEnabled(p.name)) { if (p.patches && isPluginEnabled(p.name)) {
for (const patch of p.patches) { for (const patch of p.patches) {
patch.plugin = p.name; patch.plugin = p.name;
@ -69,6 +78,7 @@ for (const p of pluginsValues)
patches.push(patch); patches.push(patch);
} }
} }
}
export const startAllPlugins = traceFunction("startAllPlugins", function startAllPlugins() { export const startAllPlugins = traceFunction("startAllPlugins", function startAllPlugins() {
for (const name in Plugins) for (const name in Plugins)

View file

@ -0,0 +1,77 @@
/*
* 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 {
ModalContent,
ModalFooter,
ModalHeader,
ModalRoot,
openModal,
} from "@utils/modal";
import { Button, Forms, React, TextInput } from "@webpack/common";
import { decrypt } from "../index";
export function DecModal(props: any) {
const secret: string = props?.message?.content;
const [password, setPassword] = React.useState("password");
return (
<ModalRoot {...props}>
<ModalHeader>
<Forms.FormTitle tag="h4">Decrypt Message</Forms.FormTitle>
</ModalHeader>
<ModalContent>
<Forms.FormTitle tag="h5" style={{ marginTop: "10px" }}>Secret</Forms.FormTitle>
<TextInput defaultValue={secret} disabled={true}></TextInput>
<Forms.FormTitle tag="h5">Password</Forms.FormTitle>
<TextInput
style={{ marginBottom: "20px" }}
onChange={setPassword}
/>
</ModalContent>
<ModalFooter>
<Button
color={Button.Colors.GREEN}
onClick={() => {
const toSend = decrypt(secret, password, true);
if (!toSend || !props?.message) return;
// @ts-expect-error
Vencord.Plugins.plugins.InvisibleChat.buildEmbed(props?.message, toSend);
props.onClose();
}}>
Decrypt
</Button>
<Button
color={Button.Colors.TRANSPARENT}
look={Button.Looks.LINK}
style={{ left: 15, position: "absolute" }}
onClick={props.onClose}
>
Cancel
</Button>
</ModalFooter>
</ModalRoot>
);
}
export function buildDecModal(msg: any): any {
openModal((props: any) => <DecModal {...props} {...msg} />);
}

View file

@ -0,0 +1,116 @@
/*
* 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 {
ModalContent,
ModalFooter,
ModalHeader,
ModalProps,
ModalRoot,
openModal,
} from "@utils/modal";
import { findLazy } from "@webpack";
import { Button, Forms, React, Switch, TextInput } from "@webpack/common";
import { encrypt } from "../index";
const ComponentDispatch = findLazy(m => m.emitter?._events?.INSERT_TEXT);
function EncModal(props: ModalProps) {
const [secret, setSecret] = React.useState("");
const [cover, setCover] = React.useState("");
const [password, setPassword] = React.useState("password");
const [noCover, setNoCover] = React.useState(false);
const isValid = secret && (noCover || (cover && /\w \w/.test(cover)));
return (
<ModalRoot {...props}>
<ModalHeader>
<Forms.FormTitle tag="h4">Encrypt Message</Forms.FormTitle>
</ModalHeader>
<ModalContent>
<Forms.FormTitle tag="h5" style={{ marginTop: "10px" }}>Secret</Forms.FormTitle>
<TextInput
onChange={(e: string) => {
setSecret(e);
}}
/>
<Forms.FormTitle tag="h5" style={{ marginTop: "10px" }}>Cover (2 or more Words!!)</Forms.FormTitle>
<TextInput
disabled={noCover}
onChange={(e: string) => {
setCover(e);
}}
/>
<Forms.FormTitle tag="h5" style={{ marginTop: "10px" }}>Password</Forms.FormTitle>
<TextInput
style={{ marginBottom: "20px" }}
defaultValue={"password"}
onChange={(e: string) => {
setPassword(e);
}}
/>
<Switch
value={noCover}
onChange={(e: boolean) => {
setNoCover(e);
}}
>
Don't use a Cover
</Switch>
</ModalContent>
<ModalFooter>
<Button
color={Button.Colors.GREEN}
disabled={!isValid}
onClick={() => {
if (!isValid) return;
const encrypted = encrypt(secret, password, noCover ? "d d" : cover);
const toSend = noCover ? encrypted.replaceAll("d", "") : encrypted;
if (!toSend) return;
ComponentDispatch.dispatchToLastSubscribed("INSERT_TEXT", {
rawText: `${toSend}`
});
props.onClose();
}}
>
Send
</Button>
<Button
color={Button.Colors.TRANSPARENT}
look={Button.Looks.LINK}
style={{ left: 15, position: "absolute" }}
onClick={() => {
props.onClose();
}}
>
Cancel
</Button>
</ModalFooter>
</ModalRoot>
);
}
export function buildEncModal(): any {
openModal(props => <EncModal {...props} />);
}

View file

@ -0,0 +1,257 @@
/*
* 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 { addButton, removeButton } from "@api/MessagePopover";
import { definePluginSettings } from "@api/settings";
import ErrorBoundary from "@components/ErrorBoundary";
import { Devs } from "@utils/constants";
import { getStegCloak } from "@utils/dependencies";
import definePlugin, { OptionType } from "@utils/types";
import { Button, ButtonLooks, ButtonWrapperClasses, ChannelStore, FluxDispatcher, Tooltip } from "@webpack/common";
import { Message } from "discord-types/general";
import { buildDecModal } from "./components/DecryptionModal";
import { buildEncModal } from "./components/EncryptionModal";
let steggo: any;
function PopOverIcon() {
return (
<svg
fill="var(--header-secondary)"
width={24} height={24}
viewBox={"0 0 64 64"}
>
<path d="M 32 9 C 24.832 9 19 14.832 19 22 L 19 27.347656 C 16.670659 28.171862 15 30.388126 15 33 L 15 49 C 15 52.314 17.686 55 21 55 L 43 55 C 46.314 55 49 52.314 49 49 L 49 33 C 49 30.388126 47.329341 28.171862 45 27.347656 L 45 22 C 45 14.832 39.168 9 32 9 z M 32 13 C 36.963 13 41 17.038 41 22 L 41 27 L 23 27 L 23 22 C 23 17.038 27.037 13 32 13 z" />
</svg>
);
}
function Indicator() {
return (
<Tooltip text="This message has a hidden message! (InvisibleChat)">
{({ onMouseEnter, onMouseLeave }) => (
<img
aria-label="Hidden Message Indicator (InvisibleChat)"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
src="https://github.com/SammCheese/invisible-chat/raw/NewReplugged/src/assets/lock.png"
width={20}
height={20}
style={{ transform: "translateY(4p)", paddingInline: 4 }}
/>
)}
</Tooltip>
);
}
function ChatBarIcon() {
return (
<Tooltip text="Encrypt Message">
{({ onMouseEnter, onMouseLeave }) => (
// size="" = Button.Sizes.NONE
/*
many themes set "> button" to display: none, as the gift button is
the only directly descending button (all the other elements are divs.)
Thus, wrap in a div here to avoid getting hidden by that.
flex is for some reason necessary as otherwise the button goes flying off
*/
<div style={{ display: "flex" }}>
<Button
aria-haspopup="dialog"
aria-label="Encrypt Message"
size=""
look={ButtonLooks.BLANK}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
innerClassName={ButtonWrapperClasses.button}
onClick={() => buildEncModal()}
style={{ marginRight: "2px" }}
>
<div className={ButtonWrapperClasses.buttonWrapper}>
<svg
aria-hidden
role="img"
width="24"
height="24"
viewBox={"0 0 64 64"}
style={{ scale: "1.1" }}
>
<path fill="currentColor" d="M 32 9 C 24.832 9 19 14.832 19 22 L 19 27.347656 C 16.670659 28.171862 15 30.388126 15 33 L 15 49 C 15 52.314 17.686 55 21 55 L 43 55 C 46.314 55 49 52.314 49 49 L 49 33 C 49 30.388126 47.329341 28.171862 45 27.347656 L 45 22 C 45 14.832 39.168 9 32 9 z M 32 13 C 36.963 13 41 17.038 41 22 L 41 27 L 23 27 L 23 22 C 23 17.038 27.037 13 32 13 z" />
</svg>
</div>
</Button>
</div>
)
}
</Tooltip >
);
}
const settings = definePluginSettings({
savedPasswords: {
type: OptionType.STRING,
default: "password, Password",
description: "Saved Passwords (Seperated with a , )"
}
});
export default definePlugin({
name: "InvisibleChat",
description: "Encrypt your Messages in a non-suspicious way! This plugin makes requests to >>https://embed.sammcheese.net<< to provide embeds to decrypted links!",
authors: [Devs.SammCheese],
patches: [
{
// Indicator
find: ".Messages.MESSAGE_EDITED,",
replacement: {
match: /var .,.,.=(.)\.className,.=.\.message,.=.\.children,.=.\.content,.=.\.onUpdate/gm,
replace: "try {$1 && $self.INV_REGEX.test($1.content[0]) ? $1.content.push($self.indicator()) : null } catch {};$&"
}
},
{
find: ".activeCommandOption",
replacement: {
match: /.=.\.activeCommand,.=.\.activeCommandOption,.{1,133}(.)=\[\];/,
replace: "$&;$1.push($self.chatBarIcon());",
}
},
],
EMBED_API_URL: "https://embed.sammcheese.net",
INV_REGEX: new RegExp(/( \u200c|\u200d |[\u2060-\u2064])[^\u200b]/),
URL_REGEX: new RegExp(
/(http(s)?:\/\/.)?(www\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_+.~#?&//=]*)/,
),
settings,
async start() {
const { default: StegCloak } = await getStegCloak();
steggo = new StegCloak(true, false);
addButton("invDecrypt", message => {
return this.INV_REGEX.test(message?.content)
? {
label: "Decrypt Message",
icon: this.popOverIcon,
message: message,
channel: ChannelStore.getChannel(message.channel_id),
onClick: async () => {
await iteratePasswords(message).then((res: string | false) => {
if (res) return void this.buildEmbed(message, res);
return void buildDecModal({ message });
});
}
}
: null;
});
},
stop() {
removeButton("invDecrypt");
},
// Gets the Embed of a Link
async getEmbed(url: URL): Promise<Object | {}> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const options: RequestInit = {
signal: controller.signal,
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
url,
}),
};
// AWS hosted url to discord embed object
const rawRes = await fetch(this.EMBED_API_URL, options);
clearTimeout(timeout);
return await rawRes.json();
},
async buildEmbed(message: any, revealed: string): Promise<void> {
const urlCheck = revealed.match(this.URL_REGEX);
message.embeds.push({
type: "rich",
title: "Decrypted Message",
color: "0x45f5f5",
description: revealed,
footer: {
text: "Made with ❤️ by c0dine and Sammy!",
},
});
if (urlCheck?.length)
message.embeds.push(await this.getEmbed(new URL(urlCheck[0])));
this.updateMessage(message);
},
updateMessage: (message: any) => {
FluxDispatcher.dispatch({
type: "MESSAGE_UPDATE",
message,
});
},
chatBarIcon: ErrorBoundary.wrap(ChatBarIcon, { noop: true }),
popOverIcon: () => <PopOverIcon />,
indicator: ErrorBoundary.wrap(Indicator, { noop: true })
});
export function encrypt(secret: string, password: string, cover: string): string {
return steggo.hide(secret + "\u200b", password, cover);
}
export function decrypt(secret: string, password: string, removeIndicator: boolean): string {
const decrypted = steggo.reveal(secret, password);
return removeIndicator ? decrypted.replace("\u200b", "") : decrypted;
}
export function isCorrectPassword(result: string): boolean {
return result.endsWith("\u200b");
}
export async function iteratePasswords(message: Message): Promise<string | false> {
const passwords = settings.store.savedPasswords.split(",").map(s => s.trim());
if (!message?.content || !passwords?.length) return false;
let { content } = message;
// we use an extra variable so we dont have to edit the message content directly
if (/^\W/.test(message.content)) content = `d ${message.content}d`;
for (let i = 0; i < passwords.length; i++) {
const result = decrypt(content, passwords[i], false);
if (isCorrectPassword(result)) {
return result;
}
}
return false;
}

View file

@ -68,7 +68,7 @@ export default definePlugin({
find: ".LOADING_DID_YOU_KNOW", find: ".LOADING_DID_YOU_KNOW",
replacement: { replacement: {
match: /\._loadingText=.+?random\(.+?;/s, match: /\._loadingText=.+?random\(.+?;/s,
replace: "._loadingText=Vencord.Plugins.plugins.LoadingQuotes.quote;", replace: "._loadingText=$self.quote;",
}, },
}, },
], ],

View file

@ -56,7 +56,7 @@ function MemberCount() {
<div {...props}> <div {...props}>
<span <span
style={{ style={{
backgroundColor: "var(--status-green-600)", backgroundColor: "var(--green-360)",
width: "12px", width: "12px",
height: "12px", height: "12px",
borderRadius: "50%", borderRadius: "50%",
@ -64,7 +64,7 @@ function MemberCount() {
marginRight: "0.5em" marginRight: "0.5em"
}} }}
/> />
<span style={{ color: "var(--status-green-600)" }}>{online}</span> <span style={{ color: "var(--green-360)" }}>{online}</span>
</div> </div>
)} )}
</Tooltip> </Tooltip>
@ -76,13 +76,13 @@ function MemberCount() {
width: "6px", width: "6px",
height: "6px", height: "6px",
borderRadius: "50%", borderRadius: "50%",
border: "3px solid var(--status-grey-500)", border: "3px solid var(--primary-400)",
display: "inline-block", display: "inline-block",
marginRight: "0.5em", marginRight: "0.5em",
marginLeft: "1em" marginLeft: "1em"
}} }}
/> />
<span style={{ color: "var(--status-grey-500)" }}>{total}</span> <span style={{ color: "var(--primary-400)" }}>{total}</span>
</div> </div>
)} )}
</Tooltip> </Tooltip>
@ -99,7 +99,7 @@ export default definePlugin({
find: ".isSidebarVisible,", find: ".isSidebarVisible,",
replacement: { replacement: {
match: /(var (.)=.\.className.+?children):\[(.\.useMemo[^}]+"aria-multiselectable")/, match: /(var (.)=.\.className.+?children):\[(.\.useMemo[^}]+"aria-multiselectable")/,
replace: "$1:[$2.startsWith('members')?Vencord.Plugins.plugins.MemberCount.render():null,$3" replace: "$1:[$2.startsWith('members')?$self.render():null,$3"
} }
}], }],

View file

@ -1,6 +1,6 @@
/* /*
* Vencord, a modification for Discord's desktop app * Vencord, a modification for Discord's desktop app
* Copyright (c) 2022 Vendicated and contributors * Copyright (c) 2023 Vendicated and contributors
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -19,7 +19,7 @@
import { addClickListener, removeClickListener } from "@api/MessageEvents"; import { addClickListener, removeClickListener } from "@api/MessageEvents";
import { migratePluginSettings } from "@api/settings"; import { migratePluginSettings } from "@api/settings";
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import definePlugin from "@utils/types"; import definePlugin, { OptionType } from "@utils/types";
import { findByPropsLazy, findLazy } from "@webpack"; import { findByPropsLazy, findLazy } from "@webpack";
import { UserStore } from "@webpack/common"; import { UserStore } from "@webpack/common";
@ -35,6 +35,19 @@ export default definePlugin({
authors: [Devs.Ven], authors: [Devs.Ven],
dependencies: ["MessageEventsAPI"], dependencies: ["MessageEventsAPI"],
options: {
enableDeleteOnClick: {
type: OptionType.BOOLEAN,
description: "Enable delete on click",
default: true
},
enableDoubleClickToEdit: {
type: OptionType.BOOLEAN,
description: "Enable double click to edit",
default: true
}
},
start() { start() {
const MessageActions = findByPropsLazy("deleteMessage", "startEditMessage"); const MessageActions = findByPropsLazy("deleteMessage", "startEditMessage");
const PermissionStore = findByPropsLazy("can", "initialize"); const PermissionStore = findByPropsLazy("can", "initialize");
@ -47,11 +60,11 @@ export default definePlugin({
this.onClick = addClickListener((msg, chan, event) => { this.onClick = addClickListener((msg, chan, event) => {
const isMe = msg.author.id === UserStore.getCurrentUser().id; const isMe = msg.author.id === UserStore.getCurrentUser().id;
if (!isDeletePressed) { if (!isDeletePressed) {
if (isMe && event.detail >= 2 && !EditStore.isEditing(chan.id, msg.id)) { if (Vencord.Settings.plugins.MessageClickActions.enableDoubleClickToEdit && (isMe && event.detail >= 2 && !EditStore.isEditing(chan.id, msg.id))) {
MessageActions.startEditMessage(chan.id, msg.id, msg.content); MessageActions.startEditMessage(chan.id, msg.id, msg.content);
event.preventDefault(); event.preventDefault();
} }
} else if (isMe || PermissionStore.can(Permissions.MANAGE_MESSAGES, chan)) { } else if (Vencord.Settings.plugins.MessageClickActions.enableDeleteOnClick && (isMe || PermissionStore.can(Permissions.MANAGE_MESSAGES, chan))) {
MessageActions.deleteMessage(chan.id, msg.id); MessageActions.deleteMessage(chan.id, msg.id);
event.preventDefault(); event.preventDefault();
} }

View file

@ -44,7 +44,7 @@ let AutomodEmbed: React.ComponentType<any>,
Endpoints: Record<string, any>; Endpoints: Record<string, any>;
waitFor(["mle_AutomodEmbed"], m => (AutomodEmbed = m.mle_AutomodEmbed)); waitFor(["mle_AutomodEmbed"], m => (AutomodEmbed = m.mle_AutomodEmbed));
waitFor(filters.byCode("().inlineMediaEmbed"), m => Embed = m); waitFor(filters.byCode(".inlineMediaEmbed"), m => Embed = m);
waitFor(m => m.type?.toString()?.includes('["message","compact","className",'), m => ChannelMessage = m); waitFor(m => m.type?.toString()?.includes('["message","compact","className",'), m => ChannelMessage = m);
waitFor(["MESSAGE_CREATE_ATTACHMENT_UPLOAD"], _ => Endpoints = _); waitFor(["MESSAGE_CREATE_ATTACHMENT_UPLOAD"], _ => Endpoints = _);
const SearchResultClasses = findByPropsLazy("message", "searchResult"); const SearchResultClasses = findByPropsLazy("message", "searchResult");
@ -139,6 +139,16 @@ interface MessageEmbedProps {
guildID: string; guildID: string;
} }
function withEmbeddedBy(message: Message, embeddedBy: string[]) {
return new Proxy(message, {
get(_, prop) {
if (prop === "vencordEmbeddedBy") return embeddedBy;
// @ts-ignore ts so bad
return Reflect.get(...arguments);
}
});
}
export default definePlugin({ export default definePlugin({
name: "MessageLinkEmbeds", name: "MessageLinkEmbeds",
description: "Adds a preview to messages that link another message", description: "Adds a preview to messages that link another message",
@ -146,14 +156,13 @@ export default definePlugin({
dependencies: ["MessageAccessoriesAPI"], dependencies: ["MessageAccessoriesAPI"],
patches: [ patches: [
{ {
find: "().embedCard", find: ".embedCard",
replacement: [{ replacement: [{
match: /{"use strict";(.{0,10})\(\)=>(.{1,2})}\);/, match: /{"use strict";(.{0,10})\(\)=>(.{1,2})}\);/,
replace: '{"use strict";$1()=>$2,me:()=>messageEmbed});' replace: '{"use strict";$1()=>$2,me:()=>messageEmbed});'
}, { }, {
match: /function (.{1,2})\((.{1,2})\){var (.{1,2})=.{1,2}\.message,(.{1,2})=.{1,2}\.channel(.{0,300})\(\)\.embedCard(.{0,500})}\)}/, match: /function (.{1,2})\(.{1,2}\){var .{1,2}=.{1,2}\.message,.{1,2}=.{1,2}\.channel.{0,300}\.embedCard.{0,500}}\)}/,
replace: "function $1($2){var $3=$2.message,$4=$2.channel$5().embedCard$6})}\ replace: "$&;var messageEmbed={mle_AutomodEmbed:$1};"
var messageEmbed={mle_AutomodEmbed:$1};"
}] }]
} }
], ],
@ -195,19 +204,24 @@ var messageEmbed={mle_AutomodEmbed:$1};"
messageEmbedAccessory(props) { messageEmbedAccessory(props) {
const { message }: { message: Message; } = props; const { message }: { message: Message; } = props;
// @ts-ignore
const embeddedBy: string[] = message.vencordEmbeddedBy ?? [];
const accessories = [] as (JSX.Element | null)[]; const accessories = [] as (JSX.Element | null)[];
let match = null as RegExpMatchArray | null; let match = null as RegExpMatchArray | null;
while ((match = this.messageLinkRegex.exec(message.content!)) !== null) { while ((match = this.messageLinkRegex.exec(message.content!)) !== null) {
const [_, guildID, channelID, messageID] = match; const [_, guildID, channelID, messageID] = match;
if (embeddedBy.includes(messageID)) {
continue;
}
const linkedChannel = ChannelStore.getChannel(channelID); const linkedChannel = ChannelStore.getChannel(channelID);
if (!linkedChannel || (guildID !== "@me" && !PermissionStore.can(1024n /* view channel */, linkedChannel))) { if (!linkedChannel || (guildID !== "@me" && !PermissionStore.can(1024n /* view channel */, linkedChannel))) {
continue; continue;
} }
let linkedMessage = messageCache[messageID]?.message as Message; let linkedMessage = messageCache[messageID]?.message;
if (!linkedMessage) { if (!linkedMessage) {
linkedMessage ??= MessageStore.getMessage(channelID, messageID); linkedMessage ??= MessageStore.getMessage(channelID, messageID);
if (linkedMessage) messageCache[messageID] = { message: linkedMessage, fetched: true }; if (linkedMessage) messageCache[messageID] = { message: linkedMessage, fetched: true };
@ -224,7 +238,7 @@ var messageEmbed={mle_AutomodEmbed:$1};"
} }
} }
const messageProps: MessageEmbedProps = { const messageProps: MessageEmbedProps = {
message: linkedMessage, message: withEmbeddedBy(linkedMessage, [...embeddedBy, message.id]),
channel: linkedChannel, channel: linkedChannel,
guildID guildID
}; };
@ -268,7 +282,7 @@ var messageEmbed={mle_AutomodEmbed:$1};"
} }
}} }}
renderDescription={() => { renderDescription={() => {
return <div key={message.id} className={classNames.join(" ")} > return <div key={message.id} className={classNames.join(" ")}>
<ChannelMessage <ChannelMessage
id={`message-link-embeds-${message.id}`} id={`message-link-embeds-${message.id}`}
message={message} message={message}

View file

@ -0,0 +1,3 @@
.messagelogger-deleted {
background-color: rgba(240 71 71 / 15%);
}

View file

@ -0,0 +1,3 @@
.messagelogger-deleted div {
color: #f04747;
}

View file

@ -19,20 +19,23 @@
import "./messageLogger.css"; import "./messageLogger.css";
import { Settings } from "@api/settings"; import { Settings } from "@api/settings";
import { disableStyle, enableStyle } from "@api/Styles";
import ErrorBoundary from "@components/ErrorBoundary"; import ErrorBoundary from "@components/ErrorBoundary";
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import Logger from "@utils/Logger"; import Logger from "@utils/Logger";
import definePlugin, { OptionType } from "@utils/types"; import definePlugin, { OptionType } from "@utils/types";
import { findByPropsLazy } from "@webpack"; import { moment, Parser, Timestamp, UserStore } from "@webpack/common";
import { Parser, UserStore } from "@webpack/common";
function addDeleteStyleClass() { import overlayStyle from "./deleteStyleOverlay.css?managed";
import textStyle from "./deleteStyleText.css?managed";
function addDeleteStyle() {
if (Settings.plugins.MessageLogger.deleteStyle === "text") { if (Settings.plugins.MessageLogger.deleteStyle === "text") {
document.body.classList.remove("messagelogger-red-overlay"); enableStyle(textStyle);
document.body.classList.add("messagelogger-red-text"); disableStyle(overlayStyle);
} else { } else {
document.body.classList.remove("messagelogger-red-text"); disableStyle(textStyle);
document.body.classList.add("messagelogger-red-overlay"); enableStyle(overlayStyle);
} }
} }
@ -41,28 +44,21 @@ export default definePlugin({
description: "Temporarily logs deleted and edited messages.", description: "Temporarily logs deleted and edited messages.",
authors: [Devs.rushii, Devs.Ven], authors: [Devs.rushii, Devs.Ven],
timestampModule: null as any,
moment: null as Function | null,
start() { start() {
this.moment = findByPropsLazy("relativeTimeRounding", "relativeTimeThreshold"); addDeleteStyle();
this.timestampModule = findByPropsLazy("messageLogger_TimestampComponent");
addDeleteStyleClass();
}, },
stop() { stop() {
document.querySelectorAll(".messageLogger-deleted").forEach(e => e.remove()); document.querySelectorAll(".messagelogger-deleted").forEach(e => e.remove());
document.querySelectorAll(".messageLogger-edited").forEach(e => e.remove()); document.querySelectorAll(".messagelogger-edited").forEach(e => e.remove());
document.body.classList.remove("messagelogger-red-overlay"); document.body.classList.remove("messagelogger-red-overlay");
document.body.classList.remove("messagelogger-red-text"); document.body.classList.remove("messagelogger-red-text");
}, },
renderEdit(edit: { timestamp: any, content: string; }) { renderEdit(edit: { timestamp: any, content: string; }) {
const Timestamp = this.timestampModule.messageLogger_TimestampComponent;
return ( return (
<ErrorBoundary noop> <ErrorBoundary noop>
<div className="messageLogger-edited"> <div className="messagelogger-edited">
{Parser.parse(edit.content)} {Parser.parse(edit.content)}
<Timestamp <Timestamp
timestamp={edit.timestamp} timestamp={edit.timestamp}
@ -78,7 +74,7 @@ export default definePlugin({
makeEdit(newMessage: any, oldMessage: any): any { makeEdit(newMessage: any, oldMessage: any): any {
return { return {
timestamp: this.moment?.call(newMessage.edited_timestamp), timestamp: moment?.call(newMessage.edited_timestamp),
content: oldMessage.content content: oldMessage.content
}; };
}, },
@ -92,7 +88,7 @@ export default definePlugin({
{ label: "Red text", value: "text", default: true }, { label: "Red text", value: "text", default: true },
{ label: "Red overlay", value: "overlay" } { label: "Red overlay", value: "overlay" }
], ],
onChange: () => addDeleteStyleClass() onChange: () => addDeleteStyle()
}, },
ignoreBots: { ignoreBots: {
type: OptionType.BOOLEAN, type: OptionType.BOOLEAN,
@ -155,7 +151,7 @@ export default definePlugin({
replace: replace:
"MESSAGE_DELETE:function($1){" + "MESSAGE_DELETE:function($1){" +
" var cache = $2getOrCreate($1.channelId);" + " var cache = $2getOrCreate($1.channelId);" +
" cache = Vencord.Plugins.plugins.MessageLogger.handleDelete(cache, $1, false);" + " cache = $self.handleDelete(cache, $1, false);" +
" $2commit(cache);" + " $2commit(cache);" +
"}," "},"
}, },
@ -165,7 +161,7 @@ export default definePlugin({
replace: replace:
"MESSAGE_DELETE_BULK:function($1){" + "MESSAGE_DELETE_BULK:function($1){" +
" var cache = $2getOrCreate($1.channelId);" + " var cache = $2getOrCreate($1.channelId);" +
" cache = Vencord.Plugins.plugins.MessageLogger.handleDelete(cache, $1, true);" + " cache = $self.handleDelete(cache, $1, true);" +
" $2commit(cache);" + " $2commit(cache);" +
"}," "},"
}, },
@ -175,7 +171,7 @@ export default definePlugin({
replace: "$1" + replace: "$1" +
".update($3,m =>" + ".update($3,m =>" +
" $2.message.content !== m.editHistory?.[0]?.content && $2.message.content !== m.content ?" + " $2.message.content !== m.editHistory?.[0]?.content && $2.message.content !== m.content ?" +
" m.set('editHistory',[...(m.editHistory || []), Vencord.Plugins.plugins.MessageLogger.makeEdit($2.message, m)]) :" + " m.set('editHistory',[...(m.editHistory || []), $self.makeEdit($2.message, m)]) :" +
" m" + " m" +
")" + ")" +
".update($3" ".update($3"
@ -259,8 +255,8 @@ export default definePlugin({
replace: "$1,deleted=$2.attachment?.deleted," replace: "$1,deleted=$2.attachment?.deleted,"
}, },
{ {
match: /(hiddenSpoilers:\w,className:)/, match: /\["className","attachment","inlineMedia".+?className:/,
replace: "$1 (deleted ? 'messageLogger-deleted-attachment ' : '') +" replace: "$& (deleted ? 'messagelogger-deleted-attachment ' : '') +"
} }
] ]
}, },
@ -276,9 +272,9 @@ export default definePlugin({
replace: "var $1=$2.id,deleted=$2.message.deleted," replace: "var $1=$2.id,deleted=$2.message.deleted,"
}, },
{ {
// Append messageLogger-deleted to classNames if deleted // Append messagelogger-deleted to classNames if deleted
match: /\)\("li",\{(.+?),className:/, match: /\)\("li",\{(.+?),className:/,
replace: ")(\"li\",{$1,className:(deleted ? \"messageLogger-deleted \" : \"\")+" replace: ")(\"li\",{$1,className:(deleted ? \"messagelogger-deleted \" : \"\")+"
} }
] ]
}, },
@ -291,7 +287,7 @@ export default definePlugin({
{ {
// Render editHistory in the deepest div for message content // Render editHistory in the deepest div for message content
match: /(\)\("div",\{id:.+?children:\[)/, match: /(\)\("div",\{id:.+?children:\[)/,
replace: "$1 (arguments[0].message.editHistory.length > 0 ? arguments[0].message.editHistory.map(edit => Vencord.Plugins.plugins.MessageLogger.renderEdit(edit)) : null), " replace: "$1 (arguments[0].message.editHistory.length > 0 ? arguments[0].message.editHistory.map(edit => $self.renderEdit(edit)) : null), "
} }
] ]
}, },
@ -312,17 +308,6 @@ export default definePlugin({
] ]
}, },
{
// Message "(edited)" timestamp component
// Module 23552
find: "Messages.MESSAGE_EDITED_TIMESTAMP_A11Y_LABEL.format",
replacement: {
// Re-export the timestamp component under a findable name
match: /{(\w{1,2}:\(\)=>(\w{1,2}))}/,
replace: "{$1,messageLogger_TimestampComponent:()=>$2}"
}
},
{ {
// Message context base menu // Message context base menu
// Module 600300 // Module 600300

View file

@ -1,27 +1,20 @@
.messagelogger-red-overlay .messageLogger-deleted { .messagelogger-deleted [class^="buttons"] {
background-color: rgba(240, 71, 71, 0.15);
}
.messagelogger-red-text .messageLogger-deleted div {
color: #f04747;
}
.messageLogger-deleted [class^="buttons"] {
display: none; display: none;
} }
.messageLogger-deleted-attachment { .messagelogger-deleted-attachment {
filter: grayscale(1); filter: grayscale(1);
} }
.messageLogger-deleted-attachment:hover { .messagelogger-deleted-attachment:hover {
filter: grayscale(0); filter: grayscale(0);
transition: 250ms filter linear; transition: 250ms filter linear;
} }
.theme-dark .messageLogger-edited { .theme-dark .messagelogger-edited {
filter: brightness(80%); filter: brightness(80%);
} }
.theme-light .messageLogger-edited { .theme-light .messagelogger-edited {
opacity: 0.5; opacity: 0.5;
} }

View file

@ -43,7 +43,7 @@ export default definePlugin({
replacement: [ replacement: [
{ {
match: /(?<=MESSAGE_CREATE:function\((\w)\){var \w=\w\.channelId,\w=\w\.message,\w=\w\.isPushNotification,\w=\w\.\w\.getOrCreate\(\w\));/, match: /(?<=MESSAGE_CREATE:function\((\w)\){var \w=\w\.channelId,\w=\w\.message,\w=\w\.isPushNotification,\w=\w\.\w\.getOrCreate\(\w\));/,
replace: ";if(Vencord.Plugins.plugins.NoBlockedMessages.isBlocked(n))return;" replace: ";if($self.isBlocked(n))return;"
} }
] ]
} }

View file

@ -51,7 +51,7 @@ export default definePlugin({
replacement: { replacement: {
match: /CREATE_PENDING_REPLY:function\((.{1,2})\){/, match: /CREATE_PENDING_REPLY:function\((.{1,2})\){/,
replace: replace:
"CREATE_PENDING_REPLY:function($1){$1.shouldMention=Vencord.Plugins.plugins.NoReplyMention.shouldMention($1);", "CREATE_PENDING_REPLY:function($1){$1.shouldMention=$self.shouldMention($1);",
}, },
}, },
], ],

View file

@ -26,7 +26,7 @@ export default definePlugin({
authors: [Devs.Ven, Devs.adryd], authors: [Devs.Ven, Devs.adryd],
start() { start() {
fetch("https://raw.githubusercontent.com/adryd325/oneko.js/14bab15a755d0e35cd4ae19c931d96d306f99f42/oneko.js") fetch("https://raw.githubusercontent.com/adryd325/oneko.js/5977144dce83e4d71af1de005d16e38eebeb7b72/oneko.js")
.then(x => x.text()) .then(x => x.text())
.then(s => s.replace("./oneko.gif", "https://raw.githubusercontent.com/adryd325/oneko.js/14bab15a755d0e35cd4ae19c931d96d306f99f42/oneko.gif")) .then(s => s.replace("./oneko.gif", "https://raw.githubusercontent.com/adryd325/oneko.js/14bab15a755d0e35cd4ae19c931d96d306f99f42/oneko.gif"))
.then(eval); .then(eval);

View file

@ -175,8 +175,8 @@ export default definePlugin({
gif.finish(); gif.finish();
const file = new File([gif.bytesView()], "petpet.gif", { type: "image/gif" }); const file = new File([gif.bytesView()], "petpet.gif", { type: "image/gif" });
// Immediately after the command finishes, Discord clears all input, including pending attachments. // Immediately after the command finishes, Discord clears all input, including pending attachments.
// Thus, setImmediate is needed to make this execute after Discord cleared the input // Thus, setTimeout is needed to make this execute after Discord cleared the input
setImmediate(() => promptToUpload([file], cmdCtx.channel, DRAFT_TYPE)); setTimeout(() => promptToUpload([file], cmdCtx.channel, DRAFT_TYPE), 10);
}, },
}, },
] ]

View file

@ -24,7 +24,7 @@ export default definePlugin({
description: "Doesn't show the small guild icons in folders", description: "Doesn't show the small guild icons in folders",
authors: [Devs.botato], authors: [Devs.botato],
patches: [{ patches: [{
find: "().expandedFolderIconWrapper", find: ".expandedFolderIconWrapper",
replacement: [{ replacement: [{
match: /\(\w\|\|\w\)&&(\(.{0,40}\(.{1,3}\.animated)/, match: /\(\w\|\|\w\)&&(\(.{0,40}\(.{1,3}\.animated)/,
replace: "$1", replace: "$1",

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