diff --git a/.eslintrc.json b/.eslintrc.json index 9bbe4f5c5..4cb86e0e8 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -84,7 +84,9 @@ "no-extra-semi": "error", "consistent-return": ["warn", { "treatUndefinedAsUnspecified": true }], "dot-notation": "error", - "no-useless-escape": "error", + "no-useless-escape": ["error", { + "extra": "i" + }], "no-fallthrough": "error", "for-direction": "error", "no-async-promise-executor": "error", diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index b23284879..000000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1,13 +0,0 @@ -# These are supported funding model platforms - -github: Vendicated -patreon: Aliucord -open_collective: # Replace with a single Open Collective username -ko_fi: # Replace with a single Ko-fi username -tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel -community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry -liberapay: # Replace with a single Liberapay username -issuehunt: # Replace with a single IssueHunt username -otechie: # Replace with a single Otechie username -lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry -custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8ef6503b3..5c9eafee4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -55,10 +55,29 @@ jobs: run: | echo "release_tag=$(git rev-parse --short HEAD)" >> $GITHUB_ENV - - name: Upload Devbuild + - name: Upload Devbuild as release run: | gh release upload devbuild --clobber dist/* gh release edit devbuild --title "DevBuild $RELEASE_TAG" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_TAG: ${{ env.release_tag }} + + - name: Upload Devbuild to builds repo + run: | + git config --global user.name "$USERNAME" + git config --global user.email actions@github.com + + git clone https://$USERNAME:$API_TOKEN@github.com/$GH_REPO.git upload + cd upload + rm -rf * + cp -r ../dist/* . + + git add -A + git commit -m "Builds for https://github.com/$GITHUB_REPOSITORY/commit/$GITHUB_SHA" + git push --force https://$USERNAME:$API_TOKEN@github.com/$GH_REPO.git + env: + API_TOKEN: ${{ secrets.BUILDS_TOKEN }} + GLOBIGNORE: .git:.gitignore:README.md:LICENSE + GH_REPO: Vencord/builds + USERNAME: GitHub-Actions diff --git a/.github/workflows/reportBrokenPlugins.yml b/.github/workflows/reportBrokenPlugins.yml index 8511b42b9..719eca753 100644 --- a/.github/workflows/reportBrokenPlugins.yml +++ b/.github/workflows/reportBrokenPlugins.yml @@ -41,3 +41,17 @@ jobs: env: DISCORD_TOKEN: ${{ secrets.DISCORD_TOKEN }} DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} + + - name: Create Report (Canary) + timeout-minutes: 10 + if: success() || failure() # even run if previous one failed + run: | + export PATH="$PWD/node_modules/.bin:$PATH" + export CHROMIUM_BIN=$(which chromium-browser) + export USE_CANARY=true + + esbuild test/generateReport.ts > dist/report.mjs + node dist/report.mjs >> $GITHUB_STEP_SUMMARY + env: + DISCORD_TOKEN: ${{ secrets.DISCORD_TOKEN }} + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} diff --git a/README.md b/README.md index 8c8466e27..10c73a0e6 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ If you're a power user who wants to contribute and make plugins or just want to Or install the browser extension for - [![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://github.com/Vendicated/Vencord/releases/download/devbuild/Vencord.user.js) - Please note that QuickCSS, shiki and other plugins making use of external resources will not work with the UserScript. +- [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. You may also build them from source, to do that do the same steps as in the manual regular install method, diff --git a/browser/content.js b/browser/content.js index 2c4b40e75..e47ef8377 100644 --- a/browser/content.js +++ b/browser/content.js @@ -2,7 +2,18 @@ if (typeof browser === "undefined") { var browser = chrome; } -var script = document.createElement("script"); +const script = document.createElement("script"); script.src = browser.runtime.getURL("dist/Vencord.js"); -// documentElement because we load before body/head are ready -document.documentElement.appendChild(script); + +const style = document.createElement("link"); +style.type = "text/css"; +style.rel = "stylesheet"; +style.href = browser.runtime.getURL("dist/Vencord.css"); + +document.documentElement.append(script); + +document.addEventListener( + "DOMContentLoaded", + () => document.documentElement.append(style), + { once: true } +); diff --git a/browser/manifestv2.json b/browser/manifestv2.json index 405b2dc99..b28b73f8d 100644 --- a/browser/manifestv2.json +++ b/browser/manifestv2.json @@ -18,7 +18,7 @@ "js": ["content.js"] } ], - "web_accessible_resources": ["dist/Vencord.js"], + "web_accessible_resources": ["dist/Vencord.js", "dist/Vencord.css"], "background": { "scripts": ["background.js"] } diff --git a/browser/manifestv3.json b/browser/manifestv3.json index ea79d1292..d15b80a1b 100644 --- a/browser/manifestv3.json +++ b/browser/manifestv3.json @@ -23,7 +23,7 @@ "web_accessible_resources": [ { - "resources": ["dist/Vencord.js"], + "resources": ["dist/Vencord.js", "dist/Vencord.css"], "matches": ["*://*.discord.com/*"] } ], diff --git a/package.json b/package.json index f0c31035f..51d384db9 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ }, "devDependencies": { "@types/diff": "^5.0.2", + "@types/lodash": "^4.14.0", "@types/node": "^18.11.9", "@types/react": "^18.0.25", "@types/react-dom": "^18.0.9", @@ -62,7 +63,19 @@ "packageManager": "pnpm@7.13.4", "pnpm": { "patchedDependencies": { - "eslint-plugin-path-alias@1.0.0": "patches/eslint-plugin-path-alias@1.0.0.patch" + "eslint-plugin-path-alias@1.0.0": "patches/eslint-plugin-path-alias@1.0.0.patch", + "eslint@8.28.0": "patches/eslint@8.28.0.patch" + }, + "peerDependencyRules": { + "ignoreMissing": [ + "eslint-plugin-import" + ] + }, + "allowedDeprecatedVersions": { + "source-map-resolve": "*", + "resolve-url": "*", + "source-map-url": "*", + "urix": "*" } }, "webExt": { diff --git a/patches/eslint@8.28.0.patch b/patches/eslint@8.28.0.patch new file mode 100644 index 000000000..994481b91 --- /dev/null +++ b/patches/eslint@8.28.0.patch @@ -0,0 +1,45 @@ +diff --git a/lib/rules/no-useless-escape.js b/lib/rules/no-useless-escape.js +index 2046a148a17fd1d5f3a4bbc9f45f7700259d11fa..f4898c6b57355a4fd72c43a9f32bf1a36a6ccf4a 100644 +--- a/lib/rules/no-useless-escape.js ++++ b/lib/rules/no-useless-escape.js +@@ -97,12 +97,30 @@ module.exports = { + escapeBackslash: "Replace the `\\` with `\\\\` to include the actual backslash character." + }, + +- schema: [] ++ schema: [{ ++ type: "object", ++ properties: { ++ extra: { ++ type: "string", ++ default: "" ++ }, ++ extraCharClass: { ++ type: "string", ++ default: "" ++ }, ++ }, ++ additionalProperties: false ++ }] + }, + + create(context) { ++ const options = context.options[0] || {}; ++ const { extra, extraCharClass } = options || '' + const sourceCode = context.getSourceCode(); + ++ const NON_CHARCLASS_ESCAPES = union(REGEX_NON_CHARCLASS_ESCAPES, new Set(extra)) ++ const CHARCLASS_ESCAPES = union(REGEX_GENERAL_ESCAPES, new Set(extraCharClass)) ++ + /** + * Reports a node + * @param {ASTNode} node The node to report +@@ -238,7 +256,7 @@ module.exports = { + .filter(charInfo => charInfo.escaped) + + // Filter out characters that are valid to escape, based on their position in the regular expression. +- .filter(charInfo => !(charInfo.inCharClass ? REGEX_GENERAL_ESCAPES : REGEX_NON_CHARCLASS_ESCAPES).has(charInfo.text)) ++ .filter(charInfo => !(charInfo.inCharClass ? CHARCLASS_ESCAPES : NON_CHARCLASS_ESCAPES).has(charInfo.text)) + + // Report all the remaining characters. + .forEach(charInfo => report(node, charInfo.index, charInfo.text)); \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f76ff3fe..02c2ea6c3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,9 +4,13 @@ patchedDependencies: eslint-plugin-path-alias@1.0.0: hash: m6sma4g6bh67km3q6igf6uxaja path: patches/eslint-plugin-path-alias@1.0.0.patch + eslint@8.28.0: + hash: 7wc6icvgtg3uswirb5tpsbjnbe + path: patches/eslint@8.28.0.patch specifiers: '@types/diff': ^5.0.2 + '@types/lodash': ^4.14.0 '@types/node': ^18.11.9 '@types/react': ^18.0.25 '@types/react-dom': ^18.0.9 @@ -38,6 +42,7 @@ dependencies: devDependencies: '@types/diff': 5.0.2 + '@types/lodash': 4.14.189 '@types/node': 18.11.9 '@types/react': 18.0.25 '@types/react-dom': 18.0.9 @@ -50,7 +55,7 @@ devDependencies: diff: 5.1.0 discord-types: 1.3.26 esbuild: 0.15.16 - eslint: 8.28.0 + eslint: 8.28.0_7wc6icvgtg3uswirb5tpsbjnbe eslint-import-resolver-alias: 1.1.2 eslint-plugin-header: 3.1.1_eslint@8.28.0 eslint-plugin-path-alias: 1.0.0_m6sma4g6bh67km3q6igf6uxaja_eslint@8.28.0 @@ -149,6 +154,10 @@ packages: resolution: {integrity: sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==} dev: true + /@types/lodash/4.14.189: + resolution: {integrity: sha512-kb9/98N6X8gyME9Cf7YaqIMvYGnBSWqEci6tiettE6iJWH1XdJz/PO8LB0GtLCG7x8dU3KWhZT+lA1a35127tA==} + dev: true + /@types/node/18.11.9: resolution: {integrity: sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg==} dev: true @@ -216,7 +225,7 @@ packages: '@typescript-eslint/type-utils': 5.45.0_hsf322ms6xhhd4b5ne6lb74y4a '@typescript-eslint/utils': 5.45.0_hsf322ms6xhhd4b5ne6lb74y4a debug: 4.3.4 - eslint: 8.28.0 + eslint: 8.28.0_7wc6icvgtg3uswirb5tpsbjnbe ignore: 5.2.0 natural-compare-lite: 1.4.0 regexpp: 3.2.0 @@ -241,7 +250,7 @@ packages: '@typescript-eslint/types': 5.45.0 '@typescript-eslint/typescript-estree': 5.45.0_typescript@4.9.3 debug: 4.3.4 - eslint: 8.28.0 + eslint: 8.28.0_7wc6icvgtg3uswirb5tpsbjnbe typescript: 4.9.3 transitivePeerDependencies: - supports-color @@ -268,7 +277,7 @@ packages: '@typescript-eslint/typescript-estree': 5.45.0_typescript@4.9.3 '@typescript-eslint/utils': 5.45.0_hsf322ms6xhhd4b5ne6lb74y4a debug: 4.3.4 - eslint: 8.28.0 + eslint: 8.28.0_7wc6icvgtg3uswirb5tpsbjnbe tsutils: 3.21.0_typescript@4.9.3 typescript: 4.9.3 transitivePeerDependencies: @@ -312,7 +321,7 @@ packages: '@typescript-eslint/scope-manager': 5.45.0 '@typescript-eslint/types': 5.45.0 '@typescript-eslint/typescript-estree': 5.45.0_typescript@4.9.3 - eslint: 8.28.0 + eslint: 8.28.0_7wc6icvgtg3uswirb5tpsbjnbe eslint-scope: 5.1.1 eslint-utils: 3.0.0_eslint@8.28.0 semver: 7.3.7 @@ -894,7 +903,7 @@ packages: peerDependencies: eslint: '>=7.7.0' dependencies: - eslint: 8.28.0 + eslint: 8.28.0_7wc6icvgtg3uswirb5tpsbjnbe dev: true /eslint-plugin-path-alias/1.0.0_m6sma4g6bh67km3q6igf6uxaja_eslint@8.28.0: @@ -902,7 +911,7 @@ packages: peerDependencies: eslint: ^7 dependencies: - eslint: 8.28.0 + eslint: 8.28.0_7wc6icvgtg3uswirb5tpsbjnbe nanomatch: 1.2.13 transitivePeerDependencies: - supports-color @@ -914,7 +923,7 @@ packages: peerDependencies: eslint: '>=5.0.0' dependencies: - eslint: 8.28.0 + eslint: 8.28.0_7wc6icvgtg3uswirb5tpsbjnbe dev: true /eslint-plugin-unused-imports/2.0.0_5am2datodjm2qi4eijrjrnoz54: @@ -928,7 +937,7 @@ packages: optional: true dependencies: '@typescript-eslint/eslint-plugin': 5.45.0_czs5uoqkd3podpy6vgtsxfc7au - eslint: 8.28.0 + eslint: 8.28.0_7wc6icvgtg3uswirb5tpsbjnbe eslint-rule-composer: 0.3.0 dev: true @@ -959,7 +968,7 @@ packages: peerDependencies: eslint: '>=5' dependencies: - eslint: 8.28.0 + eslint: 8.28.0_7wc6icvgtg3uswirb5tpsbjnbe eslint-visitor-keys: 2.1.0 dev: true @@ -973,7 +982,7 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true - /eslint/8.28.0: + /eslint/8.28.0_7wc6icvgtg3uswirb5tpsbjnbe: resolution: {integrity: sha512-S27Di+EVyMxcHiwDrFzk8dJYAaD+/5SoWKxL1ri/71CRHsnJnRDPNt2Kzj24+MT9FDupf4aqqyqPrvI8MvQ4VQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} hasBin: true @@ -1020,6 +1029,7 @@ packages: transitivePeerDependencies: - supports-color dev: true + patched: true /espree/9.4.0: resolution: {integrity: sha512-DQmnRpLj7f6TgN/NYb0MTzJXL+vJF9h3pHy4JhCIs3zwcgez8xmGg3sXHcEO97BrmO2OSvCwMdfdlyl+E9KjOw==} diff --git a/scripts/build/buildWeb.mjs b/scripts/build/buildWeb.mjs old mode 100755 new mode 100644 index c85d8aad9..3ad43b2c9 --- a/scripts/build/buildWeb.mjs +++ b/scripts/build/buildWeb.mjs @@ -20,13 +20,13 @@ import esbuild from "esbuild"; import { zip } from "fflate"; -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; -import { readFile } from "fs/promises"; -import { join, resolve } from "path"; +import { readFileSync } from "fs"; +import { appendFile, mkdir, readFile, rm, writeFile } from "fs/promises"; +import { join } from "path"; // wtf is this assert syntax import PackageJSON from "../../package.json" assert { type: "json" }; -import { commonOpts, fileIncludePlugin, gitHashPlugin, gitRemotePlugin, globPlugins, watch } from "./common.mjs"; +import { commonOpts, globPlugins, watch } from "./common.mjs"; /** * @type {esbuild.BuildOptions} @@ -39,9 +39,7 @@ const commonOptions = { external: ["plugins", "git-hash"], plugins: [ globPlugins, - gitHashPlugin, - gitRemotePlugin, - fileIncludePlugin + ...commonOpts.plugins, ], target: ["esnext"], define: { @@ -77,9 +75,13 @@ await Promise.all( ] ); +/** + * @type {(target: string, files: string[], shouldZip: boolean) => Promise} + */ async function buildPluginZip(target, files, shouldZip) { const entries = { - "dist/Vencord.js": readFileSync("dist/browser.js"), + "dist/Vencord.js": await readFile("dist/browser.js"), + "dist/Vencord.css": await readFile("dist/browser.css"), ...Object.fromEntries(await Promise.all(files.map(async f => [ (f.startsWith("manifest") ? "manifest.json" : f), await readFile(join("browser", f)) @@ -87,29 +89,47 @@ async function buildPluginZip(target, files, shouldZip) { }; if (shouldZip) { - zip(entries, {}, (err, data) => { - if (err) { - console.error(err); - process.exitCode = 1; - } else { - writeFileSync("dist/" + target, data); - console.info("Extension written to dist/" + target); - } + return new Promise((resolve, reject) => { + zip(entries, {}, (err, data) => { + if (err) { + reject(err); + } else { + const out = join("dist", target); + writeFile(out, data).then(() => { + console.info("Extension written to " + out); + resolve(); + }).catch(reject); + } + }); }); } else { - if (existsSync(target)) - rmSync(target, { recursive: true }); - for (const entry in entries) { - const destination = "dist/" + target + "/" + entry; - const parentDirectory = resolve(destination, ".."); - mkdirSync(parentDirectory, { recursive: true }); - writeFileSync(destination, entries[entry]); - } + await rm(target, { recursive: true, force: true }); + await Promise.all(Object.entries(entries).map(async ([file, content]) => { + const dest = join("dist", target, file); + const parentDirectory = join(dest, ".."); + await mkdir(parentDirectory, { recursive: true }); + await writeFile(dest, content); + })); + console.info("Unpacked Extension written to dist/" + target); } } -await buildPluginZip("extension-v3.zip", ["modifyResponseHeaders.json", "content.js", "manifestv3.json"], true); -await buildPluginZip("extension-v2.zip", ["background.js", "content.js", "manifestv2.json"], true); -await buildPluginZip("extension-v2-unpacked", ["background.js", "content.js", "manifestv2.json"], false); +const cssText = "`" + readFileSync("dist/Vencord.user.css", "utf-8").replaceAll("`", "\\`") + "`"; +const cssRuntime = ` +;document.addEventListener("DOMContentLoaded", () => document.documentElement.appendChild( + Object.assign(document.createElement("style"), { + textContent: ${cssText}, + id: "vencord-css-core" + }), + { once: true } +)); +`; + +await Promise.all([ + appendFile("dist/Vencord.user.js", cssRuntime), + buildPluginZip("extension-v3.zip", ["modifyResponseHeaders.json", "content.js", "manifestv3.json"], true), + buildPluginZip("extension-v2.zip", ["background.js", "content.js", "manifestv2.json"], true), + buildPluginZip("extension-v2-unpacked", ["background.js", "content.js", "manifestv2.json"], false), +]); diff --git a/scripts/build/common.mjs b/scripts/build/common.mjs index 11aaa81af..2743c700d 100644 --- a/scripts/build/common.mjs +++ b/scripts/build/common.mjs @@ -17,9 +17,9 @@ */ import { exec, execSync } from "child_process"; -import { existsSync } from "fs"; +import { existsSync, readFileSync } from "fs"; import { readdir, readFile } from "fs/promises"; -import { join } from "path"; +import { join, relative } from "path"; import { promisify } from "util"; export const watch = process.argv.includes("--watch"); @@ -35,7 +35,7 @@ export const banner = { // https://github.com/evanw/esbuild/issues/619#issuecomment-751995294 /** - * @type {esbuild.Plugin} + * @type {import("esbuild").Plugin} */ export const makeAllPackagesExternalPlugin = { name: "make-all-packages-external", @@ -46,7 +46,7 @@ export const makeAllPackagesExternalPlugin = { }; /** - * @type {esbuild.Plugin} + * @type {import("esbuild").Plugin} */ export const globPlugins = { name: "glob-plugins", @@ -87,7 +87,7 @@ export const globPlugins = { }; /** - * @type {esbuild.Plugin} + * @type {import("esbuild").Plugin} */ export const gitHashPlugin = { name: "git-hash-plugin", @@ -103,7 +103,7 @@ export const gitHashPlugin = { }; /** - * @type {esbuild.Plugin} + * @type {import("esbuild").Plugin} */ export const gitRemotePlugin = { name: "git-remote-plugin", @@ -125,7 +125,7 @@ export const gitRemotePlugin = { }; /** - * @type {esbuild.Plugin} + * @type {import("esbuild").Plugin} */ export const fileIncludePlugin = { name: "file-include-plugin", @@ -147,6 +147,31 @@ export const fileIncludePlugin = { } }; +const styleModule = readFileSync("./scripts/build/module/style.js", "utf-8"); +/** + * @type {import("esbuild").Plugin} + */ +export const stylePlugin = { + name: "style-plugin", + setup: ({ onResolve, onLoad }) => { + onResolve({ filter: /\.css\?managed$/, namespace: "file" }, ({ path, resolveDir }) => ({ + path: relative(process.cwd(), join(resolveDir, path.replace("?managed", ""))), + namespace: "managed-style", + })); + onLoad({ filter: /\.css$/, namespace: "managed-style" }, async ({ path }) => { + const css = await readFile(path, "utf-8"); + const name = relative(process.cwd(), path).replaceAll("\\", "/"); + + return { + loader: "js", + contents: styleModule + .replaceAll("STYLE_SOURCE", JSON.stringify(css)) + .replaceAll("STYLE_NAME", JSON.stringify(name)) + }; + }); + } +}; + /** * @type {import("esbuild").BuildOptions} */ @@ -158,7 +183,7 @@ export const commonOpts = { sourcemap: watch ? "inline" : "", legalComments: "linked", banner, - plugins: [fileIncludePlugin, gitHashPlugin, gitRemotePlugin], + plugins: [fileIncludePlugin, gitHashPlugin, gitRemotePlugin, stylePlugin], external: ["~plugins", "~git-hash", "~git-remote"], inject: ["./scripts/build/inject/react.mjs"], jsxFactory: "VencordCreateElement", diff --git a/scripts/build/inject/react.mjs b/scripts/build/inject/react.mjs index 1343b5f2f..591a25b5a 100644 --- a/scripts/build/inject/react.mjs +++ b/scripts/build/inject/react.mjs @@ -16,6 +16,6 @@ * along with this program. If not, see . */ -export const VencordFragment = Symbol.for("react.fragment"); +export const VencordFragment = /* #__PURE__*/ Symbol.for("react.fragment"); export let VencordCreateElement = (...args) => (VencordCreateElement = Vencord.Webpack.Common.React.createElement)(...args); diff --git a/scripts/build/module/style.js b/scripts/build/module/style.js new file mode 100644 index 000000000..5981a3de2 --- /dev/null +++ b/scripts/build/module/style.js @@ -0,0 +1,26 @@ +/* + * 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 . +*/ + +(window.VencordStyles ??= new Map()).set(STYLE_NAME, { + name: STYLE_NAME, + source: STYLE_SOURCE, + classNames: {}, + dom: null, +}); + +export default STYLE_NAME; diff --git a/src/Vencord.ts b/src/Vencord.ts index 464be2d8a..48e628fde 100644 --- a/src/Vencord.ts +++ b/src/Vencord.ts @@ -18,7 +18,6 @@ export * as Api from "./api"; export * as Plugins from "./plugins"; -// eslint-disable-next-line @typescript-eslint/no-restricted-imports export * as Util from "./utils"; export * as QuickCss from "./utils/quickCss"; export * as Updater from "./utils/updater"; diff --git a/src/api/Badges.ts b/src/api/Badges.ts index 55e9b3a4c..3607f37eb 100644 --- a/src/api/Badges.ts +++ b/src/api/Badges.ts @@ -17,7 +17,7 @@ */ import { User } from "discord-types/general"; -import { HTMLProps } from "react"; +import { ComponentType, HTMLProps } from "react"; import Plugins from "~plugins"; @@ -27,20 +27,21 @@ export enum BadgePosition { } export interface ProfileBadge { - /** The tooltip to show on hover */ - tooltip: string; + /** The tooltip to show on hover. Required for image badges */ + tooltip?: string; + /** Custom component for the badge (tooltip not included) */ + component?: ComponentType; /** The custom image to use */ image?: string; /** Action to perform when you click the badge */ onClick?(): void; /** Should the user display this badge? */ shouldShow?(userInfo: BadgeUserArgs): boolean; - /** Optional props (e.g. style) for the badge */ + /** Optional props (e.g. style) for the badge, ignored for component badges */ props?: HTMLProps; /** Insert at start or end? */ position?: BadgePosition; - - /** The badge name to display. Discord uses this, but we don't. */ + /** The badge name to display, Discord uses this. Required for component badges */ key?: string; } @@ -70,8 +71,8 @@ export function inject(badgeArray: ProfileBadge[], args: BadgeUserArgs) { for (const badge of Badges) { if (!badge.shouldShow || badge.shouldShow(args)) { badge.position === BadgePosition.START - ? badgeArray.unshift(badge) - : badgeArray.push(badge); + ? badgeArray.unshift({ ...badge, ...args }) + : badgeArray.push({ ...badge, ...args }); } } (Plugins.BadgeAPI as any).addDonorBadge(badgeArray, args.user.id); diff --git a/src/api/MemberListDecorators.ts b/src/api/MemberListDecorators.ts new file mode 100644 index 000000000..fade2a7ca --- /dev/null +++ b/src/api/MemberListDecorators.ts @@ -0,0 +1,65 @@ +/* + * 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 . +*/ + +import { Channel, User } from "discord-types/general/index.js"; + +interface DecoratorProps { + activities: any[]; + canUseAvatarDecorations: boolean; + channel: Channel; + /** + * Only for DM members + */ + channelName?: string; + /** + * Only for server members + */ + currentUser?: User; + guildId?: string; + isMobile: boolean; + isOwner?: boolean; + isTyping: boolean; + selected: boolean; + status: string; + user: User; + [key: string]: any; +} +export type Decorator = (props: DecoratorProps) => JSX.Element | null; +type OnlyIn = "guilds" | "dms"; + +export const decorators = new Map(); + +export function addDecorator(identifier: string, decorator: Decorator, onlyIn?: OnlyIn) { + decorators.set(identifier, { decorator, onlyIn }); +} + +export function removeDecorator(identifier: string) { + decorators.delete(identifier); +} + +export function __addDecoratorsToList(props: DecoratorProps): (JSX.Element | null)[] { + const isInGuild = !!(props.guildId); + return [...decorators.values()].map(decoratorObj => { + const { decorator, onlyIn } = decoratorObj; + // this can most likely be done cleaner + if (!onlyIn || (onlyIn === "guilds" && isInGuild) || (onlyIn === "dms" && !isInGuild)) { + return decorator(props); + } + return null; + }); +} diff --git a/src/api/MessageDecorations.ts b/src/api/MessageDecorations.ts new file mode 100644 index 000000000..d212b15b1 --- /dev/null +++ b/src/api/MessageDecorations.ts @@ -0,0 +1,63 @@ +/* + * 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 . +*/ + +import { Channel, Message } from "discord-types/general/index.js"; + +interface DecorationProps { + author: { + /** + * Will be username if the user has no nickname + */ + nick: string; + iconRoleId: string; + guildMemberAvatar: string; + colorRoleName: string; + colorString: string; + }; + channel: Channel; + compact: boolean; + decorations: { + /** + * Element for the [BOT] tag if there is one + */ + 0: JSX.Element | null; + /** + * Other decorations (including ones added with this api) + */ + 1: JSX.Element[]; + }; + message: Message; + [key: string]: any; +} +export type Decoration = (props: DecorationProps) => JSX.Element | null; + +export const decorations = new Map(); + +export function addDecoration(identifier: string, decoration: Decoration) { + decorations.set(identifier, decoration); +} + +export function removeDecoration(identifier: string) { + decorations.delete(identifier); +} + +export function __addDecorationsToMessage(props: DecorationProps): (JSX.Element | null)[] { + return [...decorations.values()].map(decoration => { + return decoration(props); + }); +} diff --git a/src/api/Styles.ts b/src/api/Styles.ts new file mode 100644 index 000000000..6b189cab8 --- /dev/null +++ b/src/api/Styles.ts @@ -0,0 +1,162 @@ +/* + * 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 . +*/ + +import type { MapValue } from "type-fest/source/entry"; + +export type Style = MapValue; + +export const styleMap = window.VencordStyles ??= new Map(); + +export function requireStyle(name: string) { + const style = styleMap.get(name); + if (!style) throw new Error(`Style "${name}" does not exist`); + return style; +} + +/** + * A style's name can be obtained from importing a stylesheet with `?managed` at the end of the import + * @param name The name of the style + * @returns `false` if the style was already enabled, `true` otherwise + * @example + * import pluginStyle from "./plugin.css?managed"; + * + * // Inside some plugin method like "start()" or "[option].onChange()" + * enableStyle(pluginStyle); + */ +export function enableStyle(name: string) { + const style = requireStyle(name); + + if (style.dom?.isConnected) + return false; + + if (!style.dom) { + style.dom = document.createElement("style"); + style.dom.dataset.vencordName = style.name; + } + compileStyle(style); + + document.head.appendChild(style.dom); + return true; +} + +/** + * @param name The name of the style + * @returns `false` if the style was already disabled, `true` otherwise + * @see {@link enableStyle} for info on getting the name of an imported style + */ +export function disableStyle(name: string) { + const style = requireStyle(name); + if (!style.dom?.isConnected) + return false; + + style.dom.remove(); + style.dom = null; + return true; +} + +/** + * @param name The name of the style + * @returns `true` in most cases, may return `false` in some edge cases + * @see {@link enableStyle} for info on getting the name of an imported style + */ +export const toggleStyle = (name: string) => isStyleEnabled(name) ? disableStyle(name) : enableStyle(name); + +/** + * @param name The name of the style + * @returns Whether the style is enabled + * @see {@link enableStyle} for info on getting the name of an imported style + */ +export const isStyleEnabled = (name: string) => requireStyle(name).dom?.isConnected ?? false; + +/** + * Sets the variables of a style + * ```ts + * // -- plugin.ts -- + * import pluginStyle from "./plugin.css?managed"; + * import { setStyleVars } from "@api/Styles"; + * import { findByPropsLazy } from "@webpack"; + * const classNames = findByPropsLazy("thin", "scrollerBase"); // { thin: "thin-31rlnD scrollerBase-_bVAAt", ... } + * + * // Inside some plugin method like "start()" + * setStyleClassNames(pluginStyle, classNames); + * enableStyle(pluginStyle); + * ``` + * ```scss + * // -- plugin.css -- + * .plugin-root [--thin]::-webkit-scrollbar { ... } + * ``` + * ```scss + * // -- final stylesheet -- + * .plugin-root .thin-31rlnD.scrollerBase-_bVAAt::-webkit-scrollbar { ... } + * ``` + * @param name The name of the style + * @param classNames An object where the keys are the variable names and the values are the variable values + * @param recompile Whether to recompile the style after setting the variables, defaults to `true` + * @see {@link enableStyle} for info on getting the name of an imported style + */ +export const setStyleClassNames = (name: string, classNames: Record, recompile = true) => { + const style = requireStyle(name); + style.classNames = classNames; + if (recompile && isStyleEnabled(style.name)) + compileStyle(style); +}; + +/** + * Updates the stylesheet after doing the following to the sourcecode: + * - Interpolate style classnames + * @param style **_Must_ be a style with a DOM element** + * @see {@link setStyleClassNames} for more info on style classnames + */ +export const compileStyle = (style: Style) => { + if (!style.dom) throw new Error("Style has no DOM element"); + + style.dom.textContent = style.source + .replace(/\[--(\w+)\]/g, (match, name) => { + const className = style.classNames[name]; + return className ? classNameToSelector(className) : match; + }); +}; + +/** + * @param name The classname + * @param prefix A prefix to add each class, defaults to `""` + * @return A css selector for the classname + * @example + * classNameToSelector("foo bar") // => ".foo.bar" + */ +export const classNameToSelector = (name: string, prefix = "") => name.split(" ").map(n => `.${prefix}${n}`).join(""); + +type ClassNameFactoryArg = string | string[] | Record; +/** + * @param prefix The prefix to add to each class, defaults to `""` + * @returns A classname generator function + * @example + * const cl = classNameFactory("plugin-"); + * + * cl("base", ["item", "editable"], { selected: null, disabled: true }) + * // => "plugin-base plugin-item plugin-editable plugin-disabled" + */ +export const classNameFactory = (prefix: string = "") => (...args: ClassNameFactoryArg[]) => { + const classNames = new Set(); + for (const arg of args) { + if (typeof arg === "string") classNames.add(arg); + else if (Array.isArray(arg)) arg.forEach(name => classNames.add(name)); + else if (typeof arg === "object") Object.entries(arg).forEach(([name, value]) => value && classNames.add(name)); + } + return Array.from(classNames, name => prefix + name).join(" "); +}; diff --git a/src/api/index.ts b/src/api/index.ts index b74da6e38..0fef99cda 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -19,11 +19,14 @@ import * as $Badges from "./Badges"; import * as $Commands from "./Commands"; import * as $DataStore from "./DataStore"; +import * as $MemberListDecorators from "./MemberListDecorators"; import * as $MessageAccessories from "./MessageAccessories"; +import * as $MessageDecorations from "./MessageDecorations"; import * as $MessageEventsAPI from "./MessageEvents"; import * as $MessagePopover from "./MessagePopover"; import * as $Notices from "./Notices"; import * as $ServerList from "./ServerList"; +import * as $Styles from "./Styles"; /** * An API allowing you to listen to Message Clicks or run your own logic @@ -31,16 +34,16 @@ import * as $ServerList from "./ServerList"; * * If your plugin uses this, you must add MessageEventsAPI to its dependencies */ -const MessageEvents = $MessageEventsAPI; +export const MessageEvents = $MessageEventsAPI; /** * An API allowing you to create custom notices * (snackbars on the top, like the Update prompt) */ -const Notices = $Notices; +export const Notices = $Notices; /** * An API allowing you to register custom commands */ -const Commands = $Commands; +export const Commands = $Commands; /** * A wrapper around IndexedDB. This can store arbitrarily * large data and supports a lot of datatypes (Blob, Map, ...). @@ -55,22 +58,33 @@ const Commands = $Commands; * This is actually just idb-keyval, so if you're familiar with that, you're golden! * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm#supported_types} */ -const DataStore = $DataStore; +export const DataStore = $DataStore; /** * An API allowing you to add custom components as message accessories */ -const MessageAccessories = $MessageAccessories; +export const MessageAccessories = $MessageAccessories; /** * An API allowing you to add custom buttons in the message popover */ -const MessagePopover = $MessagePopover; +export const MessagePopover = $MessagePopover; /** * An API allowing you to add badges to user profiles */ -const Badges = $Badges; +export const Badges = $Badges; /** * An API allowing you to add custom elements to the server list */ -const ServerList = $ServerList; - -export { Badges, Commands, DataStore, MessageAccessories, MessageEvents, MessagePopover, Notices, ServerList }; +export const ServerList = $ServerList; +/** + * An API allowing you to add components as message accessories + */ +export const MessageDecorations = $MessageDecorations; +/** + * An API allowing you to add components to member list users, in both DM's and servers + */ +export const MemberListDecorators = $MemberListDecorators; +/** + * An API allowing you to dynamically load styles + * a + */ +export const Styles = $Styles; diff --git a/src/components/Monaco.ts b/src/components/Monaco.ts index aa98f998c..59ed7bbba 100644 --- a/src/components/Monaco.ts +++ b/src/components/Monaco.ts @@ -29,7 +29,12 @@ const setCss = debounce((css: string) => { }); export async function launchMonacoEditor() { - const win = open("about:blank", void 0, "popup,width=1000,height=1000")!; + const features = `popup,width=${Math.min(window.innerWidth, 1000)},height=${Math.min(window.innerHeight, 1000)}`; + const win = open("about:blank", "VencordQuickCss", features); + if (!win) { + alert("Failed to open QuickCSS popup. Make sure to allow popups!"); + return; + } win.setCss = setCss; win.getCurrentCss = () => VencordNative.ipc.invoke(IpcEvents.GET_QUICK_CSS); @@ -41,4 +46,6 @@ export async function launchMonacoEditor() { : "vs-dark"; win.document.write(monacoHtml); + + window.__VENCORD_MONACO_WIN__ = new WeakRef(win); } diff --git a/src/components/PatchHelper.tsx b/src/components/PatchHelper.tsx index 22c2b4dc0..cb6098028 100644 --- a/src/components/PatchHelper.tsx +++ b/src/components/PatchHelper.tsx @@ -18,6 +18,7 @@ import { debounce } from "@utils/debounce"; import { makeCodeblock } from "@utils/misc"; +import { canonicalizeMatch, canonicalizeReplace, ReplaceFn } from "@utils/patches"; import { search } from "@webpack"; import { Button, Clipboard, Forms, Margins, Parser, React, Switch, Text, TextInput } from "@webpack/common"; @@ -41,20 +42,29 @@ const findCandidates = debounce(function ({ find, setModule, setError }) { setModule([keys[0], candidates[keys[0]]]); }); -function ReplacementComponent({ module, match, replacement, setReplacementError }) { +interface ReplacementComponentProps { + module: [id: number, factory: Function]; + match: string | RegExp; + replacement: string | ReplaceFn; + setReplacementError(error: any): void; +} + +function ReplacementComponent({ module, match, replacement, setReplacementError }: ReplacementComponentProps) { const [id, fact] = module; const [compileResult, setCompileResult] = React.useState<[boolean, string]>(); const [patchedCode, matchResult, diff] = React.useMemo(() => { const src: string = fact.toString().replaceAll("\n", ""); + const canonicalMatch = canonicalizeMatch(match); try { - var patched = src.replace(match, replacement); + const canonicalReplace = canonicalizeReplace(replacement, "YourPlugin"); + var patched = src.replace(canonicalMatch, canonicalReplace as string); setReplacementError(void 0); } catch (e) { setReplacementError((e as Error).message); return ["", [], []]; } - const m = src.match(match); + const m = src.match(canonicalMatch); return [patched, m, makeDiff(src, patched, m)]; }, [id, match, replacement]); @@ -179,9 +189,10 @@ function ReplacementInput({ replacement, setReplacement, replacementError }) { {Object.entries({ "$$": "Insert a $", "$&": "Insert the entire match", - "$`​": "Insert the substring before the match", + "$`\u200b": "Insert the substring before the match", "$'": "Insert the substring after the match", - "$n": "Insert the nth capturing group ($1, $2...)" + "$n": "Insert the nth capturing group ($1, $2...)", + "$self": "Insert the plugin instance", }).map(([placeholder, desc]) => ( {Parser.parse("`" + placeholder + "`")}: {desc} @@ -206,7 +217,7 @@ function ReplacementInput({ replacement, setReplacement, replacementError }) { function PatchHelper() { const [find, setFind] = React.useState(""); const [match, setMatch] = React.useState(""); - const [replacement, setReplacement] = React.useState(""); + const [replacement, setReplacement] = React.useState(""); const [replacementError, setReplacementError] = React.useState(); diff --git a/src/components/PluginSettings/components/BadgeComponent.tsx b/src/components/PluginSettings/components/BadgeComponent.tsx new file mode 100644 index 000000000..059376fd5 --- /dev/null +++ b/src/components/PluginSettings/components/BadgeComponent.tsx @@ -0,0 +1,30 @@ +/* + * 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 . +*/ + +import { BadgeStyle } from "@components/PluginSettings/styles"; + +export function Badge({ text, color }): JSX.Element { + return ( +
{text}
+ ); +} diff --git a/src/components/PluginSettings/components/index.ts b/src/components/PluginSettings/components/index.ts index 9e7506816..d44fb386f 100644 --- a/src/components/PluginSettings/components/index.ts +++ b/src/components/PluginSettings/components/index.ts @@ -29,6 +29,7 @@ export interface ISettingElementProps { onError(hasError: boolean): void; } +export * from "./BadgeComponent"; export * from "./SettingBooleanComponent"; export * from "./SettingCustomComponent"; export * from "./SettingNumericComponent"; diff --git a/src/components/PluginSettings/index.tsx b/src/components/PluginSettings/index.tsx index 5ee9cc941..981891457 100644 --- a/src/components/PluginSettings/index.tsx +++ b/src/components/PluginSettings/index.tsx @@ -16,15 +16,19 @@ * along with this program. If not, see . */ +import * as DataStore from "@api/DataStore"; import { showNotice } from "@api/Notices"; import { Settings, useSettings } from "@api/settings"; import ErrorBoundary from "@components/ErrorBoundary"; import { ErrorCard } from "@components/ErrorCard"; import { Flex } from "@components/Flex"; import { handleComponentFailed } from "@components/handleComponentFailed"; +import { Badge } from "@components/PluginSettings/components"; +import PluginModal from "@components/PluginSettings/PluginModal"; +import * as styles from "@components/PluginSettings/styles"; import { ChangeList } from "@utils/ChangeList"; import Logger from "@utils/Logger"; -import { classes, LazyComponent } from "@utils/misc"; +import { classes, LazyComponent, useAwaiter } from "@utils/misc"; import { openModalLazy } from "@utils/modal"; import { Plugin } from "@utils/types"; import { findByCode, findByPropsLazy } from "@webpack"; @@ -33,8 +37,6 @@ import { Alerts, Button, Forms, Margins, Parser, React, Select, Switch, Text, Te import Plugins from "~plugins"; import { startDependenciesRecursive, startPlugin, stopPlugin } from "../../plugins"; -import PluginModal from "./PluginModal"; -import * as styles from "./styles"; const logger = new Logger("PluginSettings", "#a6d189"); @@ -78,9 +80,10 @@ interface PluginCardProps extends React.HTMLProps { plugin: Plugin; disabled: boolean; onRestartNeeded(name: string): void; + isNew?: boolean; } -function PluginCard({ plugin, disabled, onRestartNeeded, onMouseEnter, onMouseLeave }: PluginCardProps) { +function PluginCard({ plugin, disabled, onRestartNeeded, onMouseEnter, onMouseLeave, isNew }: PluginCardProps) { const settings = useSettings(); const pluginSettings = settings.plugins[plugin.name]; @@ -162,8 +165,15 @@ function PluginCard({ plugin, disabled, onRestartNeeded, onMouseEnter, onMouseLe } hideBorder={true} > - - {plugin.name} + + + {plugin.name}{(isNew) && } +