Compare commits

...

57 commits

Author SHA1 Message Date
beaf164aa8
owo 2023-12-14 16:47:35 +09:00
Nuckyz
2cd82944e3
Move commons from discord utils; Make ThemesTab use invite modal util 2023-12-13 22:01:07 -03:00
Nuckyz
2f1dc2c704
reporter: fix icon 2023-12-13 21:46:51 -03:00
Nuckyz
a5442d87d5
Fix reporter and AlwaysAnimate patch 2023-12-13 21:41:09 -03:00
V
a8b0ce6f03
fix(notrack): murder sentry 2023-12-14 01:29:57 +01:00
Andrew Grant
b32959126e
TypingIndicator: setting to disable for current channel (#2043) 2023-12-13 19:54:09 -03:00
zImPatrick
2cf52d0775
AlwaysAnimate: Add guild banner (#2036) 2023-12-13 20:41:50 +00:00
sappho
40b3ec57ce
FakeNitro: fix non apng gif stickers being sent as images (#2050) 2023-12-13 06:37:31 -03:00
ruukulada
817cb9b60b
GameActivityToggle: Icon cleanup (#2041) 2023-12-12 23:40:13 -03:00
Nuckyz
c86de3299e
fixAll.eslint: true -> "explicit" 2023-12-12 23:29:22 -03:00
Nuckyz
1df0b571af
Fix broken patches 2023-12-12 22:57:32 -03:00
AutumnVN
6d911790e9
oneko: allow oneko in reduced motion (#2018) 2023-12-10 02:05:40 +01:00
Nuckyz
c9f7cf7540
ci: test all branches 2023-12-09 18:34:58 -03:00
Nuckyz
a9568bc055
reporter: fix bad logic 2023-12-09 17:57:25 -03:00
Nuckyz
539e538d87
commandHelpers: use MessageActions import 2023-12-09 17:57:25 -03:00
AutumnVN
799e6e7292
platformIndicators: fix (#2038) 2023-12-09 21:55:13 +01:00
maisy
510bfb8fa3
ReactErrorDecoder: fix using wrong react version's error map (#2040)
Co-authored-by: V <vendicated@riseup.net>
2023-12-09 20:36:58 +00:00
AutumnVN
534ca1e28d
fix: createBotMessage (#2033) 2023-12-07 02:44:14 +01:00
Vendicated
d629281e72
bump to v1.6.5 2023-12-07 00:35:05 +01:00
Damien Erambert
34cbb22efe
feat: add dropdown to choose vibrancy value on macOS (#1941)
Co-authored-by: V <vendicated@riseup.net>
2023-12-07 00:30:41 +01:00
Ajay Ramachandran
6ee50d30f6
fix(dearrow): remove > from DeArrow titles (#1999) 2023-12-07 00:26:08 +01:00
Han Seung Min - 한승민
fd9c675942
fix: patch helper overflow (#2007) 2023-12-07 00:25:13 +01:00
AutumnVN
920252956f
shikiCodeBlocks: support file preview (#1977) 2023-12-06 22:42:40 +00:00
Nickyux
e4942397dc
MessageClickActions: Ignore Ephemeral & Deleted Messages (#1972)
Co-authored-by: V <vendicated@riseup.net>
2023-12-06 22:18:03 +00:00
philipbry
9faa1331da
new plugin: notificationVolume (#1992)
Co-authored-by: Nuckyz <61953774+Nuckyz@users.noreply.github.com>
Co-authored-by: V <vendicated@riseup.net>
2023-12-06 22:15:34 +00:00
fres621
613b2dc5f6
Fix QuickMention not working in DMs (#2028)
Co-authored-by: V <vendicated@riseup.net>
2023-12-06 22:54:23 +01:00
nya
9a89f7b3b2
feat(plugin): XSOverlay (#1901)
Co-authored-by: V <vendicated@riseup.net>
2023-12-06 22:31:22 +01:00
AutumnVN
c0b6d8f1c4
devCompanion: add findComponentByCode (#2026) 2023-12-06 22:27:06 +01:00
Carter
3453d0c97c
feat(clearUrls): moar twitter rules (#2029) 2023-12-06 22:25:53 +01:00
Syncx
cf7028331c
Native Folder Support (#2031) 2023-12-06 22:25:29 +01:00
Nuckyz
08036f7af2
convert non lazy finds to test with reporter 2023-12-06 01:37:42 -03:00
Nuckyz
9dd00fb766
Fix SuperReactionTweaks patch 2023-12-01 23:06:18 -03:00
Nuckyz
80016180b6
FixImagesQuality: no longer make gifs play when autoplay is off 2023-12-01 16:39:15 -03:00
megumin
3e7d946296
fix(SpotifyControls): Requests double-sending when using Spotify Connect (#2023) 2023-11-30 16:32:48 -03:00
V
fccdd3dc08
migrate to new badge api
we used to store badges on the discord cdn.
since discord is now making
it harder to use their cdn for such purposes (due to expiring links), we
are forced to stop using it
thus, badges are now stored on our server, accessible via
https://badges.vencord.dev. The full list of badges is now at https://badges.vencord.dev/badges.json
2023-11-30 17:28:53 +01:00
Nuckyz
8e1546be00
Include ignored Discord errors in summary 2023-11-30 02:38:12 -03:00
Nuckyz
66dbe7ef07
Fix reporter testing for extractAndLoadChunks 2023-11-30 02:26:18 -03:00
Jack
b47a5f569e
feat: Add Decor plugin (#910) 2023-11-30 02:10:50 -03:00
Nuckyz
8ef1882d43
use findBulk on CrashHandler and cleaups 2023-11-30 00:43:23 -03:00
Nuckyz
9945219de7
openInviteModal utility
Co-authored-by: AutumnVN <autumnvnchino@gmail.com>
2023-11-29 23:15:19 -03:00
Nuckyz
091d29bf5e
CrashHandler: attempt to prevent more crashes 2023-11-29 16:14:05 -03:00
Nuckyz
9b6308a835
Fix a console shortcut and suppressExperimentalWarnings on more scripts 2023-11-28 22:12:00 -03:00
Nuckyz
597a74ff6c
ClientTheme: make color picker finder more specific 2023-11-28 17:05:11 -03:00
V
f814eeb74c
VoiceMessages: fix preview being blank 2023-11-28 16:33:02 -03:00
Nuckyz
1619ee404a
Utility function for loading Discord chunks (#2017) 2023-11-28 16:33:02 -03:00
Nuckyz
1b179f3c6d
Simplify some components finds; Make undo of patch groups more clear 2023-11-28 16:33:00 -03:00
Korbo
6573c4757c
fix: strikethrough in SilentMessageToggle's disabled SVG (#2004) 2023-11-28 18:32:29 +00:00
V
ec16fd8741
fix ci 2023-11-25 02:55:59 +01:00
V
6bbf562ab6
bump to v1.6.4 2023-11-25 02:51:58 +01:00
V
604cf00211
reporter: fix markdown output 2023-11-25 02:51:19 +01:00
V
7c3b247d84
fix WebContextMenus 2023-11-25 02:43:29 +01:00
V
68fca78541
reporter: test dev instead of main 2023-11-25 02:02:50 +01:00
V
598ffe6368
reporter: remove sleeps 2023-11-25 01:52:11 +01:00
V
534565db25
Add webpack find testing (#2016)
Co-authored-by: V <vendicated@riseup.net>
Co-authored-by: Nuckyz <61953774+Nuckyz@users.noreply.github.com>
2023-11-25 01:32:21 +01:00
Nuckyz
3e8e106be7
Fix broken patches 2023-11-24 16:49:19 -03:00
Nuckyz
fdddfdb05b
feat(plugins): FixImagesQuality 2023-11-24 16:32:30 -03:00
cat
e14fba28a5
ClearURLS for x.com links (#2005) 2023-11-23 14:31:25 -03:00
94 changed files with 3422 additions and 289 deletions

View file

@ -12,6 +12,12 @@ jobs:
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
if: ${{ github.event_name == 'schedule' }}
with:
ref: dev
- uses: actions/checkout@v3
if: ${{ github.event_name == 'workflow_dispatch' }}
- uses: pnpm/action-setup@v2 # Install pnpm using packageManager key in package.json - uses: pnpm/action-setup@v2 # Install pnpm using packageManager key in package.json
@ -29,7 +35,7 @@ jobs:
sudo apt-get install -y chromium-browser sudo apt-get install -y chromium-browser
- name: Build web - name: Build web
run: pnpm buildWeb --standalone run: pnpm buildWeb --standalone --dev
- name: Create Report - name: Create Report
timeout-minutes: 10 timeout-minutes: 10

View file

@ -1,11 +1,10 @@
name: test name: test
on: on:
push: push:
branches:
- main
pull_request: pull_request:
branches: branches:
- main - main
- dev
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View file

@ -1,7 +1,7 @@
{ {
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.eslint": true "source.fixAll.eslint": "explicit"
}, },
"[typescript]": { "[typescript]": {
"editor.defaultFormatter": "vscode.typescript-language-features" "editor.defaultFormatter": "vscode.typescript-language-features"

View file

@ -1,7 +1,7 @@
{ {
"name": "vencord", "name": "vencord",
"private": "true", "private": "true",
"version": "1.6.3", "version": "1.6.5",
"description": "The cutest Discord client mod", "description": "The cutest Discord client mod",
"homepage": "https://github.com/Vendicated/Vencord#readme", "homepage": "https://github.com/Vendicated/Vencord#readme",
"bugs": { "bugs": {
@ -17,7 +17,7 @@
"doc": "docs" "doc": "docs"
}, },
"scripts": { "scripts": {
"build": "node scripts/build/build.mjs", "build": "node --require=./scripts/suppressExperimentalWarnings.js scripts/build/build.mjs",
"buildWeb": "node --require=./scripts/suppressExperimentalWarnings.js scripts/build/buildWeb.mjs", "buildWeb": "node --require=./scripts/suppressExperimentalWarnings.js scripts/build/buildWeb.mjs",
"generatePluginJson": "tsx scripts/generatePluginList.ts", "generatePluginJson": "tsx scripts/generatePluginList.ts",
"inject": "node scripts/runInstaller.mjs", "inject": "node scripts/runInstaller.mjs",
@ -28,7 +28,7 @@
"testWeb": "pnpm lint && pnpm buildWeb && pnpm testTsc", "testWeb": "pnpm lint && pnpm buildWeb && pnpm testTsc",
"testTsc": "tsc --noEmit", "testTsc": "tsc --noEmit",
"uninject": "node scripts/runInstaller.mjs", "uninject": "node scripts/runInstaller.mjs",
"watch": "node scripts/build/build.mjs --watch" "watch": "node --require=./scripts/suppressExperimentalWarnings.js scripts/build/build.mjs --watch"
}, },
"dependencies": { "dependencies": {
"@sapphi-red/web-noise-suppressor": "0.3.3", "@sapphi-red/web-noise-suppressor": "0.3.3",
@ -68,7 +68,8 @@
"tsx": "^3.12.7", "tsx": "^3.12.7",
"type-fest": "^3.9.0", "type-fest": "^3.9.0",
"typescript": "^5.0.4", "typescript": "^5.0.4",
"zip-local": "^0.3.5" "zip-local": "^0.3.5",
"zustand": "^3.7.2"
}, },
"packageManager": "pnpm@8.10.2", "packageManager": "pnpm@8.10.2",
"pnpm": { "pnpm": {

View file

@ -1,9 +1,5 @@
lockfileVersion: '6.0' lockfileVersion: '6.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
patchedDependencies: patchedDependencies:
eslint-plugin-path-alias@1.0.0: eslint-plugin-path-alias@1.0.0:
hash: m6sma4g6bh67km3q6igf6uxaja hash: m6sma4g6bh67km3q6igf6uxaja
@ -123,6 +119,9 @@ devDependencies:
zip-local: zip-local:
specifier: ^0.3.5 specifier: ^0.3.5
version: 0.3.5 version: 0.3.5
zustand:
specifier: ^3.7.2
version: 3.7.2
packages: packages:
@ -3450,8 +3449,22 @@ packages:
q: 1.5.1 q: 1.5.1
dev: true dev: true
/zustand@3.7.2:
resolution: {integrity: sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==}
engines: {node: '>=12.7.0'}
peerDependencies:
react: '>=16.8'
peerDependenciesMeta:
react:
optional: true
dev: true
github.com/mattdesl/gifenc/64842fca317b112a8590f8fef2bf3825da8f6fe3: github.com/mattdesl/gifenc/64842fca317b112a8590f8fef2bf3825da8f6fe3:
resolution: {tarball: https://codeload.github.com/mattdesl/gifenc/tar.gz/64842fca317b112a8590f8fef2bf3825da8f6fe3} resolution: {tarball: https://codeload.github.com/mattdesl/gifenc/tar.gz/64842fca317b112a8590f8fef2bf3825da8f6fe3}
name: gifenc name: gifenc
version: 1.0.3 version: 1.0.3
dev: false dev: false
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false

View file

@ -21,11 +21,11 @@ import esbuild from "esbuild";
import { readdir } from "fs/promises"; import { readdir } from "fs/promises";
import { join } from "path"; import { join } from "path";
import { BUILD_TIMESTAMP, commonOpts, existsAsync, globPlugins, isStandalone, updaterDisabled, VERSION, watch } from "./common.mjs"; import { BUILD_TIMESTAMP, commonOpts, existsAsync, globPlugins, isDev, isStandalone, updaterDisabled, VERSION, watch } from "./common.mjs";
const defines = { const defines = {
IS_STANDALONE: isStandalone, IS_STANDALONE: isStandalone,
IS_DEV: JSON.stringify(watch), IS_DEV: JSON.stringify(isDev),
IS_UPDATER_DISABLED: updaterDisabled, IS_UPDATER_DISABLED: updaterDisabled,
IS_WEB: false, IS_WEB: false,
IS_EXTENSION: false, IS_EXTENSION: false,
@ -76,7 +76,11 @@ const globNativesPlugin = {
if (!await existsAsync(dirPath)) continue; if (!await existsAsync(dirPath)) continue;
const plugins = await readdir(dirPath); const plugins = await readdir(dirPath);
for (const p of plugins) { for (const p of plugins) {
if (!await existsAsync(join(dirPath, p, "native.ts"))) continue; const nativePath = join(dirPath, p, "native.ts");
const indexNativePath = join(dirPath, p, "native/index.ts");
if (!(await existsAsync(nativePath)) && !(await existsAsync(indexNativePath)))
continue;
const nameParts = p.split("."); const nameParts = p.split(".");
const namePartsWithoutTarget = nameParts.length === 1 ? nameParts : nameParts.slice(0, -1); const namePartsWithoutTarget = nameParts.length === 1 ? nameParts : nameParts.slice(0, -1);

View file

@ -23,7 +23,7 @@ import { appendFile, mkdir, readdir, readFile, rm, writeFile } from "fs/promises
import { join } from "path"; import { join } from "path";
import Zip from "zip-local"; import Zip from "zip-local";
import { BUILD_TIMESTAMP, commonOpts, globPlugins, VERSION, watch } from "./common.mjs"; import { BUILD_TIMESTAMP, commonOpts, globPlugins, isDev, VERSION } from "./common.mjs";
/** /**
* @type {esbuild.BuildOptions} * @type {esbuild.BuildOptions}
@ -43,7 +43,7 @@ const commonOptions = {
IS_WEB: "true", IS_WEB: "true",
IS_EXTENSION: "false", IS_EXTENSION: "false",
IS_STANDALONE: "true", IS_STANDALONE: "true",
IS_DEV: JSON.stringify(watch), IS_DEV: JSON.stringify(isDev),
IS_DISCORD_DESKTOP: "false", IS_DISCORD_DESKTOP: "false",
IS_VESKTOP: "false", IS_VESKTOP: "false",
IS_UPDATER_DISABLED: "true", IS_UPDATER_DISABLED: "true",

View file

@ -33,6 +33,7 @@ export const VERSION = PackageJSON.version;
// https://reproducible-builds.org/docs/source-date-epoch/ // https://reproducible-builds.org/docs/source-date-epoch/
export const BUILD_TIMESTAMP = Number(process.env.SOURCE_DATE_EPOCH) || Date.now(); export const BUILD_TIMESTAMP = Number(process.env.SOURCE_DATE_EPOCH) || Date.now();
export const watch = process.argv.includes("--watch"); export const watch = process.argv.includes("--watch");
export const isDev = watch || process.argv.includes("--dev");
export const isStandalone = JSON.stringify(process.argv.includes("--standalone")); export const isStandalone = JSON.stringify(process.argv.includes("--standalone"));
export const updaterDisabled = JSON.stringify(process.argv.includes("--disable-updater")); export const updaterDisabled = JSON.stringify(process.argv.includes("--disable-updater"));
export const gitHash = process.env.VENCORD_HASH || execSync("git rev-parse --short HEAD", { encoding: "utf-8" }).trim(); export const gitHash = process.env.VENCORD_HASH || execSync("git rev-parse --short HEAD", { encoding: "utf-8" }).trim();

View file

@ -34,7 +34,7 @@ for (const variable of ["DISCORD_TOKEN", "CHROMIUM_BIN"]) {
const CANARY = process.env.USE_CANARY === "true"; const CANARY = process.env.USE_CANARY === "true";
const browser = await pup.launch({ const browser = await pup.launch({
headless: true, headless: "new",
executablePath: process.env.CHROMIUM_BIN executablePath: process.env.CHROMIUM_BIN
}); });
@ -58,14 +58,16 @@ const report = {
plugin: string; plugin: string;
error: string; error: string;
}[], }[],
otherErrors: [] as string[] otherErrors: [] as string[],
badWebpackFinds: [] as string[]
}; };
const IGNORED_DISCORD_ERRORS = [ const IGNORED_DISCORD_ERRORS = [
"KeybindStore: Looking for callback action", "KeybindStore: Looking for callback action",
"Unable to process domain list delta: Client revision number is null", "Unable to process domain list delta: Client revision number is null",
"Downloading the full bad domains file", "Downloading the full bad domains file",
/\[GatewaySocket\].{0,110}Cannot access '/ /\[GatewaySocket\].{0,110}Cannot access '/,
"search for 'name' in undefined"
] as Array<string | RegExp>; ] as Array<string | RegExp>;
function toCodeBlock(s: string) { function toCodeBlock(s: string) {
@ -74,7 +76,10 @@ function toCodeBlock(s: string) {
} }
async function printReport() { async function printReport() {
console.log();
console.log("# Vencord Report" + (CANARY ? " (Canary)" : "")); console.log("# Vencord Report" + (CANARY ? " (Canary)" : ""));
console.log(); console.log();
console.log("## Bad Patches"); console.log("## Bad Patches");
@ -87,21 +92,43 @@ async function printReport() {
console.log(); console.log();
console.log("## Bad Webpack Finds");
report.badWebpackFinds.forEach(p => console.log("- " + p));
console.log();
console.log("## Bad Starts"); console.log("## Bad Starts");
report.badStarts.forEach(p => { report.badStarts.forEach(p => {
console.log(`- ${p.plugin}`); console.log(`- ${p.plugin}`);
console.log(` - Error: ${toCodeBlock(p.error)}`); console.log(` - Error: ${toCodeBlock(p.error)}`);
}); });
report.otherErrors = report.otherErrors.filter(e => !IGNORED_DISCORD_ERRORS.some(regex => e.match(regex))); console.log();
const ignoredErrors = [] as string[];
report.otherErrors = report.otherErrors.filter(e => {
if (IGNORED_DISCORD_ERRORS.some(regex => e.match(regex))) {
ignoredErrors.push(e);
return false;
}
return true;
});
console.log("## Discord Errors"); console.log("## Discord Errors");
report.otherErrors.forEach(e => { report.otherErrors.forEach(e => {
console.log(`- ${toCodeBlock(e)}`); console.log(`- ${toCodeBlock(e)}`);
}); });
console.log();
console.log("## Ignored Discord Errors");
ignoredErrors.forEach(e => {
console.log(`- ${toCodeBlock(e)}`);
});
console.log();
if (process.env.DISCORD_WEBHOOK) { if (process.env.DISCORD_WEBHOOK) {
// this code was written almost entirely by Copilot xD
await fetch(process.env.DISCORD_WEBHOOK, { await fetch(process.env.DISCORD_WEBHOOK, {
method: "POST", method: "POST",
headers: { headers: {
@ -110,7 +137,7 @@ async function printReport() {
body: JSON.stringify({ body: JSON.stringify({
description: "Here's the latest Vencord Report!", description: "Here's the latest Vencord Report!",
username: "Vencord Reporter" + (CANARY ? " (Canary)" : ""), username: "Vencord Reporter" + (CANARY ? " (Canary)" : ""),
avatar_url: "https://cdn.discordapp.com/icons/1015060230222131221/f0204a918c6c9c9a43195997e97d8adf.webp", avatar_url: "https://cdn.discordapp.com/avatars/1017176847865352332/c312b6b44179ae6817de7e4b09e9c6af.webp?size=512",
embeds: [ embeds: [
{ {
title: "Bad Patches", title: "Bad Patches",
@ -125,6 +152,11 @@ async function printReport() {
}).join("\n\n") || "None", }).join("\n\n") || "None",
color: report.badPatches.length ? 0xff0000 : 0x00ff00 color: report.badPatches.length ? 0xff0000 : 0x00ff00
}, },
{
title: "Bad Webpack Finds",
description: report.badWebpackFinds.map(toCodeBlock).join("\n") || "None",
color: report.badWebpackFinds.length ? 0xff0000 : 0x00ff00
},
{ {
title: "Bad Starts", title: "Bad Starts",
description: report.badStarts.map(p => { description: report.badStarts.map(p => {
@ -153,29 +185,38 @@ async function printReport() {
page.on("console", async e => { page.on("console", async e => {
const level = e.type(); const level = e.type();
const args = e.args(); const rawArgs = e.args();
const firstArg = (await args[0]?.jsonValue()); const firstArg = await rawArgs[0]?.jsonValue();
if (firstArg === "PUPPETEER_TEST_DONE_SIGNAL") { if (firstArg === "[PUPPETEER_TEST_DONE_SIGNAL]") {
await browser.close(); await browser.close();
await printReport(); await printReport();
process.exit(); process.exit();
} }
const isVencord = (await args[0]?.jsonValue()) === "[Vencord]"; const isVencord = firstArg === "[Vencord]";
const isDebug = (await args[0]?.jsonValue()) === "[PUP_DEBUG]"; const isDebug = firstArg === "[PUP_DEBUG]";
const isWebpackFindFail = firstArg === "[PUP_WEBPACK_FIND_FAIL]";
if (isWebpackFindFail) {
process.exitCode = 1;
report.badWebpackFinds.push(await rawArgs[1].jsonValue() as string);
}
if (isVencord) { if (isVencord) {
// make ci fail const args = await Promise.all(e.args().map(a => a.jsonValue()));
process.exitCode = 1;
const jsonArgs = await Promise.all(args.map(a => a.jsonValue())); const [, tag, message] = args as Array<string>;
const [, tag, message] = jsonArgs; const cause = await maybeGetError(e.args()[3]);
const cause = await maybeGetError(args[3]);
switch (tag) { switch (tag) {
case "WebpackInterceptor:": case "WebpackInterceptor:":
const [, plugin, type, id, regex] = (message as string).match(/Patch by (.+?) (had no effect|errored|found no module) \(Module id is (.+?)\): (.+)/)!; const patchFailMatch = message.match(/Patch by (.+?) (had no effect|errored|found no module) \(Module id is (.+?)\): (.+)/)!;
if (!patchFailMatch) break;
process.exitCode = 1;
const [, plugin, type, id, regex] = patchFailMatch;
report.badPatches.push({ report.badPatches.push({
plugin, plugin,
type, type,
@ -183,16 +224,25 @@ page.on("console", async e => {
match: regex.replace(/\[A-Za-z_\$\]\[\\w\$\]\*/g, "\\i"), match: regex.replace(/\[A-Za-z_\$\]\[\\w\$\]\*/g, "\\i"),
error: cause error: cause
}); });
break; break;
case "PluginManager:": case "PluginManager:":
const [, name] = (message as string).match(/Failed to start (.+)/)!; const failedToStartMatch = message.match(/Failed to start (.+)/);
if (!failedToStartMatch) break;
process.exitCode = 1;
const [, name] = failedToStartMatch;
report.badStarts.push({ report.badStarts.push({
plugin: name, plugin: name,
error: cause error: cause
}); });
break; break;
} }
} else if (isDebug) { }
if (isDebug) {
console.error(e.text()); console.error(e.text());
} else if (level === "error") { } else if (level === "error") {
const text = await Promise.all( const text = await Promise.all(
@ -206,8 +256,8 @@ page.on("console", async e => {
).then(a => a.join(" ").trim()); ).then(a => a.join(" ").trim());
if (text.length && !text.startsWith("Failed to load resource: the server responded with a status of")) { if (text.length && !text.startsWith("Failed to load resource: the server responded with a status of") && !text.includes("Webpack")) {
console.error("Got unexpected error", text); console.error("[Unexpected Error]", text);
report.otherErrors.push(text); report.otherErrors.push(text);
} }
} }
@ -219,17 +269,16 @@ page.on("pageerror", e => console.error("[Page Error]", e));
await page.setBypassCSP(true); await page.setBypassCSP(true);
function runTime(token: string) { function runTime(token: string) {
console.error("[PUP_DEBUG]", "Starting test..."); console.log("[PUP_DEBUG]", "Starting test...");
try { try {
// spoof languages to not be suspicious // Spoof languages to not be suspicious
Object.defineProperty(navigator, "languages", { Object.defineProperty(navigator, "languages", {
get: function () { get: function () {
return ["en-US", "en"]; return ["en-US", "en"];
}, },
}); });
// Monkey patch Logger to not log with custom css // Monkey patch Logger to not log with custom css
// @ts-ignore // @ts-ignore
Vencord.Util.Logger.prototype._log = function (level, levelColor, args) { Vencord.Util.Logger.prototype._log = function (level, levelColor, args) {
@ -237,7 +286,7 @@ function runTime(token: string) {
console[level]("[Vencord]", this.name + ":", ...args); console[level]("[Vencord]", this.name + ":", ...args);
}; };
// force enable all plugins and patches // Force enable all plugins and patches
Vencord.Plugins.patches.length = 0; Vencord.Plugins.patches.length = 0;
Object.values(Vencord.Plugins.plugins).forEach(p => { Object.values(Vencord.Plugins.plugins).forEach(p => {
// Needs native server to run // Needs native server to run
@ -247,8 +296,15 @@ function runTime(token: string) {
p.patches?.forEach(patch => { p.patches?.forEach(patch => {
patch.plugin = p.name; patch.plugin = p.name;
delete patch.predicate; delete patch.predicate;
delete patch.group;
if (!Array.isArray(patch.replacement)) if (!Array.isArray(patch.replacement))
patch.replacement = [patch.replacement]; patch.replacement = [patch.replacement];
patch.replacement.forEach(r => {
delete r.predicate;
});
Vencord.Plugins.patches.push(patch); Vencord.Plugins.patches.push(patch);
}); });
}); });
@ -256,41 +312,145 @@ function runTime(token: string) {
Vencord.Webpack.waitFor( Vencord.Webpack.waitFor(
"loginToken", "loginToken",
m => { m => {
console.error("[PUP_DEBUG]", "Logging in with token..."); console.log("[PUP_DEBUG]", "Logging in with token...");
m.loginToken(token); m.loginToken(token);
} }
); );
// force load all chunks // Force load all chunks
Vencord.Webpack.onceReady.then(() => setTimeout(async () => { Vencord.Webpack.onceReady.then(() => setTimeout(async () => {
console.error("[PUP_DEBUG]", "Webpack is ready!"); console.log("[PUP_DEBUG]", "Webpack is ready!");
const { wreq } = Vencord.Webpack; const { wreq } = Vencord.Webpack;
console.error("[PUP_DEBUG]", "Loading all chunks..."); console.log("[PUP_DEBUG]", "Loading all chunks...");
const ids = Function("return" + wreq.u.toString().match(/(?<=\()\{.+?\}/s)![0])();
for (const id in ids) { let chunks = null as Record<number, string[]> | null;
const sym = Symbol("Vencord.chunksExtract");
Object.defineProperty(Object.prototype, sym, {
get() {
chunks = this;
},
set() { },
configurable: true,
});
await (wreq as any).el(sym);
delete Object.prototype[sym];
const validChunksEntryPoints = new Set<string>();
const validChunks = new Set<string>();
const invalidChunks = new Set<string>();
if (!chunks) throw new Error("Failed to get chunks");
for (const entryPoint in chunks) {
const chunkIds = chunks[entryPoint];
let invalidEntryPoint = false;
for (const id of chunkIds) {
if (!wreq.u(id)) continue;
const isWasm = await fetch(wreq.p + wreq.u(id))
.then(r => r.text())
.then(t => t.includes(".module.wasm") || !t.includes("(this.webpackChunkdiscord_app=this.webpackChunkdiscord_app||[]).push"));
if (isWasm) {
invalidChunks.add(id);
invalidEntryPoint = true;
continue;
}
validChunks.add(id);
}
if (!invalidEntryPoint)
validChunksEntryPoints.add(entryPoint);
}
for (const entryPoint of validChunksEntryPoints) {
try {
// Loads all chunks required for an entry point
await (wreq as any).el(entryPoint);
} catch (err) { }
}
const allChunks = Function("return " + (wreq.u.toString().match(/(?<=\()\{.+?\}/s)?.[0] ?? "null"))() as Record<string | number, string[]> | null;
if (!allChunks) throw new Error("Failed to get all chunks");
const chunksLeft = Object.keys(allChunks).filter(id => {
return !(validChunks.has(id) || invalidChunks.has(id));
});
for (const id of chunksLeft) {
const isWasm = await fetch(wreq.p + wreq.u(id)) const isWasm = await fetch(wreq.p + wreq.u(id))
.then(r => r.text()) .then(r => r.text())
.then(t => t.includes(".module.wasm") || !t.includes("(this.webpackChunkdiscord_app=this.webpackChunkdiscord_app||[]).push")); .then(t => t.includes(".module.wasm") || !t.includes("(this.webpackChunkdiscord_app=this.webpackChunkdiscord_app||[]).push"));
if (!isWasm) // Loads a chunk
await wreq.e(id as any); if (!isWasm) await wreq.e(id as any);
await new Promise(r => setTimeout(r, 150));
} }
console.error("[PUP_DEBUG]", "Finished loading chunks!");
// Make sure every chunk has finished loading
await new Promise(r => setTimeout(r, 1000));
for (const entryPoint of validChunksEntryPoints) {
try {
if (wreq.m[entryPoint]) wreq(entryPoint as any);
} catch (err) {
console.error(err);
}
}
console.log("[PUP_DEBUG]", "Finished loading all chunks!");
for (const patch of Vencord.Plugins.patches) { for (const patch of Vencord.Plugins.patches) {
if (!patch.all) { if (!patch.all) {
new Vencord.Util.Logger("WebpackInterceptor").warn(`Patch by ${patch.plugin} found no module (Module id is -): ${patch.find}`); new Vencord.Util.Logger("WebpackInterceptor").warn(`Patch by ${patch.plugin} found no module (Module id is -): ${patch.find}`);
} }
} }
setTimeout(() => console.log("PUPPETEER_TEST_DONE_SIGNAL"), 1000);
for (const [searchType, args] of Vencord.Webpack.lazyWebpackSearchHistory) {
let method = searchType;
if (searchType === "findComponent") method = "find";
if (searchType === "findExportedComponent") method = "findByProps";
if (searchType === "waitFor" || searchType === "waitForComponent" || searchType === "waitForStore") {
if (typeof args[0] === "string") method = "findByProps";
else method = "find";
}
try {
let result: any;
if (method === "proxyLazyWebpack" || method === "LazyComponentWebpack") {
const [factory] = args;
result = factory();
} else if (method === "extractAndLoadChunks") {
const [code, matcher] = args;
const module = Vencord.Webpack.findModuleFactory(...code);
if (module) result = module.toString().match(Vencord.Util.canonicalizeMatch(matcher));
} else {
// @ts-ignore
result = Vencord.Webpack[method](...args);
}
if (result == null || ("$$vencordInternal" in result && result.$$vencordInternal() == null)) throw "a rock at ben shapiro";
} catch (e) {
let logMessage = searchType;
if (method === "find" || method === "proxyLazyWebpack" || method === "LazyComponentWebpack") logMessage += `(${args[0].toString().slice(0, 147)}...)`;
else if (method === "extractAndLoadChunks") logMessage += `([${args[0].map(arg => `"${arg}"`).join(", ")}], ${args[1].toString()})`;
else logMessage += `(${args.map(arg => `"${arg}"`).join(", ")})`;
console.log("[PUP_WEBPACK_FIND_FAIL]", logMessage);
}
}
setTimeout(() => console.log("[PUPPETEER_TEST_DONE_SIGNAL]"), 1000);
}, 1000)); }, 1000));
} catch (e) { } catch (e) {
console.error("[PUP_DEBUG]", "A fatal error occurred"); console.log("[PUP_DEBUG]", "A fatal error occurred:", e);
console.error("[PUP_DEBUG]", e);
process.exit(1); process.exit(1);
} }
} }

View file

@ -18,14 +18,13 @@
import { mergeDefaults } from "@utils/misc"; import { mergeDefaults } from "@utils/misc";
import { findByPropsLazy } from "@webpack"; import { findByPropsLazy } from "@webpack";
import { SnowflakeUtils } from "@webpack/common"; import { MessageActions, SnowflakeUtils } from "@webpack/common";
import { Message } from "discord-types/general"; import { Message } from "discord-types/general";
import type { PartialDeep } from "type-fest"; import type { PartialDeep } from "type-fest";
import { Argument } from "./types"; import { Argument } from "./types";
const MessageCreator = findByPropsLazy("createBotMessage"); const MessageCreator = findByPropsLazy("createBotMessage");
const MessageSender = findByPropsLazy("receiveMessage");
export function generateId() { export function generateId() {
return `-${SnowflakeUtils.fromTimestamp(Date.now())}`; return `-${SnowflakeUtils.fromTimestamp(Date.now())}`;
@ -40,7 +39,7 @@ export function generateId() {
export function sendBotMessage(channelId: string, message: PartialDeep<Message>): Message { export function sendBotMessage(channelId: string, message: PartialDeep<Message>): Message {
const botMessage = MessageCreator.createBotMessage({ channelId, content: "", embeds: [] }); const botMessage = MessageCreator.createBotMessage({ channelId, content: "", embeds: [] });
MessageSender.receiveMessage(channelId, mergeDefaults(message, botMessage)); MessageActions.receiveMessage(channelId, mergeDefaults(message, botMessage));
return message as Message; return message as Message;
} }

View file

@ -38,7 +38,21 @@ export interface Settings {
frameless: boolean; frameless: boolean;
transparent: boolean; transparent: boolean;
winCtrlQ: boolean; winCtrlQ: boolean;
macosTranslucency: boolean; macosVibrancyStyle:
| "content"
| "fullscreen-ui"
| "header"
| "hud"
| "menu"
| "popover"
| "selection"
| "sidebar"
| "titlebar"
| "tooltip"
| "under-page"
| "window"
| undefined;
macosTranslucency: boolean | undefined;
disableMinSize: boolean; disableMinSize: boolean;
winNativeTitleBar: boolean; winNativeTitleBar: boolean;
plugins: { plugins: {
@ -74,7 +88,9 @@ const DefaultSettings: Settings = {
frameless: false, frameless: false,
transparent: false, transparent: false,
winCtrlQ: false, winCtrlQ: false,
macosTranslucency: false, // Replaced by macosVibrancyStyle
macosTranslucency: undefined,
macosVibrancyStyle: undefined,
disableMinSize: false, disableMinSize: false,
winNativeTitleBar: false, winNativeTitleBar: false,
plugins: {}, plugins: {},

View file

@ -255,3 +255,38 @@ export function DeleteIcon(props: IconProps) {
</Icon> </Icon>
); );
} }
export function PlusIcon(props: IconProps) {
return (
<Icon
{...props}
className={classes(props.className, "vc-plus-icon")}
viewBox="0 0 18 18"
>
<polygon
fill-rule="nonzero"
fill="currentColor"
points="15 10 10 10 10 15 8 15 8 10 3 10 3 8 8 8 8 3 10 3 10 8 15 8"
/>
</Icon>
);
}
export function NoEntrySignIcon(props: IconProps) {
return (
<Icon
{...props}
className={classes(props.className, "vc-no-entry-sign-icon")}
viewBox="0 0 24 24"
>
<path
d="M0 0h24v24H0z"
fill="none"
/>
<path
fill="currentColor"
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8 0-1.85.63-3.55 1.69-4.9L16.9 18.31C15.55 19.37 13.85 20 12 20zm6.31-3.1L7.1 5.69C8.45 4.63 10.15 4 12 4c4.42 0 8 3.58 8 8 0 1.85-.63 3.55-1.69 4.9z"
/>
</Icon>
);
}

View file

@ -108,7 +108,7 @@ function ReplacementComponent({ module, match, replacement, setReplacementError
function renderDiff() { function renderDiff() {
return diff?.map(p => { return diff?.map(p => {
const color = p.added ? "lime" : p.removed ? "red" : "grey"; const color = p.added ? "lime" : p.removed ? "red" : "grey";
return <div style={{ color, userSelect: "text" }}>{p.value}</div>; return <div style={{ color, userSelect: "text", wordBreak: "break-all", lineBreak: "anywhere" }}>{p.value}</div>;
}); });
} }

View file

@ -21,12 +21,13 @@ import { classNameFactory } from "@api/Styles";
import { Flex } from "@components/Flex"; import { Flex } from "@components/Flex";
import { DeleteIcon } from "@components/Icons"; import { DeleteIcon } from "@components/Icons";
import { Link } from "@components/Link"; import { Link } from "@components/Link";
import { openInviteModal } from "@utils/discord";
import { Margins } from "@utils/margins"; import { Margins } from "@utils/margins";
import { classes } from "@utils/misc"; import { classes } from "@utils/misc";
import { showItemInFolder } from "@utils/native"; import { showItemInFolder } from "@utils/native";
import { useAwaiter } from "@utils/react"; import { useAwaiter } from "@utils/react";
import { findByPropsLazy, findLazy } from "@webpack"; import { findByPropsLazy, findLazy } from "@webpack";
import { Button, Card, FluxDispatcher, Forms, React, showToast, TabBar, TextArea, useEffect, useRef, useState } from "@webpack/common"; import { Button, Card, Forms, React, showToast, TabBar, TextArea, useEffect, useRef, useState } from "@webpack/common";
import { UserThemeHeader } from "main/themes"; import { UserThemeHeader } from "main/themes";
import type { ComponentType, Ref, SyntheticEvent } from "react"; import type { ComponentType, Ref, SyntheticEvent } from "react";
@ -125,15 +126,7 @@ function ThemeCard({ theme, enabled, onChange, onDelete }: ThemeCardProps) {
href={`https://discord.gg/${theme.invite}`} href={`https://discord.gg/${theme.invite}`}
onClick={async e => { onClick={async e => {
e.preventDefault(); e.preventDefault();
const { invite } = await InviteActions.resolveInvite(theme.invite, "Desktop Modal"); theme.invite != null && openInviteModal(theme.invite).catch(() => showToast("Invalid or expired invite"));
if (!invite) return showToast("Invalid or expired invite");
FluxDispatcher.dispatch({
type: "INVITE_MODAL_OPEN",
invite,
code: theme.invite,
context: "APP"
});
}} }}
> >
Discord Server Discord Server

View file

@ -48,6 +48,15 @@ function VencordSettings() {
const isWindows = navigator.platform.toLowerCase().startsWith("win"); const isWindows = navigator.platform.toLowerCase().startsWith("win");
const isMac = navigator.platform.toLowerCase().startsWith("mac"); const isMac = navigator.platform.toLowerCase().startsWith("mac");
const needsVibrancySettings = IS_DISCORD_DESKTOP && isMac;
// One-time migration of the old setting to the new one if necessary.
React.useEffect(() => {
if (settings.macosTranslucency === true && !settings.macosVibrancyStyle) {
settings.macosVibrancyStyle = "sidebar";
settings.macosTranslucency = undefined;
}
}, []);
const Switches: Array<false | { const Switches: Array<false | {
key: KeysOfType<typeof settings, boolean>; key: KeysOfType<typeof settings, boolean>;
@ -89,11 +98,6 @@ function VencordSettings() {
title: "Disable minimum window size", title: "Disable minimum window size",
note: "Requires a full restart" note: "Requires a full restart"
}, },
IS_DISCORD_DESKTOP && isMac && {
key: "macosTranslucency",
title: "Enable translucent window",
note: "Requires a full restart"
}
]; ];
return ( return (
@ -152,6 +156,71 @@ function VencordSettings() {
</Forms.FormSection> </Forms.FormSection>
{needsVibrancySettings && <>
<Forms.FormTitle tag="h5">Window vibrancy style (requires restart)</Forms.FormTitle>
<Select
className={Margins.bottom20}
placeholder="Window vibrancy style"
options={[
// Sorted from most opaque to most transparent
{
label: "No vibrancy", default: !settings.macosTranslucency, value: undefined
},
{
label: "Under Page (window tinting)",
value: "under-page"
},
{
label: "Content",
value: "content"
},
{
label: "Window",
value: "window"
},
{
label: "Selection",
value: "selection"
},
{
label: "Titlebar",
value: "titlebar"
},
{
label: "Header",
value: "header"
},
{
label: "Sidebar (old value for transparent windows)",
value: "sidebar",
default: settings.macosTranslucency
},
{
label: "Tooltip",
value: "tooltip"
},
{
label: "Menu",
value: "menu"
},
{
label: "Popover",
value: "popover"
},
{
label: "Fullscreen UI (transparent but slightly muted)",
value: "fullscreen-ui"
},
{
label: "HUD (Most transparent)",
value: "hud"
},
]}
select={v => settings.macosVibrancyStyle = v}
isSelected={v => settings.macosVibrancyStyle === v}
serialize={identity} />
</>}
{typeof Notification !== "undefined" && <NotificationSection settings={settings.notifications} />} {typeof Notification !== "undefined" && <NotificationSection settings={settings.notifications} />}
</SettingsTab> </SettingsTab>
); );

View file

@ -85,9 +85,15 @@ if (!IS_VANILLA) {
options.backgroundColor = "#00000000"; options.backgroundColor = "#00000000";
} }
if (settings.macosTranslucency && process.platform === "darwin") { const needsVibrancy = process.platform === "darwin" || (settings.macosVibrancyStyle || settings.macosTranslucency);
if (needsVibrancy) {
options.backgroundColor = "#00000000"; options.backgroundColor = "#00000000";
options.vibrancy = "sidebar"; if (settings.macosTranslucency) {
options.vibrancy = "sidebar";
} else if (settings.macosVibrancyStyle) {
options.vibrancy = settings.macosVibrancyStyle;
}
} }
process.env.DISCORD_PRELOAD = original; process.env.DISCORD_PRELOAD = original;

View file

@ -22,14 +22,13 @@ import ErrorBoundary from "@components/ErrorBoundary";
import { Flex } from "@components/Flex"; import { Flex } from "@components/Flex";
import { Heart } from "@components/Heart"; import { Heart } from "@components/Heart";
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import { Logger } from "@utils/Logger";
import { Margins } from "@utils/margins"; import { Margins } from "@utils/margins";
import { isPluginDev } from "@utils/misc"; import { isPluginDev } from "@utils/misc";
import { closeModal, Modals, openModal } from "@utils/modal"; import { closeModal, Modals, openModal } from "@utils/modal";
import definePlugin from "@utils/types"; import definePlugin from "@utils/types";
import { Forms, Toasts } from "@webpack/common"; import { Forms, Toasts } from "@webpack/common";
const CONTRIBUTOR_BADGE = "https://cdn.discordapp.com/attachments/1033680203433660458/1092089947126780035/favicon.png"; const CONTRIBUTOR_BADGE = "https://vencord.dev/assets/favicon.png";
const ContributorBadge: ProfileBadge = { const ContributorBadge: ProfileBadge = {
description: "Vencord Contributor", description: "Vencord Contributor",
@ -45,7 +44,7 @@ const ContributorBadge: ProfileBadge = {
link: "https://github.com/Vendicated/Vencord" link: "https://github.com/Vendicated/Vencord"
}; };
let DonorBadges = {} as Record<string, Pick<ProfileBadge, "image" | "description">[]>; let DonorBadges = {} as Record<string, Array<Record<"tooltip" | "badge", string>>>;
async function loadBadges(noCache = false) { async function loadBadges(noCache = false) {
DonorBadges = {}; DonorBadges = {};
@ -54,19 +53,8 @@ async function loadBadges(noCache = false) {
if (noCache) if (noCache)
init.cache = "no-cache"; init.cache = "no-cache";
const badges = await fetch("https://gist.githubusercontent.com/Vendicated/51a3dd775f6920429ec6e9b735ca7f01/raw/badges.csv", init) DonorBadges = await fetch("https://badges.vencord.dev/badges.json", init)
.then(r => r.text()); .then(r => r.json());
const lines = badges.trim().split("\n");
if (lines.shift() !== "id,tooltip,image") {
new Logger("BadgeAPI").error("Invalid badges.csv file!");
return;
}
for (const line of lines) {
const [id, description, image] = line.split(",");
(DonorBadges[id] ??= []).push({ image, description });
}
} }
export default definePlugin({ export default definePlugin({
@ -127,7 +115,8 @@ export default definePlugin({
getDonorBadges(userId: string) { getDonorBadges(userId: string) {
return DonorBadges[userId]?.map(badge => ({ return DonorBadges[userId]?.map(badge => ({
...badge, image: badge.badge,
description: badge.tooltip,
position: BadgePosition.START, position: BadgePosition.START,
props: { props: {
style: { style: {

View file

@ -46,6 +46,14 @@ export default definePlugin({
match: /(?<=\.activityEmoji,.+?animate:)\i/, match: /(?<=\.activityEmoji,.+?animate:)\i/,
replace: "!0" replace: "!0"
} }
},
{
// Guild Banner
find: ".animatedBannerHoverLayer,onMouseEnter:",
replacement: {
match: /(?<=guildBanner:\i,animate:)\i(?=}\))/,
replace: "!0"
}
} }
] ]
}); });

43
src/plugins/badge.ts Normal file
View file

@ -0,0 +1,43 @@
/* eslint-disable header/header */
import { BadgePosition, ProfileBadge } from "@api/Badges";
import { Badges } from "@api/index";
import { Devs } from "@utils/constants";
import definePlugin from "@utils/types";
import { UserStore } from "@webpack/common";
const SHIGGY_BADGE = "https://cdn.discordapp.com/emojis/1101838344146665502.gif?size=240&quality=lossless";
const BLOBFOXBOX_BADGE = "https://cdn.discordapp.com/emojis/1036216552736952350.webp?size=240&quality=lossless";
const ShiggyBadge: ProfileBadge = {
description: "true shiggy fan",
image: SHIGGY_BADGE,
position: BadgePosition.START,
props: {
style: { transform: "scale(0.9)" }
},
shouldShow: ({ user }) => user.id === UserStore.getCurrentUser().id,
link: "https://ryanccn.dev/"
};
const BlobfoxBoxBadge: ProfileBadge = {
description: "blobfox",
image: BLOBFOXBOX_BADGE,
position: BadgePosition.START,
props: {
style: { transform: "scale(0.9)" }
},
shouldShow: ({ user }) => user.id === UserStore.getCurrentUser().id,
link: "https://ryanccn.dev/"
};
export default definePlugin({
name: "Ryan's Extra Badges",
description: "shiggy",
authors: [Devs.RyanCaoDev],
dependencies: ["BadgeAPI"],
start() {
Badges.addBadge(ShiggyBadge);
Badges.addBadge(BlobfoxBoxBadge);
},
});

View file

@ -0,0 +1,63 @@
/*
* 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/>.
*/
/*
* This plugin was modified from code licensed under the following license:
*
* MIT License
*
* Copyright (c) 2021-present Sebastian Law
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import { findByPropsLazy } from "@webpack";
import { Tooltip } from "@webpack/common";
export default function Indicator({ layers, bottom }: { layers: number; bottom: boolean; }) {
return (
<Tooltip color="black" position="top" text={layers <= 1 ? "🥺" : `Decoded from ${layers} nested bottom messages`}>
{({ onMouseLeave, onMouseEnter }) => (
<span
className={`power-bottom-indicator ${findByPropsLazy("edited").edited}`}
style={{ color: "var(--text-muted)" }}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{bottom ? "(bottom)" : "(original)"}
</span>
)}
</Tooltip>
);
}

View file

@ -0,0 +1,73 @@
/*
* 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/>.
*/
/*
* This file was adapted from https://github.com/bottom-software-foundation/bottom-js
* Which is, hopefully, licensed under MIT.
*/
const CHARACTER_VALUES: [number, string][] = [
[200, "🫂"],
[50, "💖"],
[10, "✨"],
[5, "🥺"],
[1, ","],
[0, "❤️"],
];
const SECTION_SEPERATOR = "👉👈";
const FINAL_TERMINATOR = new RegExp(`(${SECTION_SEPERATOR})?$`);
function encodeChar(charValue: number): string {
if (charValue === 0) return "";
const [val, currentCase]: [number, string] =
CHARACTER_VALUES.find(([val]) => charValue >= val) || CHARACTER_VALUES[-1];
return `${currentCase}${encodeChar(charValue - val)}`;
}
export function encode(value: string): string {
return Array.from(new TextEncoder().encode(value))
.map((v: number) => encodeChar(v) + SECTION_SEPERATOR)
.join("");
}
export function decode(value: string): string {
return new TextDecoder().decode(Uint8Array.from(
value
.trim()
.replace(FINAL_TERMINATOR, "")
.split(SECTION_SEPERATOR)
.map(letters => {
return Array.from(letters)
.map(character => {
const [value, emoji]: [number, string] = CHARACTER_VALUES.find(
([_, em]) => em === character
) || [-1, ""];
if (!emoji) {
throw new TypeError(`Invalid bottom text: '${character}'`);
}
return value;
})
.reduce((p, c) => p + c);
})
));
}
export default {
encode: encode,
decode: decode
};

View file

@ -0,0 +1,158 @@
/*
* 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/>.
*/
/*
* This plugin was modified from code licensed under the following license:
*
* MIT License
*
* Copyright (c) 2021-present Sebastian Law
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import { FluxDispatcher, MessageStore } from "@webpack/common";
import type { Message } from "discord-types/general";
import Bottom from "./encoding";
class BottomHandler {
cache: Record<string, Record<string, { originalContent: string; top?: boolean; layers?: number; }>>;
re: RegExp;
constructor() {
this.cache = {};
this.re = /((?:((?:\uD83E\uDEC2)?(?:💖)*(?:✨)*(?:🥺)*(?:,)*(❤️)?)(?:👉👈|\u200b))+)/gm;
}
isTranslated(message) {
if (
!this.cache[message.channel_id] ||
!this.cache[message.channel_id][message.id]
) { return false; }
return this.cache[message.channel_id][message.id].originalContent !== message.content;
}
translate(text: string, notNested: boolean) {
var original = text;
var translated = text;
var layers = 0;
while (original.match(this.re)) {
translated = original.replace(this.re, (str, p1, offset, s) => Bottom.decode(p1) || p1);
// the regex can sometimes pick up invalid bottom in which case we want to return to avoid an infinite loop
if (translated === original || notNested) break;
else {
original = translated;
layers++;
}
}
return {
translated: translated,
layers: layers,
};
}
translateMessage(message: Message, decodeLayers: boolean) {
if (!message.content || message.content.length === 0) {
return "";
}
// Build cache if it doesn't exist
if (!this.cache[message.channel_id]) {
this.cache[message.channel_id] = {};
}
if (!this.cache[message.channel_id][message.id]) {
this.cache[message.channel_id][message.id] = {
originalContent: message.content,
};
}
const cached = this.cache[message.channel_id][message.id];
if (this.isTranslated(message)) {
// if we're reverting back to original, just set the content back to original
message.content = cached.originalContent;
this.updateMessage(message);
} else {
// the message hasn't been edited, let's try to decode it
const { translated, layers } = this.translate(message.content, !decodeLayers);
if (translated === message.content) {
// we don't want to do anything if there is no bottom
// since the translation fails, mark this message to not show the indicator
cached.top = true;
throw new Error("No Bottom detected 🥺");
} else {
// let the indicator show how many layers of decoding we did
cached.layers = layers;
message.content = translated;
this.updateMessage(message);
}
}
}
updateMessage(message: Message) {
console.log({
bottomTranslation: true,
type: "MESSAGE_UPDATE",
message,
});
FluxDispatcher.dispatch({
bottomTranslation: true,
type: "MESSAGE_UPDATE",
message,
});
}
clearCache() {
for (const channelID in this.cache) {
for (const messageID in this.cache[channelID]) {
this.removeMessage(channelID, messageID);
}
}
this.cache = {};
}
removeMessage(channelID: string, messageID: string, reset = true) {
const message = MessageStore.getMessage(channelID, messageID);
if (reset) {
message.content = this.cache[channelID][messageID].originalContent;
this.updateMessage(message);
}
delete this.cache[channelID][messageID];
}
}
export default BottomHandler;

View file

@ -0,0 +1,262 @@
/*
* 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/>.
*/
/*
* This plugin was modified from code licensed under the following license:
*
* MIT License
*
* Copyright (c) 2021-present Sebastian Law
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import { findOption, RequiredMessageOption } from "@api/Commands";
import { addAccessory, removeAccessory } from "@api/MessageAccessories";
import { addPreSendListener, removePreSendListener } from "@api/MessageEvents";
import { addButton, removeButton } from "@api/MessagePopover";
import { definePluginSettings } from "@api/settings";
import ErrorBoundary from "@components/ErrorBoundary";
import definePlugin, { OptionType } from "@utils/types";
import { ChannelStore, Toasts } from "@webpack/common";
import Indicator from "./components/Indicator";
import Bottom from "./encoding";
import BottomHandler from "./handler";
const Handler = new BottomHandler();
const settings = definePluginSettings({
"decode-layers": {
description: "Decode Layers",
type: OptionType.BOOLEAN,
default: true,
},
"auto-encode-send": {
description: "Automatically encode outgoing messages",
type: OptionType.BOOLEAN,
default: false,
},
"encode-send-type": {
description: "Automatic Encode Behavior",
type: OptionType.SELECT,
options:
[
{
label: "All",
default: true,
value: 0,
},
{
label: "Inline (Greedy)",
value: 1,
},
{
label: "Inline (Parsed)",
value: 2,
}
],
},
"inline-bottom-prefix": {
description: "Inline bottom prefix",
type: OptionType.STRING,
default: "👉",
},
"inline-bottom-suffix": {
description: "Inline bottom suffix",
type: OptionType.STRING,
default: "👈",
},
});
const escapeRegex: (string: string) => string = string => string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
function count(string: string, subString: string): number {
var n = 0;
var pos = 0;
const step = subString.length;
while (true) {
pos = string.indexOf(subString, pos);
if (pos >= 0) {
n++;
pos += step;
} else break;
}
return n;
}
function inlineEncode(p: string, s: string, text: string): string {
var np = count(text, p);
var ns = count(text, s);
if (np === 0 || ns === 0) return text;
var pl = p.length;
var sl = s.length;
const result: string[] = [];
let idx = 0;
while (true) {
var startIndex = text.indexOf(p, idx);
if (startIndex < 0) {
result.push(text.slice(idx));
break;
}
var endIndex = text.indexOf(s, startIndex + pl);
if (endIndex < 0) {
result.push(text.slice(idx));
break;
}
result.push(text.slice(idx, startIndex));
startIndex += pl;
result.push(Bottom.encode(text.slice(startIndex, endIndex)));
endIndex += sl;
idx = endIndex;
}
return result.join("");
}
export default definePlugin({
name: "Bottom",
description: "The Vencord plugin for bottom 🥺",
authors: [
{
id: 1038096782963507210n,
name: "skyevg",
},
],
dependencies: ["MessagePopoverAPI", "CommandsAPI", "MessageEventsAPI", "MessageAccessoriesAPI"],
settings,
start() {
addButton("bottom", msg => {
return {
label: "Translate Bottom",
icon: () => (
<svg x="0" y="0" aria-hidden="false" width="22" height="22" viewBox="0 0 36 36" fill="currentColor" className="icon">
<circle fill="#FFCC4D" cx="18" cy="18" r="18" />
<path fill="#65471B" d="M20.996 27c-.103 0-.206-.016-.309-.049-1.76-.571-3.615-.571-5.375 0-.524.169-1.089-.117-1.26-.642-.171-.525.117-1.089.643-1.26 2.162-.702 4.447-.702 6.609 0 .525.171.813.735.643 1.26-.137.421-.529.691-.951.691z" />
<path fill="#FFF" d="M30.335 12.068c-.903 2.745-3.485 4.715-6.494 4.715-.144 0-.289-.005-.435-.014-1.477-.093-2.842-.655-3.95-1.584.036.495.076.997.136 1.54.152 1.388.884 2.482 2.116 3.163.82.454 1.8.688 2.813.752 1.734.109 3.57-.28 4.873-.909 1.377-.665 2.272-1.862 2.456-3.285.183-1.415-.354-2.924-1.515-4.378z" />
<path fill="#65471B" d="M21.351 7.583c-1.297.55-1.947 2.301-1.977 5.289l.039.068c.897 1.319 2.373 2.224 4.088 2.332.114.007.228.011.341.011 2.634 0 4.849-1.937 5.253-4.524-.115-.105-.221-.212-.343-.316-3.715-3.17-6.467-3.257-7.401-2.86z" />
<path fill="#F4900C" d="M23.841 16.783c3.009 0 5.591-1.97 6.494-4.715-.354-.443-.771-.88-1.241-1.309-.404 2.587-2.619 4.524-5.253 4.524-.113 0-.227-.004-.341-.011-1.715-.108-3.191-1.013-4.088-2.332l-.039-.068c-.007.701.021 1.473.083 2.313 1.108.929 2.473 1.491 3.95 1.584.146.01.291.014.435.014z" />
<circle fill="#FFF" cx="21.413" cy="10.705" r="1.107" />
<path fill="#FFF" d="M12.159 16.783c-3.009 0-5.591-1.97-6.494-4.715-1.161 1.454-1.697 2.963-1.515 4.377.185 1.423 1.079 2.621 2.456 3.285 1.303.629 3.138 1.018 4.873.909 1.013-.064 1.993-.297 2.813-.752 1.231-.681 1.963-1.775 2.116-3.163.06-.542.1-1.042.136-1.536-1.103.923-2.47 1.487-3.95 1.58-.146.011-.291.015-.435.015z" />
<path fill="#65471B" d="M12.159 15.283c.113 0 .227-.004.341-.011 1.715-.108 3.191-1.013 4.088-2.332l.039-.068c-.031-2.988-.68-4.739-1.977-5.289-.934-.397-3.687-.31-7.401 2.859-.122.104-.227.211-.343.316.404 2.588 2.619 4.525 5.253 4.525z" />
<path fill="#F4900C" d="M16.626 12.872l-.039.068c-.897 1.319-2.373 2.224-4.088 2.332-.114.007-.228.011-.341.011-2.634 0-4.849-1.937-5.253-4.524-.47.429-.887.866-1.241 1.309.903 2.745 3.485 4.715 6.494 4.715.144 0 .289-.005.435-.014 1.48-.093 2.847-.657 3.95-1.58.062-.841.091-1.614.083-2.317z" />
<path fill="#FFF" d="M9.781 11.81c.61-.038 1.074-.564 1.035-1.174-.038-.61-.564-1.074-1.174-1.036-.61.038-1.074.564-1.036 1.174.039.61.565 1.074 1.175 1.036z" />
</svg>
),
message: msg,
channel: ChannelStore.getChannel(msg.channel_id),
onClick: async () => {
try {
Handler.translateMessage(msg, settings.store["decode-layers"]);
} catch (e) {
console.error(e);
Toasts.show(
{
id: Toasts.genId(),
message: e.message,
type: Toasts.Type.MESSAGE
}
);
}
}
};
});
addAccessory("bottom", props => {
try {
if (!Handler.cache[props.message.channel_id][props.message.id].top) {
try {
return (
<ErrorBoundary>
<Indicator layers={Handler.cache[props.message.channel_id][props.message.id].layers ?? 0} bottom={!Handler.isTranslated(props.message)} />
</ErrorBoundary>
);
} catch { }
}
}
catch { }
return null;
});
this.preSend = addPreSendListener((_, msg) => {
if (settings.store["auto-encode-send"]) {
const sendType = settings.store["encode-send-type"];
var { content } = msg;
switch (sendType) {
case 0: // all
content = Bottom.encode(content);
break;
case 1: // inline greedy
var prefix = escapeRegex(settings.store["inline-bottom-prefix"]);
var suffix = escapeRegex(settings.store["inline-bottom-suffix"]);
var reg = new RegExp(`${prefix}(.+)${suffix}`, "gm");
content = content.replace(reg, (str, p1, o, s) => Bottom.encode(p1));
break;
case 2: // inline parsed
var prefix = settings.store["inline-bottom-prefix"];
var suffix = settings.store["inline-bottom-prefix"];
content = inlineEncode(prefix, suffix, content);
break;
}
msg.content = content;
}
});
},
stop() {
removeButton("bottom");
removeAccessory("bottom");
removePreSendListener(this.preSend);
},
commands: [
{
name: "bottom",
description: "Translate and send text as bottom 🥺",
options: [RequiredMessageOption],
execute: opts => ({
content: Bottom.encode(findOption(opts, "message", "")),
})
}
]
});

View file

@ -121,6 +121,21 @@ export const defaultRules = [
"t@*.twitter.com", "t@*.twitter.com",
"s@*.twitter.com", "s@*.twitter.com",
"ref_*@*.twitter.com", "ref_*@*.twitter.com",
"t@*.x.com",
"s@*.x.com",
"ref_*@*.x.com",
"t@*.fixupx.com",
"s@*.fixupx.com",
"ref_*@*.fixupx.com",
"t@*.fxtwitter.com",
"s@*.fxtwitter.com",
"ref_*@*.fxtwitter.com",
"t@*.twittpr.com",
"s@*.twittpr.com",
"ref_*@*.twittpr.com",
"t@*.fixvx.com",
"s@*.fixvx.com",
"ref_*@*.fixvx.com",
"tt_medium", "tt_medium",
"tt_content", "tt_content",
"lr@yandex.*", "lr@yandex.*",

View file

@ -15,7 +15,7 @@ import definePlugin, { OptionType, StartAt } from "@utils/types";
import { findComponentByCodeLazy } from "@webpack"; import { findComponentByCodeLazy } from "@webpack";
import { Button, Forms } from "@webpack/common"; import { Button, Forms } from "@webpack/common";
const ColorPicker = findComponentByCodeLazy(".Messages.USER_SETTINGS_PROFILE_COLOR_SELECT_COLOR"); const ColorPicker = findComponentByCodeLazy(".Messages.USER_SETTINGS_PROFILE_COLOR_SELECT_COLOR", ".BACKGROUND_PRIMARY)");
const colorPresets = [ const colorPresets = [
"#1E1514", "#172019", "#13171B", "#1C1C28", "#402D2D", "#1E1514", "#172019", "#13171B", "#1C1C28", "#402D2D",

View file

@ -63,6 +63,7 @@ export default definePlugin({
let fakeRenderWin: WeakRef<Window> | undefined; let fakeRenderWin: WeakRef<Window> | undefined;
const find = newFindWrapper(f => f); const find = newFindWrapper(f => f);
const findByProps = newFindWrapper(filters.byProps);
return { return {
...Vencord.Webpack.Common, ...Vencord.Webpack.Common,
wp: Vencord.Webpack, wp: Vencord.Webpack,
@ -73,13 +74,13 @@ export default definePlugin({
wpexs: (code: string) => extract(Webpack.findModuleId(code)!), wpexs: (code: string) => extract(Webpack.findModuleId(code)!),
find, find,
findAll, findAll,
findByProps: newFindWrapper(filters.byProps), findByProps,
findAllByProps: (...props: string[]) => findAll(filters.byProps(...props)), findAllByProps: (...props: string[]) => findAll(filters.byProps(...props)),
findByCode: newFindWrapper(filters.byCode), findByCode: newFindWrapper(filters.byCode),
findAllByCode: (code: string) => findAll(filters.byCode(code)), findAllByCode: (code: string) => findAll(filters.byCode(code)),
findComponentByCode: newFindWrapper(filters.componentByCode), findComponentByCode: newFindWrapper(filters.componentByCode),
findAllComponentsByCode: (...code: string[]) => findAll(filters.componentByCode(...code)), findAllComponentsByCode: (...code: string[]) => findAll(filters.componentByCode(...code)),
findExportedComponent: (...props: string[]) => find(...props)[props[0]], findExportedComponent: (...props: string[]) => findByProps(...props)[props[0]],
findStore: newFindWrapper(filters.byStoreName), findStore: newFindWrapper(filters.byStoreName),
PluginsApi: Vencord.Plugins, PluginsApi: Vencord.Plugins,
plugins: Vencord.Plugins.plugins, plugins: Vencord.Plugins.plugins,

View file

@ -23,12 +23,26 @@ import { Logger } from "@utils/Logger";
import { closeAllModals } from "@utils/modal"; import { closeAllModals } from "@utils/modal";
import definePlugin, { OptionType } from "@utils/types"; import definePlugin, { OptionType } from "@utils/types";
import { maybePromptToUpdate } from "@utils/updater"; import { maybePromptToUpdate } from "@utils/updater";
import { findByPropsLazy } from "@webpack"; import { filters, findBulk, proxyLazyWebpack } from "@webpack";
import { FluxDispatcher, NavigationRouter } from "@webpack/common"; import { FluxDispatcher, NavigationRouter, SelectedChannelStore } from "@webpack/common";
import type { ReactElement } from "react"; import type { ReactElement } from "react";
const CrashHandlerLogger = new Logger("CrashHandler"); const CrashHandlerLogger = new Logger("CrashHandler");
const ModalStack = findByPropsLazy("pushLazy", "popAll"); const { ModalStack, DraftManager, DraftType, closeExpressionPicker } = proxyLazyWebpack(() => {
const modules = findBulk(
filters.byProps("pushLazy", "popAll"),
filters.byProps("clearDraft", "saveDraft"),
filters.byProps("DraftType"),
filters.byProps("closeExpressionPicker", "openExpressionPicker"),
);
return {
ModalStack: modules[0],
DraftManager: modules[1],
DraftType: modules[2]?.DraftType,
closeExpressionPicker: modules[3]?.closeExpressionPicker,
};
});
const settings = definePluginSettings({ const settings = definePluginSettings({
attemptToPreventCrashes: { attemptToPreventCrashes: {
@ -115,13 +129,27 @@ export default definePlugin({
} catch { } } catch { }
} }
try {
const channelId = SelectedChannelStore.getChannelId();
DraftManager.clearDraft(channelId, DraftType.ChannelMessage);
DraftManager.clearDraft(channelId, DraftType.FirstThreadMessage);
} catch (err) {
CrashHandlerLogger.debug("Failed to clear drafts.", err);
}
try {
closeExpressionPicker();
}
catch (err) {
CrashHandlerLogger.debug("Failed to close expression picker.", err);
}
try { try {
FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" }); FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" });
} catch (err) { } catch (err) {
CrashHandlerLogger.debug("Failed to close open context menu.", err); CrashHandlerLogger.debug("Failed to close open context menu.", err);
} }
try { try {
ModalStack?.popAll(); ModalStack.popAll();
} catch (err) { } catch (err) {
CrashHandlerLogger.debug("Failed to close old modals.", err); CrashHandlerLogger.debug("Failed to close old modals.", err);
} }

View file

@ -60,7 +60,7 @@ async function embedDidMount(this: Component<Props>) {
if (hasTitle) { if (hasTitle) {
embed.dearrow.oldTitle = embed.rawTitle; embed.dearrow.oldTitle = embed.rawTitle;
embed.rawTitle = titles[0].title; embed.rawTitle = titles[0].title.replace(/ >(\S)/g, " $1");
} }
if (hasThumb) { if (hasThumb) {

View file

@ -0,0 +1,17 @@
# Decor
Custom avatar decorations!
![Custom decorations in chat](https://github.com/Vendicated/Vencord/assets/30497388/b0c4c4c8-8723-42a8-b50f-195ad4e26136)
Create and use your own custom avatar decorations, or pick your favorite from the presets.
You'll be able to see the custom avatar decorations of other users of this plugin, and they'll be able to see your custom avatar decoration.
You can select and manage your custom avatar decorations under the "Profiles" page in settings, or in the plugin settings.
![Custom decorations management](https://github.com/Vendicated/Vencord/assets/30497388/74fe8a9e-a2a2-4b29-bc10-9eaa58208ad4)
Review the [guidelines](https://github.com/decor-discord/.github/blob/main/GUIDELINES.md) before creating your own custom avatar decoration.
Join the [Discord server](https://discord.gg/dXp2SdxDcP) for support and notifications on your decoration's review.

168
src/plugins/decor/index.tsx Normal file
View file

@ -0,0 +1,168 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated, FieryFlames and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./ui/styles.css";
import { definePluginSettings } from "@api/Settings";
import ErrorBoundary from "@components/ErrorBoundary";
import { Link } from "@components/Link";
import { Devs } from "@utils/constants";
import { Margins } from "@utils/margins";
import { classes } from "@utils/misc";
import { closeAllModals } from "@utils/modal";
import definePlugin, { OptionType } from "@utils/types";
import { findByPropsLazy } from "@webpack";
import { FluxDispatcher, Forms, UserStore } from "@webpack/common";
import { CDN_URL, RAW_SKU_ID, SKU_ID } from "./lib/constants";
import { useAuthorizationStore } from "./lib/stores/AuthorizationStore";
import { useCurrentUserDecorationsStore } from "./lib/stores/CurrentUserDecorationsStore";
import { useUserDecorAvatarDecoration, useUsersDecorationsStore } from "./lib/stores/UsersDecorationsStore";
import { setDecorationGridDecoration, setDecorationGridItem } from "./ui/components";
import DecorSection from "./ui/components/DecorSection";
const { isAnimatedAvatarDecoration } = findByPropsLazy("isAnimatedAvatarDecoration");
export interface AvatarDecoration {
asset: string;
skuId: string;
}
const settings = definePluginSettings({
changeDecoration: {
type: OptionType.COMPONENT,
description: "Change your avatar decoration",
component() {
return <div>
<DecorSection hideTitle hideDivider noMargin />
<Forms.FormText type="description" className={classes(Margins.top8, Margins.bottom8)}>
You can also access Decor decorations from the <Link
href="/settings/profile-customization"
onClick={e => {
e.preventDefault();
closeAllModals();
FluxDispatcher.dispatch({ type: "USER_SETTINGS_MODAL_SET_SECTION", section: "Profile Customization" });
}}
>Profiles</Link> page.
</Forms.FormText>
</div>;
}
}
});
export default definePlugin({
name: "Decor",
description: "Create and use your own custom avatar decorations, or pick your favorite from the presets.",
authors: [Devs.FieryFlames],
patches: [
// Patch MediaResolver to return correct URL for Decor avatar decorations
{
find: "getAvatarDecorationURL:",
replacement: {
match: /(?<=function \i\(\i\){)(?=let{avatarDecoration)/,
replace: "const vcDecorDecoration=$self.getDecorAvatarDecorationURL(arguments[0]);if(vcDecorDecoration)return vcDecorDecoration;"
}
},
// Patch profile customization settings to include Decor section
{
find: "DefaultCustomizationSections",
replacement: {
match: /(?<={user:\i},"decoration"\),)/,
replace: "$self.DecorSection(),"
}
},
// Decoration modal module
{
find: ".decorationGridItem",
replacement: [
{
match: /(?<==)\i=>{let{children.{20,100}decorationGridItem/,
replace: "$self.DecorationGridItem=$&"
},
{
match: /(?<==)\i=>{let{user:\i,avatarDecoration.{300,600}decorationGridItemChurned/,
replace: "$self.DecorationGridDecoration=$&"
},
// Remove NEW label from decor avatar decorations
{
match: /(?<=\.Section\.PREMIUM_PURCHASE&&\i;if\()(?<=avatarDecoration:(\i).+?)/,
replace: "$1.skuId===$self.SKU_ID||"
}
]
},
{
find: "isAvatarDecorationAnimating:",
group: true,
replacement: [
// Add Decor avatar decoration hook to avatar decoration hook
{
match: /(?<=TryItOut:\i}\),)(?<=user:(\i).+?)/,
replace: "vcDecorAvatarDecoration=$self.useUserDecorAvatarDecoration($1),"
},
// Use added hook
{
match: /(?<={avatarDecoration:).{1,20}?(?=,)(?<=avatarDecorationOverride:(\i).+?)/,
replace: "$1??vcDecorAvatarDecoration??($&)"
},
// Make memo depend on added hook
{
match: /(?<=size:\i}\),\[)/,
replace: "vcDecorAvatarDecoration,"
}
]
},
// Current user area, at bottom of channels/dm list
{
find: "renderAvatarWithPopout(){",
replacement: [
// Use Decor avatar decoration hook
{
match: /(?<=getAvatarDecorationURL\)\({avatarDecoration:)(\i).avatarDecoration(?=,)/,
replace: "$self.useUserDecorAvatarDecoration($1)??$&"
}
]
}
],
settings,
flux: {
CONNECTION_OPEN: () => {
useAuthorizationStore.getState().init();
useCurrentUserDecorationsStore.getState().clear();
useUsersDecorationsStore.getState().fetch(UserStore.getCurrentUser().id, true);
},
USER_PROFILE_MODAL_OPEN: data => {
useUsersDecorationsStore.getState().fetch(data.userId, true);
},
},
set DecorationGridItem(e: any) {
setDecorationGridItem(e);
},
set DecorationGridDecoration(e: any) {
setDecorationGridDecoration(e);
},
SKU_ID,
useUserDecorAvatarDecoration,
async start() {
useUsersDecorationsStore.getState().fetch(UserStore.getCurrentUser().id, true);
},
getDecorAvatarDecorationURL({ avatarDecoration, canAnimate }: { avatarDecoration: AvatarDecoration | null; canAnimate?: boolean; }) {
// Only Decor avatar decorations have this SKU ID
if (avatarDecoration?.skuId === SKU_ID) {
const url = new URL(`${CDN_URL}/${avatarDecoration.asset}.png`);
url.searchParams.set("animate", (!!canAnimate && isAnimatedAvatarDecoration(avatarDecoration.asset)).toString());
return url.toString();
} else if (avatarDecoration?.skuId === RAW_SKU_ID) {
return avatarDecoration.asset;
}
},
DecorSection: ErrorBoundary.wrap(DecorSection)
});

View file

@ -0,0 +1,83 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { API_URL } from "./constants";
import { useAuthorizationStore } from "./stores/AuthorizationStore";
export interface Preset {
id: string;
name: string;
description: string | null;
decorations: Decoration[];
authorIds: string[];
}
export interface Decoration {
hash: string;
animated: boolean;
alt: string | null;
authorId: string | null;
reviewed: boolean | null;
presetId: string | null;
}
export interface NewDecoration {
file: File;
alt: string | null;
}
export async function fetchApi(url: RequestInfo, options?: RequestInit) {
const res = await fetch(url, {
...options,
headers: {
...options?.headers,
Authorization: `Bearer ${useAuthorizationStore.getState().token}`
}
});
if (res.ok) return res;
else throw new Error(await res.text());
}
export const getUsersDecorations = async (ids?: string[]): Promise<Record<string, string | null>> => {
if (ids?.length === 0) return {};
const url = new URL(API_URL + "/users");
if (ids && ids.length !== 0) url.searchParams.set("ids", JSON.stringify(ids));
return await fetch(url).then(c => c.json());
};
export const getUserDecorations = async (id: string = "@me"): Promise<Decoration[]> =>
fetchApi(API_URL + `/users/${id}/decorations`).then(c => c.json());
export const getUserDecoration = async (id: string = "@me"): Promise<Decoration | null> =>
fetchApi(API_URL + `/users/${id}/decoration`).then(c => c.json());
export const setUserDecoration = async (decoration: Decoration | NewDecoration | null, id: string = "@me"): Promise<string | Decoration> => {
const formData = new FormData();
if (!decoration) {
formData.append("hash", "null");
} else if ("hash" in decoration) {
formData.append("hash", decoration.hash);
} else if ("file" in decoration) {
formData.append("image", decoration.file);
formData.append("alt", decoration.alt ?? "null");
}
return fetchApi(API_URL + `/users/${id}/decoration`, { method: "PUT", body: formData }).then(c =>
decoration && "file" in decoration ? c.json() : c.text()
);
};
export const getDecoration = async (hash: string): Promise<Decoration> => fetch(API_URL + `/decorations/${hash}`).then(c => c.json());
export const deleteDecoration = async (hash: string): Promise<void> => {
await fetchApi(API_URL + `/decorations/${hash}`, { method: "DELETE" });
};
export const getPresets = async (): Promise<Preset[]> => fetch(API_URL + "/decorations/presets").then(c => c.json());

View file

@ -0,0 +1,16 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
export const BASE_URL = "https://decor.fieryflames.dev";
export const API_URL = BASE_URL + "/api";
export const AUTHORIZE_URL = API_URL + "/authorize";
export const CDN_URL = "https://ugc.decor.fieryflames.dev";
export const CLIENT_ID = "1096966363416899624";
export const SKU_ID = "100101099111114"; // decor in ascii numbers
export const RAW_SKU_ID = "11497119"; // raw in ascii numbers
export const GUILD_ID = "1096357702931841148";
export const INVITE_KEY = "dXp2SdxDcP";
export const DECORATION_FETCH_COOLDOWN = 1000 * 60 * 60 * 4; // 4 hours

View file

@ -0,0 +1,102 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { DataStore } from "@api/index";
import { proxyLazy } from "@utils/lazy";
import { Logger } from "@utils/Logger";
import { openModal } from "@utils/modal";
import { OAuth2AuthorizeModal, showToast, Toasts, UserStore, zustandCreate, zustandPersist } from "@webpack/common";
import type { StateStorage } from "zustand/middleware";
import { AUTHORIZE_URL, CLIENT_ID } from "../constants";
interface AuthorizationState {
token: string | null;
tokens: Record<string, string>;
init: () => void;
authorize: () => Promise<void>;
setToken: (token: string) => void;
remove: (id: string) => void;
isAuthorized: () => boolean;
}
const indexedDBStorage: StateStorage = {
async getItem(name: string): Promise<string | null> {
return DataStore.get(name).then(v => v ?? null);
},
async setItem(name: string, value: string): Promise<void> {
await DataStore.set(name, value);
},
async removeItem(name: string): Promise<void> {
await DataStore.del(name);
},
};
// TODO: Move switching accounts subscription inside the store?
export const useAuthorizationStore = proxyLazy(() => zustandCreate<AuthorizationState>(
zustandPersist(
(set, get) => ({
token: null,
tokens: {},
init: () => { set({ token: get().tokens[UserStore.getCurrentUser().id] ?? null }); },
setToken: (token: string) => set({ token, tokens: { ...get().tokens, [UserStore.getCurrentUser().id]: token } }),
remove: (id: string) => {
const { tokens, init } = get();
const newTokens = { ...tokens };
delete newTokens[id];
set({ tokens: newTokens });
init();
},
async authorize() {
return new Promise((resolve, reject) => openModal(props =>
<OAuth2AuthorizeModal
{...props}
scopes={["identify"]}
responseType="code"
redirectUri={AUTHORIZE_URL}
permissions={0n}
clientId={CLIENT_ID}
cancelCompletesFlow={false}
callback={async (response: any) => {
try {
const url = new URL(response.location);
url.searchParams.append("client", "vencord");
const req = await fetch(url);
if (req?.ok) {
const token = await req.text();
get().setToken(token);
} else {
throw new Error("Request not OK");
}
resolve(void 0);
} catch (e) {
if (e instanceof Error) {
showToast(`Failed to authorize: ${e.message}`, Toasts.Type.FAILURE);
new Logger("Decor").error("Failed to authorize", e);
reject(e);
}
}
}}
/>, {
onCloseCallback() {
reject(new Error("Authorization cancelled"));
},
}
));
},
isAuthorized: () => !!get().token,
}),
{
name: "decor-auth",
getStorage: () => indexedDBStorage,
partialize: state => ({ tokens: state.tokens }),
onRehydrateStorage: () => state => state?.init()
}
)
));

View file

@ -0,0 +1,56 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { proxyLazy } from "@utils/lazy";
import { UserStore, zustandCreate } from "@webpack/common";
import { Decoration, deleteDecoration, getUserDecoration, getUserDecorations, NewDecoration, setUserDecoration } from "../api";
import { decorationToAsset } from "../utils/decoration";
import { useUsersDecorationsStore } from "./UsersDecorationsStore";
interface UserDecorationsState {
decorations: Decoration[];
selectedDecoration: Decoration | null;
fetch: () => Promise<void>;
delete: (decoration: Decoration | string) => Promise<void>;
create: (decoration: NewDecoration) => Promise<void>;
select: (decoration: Decoration | null) => Promise<void>;
clear: () => void;
}
export const useCurrentUserDecorationsStore = proxyLazy(() => zustandCreate<UserDecorationsState>((set, get) => ({
decorations: [],
selectedDecoration: null,
async fetch() {
const decorations = await getUserDecorations();
const selectedDecoration = await getUserDecoration();
set({ decorations, selectedDecoration });
},
async create(newDecoration: NewDecoration) {
const decoration = (await setUserDecoration(newDecoration)) as Decoration;
set({ decorations: [...get().decorations, decoration] });
},
async delete(decoration: Decoration | string) {
const hash = typeof decoration === "object" ? decoration.hash : decoration;
await deleteDecoration(hash);
const { selectedDecoration, decorations } = get();
const newState = {
decorations: decorations.filter(d => d.hash !== hash),
selectedDecoration: selectedDecoration?.hash === hash ? null : selectedDecoration
};
set(newState);
},
async select(decoration: Decoration | null) {
if (get().selectedDecoration === decoration) return;
set({ selectedDecoration: decoration });
setUserDecoration(decoration);
useUsersDecorationsStore.getState().set(UserStore.getCurrentUser().id, decoration ? decorationToAsset(decoration) : null);
},
clear: () => set({ decorations: [], selectedDecoration: null })
})));

View file

@ -0,0 +1,118 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { debounce } from "@utils/debounce";
import { proxyLazy } from "@utils/lazy";
import { useEffect, useState, zustandCreate } from "@webpack/common";
import { User } from "discord-types/general";
import { AvatarDecoration } from "../../";
import { getUsersDecorations } from "../api";
import { DECORATION_FETCH_COOLDOWN, SKU_ID } from "../constants";
interface UserDecorationData {
asset: string | null;
fetchedAt: Date;
}
interface UsersDecorationsState {
usersDecorations: Map<string, UserDecorationData>;
fetchQueue: Set<string>;
bulkFetch: () => Promise<void>;
fetch: (userId: string, force?: boolean) => Promise<void>;
fetchMany: (userIds: string[]) => Promise<void>;
get: (userId: string) => UserDecorationData | undefined;
getAsset: (userId: string) => string | null | undefined;
has: (userId: string) => boolean;
set: (userId: string, decoration: string | null) => void;
}
export const useUsersDecorationsStore = proxyLazy(() => zustandCreate<UsersDecorationsState>((set, get) => ({
usersDecorations: new Map<string, UserDecorationData>(),
fetchQueue: new Set(),
bulkFetch: debounce(async () => {
const { fetchQueue, usersDecorations } = get();
if (fetchQueue.size === 0) return;
set({ fetchQueue: new Set() });
const fetchIds = Array.from(fetchQueue);
const fetchedUsersDecorations = await getUsersDecorations(fetchIds);
const newUsersDecorations = new Map(usersDecorations);
const now = new Date();
for (const fetchId of fetchIds) {
const newDecoration = fetchedUsersDecorations[fetchId] ?? null;
newUsersDecorations.set(fetchId, { asset: newDecoration, fetchedAt: now });
}
set({ usersDecorations: newUsersDecorations });
}),
async fetch(userId: string, force: boolean = false) {
const { usersDecorations, fetchQueue, bulkFetch } = get();
const { fetchedAt } = usersDecorations.get(userId) ?? {};
if (fetchedAt) {
if (!force && Date.now() - fetchedAt.getTime() < DECORATION_FETCH_COOLDOWN) return;
}
set({ fetchQueue: new Set(fetchQueue).add(userId) });
bulkFetch();
},
async fetchMany(userIds) {
if (!userIds.length) return;
const { usersDecorations, fetchQueue, bulkFetch } = get();
const newFetchQueue = new Set(fetchQueue);
const now = Date.now();
for (const userId of userIds) {
const { fetchedAt } = usersDecorations.get(userId) ?? {};
if (fetchedAt) {
if (now - fetchedAt.getTime() < DECORATION_FETCH_COOLDOWN) continue;
}
newFetchQueue.add(userId);
}
set({ fetchQueue: newFetchQueue });
bulkFetch();
},
get(userId: string) { return get().usersDecorations.get(userId); },
getAsset(userId: string) { return get().usersDecorations.get(userId)?.asset; },
has(userId: string) { return get().usersDecorations.has(userId); },
set(userId: string, decoration: string | null) {
const { usersDecorations } = get();
const newUsersDecorations = new Map(usersDecorations);
newUsersDecorations.set(userId, { asset: decoration, fetchedAt: new Date() });
set({ usersDecorations: newUsersDecorations });
}
})));
export function useUserDecorAvatarDecoration(user?: User): AvatarDecoration | null | undefined {
const [decorAvatarDecoration, setDecorAvatarDecoration] = useState<string | null>(user ? useUsersDecorationsStore.getState().getAsset(user.id) ?? null : null);
useEffect(() => {
const destructor = useUsersDecorationsStore.subscribe(
state => {
if (!user) return;
const newDecorAvatarDecoration = state.getAsset(user.id);
if (!newDecorAvatarDecoration) return;
if (decorAvatarDecoration !== newDecorAvatarDecoration) setDecorAvatarDecoration(newDecorAvatarDecoration);
}
);
if (user) {
const { fetch: fetchUserDecorAvatarDecoration } = useUsersDecorationsStore.getState();
fetchUserDecorAvatarDecoration(user.id);
}
return destructor;
}, []);
return decorAvatarDecoration ? { asset: decorAvatarDecoration, skuId: SKU_ID } : null;
}

View file

@ -0,0 +1,17 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { AvatarDecoration } from "../../";
import { Decoration } from "../api";
import { SKU_ID } from "../constants";
export function decorationToAsset(decoration: Decoration) {
return `${decoration.animated ? "a_" : ""}${decoration.hash}`;
}
export function decorationToAvatarDecoration(decoration: Decoration): AvatarDecoration {
return { asset: decorationToAsset(decoration), skuId: SKU_ID };
}

View file

@ -0,0 +1,35 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { ContextMenuApi } from "@webpack/common";
import type { HTMLProps } from "react";
import { Decoration } from "../../lib/api";
import { decorationToAvatarDecoration } from "../../lib/utils/decoration";
import { DecorationGridDecoration } from ".";
import DecorationContextMenu from "./DecorationContextMenu";
interface DecorDecorationGridDecorationProps extends HTMLProps<HTMLDivElement> {
decoration: Decoration;
isSelected: boolean;
onSelect: () => void;
}
export default function DecorDecorationGridDecoration(props: DecorDecorationGridDecorationProps) {
const { decoration } = props;
return <DecorationGridDecoration
{...props}
onContextMenu={e => {
ContextMenuApi.openContextMenu(e, () => (
<DecorationContextMenu
decoration={decoration}
/>
));
}}
avatarDecoration={decorationToAvatarDecoration(decoration)}
/>;
}

View file

@ -0,0 +1,59 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Flex } from "@components/Flex";
import { findByCodeLazy } from "@webpack";
import { Button, useEffect } from "@webpack/common";
import { useAuthorizationStore } from "../../lib/stores/AuthorizationStore";
import { useCurrentUserDecorationsStore } from "../../lib/stores/CurrentUserDecorationsStore";
import { cl } from "../";
import { openChangeDecorationModal } from "../modals/ChangeDecorationModal";
const CustomizationSection = findByCodeLazy(".customizationSectionBackground");
interface DecorSectionProps {
hideTitle?: boolean;
hideDivider?: boolean;
noMargin?: boolean;
}
export default function DecorSection({ hideTitle = false, hideDivider = false, noMargin = false }: DecorSectionProps) {
const authorization = useAuthorizationStore();
const { selectedDecoration, select: selectDecoration, fetch: fetchDecorations } = useCurrentUserDecorationsStore();
useEffect(() => {
if (authorization.isAuthorized()) fetchDecorations();
}, [authorization.token]);
return <CustomizationSection
title={!hideTitle && "Decor"}
hasBackground={true}
hideDivider={hideDivider}
className={noMargin && cl("section-remove-margin")}
>
<Flex>
<Button
onClick={() => {
if (!authorization.isAuthorized()) {
authorization.authorize().then(openChangeDecorationModal).catch(() => { });
} else openChangeDecorationModal();
}}
size={Button.Sizes.SMALL}
>
Change Decoration
</Button>
{selectedDecoration && authorization.isAuthorized() && <Button
onClick={() => selectDecoration(null)}
color={Button.Colors.PRIMARY}
size={Button.Sizes.SMALL}
look={Button.Looks.LINK}
>
Remove Decoration
</Button>}
</Flex>
</CustomizationSection>;
}

View file

@ -0,0 +1,47 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { CopyIcon, DeleteIcon } from "@components/Icons";
import { Alerts, Clipboard, ContextMenuApi, Menu, UserStore } from "webpack/common";
import { Decoration } from "../../lib/api";
import { useCurrentUserDecorationsStore } from "../../lib/stores/CurrentUserDecorationsStore";
import { cl } from "../";
export default function DecorationContextMenu({ decoration }: { decoration: Decoration; }) {
const { delete: deleteDecoration } = useCurrentUserDecorationsStore();
return <Menu.Menu
navId={cl("decoration-context-menu")}
onClose={ContextMenuApi.closeContextMenu}
aria-label="Decoration Options"
>
<Menu.MenuItem
id={cl("decoration-context-menu-copy-hash")}
label="Copy Decoration Hash"
icon={CopyIcon}
action={() => Clipboard.copy(decoration.hash)}
/>
{decoration.authorId === UserStore.getCurrentUser().id &&
<Menu.MenuItem
id={cl("decoration-context-menu-delete")}
label="Delete Decoration"
color="danger"
icon={DeleteIcon}
action={() => Alerts.show({
title: "Delete Decoration",
body: `Are you sure you want to delete ${decoration.alt}?`,
confirmText: "Delete",
confirmColor: cl("danger-btn"),
cancelText: "Cancel",
onConfirm() {
deleteDecoration(decoration);
}
})}
/>
}
</Menu.Menu>;
}

View file

@ -0,0 +1,30 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { PlusIcon } from "@components/Icons";
import { i18n, Text } from "@webpack/common";
import { HTMLProps } from "react";
import { DecorationGridItem } from ".";
type DecorationGridCreateProps = HTMLProps<HTMLDivElement> & {
onSelect: () => void;
};
export default function DecorationGridCreate(props: DecorationGridCreateProps) {
return <DecorationGridItem
{...props}
isSelected={false}
>
<PlusIcon />
<Text
variant="text-xs/normal"
color="header-primary"
>
{i18n.Messages.CREATE}
</Text>
</DecorationGridItem >;
}

View file

@ -0,0 +1,30 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { NoEntrySignIcon } from "@components/Icons";
import { i18n, Text } from "@webpack/common";
import { HTMLProps } from "react";
import { DecorationGridItem } from ".";
type DecorationGridNoneProps = HTMLProps<HTMLDivElement> & {
isSelected: boolean;
onSelect: () => void;
};
export default function DecorationGridNone(props: DecorationGridNoneProps) {
return <DecorationGridItem
{...props}
>
<NoEntrySignIcon />
<Text
variant="text-xs/normal"
color="header-primary"
>
{i18n.Messages.NONE}
</Text>
</DecorationGridItem >;
}

View file

@ -0,0 +1,28 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { React } from "@webpack/common";
import { cl } from "../";
export interface GridProps<ItemT> {
renderItem: (item: ItemT) => JSX.Element;
getItemKey: (item: ItemT) => string;
itemKeyPrefix?: string;
items: Array<ItemT>;
}
export default function Grid<ItemT,>({ renderItem, getItemKey, itemKeyPrefix: ikp, items }: GridProps<ItemT>) {
return <div className={cl("sectioned-grid-list-grid")}>
{items.map(item =>
<React.Fragment
key={`${ikp ? `${ikp}-` : ""}${getItemKey(item)}`}
>
{renderItem(item)}
</React.Fragment>
)}
</div>;
}

View file

@ -0,0 +1,38 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { classes } from "@utils/misc";
import { findByPropsLazy } from "@webpack";
import { React } from "@webpack/common";
import { cl } from "../";
import Grid, { GridProps } from "./Grid";
const ScrollerClasses = findByPropsLazy("managedReactiveScroller");
type Section<SectionT, ItemT> = SectionT & {
items: Array<ItemT>;
};
interface SectionedGridListProps<ItemT, SectionT, SectionU = Section<SectionT, ItemT>> extends Omit<GridProps<ItemT>, "items"> {
renderSectionHeader: (section: SectionU) => JSX.Element;
getSectionKey: (section: SectionU) => string;
sections: SectionU[];
}
export default function SectionedGridList<ItemT, SectionU,>(props: SectionedGridListProps<ItemT, SectionU>) {
return <div className={classes(cl("sectioned-grid-list-container"), ScrollerClasses.thin)}>
{props.sections.map(section => <div key={props.getSectionKey(section)} className={cl("sectioned-grid-list-section")}>
{props.renderSectionHeader(section)}
<Grid
renderItem={props.renderItem}
getItemKey={props.getItemKey}
itemKeyPrefix={props.getSectionKey(section)}
items={section.items}
/>
</div>)}
</div>;
}

View file

@ -0,0 +1,33 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { findComponentByCode, LazyComponentWebpack } from "@webpack";
import { React } from "@webpack/common";
import type { ComponentType, HTMLProps, PropsWithChildren } from "react";
import { AvatarDecoration } from "../..";
type DecorationGridItemComponent = ComponentType<PropsWithChildren<HTMLProps<HTMLDivElement>> & {
onSelect: () => void,
isSelected: boolean,
}>;
export let DecorationGridItem: DecorationGridItemComponent;
export const setDecorationGridItem = v => DecorationGridItem = v;
export const AvatarDecorationModalPreview = LazyComponentWebpack(() => {
const component = findComponentByCode("AvatarDecorationModalPreview");
return React.memo(component);
});
type DecorationGridDecorationComponent = React.ComponentType<HTMLProps<HTMLDivElement> & {
avatarDecoration: AvatarDecoration;
onSelect: () => void,
isSelected: boolean,
}>;
export let DecorationGridDecoration: DecorationGridDecorationComponent;
export const setDecorationGridDecoration = v => DecorationGridDecoration = v;

View file

@ -0,0 +1,13 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { classNameFactory } from "@api/Styles";
import { extractAndLoadChunksLazy } from "@webpack";
export const cl = classNameFactory("vc-decor-");
export const requireAvatarDecorationModal = extractAndLoadChunksLazy(["openAvatarDecorationModal:"]);
export const requireCreateStickerModal = extractAndLoadChunksLazy(["stickerInspected]:"]);

View file

@ -0,0 +1,270 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Flex } from "@components/Flex";
import { openInviteModal } from "@utils/discord";
import { Margins } from "@utils/margins";
import { classes } from "@utils/misc";
import { closeAllModals, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { findByPropsLazy, findComponentByCodeLazy } from "@webpack";
import { Alerts, Button, FluxDispatcher, Forms, GuildStore, NavigationRouter, Parser, Text, Tooltip, useEffect, UserStore, UserUtils, useState } from "@webpack/common";
import { User } from "discord-types/general";
import { Decoration, getPresets, Preset } from "../../lib/api";
import { GUILD_ID, INVITE_KEY } from "../../lib/constants";
import { useAuthorizationStore } from "../../lib/stores/AuthorizationStore";
import { useCurrentUserDecorationsStore } from "../../lib/stores/CurrentUserDecorationsStore";
import { decorationToAvatarDecoration } from "../../lib/utils/decoration";
import { cl, requireAvatarDecorationModal } from "../";
import { AvatarDecorationModalPreview } from "../components";
import DecorationGridCreate from "../components/DecorationGridCreate";
import DecorationGridNone from "../components/DecorationGridNone";
import DecorDecorationGridDecoration from "../components/DecorDecorationGridDecoration";
import SectionedGridList from "../components/SectionedGridList";
import { openCreateDecorationModal } from "./CreateDecorationModal";
const UserSummaryItem = findComponentByCodeLazy("defaultRenderUser", "showDefaultAvatarsForNullUsers");
const DecorationModalStyles = findByPropsLazy("modalFooterShopButton");
function usePresets() {
const [presets, setPresets] = useState<Preset[]>([]);
useEffect(() => { getPresets().then(setPresets); }, []);
return presets;
}
interface Section {
title: string;
subtitle?: string;
sectionKey: string;
items: ("none" | "create" | Decoration)[];
authorIds?: string[];
}
function SectionHeader({ section }: { section: Section; }) {
const hasSubtitle = typeof section.subtitle !== "undefined";
const hasAuthorIds = typeof section.authorIds !== "undefined";
const [authors, setAuthors] = useState<User[]>([]);
useEffect(() => {
(async () => {
if (!section.authorIds) return;
for (const authorId of section.authorIds) {
const author = UserStore.getUser(authorId) ?? await UserUtils.getUser(authorId);
setAuthors(authors => [...authors, author]);
}
})();
}, [section.authorIds]);
return <div>
<Flex>
<Forms.FormTitle style={{ flexGrow: 1 }}>{section.title}</Forms.FormTitle>
{hasAuthorIds && <UserSummaryItem
users={authors}
guildId={undefined}
renderIcon={false}
max={5}
showDefaultAvatarsForNullUsers
size={16}
showUserPopout
className={Margins.bottom8}
/>
}
</Flex>
{hasSubtitle &&
<Forms.FormText type="description" className={Margins.bottom8}>
{section.subtitle}
</Forms.FormText>
}
</div>;
}
export default function ChangeDecorationModal(props: any) {
// undefined = not trying, null = none, Decoration = selected
const [tryingDecoration, setTryingDecoration] = useState<Decoration | null | undefined>(undefined);
const isTryingDecoration = typeof tryingDecoration !== "undefined";
const avatarDecorationOverride = tryingDecoration != null ? decorationToAvatarDecoration(tryingDecoration) : tryingDecoration;
const {
decorations,
selectedDecoration,
fetch: fetchUserDecorations,
select: selectDecoration
} = useCurrentUserDecorationsStore();
useEffect(() => {
fetchUserDecorations();
}, []);
const activeSelectedDecoration = isTryingDecoration ? tryingDecoration : selectedDecoration;
const activeDecorationHasAuthor = typeof activeSelectedDecoration?.authorId !== "undefined";
const hasDecorationPendingReview = decorations.some(d => d.reviewed === false);
const presets = usePresets();
const presetDecorations = presets.flatMap(preset => preset.decorations);
const activeDecorationPreset = presets.find(preset => preset.id === activeSelectedDecoration?.presetId);
const isActiveDecorationPreset = typeof activeDecorationPreset !== "undefined";
const ownDecorations = decorations.filter(d => !presetDecorations.some(p => p.hash === d.hash));
const data = [
{
title: "Your Decorations",
sectionKey: "ownDecorations",
items: ["none", ...ownDecorations, "create"]
},
...presets.map(preset => ({
title: preset.name,
subtitle: preset.description || undefined,
sectionKey: `preset-${preset.id}`,
items: preset.decorations,
authorIds: preset.authorIds
}))
] as Section[];
return <ModalRoot
{...props}
size={ModalSize.DYNAMIC}
className={DecorationModalStyles.modal}
>
<ModalHeader separator={false} className={cl("modal-header")}>
<Text
color="header-primary"
variant="heading-lg/semibold"
tag="h1"
style={{ flexGrow: 1 }}
>
Change Decoration
</Text>
<ModalCloseButton onClick={props.onClose} />
</ModalHeader>
<ModalContent
className={cl("change-decoration-modal-content")}
scrollbarType="none"
>
<SectionedGridList
renderItem={item => {
if (typeof item === "string") {
switch (item) {
case "none":
return <DecorationGridNone
className={cl("change-decoration-modal-decoration")}
isSelected={activeSelectedDecoration === null}
onSelect={() => setTryingDecoration(null)}
/>;
case "create":
return <Tooltip text="You already have a decoration pending review" shouldShow={hasDecorationPendingReview}>
{tooltipProps => <DecorationGridCreate
className={cl("change-decoration-modal-decoration")}
{...tooltipProps}
onSelect={!hasDecorationPendingReview ? openCreateDecorationModal : () => { }}
/>}
</Tooltip>;
}
} else {
return <Tooltip text={"Pending review"} shouldShow={item.reviewed === false}>
{tooltipProps => (
<DecorDecorationGridDecoration
{...tooltipProps}
className={cl("change-decoration-modal-decoration")}
onSelect={item.reviewed !== false ? () => setTryingDecoration(item) : () => { }}
isSelected={activeSelectedDecoration?.hash === item.hash}
decoration={item}
/>
)}
</Tooltip>;
}
}}
getItemKey={item => typeof item === "string" ? item : item.hash}
getSectionKey={section => section.sectionKey}
renderSectionHeader={section => <SectionHeader section={section} />}
sections={data}
/>
<div className={cl("change-decoration-modal-preview")}>
<AvatarDecorationModalPreview
avatarDecorationOverride={avatarDecorationOverride}
user={UserStore.getCurrentUser()}
/>
{isActiveDecorationPreset && <Forms.FormTitle className="">Part of the {activeDecorationPreset.name} Preset</Forms.FormTitle>}
{typeof activeSelectedDecoration === "object" &&
<Text
variant="text-sm/semibold"
color="header-primary"
>
{activeSelectedDecoration?.alt}
</Text>
}
{activeDecorationHasAuthor && <Text key={`createdBy-${activeSelectedDecoration.authorId}`}>Created by {Parser.parse(`<@${activeSelectedDecoration.authorId}>`)}</Text>}
</div>
</ModalContent>
<ModalFooter className={classes(cl("change-decoration-modal-footer", cl("modal-footer")))}>
<div className={cl("change-decoration-modal-footer-btn-container")}>
<Button
onClick={() => {
selectDecoration(tryingDecoration!).then(props.onClose);
}}
disabled={!isTryingDecoration}
>
Apply
</Button>
<Button
onClick={props.onClose}
color={Button.Colors.PRIMARY}
look={Button.Looks.LINK}
>
Cancel
</Button>
</div>
<div className={cl("change-decoration-modal-footer-btn-container")}>
<Button
onClick={() => Alerts.show({
title: "Log Out",
body: "Are you sure you want to log out of Decor?",
confirmText: "Log Out",
confirmColor: cl("danger-btn"),
cancelText: "Cancel",
onConfirm() {
useAuthorizationStore.getState().remove(UserStore.getCurrentUser().id);
props.onClose();
}
})}
color={Button.Colors.PRIMARY}
look={Button.Looks.LINK}
>
Log Out
</Button>
<Tooltip text="Join Decor's Discord Server for notifications on your decoration's review, and when new presets are released">
{tooltipProps => <Button
{...tooltipProps}
onClick={async () => {
if (!GuildStore.getGuild(GUILD_ID)) {
const inviteAccepted = await openInviteModal(INVITE_KEY);
if (inviteAccepted) {
closeAllModals();
FluxDispatcher.dispatch({ type: "LAYER_POP_ALL" });
}
} else {
props.onClose();
FluxDispatcher.dispatch({ type: "LAYER_POP_ALL" });
NavigationRouter.transitionToGuild(GUILD_ID);
}
}}
color={Button.Colors.PRIMARY}
look={Button.Looks.LINK}
>
Discord Server
</Button>}
</Tooltip>
</div>
</ModalFooter>
</ModalRoot>;
}
export const openChangeDecorationModal = () =>
requireAvatarDecorationModal().then(() => openModal(props => <ChangeDecorationModal {...props} />));

View file

@ -0,0 +1,163 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Link } from "@components/Link";
import { openInviteModal } from "@utils/discord";
import { Margins } from "@utils/margins";
import { closeAllModals, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { findByPropsLazy, findComponentByCodeLazy } from "@webpack";
import { Button, FluxDispatcher, Forms, GuildStore, NavigationRouter, Text, TextInput, useEffect, useMemo, UserStore, useState } from "@webpack/common";
import { GUILD_ID, INVITE_KEY, RAW_SKU_ID } from "../../lib/constants";
import { useCurrentUserDecorationsStore } from "../../lib/stores/CurrentUserDecorationsStore";
import { cl, requireAvatarDecorationModal, requireCreateStickerModal } from "../";
import { AvatarDecorationModalPreview } from "../components";
const DecorationModalStyles = findByPropsLazy("modalFooterShopButton");
const FileUpload = findComponentByCodeLazy("fileUploadInput,");
function useObjectURL(object: Blob | MediaSource | null) {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
if (!object) return;
const objectUrl = URL.createObjectURL(object);
setUrl(objectUrl);
return () => {
URL.revokeObjectURL(objectUrl);
setUrl(null);
};
}, [object]);
return url;
}
export default function CreateDecorationModal(props) {
const [name, setName] = useState("");
const [file, setFile] = useState<File | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
if (error) setError(null);
}, [file]);
const { create: createDecoration } = useCurrentUserDecorationsStore();
const fileUrl = useObjectURL(file);
const decoration = useMemo(() => fileUrl ? { asset: fileUrl, skuId: RAW_SKU_ID } : null, [fileUrl]);
return <ModalRoot
{...props}
size={ModalSize.MEDIUM}
className={DecorationModalStyles.modal}
>
<ModalHeader separator={false} className={cl("modal-header")}>
<Text
color="header-primary"
variant="heading-lg/semibold"
tag="h1"
style={{ flexGrow: 1 }}
>
Create Decoration
</Text>
<ModalCloseButton onClick={props.onClose} />
</ModalHeader>
<ModalContent
className={cl("create-decoration-modal-content")}
scrollbarType="none"
>
<div className={cl("create-decoration-modal-form-preview-container")}>
<div className={cl("create-decoration-modal-form")}>
{error !== null && <Text color="text-danger" variant="text-xs/normal">{error.message}</Text>}
<Forms.FormSection title="File">
<FileUpload
filename={file?.name}
placeholder="Choose a file"
buttonText="Browse"
filters={[{ name: "Decoration file", extensions: ["png", "apng"] }]}
onFileSelect={setFile}
/>
<Forms.FormText type="description" className={Margins.top8}>
File should be APNG or PNG.
</Forms.FormText>
</Forms.FormSection>
<Forms.FormSection title="Name">
<TextInput
placeholder="Companion Cube"
value={name}
onChange={setName}
/>
<Forms.FormText type="description" className={Margins.top8}>
This name will be used when referring to this decoration.
</Forms.FormText>
</Forms.FormSection>
</div>
<div>
<AvatarDecorationModalPreview
avatarDecorationOverride={decoration}
user={UserStore.getCurrentUser()}
/>
</div>
</div>
<Forms.FormText type="description" className={Margins.bottom16}>
Make sure your decoration does not violate <Link
href="https://github.com/decor-discord/.github/blob/main/GUIDELINES.md"
>
the guidelines
</Link> before creating your decoration.
<br />You can receive updates on your decoration's review by joining <Link
href={`https://discord.gg/${INVITE_KEY}`}
onClick={async e => {
e.preventDefault();
if (!GuildStore.getGuild(GUILD_ID)) {
const inviteAccepted = await openInviteModal(INVITE_KEY);
if (inviteAccepted) {
closeAllModals();
FluxDispatcher.dispatch({ type: "LAYER_POP_ALL" });
}
} else {
closeAllModals();
FluxDispatcher.dispatch({ type: "LAYER_POP_ALL" });
NavigationRouter.transitionToGuild(GUILD_ID);
}
}}
>
Decor's Discord server
</Link>.
</Forms.FormText>
</ModalContent>
<ModalFooter className={cl("modal-footer")}>
<Button
onClick={() => {
setSubmitting(true);
createDecoration({ alt: name, file: file! })
.then(props.onClose).catch(e => { setSubmitting(false); setError(e); });
}}
disabled={!file || !name}
submitting={submitting}
>
Create
</Button>
<Button
onClick={props.onClose}
color={Button.Colors.PRIMARY}
look={Button.Looks.LINK}
>
Cancel
</Button>
</ModalFooter>
</ModalRoot>;
}
export const openCreateDecorationModal = () =>
Promise.all([requireAvatarDecorationModal(), requireCreateStickerModal()])
.then(() => openModal(props => <CreateDecorationModal {...props} />));

View file

@ -0,0 +1,80 @@
.vc-decor-danger-btn {
color: var(--white-500);
background-color: var(--button-danger-background);
}
.vc-decor-change-decoration-modal-content {
position: relative;
display: flex;
border-radius: 5px 5px 0 0;
padding: 0 16px;
gap: 4px
}
.vc-decor-change-decoration-modal-preview {
display: flex;
flex-direction: column;
margin-top: 24px;
gap: 8px;
max-width: 280px;
}
.vc-decor-change-decoration-modal-decoration {
width: 80px;
height: 80px;
}
.vc-decor-change-decoration-modal-footer {
justify-content: space-between;
}
.vc-decor-change-decoration-modal-footer-btn-container {
display: flex;
flex-direction: row-reverse;
}
.vc-decor-create-decoration-modal-content {
display: flex;
flex-direction: column;
gap: 20px;
padding: 0 16px;
}
.vc-decor-create-decoration-modal-form-preview-container {
display: flex;
gap: 16px;
}
.vc-decor-modal-header {
padding: 16px;
}
.vc-decor-modal-footer {
padding: 16px;
}
.vc-decor-create-decoration-modal-form {
display: flex;
flex-direction: column;
flex-grow: 1;
gap: 16px;
}
.vc-decor-sectioned-grid-list-container {
display: flex;
flex-direction: column;
overflow: hidden scroll;
max-height: 512px;
width: 352px; /* ((80 + 8 (grid gap)) * desired columns) (scrolled takes the extra 8 padding off conveniently) */
gap: 12px;
}
.vc-decor-sectioned-grid-list-grid {
display: flex;
flex-wrap: wrap;
gap: 8px
}
.vc-decor-section-remove-margin {
margin-bottom: 0;
}

View file

@ -215,6 +215,9 @@ function initWs(isManual = false) {
case "ModuleId": case "ModuleId":
results = Object.keys(search(parsedArgs[0])); results = Object.keys(search(parsedArgs[0]));
break; break;
case "ComponentByCode":
results = findAll(filters.componentByCode(...parsedArgs));
break;
default: default:
return reply("Unknown Find Type " + type); return reply("Unknown Find Type " + type);
} }

View file

@ -21,10 +21,9 @@ import { definePluginSettings, Settings } from "@api/Settings";
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import { ApngBlendOp, ApngDisposeOp, importApngJs } from "@utils/dependencies"; import { ApngBlendOp, ApngDisposeOp, importApngJs } from "@utils/dependencies";
import { getCurrentGuild } from "@utils/discord"; import { getCurrentGuild } from "@utils/discord";
import { proxyLazy } from "@utils/lazy";
import { Logger } from "@utils/Logger"; import { Logger } from "@utils/Logger";
import definePlugin, { OptionType } from "@utils/types"; import definePlugin, { OptionType } from "@utils/types";
import { findByPropsLazy, findStoreLazy } from "@webpack"; import { findByPropsLazy, findStoreLazy, proxyLazyWebpack } from "@webpack";
import { ChannelStore, EmojiStore, FluxDispatcher, lodash, Parser, PermissionStore, UploadHandler, UserSettingsActionCreators, UserStore } from "@webpack/common"; import { ChannelStore, EmojiStore, FluxDispatcher, lodash, Parser, PermissionStore, UploadHandler, UserSettingsActionCreators, UserStore } from "@webpack/common";
import type { Message } from "discord-types/general"; import type { Message } from "discord-types/general";
import { applyPalette, GIFEncoder, quantize } from "gifenc"; import { applyPalette, GIFEncoder, quantize } from "gifenc";
@ -48,9 +47,9 @@ function searchProtoClassField(localName: string, protoClass: any) {
return fieldGetter?.(); return fieldGetter?.();
} }
const PreloadedUserSettingsActionCreators = proxyLazy(() => UserSettingsActionCreators.PreloadedUserSettingsActionCreators); const PreloadedUserSettingsActionCreators = proxyLazyWebpack(() => UserSettingsActionCreators.PreloadedUserSettingsActionCreators);
const AppearanceSettingsActionCreators = proxyLazy(() => searchProtoClassField("appearance", PreloadedUserSettingsActionCreators.ProtoClass)); const AppearanceSettingsActionCreators = proxyLazyWebpack(() => searchProtoClassField("appearance", PreloadedUserSettingsActionCreators.ProtoClass));
const ClientThemeSettingsActionsCreators = proxyLazy(() => searchProtoClassField("clientThemeSettings", AppearanceSettingsActionCreators)); const ClientThemeSettingsActionsCreators = proxyLazyWebpack(() => searchProtoClassField("clientThemeSettings", AppearanceSettingsActionCreators));
const USE_EXTERNAL_EMOJIS = 1n << 18n; const USE_EXTERNAL_EMOJIS = 1n << 18n;
const USE_EXTERNAL_STICKERS = 1n << 37n; const USE_EXTERNAL_STICKERS = 1n << 37n;
@ -360,7 +359,7 @@ export default definePlugin({
}, },
// Separate patch for allowing using custom app icons // Separate patch for allowing using custom app icons
{ {
find: "location:\"AppIconHome\"", find: ".FreemiumAppIconIds.DEFAULT&&(",
replacement: { replacement: {
match: /\i\.\i\.isPremium\(\i\.\i\.getCurrentUser\(\)\)/, match: /\i\.\i\.isPremium\(\i\.\i\.getCurrentUser\(\)\)/,
replace: "true" replace: "true"
@ -788,7 +787,14 @@ export default definePlugin({
if (sticker.available !== false && (canUseStickers || sticker.guild_id === guildId)) if (sticker.available !== false && (canUseStickers || sticker.guild_id === guildId))
break stickerBypass; break stickerBypass;
const link = this.getStickerLink(sticker.id); // [12/12/2023]
// Work around an annoying bug where getStickerLink will return StickerType.GIF,
// but will give us a normal non animated png for no reason
// TODO: Remove this workaround when it's not needed anymore
let link = this.getStickerLink(sticker.id);
if (sticker.format_type === StickerType.GIF && link.includes(".png")) {
link = link.replace(".png", ".gif");
}
if (sticker.format_type === StickerType.APNG) { if (sticker.format_type === StickerType.APNG) {
this.sendAnimatedSticker(link, sticker.id, channelId); this.sendAnimatedSticker(link, sticker.id, channelId);
return { cancel: true }; return { cancel: true };

View file

@ -0,0 +1,25 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Devs } from "@utils/constants";
import definePlugin from "@utils/types";
export default definePlugin({
name: "FixImagesQuality",
description: "Fixes the quality of images in the chat being horrible.",
authors: [Devs.Nuckyz],
patches: [
{
find: "handleImageLoad=",
replacement: [
{
match: /(?<=getSrc\(\i\){.+?return )\i\.SUPPORTS_WEBP.+?:(?=\i&&\(\i="png"\))/,
replace: ""
}
]
}
]
});

View file

@ -28,21 +28,22 @@ import style from "./style.css?managed";
const Button = findComponentByCodeLazy("Button.Sizes.NONE,disabled:"); const Button = findComponentByCodeLazy("Button.Sizes.NONE,disabled:");
function makeIcon(showCurrentGame?: boolean) { function makeIcon(showCurrentGame?: boolean) {
const controllerIcon = "M3.06 20.4q-1.53 0-2.37-1.065T.06 16.74l1.26-9q.27-1.8 1.605-2.97T6.06 3.6h11.88q1.8 0 3.135 1.17t1.605 2.97l1.26 9q.21 1.53-.63 2.595T20.94 20.4q-.63 0-1.17-.225T18.78 19.5l-2.7-2.7H7.92l-2.7 2.7q-.45.45-.99.675t-1.17.225Zm14.94-7.2q.51 0 .855-.345T19.2 12q0-.51-.345-.855T18 10.8q-.51 0-.855.345T16.8 12q0 .51.345 .855T18 13.2Zm-2.4-3.6q.51 0 .855-.345T16.8 8.4q0-.51-.345-.855T15.6 7.2q-.51 0-.855.345T14.4 8.4q0 .51.345 .855T15.6 9.6ZM6.9 13.2h1.8v-2.1h2.1v-1.8h-2.1v-2.1h-1.8v2.1h-2.1v1.8h2.1v2.1Z";
return function () { return function () {
return ( return (
<svg <svg width="20" height="20" viewBox="0 0 24 24">
width="20" {showCurrentGame ? (
height="20" <path fill="currentColor" d={controllerIcon} />
viewBox="0 0 24 24" ) : (
> <>
<path fill="currentColor" mask="url(#gameActivityMask)" d="M3.06 20.4q-1.53 0-2.37-1.065T.06 16.74l1.26-9q.27-1.8 1.605-2.97T6.06 3.6h11.88q1.8 0 3.135 1.17t1.605 2.97l1.26 9q.21 1.53-.63 2.595T20.94 20.4q-.63 0-1.17-.225T18.78 19.5l-2.7-2.7H7.92l-2.7 2.7q-.45.45-.99.675t-1.17.225Zm14.94-7.2q.51 0 .855-.345T19.2 12q0-.51-.345-.855T18 10.8q-.51 0-.855.345T16.8 12q0 .51.345 .855T18 13.2Zm-2.4-3.6q.51 0 .855-.345T16.8 8.4q0-.51-.345-.855T15.6 7.2q-.51 0-.855.345T14.4 8.4q0 .51.345 .855T15.6 9.6ZM6.9 13.2h1.8v-2.1h2.1v-1.8h-2.1v-2.1h-1.8v2.1h-2.1v1.8h2.1v2.1Z" /> <mask id="gameActivityMask" >
{!showCurrentGame && <> <rect fill="white" x="0" y="0" width="24" height="24" />
<mask id="gameActivityMask" > <path fill="black" d="M23.27 4.73 19.27 .73 -.27 20.27 3.73 24.27Z" />
<rect fill="white" x="0" y="0" width="24" height="24" /> </mask>
<path fill="black" d="M23.27 4.54 19.46.73 .73 19.46 4.54 23.27 23.27 4.54Z" /> <path fill="var(--status-danger)" mask="url(#gameActivityMask)" d={controllerIcon} />
</mask> <path fill="var(--status-danger)" d="M22.7 2.7a1 1 0 0 0-1.4-1.4l-20 20a1 1 0 1 0 1.4 1.4Z" />
<path fill="var(--status-danger)" d="M23 2.27 21.73 1 1 21.73 2.27 23 23 2.27Z" /> </>
</>} )}
</svg> </svg>
); );
}; };

View file

@ -20,7 +20,7 @@ import { definePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types"; import definePlugin, { OptionType } from "@utils/types";
import { findByPropsLazy } from "@webpack"; import { findByPropsLazy } from "@webpack";
import { ContextMenuApi, FluxDispatcher, Menu } from "@webpack/common"; import { ContextMenuApi, FluxDispatcher, Menu, MessageActions } from "@webpack/common";
import { Channel, Message } from "discord-types/general"; import { Channel, Message } from "discord-types/general";
interface Sticker { interface Sticker {
@ -49,7 +49,6 @@ const settings = definePluginSettings({
unholyMultiGreetEnabled?: boolean; unholyMultiGreetEnabled?: boolean;
}>(); }>();
const MessageActions = findByPropsLazy("sendGreetMessage");
const { WELCOME_STICKERS } = findByPropsLazy("WELCOME_STICKERS"); const { WELCOME_STICKERS } = findByPropsLazy("WELCOME_STICKERS");
function greet(channel: Channel, message: Message, stickers: string[]) { function greet(channel: Channel, message: Message, stickers: string[]) {

View file

@ -22,7 +22,7 @@ import definePlugin from "@utils/types";
export default definePlugin({ export default definePlugin({
name: "iLoveSpam", name: "iLoveSpam",
description: "Do not hide messages from 'likely spammers'", description: "Do not hide messages from 'likely spammers'",
authors: [Devs.botato, Devs.Animal], authors: [Devs.botato, Devs.Nyako],
patches: [ patches: [
{ {
find: "hasFlag:{writable", find: "hasFlag:{writable",

View file

@ -72,6 +72,7 @@ export default definePlugin({
if (event.detail < 2) return; if (event.detail < 2) return;
if (settings.store.requireModifier && !event.ctrlKey && !event.shiftKey) return; if (settings.store.requireModifier && !event.ctrlKey && !event.shiftKey) return;
if (channel.guild_id && !PermissionStore.can(PermissionsBits.SEND_MESSAGES, channel)) return; if (channel.guild_id && !PermissionStore.can(PermissionsBits.SEND_MESSAGES, channel)) return;
if (msg.deleted === true) return;
if (isMe) { if (isMe) {
if (!settings.store.enableDoubleClickToEdit || EditStore.isEditing(channel.id, msg.id)) return; if (!settings.store.enableDoubleClickToEdit || EditStore.isEditing(channel.id, msg.id)) return;
@ -81,6 +82,9 @@ export default definePlugin({
} else { } else {
if (!settings.store.enableDoubleClickToReply) return; if (!settings.store.enableDoubleClickToReply) return;
const EPHEMERAL = 64;
if (msg.hasFlag(EPHEMERAL)) return;
FluxDispatcher.dispatch({ FluxDispatcher.dispatch({
type: "CREATE_PENDING_REPLY", type: "CREATE_PENDING_REPLY",
channel, channel,

View file

@ -19,7 +19,9 @@
import { definePluginSettings } from "@api/Settings"; import { definePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types"; import definePlugin, { OptionType } from "@utils/types";
import { findByProps } from "@webpack"; import { findByPropsLazy } from "@webpack";
const { updateGuildNotificationSettings } = findByPropsLazy("updateGuildNotificationSettings");
const settings = definePluginSettings({ const settings = definePluginSettings({
guild: { guild: {
@ -63,7 +65,7 @@ export default definePlugin({
handleMute(guildId: string | null) { handleMute(guildId: string | null) {
if (guildId === "@me" || guildId === "null" || guildId == null) return; if (guildId === "@me" || guildId === "null" || guildId == null) return;
findByProps("updateGuildNotificationSettings").updateGuildNotificationSettings(guildId, updateGuildNotificationSettings(guildId,
{ {
muted: settings.store.guild, muted: settings.store.guild,
suppress_everyone: settings.store.everyone, suppress_everyone: settings.store.everyone,

View file

@ -0,0 +1,3 @@
# NotificationVolume
Set a separate volume for notifications and in-app sounds (e.g. messages, call sound, mute/unmute) helping your ears stay healthy for many years to come.

View file

@ -0,0 +1,35 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
const settings = definePluginSettings({
notificationVolume: {
type: OptionType.SLIDER,
description: "Notification volume",
markers: [0, 25, 50, 75, 100],
default: 100,
stickToMarkers: false
}
});
export default definePlugin({
name: "NotificationVolume",
description: "Save your ears and set a separate volume for notifications and in-app sounds",
authors: [Devs.philipbry],
settings,
patches: [
{
find: "_ensureAudio(){",
replacement: {
match: /onloadeddata=\(\)=>\{.\.volume=/,
replace: "$&$self.settings.store.notificationVolume/100*"
},
},
],
});

View file

@ -28,7 +28,8 @@ export default definePlugin({
start() { start() {
fetch("https://raw.githubusercontent.com/adryd325/oneko.js/8fa8a1864aa71cd7a794d58bc139e755e96a236c/oneko.js") fetch("https://raw.githubusercontent.com/adryd325/oneko.js/8fa8a1864aa71cd7a794d58bc139e755e96a236c/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")
.replace("(isReducedMotion)", "(false)"))
.then(eval); .then(eval);
}, },

View file

@ -18,9 +18,8 @@
import ErrorBoundary from "@components/ErrorBoundary"; import ErrorBoundary from "@components/ErrorBoundary";
import ExpandableHeader from "@components/ExpandableHeader"; import ExpandableHeader from "@components/ExpandableHeader";
import { proxyLazy } from "@utils/lazy";
import { classes } from "@utils/misc"; import { classes } from "@utils/misc";
import { filters, findBulk } from "@webpack"; import { filters, findBulk, proxyLazyWebpack } from "@webpack";
import { i18n, PermissionsBits, Text, Tooltip, useMemo, UserStore } from "@webpack/common"; import { i18n, PermissionsBits, Text, Tooltip, useMemo, UserStore } from "@webpack/common";
import type { Guild, GuildMember } from "discord-types/general"; import type { Guild, GuildMember } from "discord-types/general";
@ -36,15 +35,13 @@ interface UserPermission {
type UserPermissions = Array<UserPermission>; type UserPermissions = Array<UserPermission>;
const Classes = proxyLazy(() => { const Classes = proxyLazyWebpack(() =>
const modules = findBulk( Object.assign({}, ...findBulk(
filters.byProps("roles", "rolePill", "rolePillBorder"), filters.byProps("roles", "rolePill", "rolePillBorder"),
filters.byProps("roleCircle", "dotBorderBase", "dotBorderColor"), filters.byProps("roleCircle", "dotBorderBase", "dotBorderColor"),
filters.byProps("roleNameOverflow", "root", "roleName", "roleRemoveButton") filters.byProps("roleNameOverflow", "root", "roleName", "roleRemoveButton")
); ))
) as Record<"roles" | "rolePill" | "rolePillBorder" | "desaturateUserColors" | "flex" | "alignCenter" | "justifyCenter" | "svg" | "background" | "dot" | "dotBorderColor" | "roleCircle" | "dotBorderBase" | "flex" | "alignCenter" | "justifyCenter" | "wrap" | "root" | "role" | "roleRemoveButton" | "roleDot" | "roleFlowerStar" | "roleRemoveIcon" | "roleRemoveIconFocused" | "roleVerifiedIcon" | "roleName" | "roleNameOverflow" | "actionButton" | "overflowButton" | "addButton" | "addButtonIcon" | "overflowRolesPopout" | "overflowRolesPopoutArrowWrapper" | "overflowRolesPopoutArrow" | "popoutBottom" | "popoutTop" | "overflowRolesPopoutHeader" | "overflowRolesPopoutHeaderIcon" | "overflowRolesPopoutHeaderText" | "roleIcon", string>;
return Object.assign({}, ...modules);
}) as Record<"roles" | "rolePill" | "rolePillBorder" | "desaturateUserColors" | "flex" | "alignCenter" | "justifyCenter" | "svg" | "background" | "dot" | "dotBorderColor" | "roleCircle" | "dotBorderBase" | "flex" | "alignCenter" | "justifyCenter" | "wrap" | "root" | "role" | "roleRemoveButton" | "roleDot" | "roleFlowerStar" | "roleRemoveIcon" | "roleRemoveIconFocused" | "roleVerifiedIcon" | "roleName" | "roleNameOverflow" | "actionButton" | "overflowButton" | "addButton" | "addButtonIcon" | "overflowRolesPopout" | "overflowRolesPopoutArrowWrapper" | "overflowRolesPopoutArrow" | "popoutBottom" | "popoutTop" | "overflowRolesPopoutHeader" | "overflowRolesPopoutHeaderIcon" | "overflowRolesPopoutHeaderText" | "roleIcon", string>;
function UserPermissionsComponent({ guild, guildMember, showBorder }: { guild: Guild; guildMember: GuildMember; showBorder: boolean; }) { function UserPermissionsComponent({ guild, guildMember, showBorder }: { guild: Guild; guildMember: GuildMember; showBorder: boolean; }) {
const stns = settings.use(["permissionsSortOrder"]); const stns = settings.use(["permissionsSortOrder"]);

View file

@ -55,13 +55,13 @@ const Icons = {
}; };
type Platform = keyof typeof Icons; type Platform = keyof typeof Icons;
const StatusUtils = findByPropsLazy("getStatusColor", "StatusTypes"); const StatusUtils = findByPropsLazy("useStatusFillColor", "StatusTypes");
const PlatformIcon = ({ platform, status, small }: { platform: Platform, status: string; small: boolean; }) => { const PlatformIcon = ({ platform, status, small }: { platform: Platform, status: string; small: boolean; }) => {
const tooltip = platform[0].toUpperCase() + platform.slice(1); const tooltip = platform[0].toUpperCase() + platform.slice(1);
const Icon = Icons[platform] ?? Icons.desktop; const Icon = Icons[platform] ?? Icons.desktop;
return <Icon color={`var(--${StatusUtils.getStatusColor(status)}`} tooltip={tooltip} small={small} />; return <Icon color={StatusUtils.useStatusFillColor(status)} tooltip={tooltip} small={small} />;
}; };
const getStatus = (id: string): Record<Platform, string> => PresenceStore.getState()?.clientStatuses?.[id]; const getStatus = (id: string): Record<Platform, string> => PresenceStore.getState()?.clientStatuses?.[id];

View file

@ -31,7 +31,7 @@ export default definePlugin({
start() { start() {
addButton("QuickMention", msg => { addButton("QuickMention", msg => {
const channel = ChannelStore.getChannel(msg.channel_id); const channel = ChannelStore.getChannel(msg.channel_id);
if (!PermissionStore.can(PermissionsBits.SEND_MESSAGES, channel)) return null; if (channel.guild_id && !PermissionStore.can(PermissionsBits.SEND_MESSAGES, channel)) return null;
return { return {
label: "Quick Mention", label: "Quick Mention",

View file

@ -18,27 +18,28 @@
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import definePlugin from "@utils/types"; import definePlugin from "@utils/types";
import { React } from "@webpack/common";
let ERROR_CODES: any; let ERROR_CODES: any;
const CODES_URL =
"https://raw.githubusercontent.com/facebook/react/17.0.2/scripts/error-codes/codes.json";
export default definePlugin({ export default definePlugin({
name: "ReactErrorDecoder", name: "ReactErrorDecoder",
description: 'Replaces "Minifed React Error" with the actual error.', description: 'Replaces "Minifed React Error" with the actual error.',
authors: [Devs.Cyn], authors: [Devs.Cyn, Devs.maisymoe],
patches: [ patches: [
{ {
find: '"https://reactjs.org/docs/error-decoder.html?invariant="', find: '"https://reactjs.org/docs/error-decoder.html?invariant="',
replacement: { replacement: {
match: /(function .\(.\)){(for\(var .="https:\/\/reactjs\.org\/docs\/error-decoder\.html\?invariant="\+.,.=1;.<arguments\.length;.\+\+\).\+="&args\[\]="\+encodeURIComponent\(arguments\[.\]\);return"Minified React error #"\+.\+"; visit "\+.\+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings.")}/, match: /(function .\(.\)){(for\(var .="https:\/\/reactjs\.org\/docs\/error-decoder\.html\?invariant="\+.,.=1;.<arguments\.length;.\+\+\).\+="&args\[\]="\+encodeURIComponent\(arguments\[.\]\);return"Minified React error #"\+.\+"; visit "\+.\+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings.")}/,
replace: (_, func, original) => replace: (_, func, original) =>
`${func}{var decoded=Vencord.Plugins.plugins.ReactErrorDecoder.decodeError.apply(null, arguments);if(decoded)return decoded;${original}}`, `${func}{var decoded=$self.decodeError.apply(null, arguments);if(decoded)return decoded;${original}}`,
}, },
}, },
], ],
async start() { async start() {
const CODES_URL = `https://raw.githubusercontent.com/facebook/react/v${React.version}/scripts/error-codes/codes.json`;
ERROR_CODES = await fetch(CODES_URL) ERROR_CODES = await fetch(CODES_URL)
.then(res => res.json()) .then(res => res.json())
.catch(e => console.error("[ReactErrorDecoder] Failed to fetch React error codes\n", e)); .catch(e => console.error("[ReactErrorDecoder] Failed to fetch React error codes\n", e));

View file

@ -42,6 +42,13 @@ export default definePlugin({
match: /codeBlock:\{react\((\i),(\i),(\i)\)\{/, match: /codeBlock:\{react\((\i),(\i),(\i)\)\{/,
replace: "$&return $self.renderHighlighter($1,$2,$3);" replace: "$&return $self.renderHighlighter($1,$2,$3);"
} }
},
{
find: ".PREVIEW_NUM_LINES",
replacement: {
match: /(?<=function \i\((\i)\)\{)(?=let\{text:\i,language:)/,
replace: "return $self.renderHighlighter({lang:$1.language,content:$1.text});"
}
} }
], ],
start: async () => { start: async () => {

View file

@ -77,7 +77,7 @@ export default definePlugin({
}, },
// Do not check for unreads when selecting the render level if the channel is hidden // Do not check for unreads when selecting the render level if the channel is hidden
{ {
match: /(?=!\(0,\i\.getHasImportantUnread\)\(this\.record\))/, match: /(?<=&&)(?=!\i\.\i\.hasUnread\(this\.record\.id\))/,
replace: "$self.isHiddenChannel(this.record)||" replace: "$self.isHiddenChannel(this.record)||"
}, },
// Make channels we dont have access to be the same level as normal ones // Make channels we dont have access to be the same level as normal ones
@ -334,12 +334,12 @@ export default definePlugin({
replacement: [ replacement: [
{ {
// Remove the divider and the open chat button for the HiddenChannelLockScreen // Remove the divider and the open chat button for the HiddenChannelLockScreen
match: /"more-options-popout"\)\),(?<=let{channel:(\i).+?inCall:(\i).+?)/, match: /"more-options-popout"\)\),(?<=channel:(\i).+?inCall:(\i).+?)/,
replace: (m, channel, inCall) => `${m}${inCall}||!$self.isHiddenChannel(${channel},true)&&` replace: (m, channel, inCall) => `${m}${inCall}||!$self.isHiddenChannel(${channel},true)&&`
}, },
{ {
// Remove invite users button for the HiddenChannelLockScreen // Remove invite users button for the HiddenChannelLockScreen
match: /"popup".{0,100}?if\((?<=let{channel:(\i).+?inCall:(\i).+?)/, match: /"popup".{0,100}?if\((?<=channel:(\i).+?inCall:(\i).+?)/,
replace: (m, channel, inCall) => `${m}(${inCall}||!$self.isHiddenChannel(${channel},true))&&` replace: (m, channel, inCall) => `${m}(${inCall}||!$self.isHiddenChannel(${channel},true))&&`
}, },
] ]

View file

@ -80,16 +80,15 @@ function SilentMessageToggle(chatBoxProps: {
style={{ padding: "0 6px" }} style={{ padding: "0 6px" }}
> >
<div className={ButtonWrapperClasses.buttonWrapper}> <div className={ButtonWrapperClasses.buttonWrapper}>
<svg <svg width="24" height="24" viewBox="0 0 24 24">
width="24" <path fill="currentColor" mask="url(#_)" d="M18 10.7101C15.1085 9.84957 13 7.17102 13 4c0-.30736.0198-.6101.0582-.907C12.7147 3.03189 12.3611 3 12 3 8.686 3 6 5.686 6 9v5c0 1.657-1.344 3-3 3v1h18v-1c-1.656 0-3-1.343-3-3v-3.2899ZM8.55493 19c.693 1.19 1.96897 2 3.44497 2s2.752-.81 3.445-2H8.55493ZM18.2624 5.50209 21 2.5V1h-4.9651v1.49791h2.4411L16 5.61088V7h5V5.50209h-2.7376Z" />
height="24" {!enabled && <>
viewBox="0 0 24 24" <mask id="_">
> <path fill="#fff" d="M0 0h24v24H0Z" />
<g fill="currentColor"> <path stroke="#000" stroke-width="5.99068" d="M0 24 24 0" />
<path d="M18 10.7101C15.1085 9.84957 13 7.17102 13 4C13 3.69264 13.0198 3.3899 13.0582 3.093C12.7147 3.03189 12.3611 3 12 3C8.686 3 6 5.686 6 9V14C6 15.657 4.656 17 3 17V18H21V17C19.344 17 18 15.657 18 14V10.7101ZM8.55493 19C9.24793 20.19 10.5239 21 11.9999 21C13.4759 21 14.7519 20.19 15.4449 19H8.55493Z" /> </mask>
<path d="M18.2624 5.50209L21 2.5V1H16.0349V2.49791H18.476L16 5.61088V7H21V5.50209H18.2624Z" /> <path fill="var(--status-danger)" d="m21.178 1.70703 1.414 1.414L4.12103 21.593l-1.414-1.415L21.178 1.70703Z" />
{!enabled && <line x1="22" y1="2" x2="2" y2="22" stroke="var(--red-500)" stroke-width="2.5" />} </>}
</g>
</svg> </svg>
</div> </div>
</Button> </Button>

View file

@ -16,17 +16,27 @@
* 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 { definePluginSettings } from "@api/Settings";
import { Flex } from "@components/Flex"; import { Flex } from "@components/Flex";
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types"; import definePlugin, { OptionType } from "@utils/types";
import { RelationshipStore } from "@webpack/common"; import { RelationshipStore } from "@webpack/common";
import { User } from "discord-types/general"; import { User } from "discord-types/general";
import { Settings } from "Vencord";
const settings = definePluginSettings({
showDates: {
type: OptionType.BOOLEAN,
description: "Show dates on friend requests",
default: false,
restartNeeded: true
}
});
export default definePlugin({ export default definePlugin({
name: "SortFriendRequests", name: "SortFriendRequests",
authors: [Devs.Megu], authors: [Devs.Megu],
description: "Sorts friend requests by date of receipt", description: "Sorts friend requests by date of receipt",
settings,
patches: [{ patches: [{
find: "getRelationshipCounts(){", find: "getRelationshipCounts(){",
@ -35,13 +45,11 @@ export default definePlugin({
replace: ".sortBy((row) => $self.sortList(row))" replace: ".sortBy((row) => $self.sortList(row))"
} }
}, { }, {
find: "RelationshipTypes.PENDING_INCOMING?", find: ".Messages.FRIEND_REQUEST_CANCEL",
replacement: { replacement: {
predicate: () => Settings.plugins.SortFriendRequests.showDates, predicate: () => settings.store.showDates,
match: /(user:(\i),.{10,50}),subText:(\i),(className:\i\.userInfo}\))/, match: /subText:(\i)(?=,className:\i\.userInfo}\))(?<=user:(\i).+?)/,
replace: (_, pre, user, subtext, post) => `${pre}, replace: (_, subtext, user) => `subText:$self.makeSubtext(${subtext},${user})`
subText: $self.makeSubtext(${subtext}, ${user}),
${post}`
} }
}], }],
@ -63,14 +71,5 @@ export default definePlugin({
{!isNaN(since.getTime()) && <span>Received &mdash; {since.toDateString()}</span>} {!isNaN(since.getTime()) && <span>Received &mdash; {since.toDateString()}</span>}
</Flex> </Flex>
); );
},
options: {
showDates: {
type: OptionType.BOOLEAN,
description: "Show dates on friend requests",
default: false,
restartNeeded: true
}
} }
}); });

View file

@ -17,8 +17,7 @@
*/ */
import { Settings } from "@api/Settings"; import { Settings } from "@api/Settings";
import { proxyLazy } from "@utils/lazy"; import { findByProps, proxyLazyWebpack } from "@webpack";
import { findByPropsLazy } from "@webpack";
import { Flux, FluxDispatcher } from "@webpack/common"; import { Flux, FluxDispatcher } from "@webpack/common";
export interface Track { export interface Track {
@ -66,12 +65,12 @@ interface Device {
type Repeat = "off" | "track" | "context"; type Repeat = "off" | "track" | "context";
// Don't wanna run before Flux and Dispatcher are ready! // Don't wanna run before Flux and Dispatcher are ready!
export const SpotifyStore = proxyLazy(() => { export const SpotifyStore = proxyLazyWebpack(() => {
// For some reason ts hates extends Flux.Store // For some reason ts hates extends Flux.Store
const { Store } = Flux; const { Store } = Flux;
const SpotifySocket = findByPropsLazy("getActiveSocketAndDevice"); const SpotifySocket = findByProps("getActiveSocketAndDevice");
const SpotifyUtils = findByPropsLazy("SpotifyAPI"); const SpotifyUtils = findByProps("SpotifyAPI");
const API_BASE = "https://api.spotify.com/v1/me/player"; const API_BASE = "https://api.spotify.com/v1/me/player";

View file

@ -55,13 +55,19 @@ export default definePlugin({
replace: "return [$self.renderPlayer(),$1]" replace: "return [$self.renderPlayer(),$1]"
} }
}, },
// Adds POST and a Marker to the SpotifyAPI (so we can easily find it)
{ {
find: ".PLAYER_DEVICES", find: ".PLAYER_DEVICES",
replacement: { replacement: [{
// Adds POST and a Marker to the SpotifyAPI (so we can easily find it)
match: /get:(\i)\.bind\(null,(\i\.\i)\.get\)/, match: /get:(\i)\.bind\(null,(\i\.\i)\.get\)/,
replace: "post:$1.bind(null,$2.post),$&" replace: "post:$1.bind(null,$2.post),$&"
} },
{
// Spotify Connect API returns status 202 instead of 204 when skipping tracks.
// Discord rejects 202 which causes the request to send twice. This patch prevents this.
match: /202===\i\.status/,
replace: "false",
}]
}, },
// Discord doesn't give you the repeat kind, only a boolean // Discord doesn't give you the repeat kind, only a boolean
{ {

View file

@ -20,7 +20,7 @@ import { ApplicationCommandInputType, sendBotMessage } from "@api/Commands";
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import definePlugin from "@utils/types"; import definePlugin from "@utils/types";
import { findByPropsLazy } from "@webpack"; import { findByPropsLazy } from "@webpack";
import { FluxDispatcher } from "@webpack/common"; import { FluxDispatcher, MessageActions } from "@webpack/common";
interface Album { interface Album {
id: string; id: string;
@ -53,7 +53,6 @@ interface Track {
} }
const Spotify = findByPropsLazy("getPlayerState"); const Spotify = findByPropsLazy("getPlayerState");
const MessageCreator = findByPropsLazy("getSendMessageOptionsForReply", "sendMessage");
const PendingReplyStore = findByPropsLazy("getPendingReply"); const PendingReplyStore = findByPropsLazy("getPendingReply");
function sendMessage(channelId, message) { function sendMessage(channelId, message) {
@ -65,7 +64,7 @@ function sendMessage(channelId, message) {
...message ...message
}; };
const reply = PendingReplyStore.getPendingReply(channelId); const reply = PendingReplyStore.getPendingReply(channelId);
MessageCreator.sendMessage(channelId, message, void 0, MessageCreator.getSendMessageOptionsForReply(reply)) MessageActions.sendMessage(channelId, message, void 0, MessageActions.getSendMessageOptionsForReply(reply))
.then(() => { .then(() => {
if (reply) { if (reply) {
FluxDispatcher.dispatch({ type: "DELETE_PENDING_REPLY", channelId }); FluxDispatcher.dispatch({ type: "DELETE_PENDING_REPLY", channelId });

View file

@ -46,10 +46,10 @@ export default definePlugin({
} }
}, },
{ {
find: ".hasAvailableBurstCurrency)", find: ".trackEmojiSearchEmpty,200",
replacement: { replacement: {
match: /(?<=\.useBurstReactionsExperiment.{0,20})useState\(!1\)(?=.+?(\i===\i\.EmojiIntention.REACTION))/, match: /(\.trackEmojiSearchEmpty,200(?=.+?isBurstReaction:(\i).+?(\i===\i\.EmojiIntention.REACTION)).+?\[\2,\i\]=\i\.useState\().+?\)/,
replace: "useState($self.settings.store.superReactByDefault && $1)" replace: (_, rest, isBurstReactionVariable, isReactionIntention) => `${rest}$self.settings.store.superReactByDefault&&${isReactionIntention})`
} }
} }
], ],

View file

@ -19,19 +19,13 @@
import { definePluginSettings, Settings } from "@api/Settings"; import { definePluginSettings, Settings } from "@api/Settings";
import ErrorBoundary from "@components/ErrorBoundary"; import ErrorBoundary from "@components/ErrorBoundary";
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import { LazyComponent } from "@utils/react";
import definePlugin, { OptionType } from "@utils/types"; import definePlugin, { OptionType } from "@utils/types";
import { find, findStoreLazy } from "@webpack"; import { findExportedComponentLazy, findStoreLazy } from "@webpack";
import { ChannelStore, GuildMemberStore, i18n, RelationshipStore, Tooltip, UserStore, useStateFromStores } from "@webpack/common"; import { ChannelStore, GuildMemberStore, i18n, RelationshipStore, SelectedChannelStore, Tooltip, UserStore, useStateFromStores } from "@webpack/common";
import { buildSeveralUsers } from "../typingTweaks"; import { buildSeveralUsers } from "../typingTweaks";
const ThreeDots = LazyComponent(() => { const ThreeDots = findExportedComponentLazy("Dots", "AnimatedDots");
// This doesn't really need to explicitly find Dots' own module, but it's fine
const res = find(m => m.Dots && !m.Menu);
return res?.Dots;
});
const TypingStore = findStoreLazy("TypingStore"); const TypingStore = findStoreLazy("TypingStore");
const UserGuildSettingsStore = findStoreLazy("UserGuildSettingsStore"); const UserGuildSettingsStore = findStoreLazy("UserGuildSettingsStore");
@ -53,7 +47,7 @@ function TypingIndicator({ channelId }: { channelId: string; }) {
return oldKeys.length === currentKeys.length && currentKeys.every(key => old[key] != null); return oldKeys.length === currentKeys.length && currentKeys.every(key => old[key] != null);
} }
); );
const currentChannelId: string = useStateFromStores([SelectedChannelStore], () => SelectedChannelStore.getChannelId());
const guildId = ChannelStore.getChannel(channelId).guild_id; const guildId = ChannelStore.getChannel(channelId).guild_id;
if (!settings.store.includeMutedChannels) { if (!settings.store.includeMutedChannels) {
@ -61,6 +55,10 @@ function TypingIndicator({ channelId }: { channelId: string; }) {
if (isChannelMuted) return null; if (isChannelMuted) return null;
} }
if (!settings.store.includeCurrentChannel) {
if (currentChannelId === channelId) return null;
}
const myId = UserStore.getCurrentUser()?.id; const myId = UserStore.getCurrentUser()?.id;
const typingUsersArray = Object.keys(typingUsers).filter(id => id !== myId && !(RelationshipStore.isBlocked(id) && !settings.store.includeBlockedUsers)); const typingUsersArray = Object.keys(typingUsers).filter(id => id !== myId && !(RelationshipStore.isBlocked(id) && !settings.store.includeBlockedUsers));
@ -107,6 +105,11 @@ function TypingIndicator({ channelId }: { channelId: string; }) {
} }
const settings = definePluginSettings({ const settings = definePluginSettings({
includeCurrentChannel: {
type: OptionType.BOOLEAN,
description: "Whether to show the typing indicator for the currently selected channel",
default: true
},
includeMutedChannels: { includeMutedChannels: {
type: OptionType.BOOLEAN, type: OptionType.BOOLEAN,
description: "Whether to show the typing indicator for muted channels.", description: "Whether to show the typing indicator for muted channels.",

View file

@ -0,0 +1,46 @@
/*
* 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 definePlugin, { OptionType } from "@utils/types";
export default definePlugin({
name: "UseAlternativeSearch",
description: "Use alternative search engine in right click menu",
authors: [
{
id: 1038096782963507210n,
name: "skyevg",
},
],
patches: [
{
find: "https://www.google.com/search?q=",
replacement: {
match: /"https:\/\/www.google.com\/search\?q=".concat\(encodeURIComponent\(e\)\)/,
replace: "Vencord.Settings.plugins.UseAlternativeSearch.source.replace(\"!QUERY!\", encodeURIComponent(e))"
}
}
],
options: {
source: {
description: "Search engine's url (use !QUERY! as replacement for the search term)",
type: OptionType.STRING,
default: "https://duckduckgo.com/?q=!QUERY!",
}
}
});

143
src/plugins/uwuifier.ts Normal file
View file

@ -0,0 +1,143 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2022 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { findOption, RequiredMessageOption } from "@api/Commands";
import { addPreEditListener, addPreSendListener, MessageObject, removePreEditListener, removePreSendListener } from "@api/MessageEvents";
import { definePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
const endings = [
"rawr x3",
"OwO",
"UwU",
"o.O",
"-.-",
">w<",
"(⑅˘꒳˘)",
"(ꈍᴗꈍ)",
"(˘ω˘)",
"(U ᵕ U❁)",
"σωσ",
"òωó",
"(///ˬ///✿)",
"(U U)",
"( ͡o ω ͡o )",
"ʘwʘ",
":3",
":3", // important enough to have twice
"XD",
"nyaa~~",
"mya",
">_<",
"😳",
"🥺",
"😳😳😳",
"rawr",
"^^",
"^^;;",
"(ˆˆ)♡",
"^•ﻌ•^",
"/(^•ω•^)",
"(✿oωo)"
];
const replacements = [
["small", "smol"],
["cute", "kawaii~"],
["fluff", "floof"],
["love", "luv"],
["stupid", "baka"],
["what", "nani"],
["meow", "nya~"],
["hello", "hewwo"],
];
const settings = definePluginSettings({
uwuEveryMessage: {
description: "Make every single message uwuified",
type: OptionType.BOOLEAN,
default: false,
restartNeeded: false
}
});
function selectRandomElement(arr) {
// generate a random index based on the length of the array
const randomIndex = Math.floor(Math.random() * arr.length);
// return the element at the randomly generated index
return arr[randomIndex];
}
function uwuify(message: string): string {
message = message.toLowerCase();
// words
for (const pair of replacements) {
message = message.replaceAll(pair[0], pair[1]);
}
message = message
.replaceAll(/([ \t\n])n/g, "$1ny") // nyaify
.replaceAll(/[lr]/g, "w") // [lr] > w
.replaceAll(/([ \t\n])([a-z])/g, (_, p1, p2) => Math.random() < .5 ? `${p1}${p2}-${p2}` : `${p1}${p2}`) // stutter
.replaceAll(/([^.,!][.,!])([ \t\n])/g, (_, p1, p2) => `${p1} ${selectRandomElement(endings)}${p2}`); // endings
return message;
}
// actual command declaration
export default definePlugin({
name: "UwUifier",
description: "Simply uwuify commands",
authors: [Devs.echo, Devs.skyevg, Devs.PandaNinjas],
dependencies: ["CommandsAPI", "MessageEventsAPI"],
settings,
commands: [
{
name: "uwuify",
description: "uwuifies your messages",
options: [RequiredMessageOption],
execute: opts => ({
content: uwuify(findOption(opts, "message", "")),
}),
},
],
onSend(msg: MessageObject) {
// Only run when it's enabled
if (settings.store.uwuEveryMessage) {
msg.content = uwuify(msg.content);
}
},
start() {
this.preSend = addPreSendListener((_, msg) => this.onSend(msg));
this.preEdit = addPreEditListener((_cid, _mid, msg) =>
this.onSend(msg)
);
},
stop() {
removePreSendListener(this.preSend);
removePreEditListener(this.preEdit);
},
});

View file

@ -22,16 +22,12 @@ import { openNotificationLogModal } from "@api/Notifications/notificationLog";
import { Settings } from "@api/Settings"; import { Settings } from "@api/Settings";
import ErrorBoundary from "@components/ErrorBoundary"; import ErrorBoundary from "@components/ErrorBoundary";
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import { LazyComponent } from "@utils/react";
import definePlugin from "@utils/types"; import definePlugin from "@utils/types";
import { filters, find } from "@webpack"; import { findExportedComponentLazy } from "@webpack";
import { Menu, Popout, useState } from "@webpack/common"; import { Menu, Popout, useState } from "@webpack/common";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
const HeaderBarIcon = LazyComponent(() => { const HeaderBarIcon = findExportedComponentLazy("Icon", "Divider");
const filter = filters.byCode(".HEADER_BAR_BADGE");
return find(m => m.Icon && filter(m.Icon)).Icon;
});
function VencordPopout(onClose: () => void) { function VencordPopout(onClose: () => void) {
const pluginEntries = [] as ReactNode[]; const pluginEntries = [] as ReactNode[];

View file

@ -25,7 +25,7 @@ interface VoiceMessageProps {
src: string; src: string;
waveform: string; waveform: string;
} }
const VoiceMessage = findComponentByCodeLazy<VoiceMessageProps>("waveform:"); const VoiceMessage = findComponentByCodeLazy<VoiceMessageProps>("waveform:", "onVolumeChange");
export type VoicePreviewOptions = { export type VoicePreviewOptions = {
src?: string; src?: string;

View file

@ -26,7 +26,7 @@ import { useAwaiter } from "@utils/react";
import definePlugin from "@utils/types"; import definePlugin from "@utils/types";
import { chooseFile } from "@utils/web"; import { chooseFile } from "@utils/web";
import { findByPropsLazy, findStoreLazy } from "@webpack"; import { findByPropsLazy, findStoreLazy } from "@webpack";
import { Button, FluxDispatcher, Forms, lodash, Menu, PermissionsBits, PermissionStore, RestAPI, SelectedChannelStore, showToast, SnowflakeUtils, Toasts, useEffect, useState } from "@webpack/common"; import { Button, FluxDispatcher, Forms, lodash, Menu, MessageActions, PermissionsBits, PermissionStore, RestAPI, SelectedChannelStore, showToast, SnowflakeUtils, Toasts, useEffect, useState } from "@webpack/common";
import { ComponentType } from "react"; import { ComponentType } from "react";
import { VoiceRecorderDesktop } from "./DesktopRecorder"; import { VoiceRecorderDesktop } from "./DesktopRecorder";
@ -36,7 +36,6 @@ import { VoicePreview } from "./VoicePreview";
import { VoiceRecorderWeb } from "./WebRecorder"; import { VoiceRecorderWeb } from "./WebRecorder";
const CloudUtils = findByPropsLazy("CloudUpload"); const CloudUtils = findByPropsLazy("CloudUpload");
const MessageCreator = findByPropsLazy("getSendMessageOptionsForReply", "sendMessage");
const PendingReplyStore = findStoreLazy("PendingReplyStore"); const PendingReplyStore = findStoreLazy("PendingReplyStore");
const OptionClasses = findByPropsLazy("optionName", "optionIcon", "optionLabel"); const OptionClasses = findByPropsLazy("optionName", "optionIcon", "optionLabel");
@ -100,7 +99,7 @@ function sendAudio(blob: Blob, meta: AudioMetadata) {
waveform: meta.waveform, waveform: meta.waveform,
duration_secs: meta.duration, duration_secs: meta.duration,
}], }],
message_reference: reply ? MessageCreator.getSendMessageOptionsForReply(reply)?.messageReference : null, message_reference: reply ? MessageActions.getSendMessageOptionsForReply(reply)?.messageReference : null,
} }
}); });
}); });

View file

@ -20,9 +20,11 @@ import { definePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types"; import definePlugin, { OptionType } from "@utils/types";
import { saveFile } from "@utils/web"; import { saveFile } from "@utils/web";
import { findByProps } from "@webpack"; import { findByPropsLazy } from "@webpack";
import { Clipboard, ComponentDispatch } from "@webpack/common"; import { Clipboard, ComponentDispatch } from "@webpack/common";
const ctxMenuCallbacks = findByPropsLazy("contextMenuCallbackNative");
async function fetchImage(url: string) { async function fetchImage(url: string) {
const res = await fetch(url); const res = await fetch(url);
if (res.status !== 200) return; if (res.status !== 200) return;
@ -55,7 +57,6 @@ export default definePlugin({
start() { start() {
if (settings.store.addBack) { if (settings.store.addBack) {
const ctxMenuCallbacks = findByProps("contextMenuCallbackNative");
window.removeEventListener("contextmenu", ctxMenuCallbacks.contextMenuCallbackWeb); window.removeEventListener("contextmenu", ctxMenuCallbacks.contextMenuCallbackWeb);
window.addEventListener("contextmenu", ctxMenuCallbacks.contextMenuCallbackNative); window.addEventListener("contextmenu", ctxMenuCallbacks.contextMenuCallbackNative);
this.changedListeners = true; this.changedListeners = true;
@ -64,7 +65,6 @@ export default definePlugin({
stop() { stop() {
if (this.changedListeners) { if (this.changedListeners) {
const ctxMenuCallbacks = findByProps("contextMenuCallbackNative");
window.removeEventListener("contextmenu", ctxMenuCallbacks.contextMenuCallbackNative); window.removeEventListener("contextmenu", ctxMenuCallbacks.contextMenuCallbackNative);
window.addEventListener("contextmenu", ctxMenuCallbacks.contextMenuCallbackWeb); window.addEventListener("contextmenu", ctxMenuCallbacks.contextMenuCallbackWeb);
} }
@ -118,11 +118,12 @@ export default definePlugin({
// Add back image context menu // Add back image context menu
{ {
find: 'navId:"image-context"', find: 'navId:"image-context"',
all: true,
predicate: () => settings.store.addBack, predicate: () => settings.store.addBack,
replacement: { replacement: {
// return IS_DESKTOP ? React.createElement(Menu, ...) // return IS_DESKTOP ? React.createElement(Menu, ...)
match: /return \i\.\i\?/, match: /return \i\.\i(?=\?|&&)/,
replace: "return true?" replace: "return true"
} }
}, },

View file

@ -0,0 +1,15 @@
# XSOverlay Notifier
Sends Discord messages to [XSOverlay](https://store.steampowered.com/app/1173510/XSOverlay/) for easier viewing while using VR.
## Preview
![](https://github.com/Vendicated/Vencord/assets/24845294/205d2055-bb4a-44e4-b7e3-265391bccd40)
![](https://github.com/Vendicated/Vencord/assets/24845294/f15eff61-2d52-4620-bcab-808ecb1606d2)
## Usage
- Enable this plugin
- Set plugin settings as desired
- Open XSOverlay
- get ping spammed

View file

@ -0,0 +1,288 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { makeRange } from "@components/PluginSettings/components";
import { Devs } from "@utils/constants";
import { Logger } from "@utils/Logger";
import definePlugin, { OptionType, PluginNative } from "@utils/types";
import { findByPropsLazy } from "@webpack";
import { ChannelStore, GuildStore, UserStore } from "@webpack/common";
import type { Channel, Embed, GuildMember, MessageAttachment, User } from "discord-types/general";
const enum ChannelTypes {
DM = 1,
GROUP_DM = 3
}
interface Message {
guild_id: string,
attachments: MessageAttachment[],
author: User,
channel_id: string,
components: any[],
content: string,
edited_timestamp: string,
embeds: Embed[],
sticker_items?: Sticker[],
flags: number,
id: string,
member: GuildMember,
mention_everyone: boolean,
mention_roles: string[],
mentions: Mention[],
nonce: string,
pinned: false,
referenced_message: any,
timestamp: string,
tts: boolean,
type: number;
}
interface Mention {
avatar: string,
avatar_decoration_data: any,
discriminator: string,
global_name: string,
id: string,
public_flags: number,
username: string;
}
interface Sticker {
t: "Sticker";
description: string;
format_type: number;
guild_id: string;
id: string;
name: string;
tags: string;
type: number;
}
interface Call {
channel_id: string,
guild_id: string,
message_id: string,
region: string,
ringing: string[];
}
const MuteStore = findByPropsLazy("isSuppressEveryoneEnabled");
const XSLog = new Logger("XSOverlay");
const settings = definePluginSettings({
ignoreBots: {
type: OptionType.BOOLEAN,
description: "Ignore messages from bots",
default: false
},
pingColor: {
type: OptionType.STRING,
description: "User mention color",
default: "#7289da"
},
channelPingColor: {
type: OptionType.STRING,
description: "Channel mention color",
default: "#8a2be2"
},
soundPath: {
type: OptionType.STRING,
description: "Notification sound (default/warning/error)",
default: "default"
},
timeout: {
type: OptionType.NUMBER,
description: "Notif duration (secs)",
default: 1.0,
},
opacity: {
type: OptionType.SLIDER,
description: "Notif opacity",
default: 1,
markers: makeRange(0, 1, 0.1)
},
volume: {
type: OptionType.SLIDER,
description: "Volume",
default: 0.2,
markers: makeRange(0, 1, 0.1)
},
});
const Native = VencordNative.pluginHelpers.XsOverlay as PluginNative<typeof import("./native")>;
export default definePlugin({
name: "XSOverlay",
description: "Forwards discord notifications to XSOverlay, for easy viewing in VR",
authors: [Devs.Nyako],
tags: ["vr", "notify"],
settings,
flux: {
CALL_UPDATE({ call }: { call: Call; }) {
if (call?.ringing?.includes(UserStore.getCurrentUser().id)) {
const channel = ChannelStore.getChannel(call.channel_id);
sendOtherNotif("Incoming call", `${channel.name} is calling you...`);
}
},
MESSAGE_CREATE({ message, optimistic }: { message: Message; optimistic: boolean; }) {
// Apparently without this try/catch, discord's socket connection dies if any part of this errors
try {
if (optimistic) return;
const channel = ChannelStore.getChannel(message.channel_id);
if (!shouldNotify(message, channel)) return;
const pingColor = settings.store.pingColor.replaceAll("#", "").trim();
const channelPingColor = settings.store.channelPingColor.replaceAll("#", "").trim();
let finalMsg = message.content;
let titleString = "";
if (channel.guild_id) {
const guild = GuildStore.getGuild(channel.guild_id);
titleString = `${message.author.username} (${guild.name}, #${channel.name})`;
}
switch (channel.type) {
case ChannelTypes.DM:
titleString = message.author.username.trim();
break;
case ChannelTypes.GROUP_DM:
const channelName = channel.name.trim() ?? channel.rawRecipients.map(e => e.username).join(", ");
titleString = `${message.author.username} (${channelName})`;
break;
}
if (message.referenced_message) {
titleString += " (reply)";
}
if (message.embeds.length > 0) {
finalMsg += " [embed] ";
if (message.content === "") {
finalMsg = "sent message embed(s)";
}
}
if (message.sticker_items) {
finalMsg += " [sticker] ";
if (message.content === "") {
finalMsg = "sent a sticker";
}
}
const images = message.attachments.filter(e =>
typeof e?.content_type === "string"
&& e?.content_type.startsWith("image")
);
images.forEach(img => {
finalMsg += ` [image: ${img.filename}] `;
});
message.attachments.filter(a => a && !a.content_type?.startsWith("image")).forEach(a => {
finalMsg += ` [attachment: ${a.filename}] `;
});
// make mentions readable
if (message.mentions.length > 0) {
finalMsg = finalMsg.replace(/<@!?(\d{17,20})>/g, (_, id) => `<color=#${pingColor}><b>@${UserStore.getUser(id)?.username || "unknown-user"}</color></b>`);
}
if (message.mention_roles.length > 0) {
for (const roleId of message.mention_roles) {
const role = GuildStore.getGuild(channel.guild_id).roles[roleId];
if (!role) continue;
const roleColor = role.colorString ?? `#${pingColor}`;
finalMsg = finalMsg.replace(`<@&${roleId}>`, `<b><color=${roleColor}>@${role.name}</color></b>`);
}
}
// make emotes and channel mentions readable
const emoteMatches = finalMsg.match(new RegExp("(<a?:\\w+:\\d+>)", "g"));
const channelMatches = finalMsg.match(new RegExp("<(#\\d+)>", "g"));
if (emoteMatches) {
for (const eMatch of emoteMatches) {
finalMsg = finalMsg.replace(new RegExp(`${eMatch}`, "g"), `:${eMatch.split(":")[1]}:`);
}
}
if (channelMatches) {
for (const cMatch of channelMatches) {
let channelId = cMatch.split("<#")[1];
channelId = channelId.substring(0, channelId.length - 1);
finalMsg = finalMsg.replace(new RegExp(`${cMatch}`, "g"), `<b><color=#${channelPingColor}>#${ChannelStore.getChannel(channelId).name}</color></b>`);
}
}
sendMsgNotif(titleString, finalMsg, message);
} catch (err) {
XSLog.error(`Failed to catch MESSAGE_CREATE: ${err}`);
}
}
}
});
function sendMsgNotif(titleString: string, content: string, message: Message) {
fetch(`https://cdn.discordapp.com/avatars/${message.author.id}/${message.author.avatar}.png?size=128`).then(response => response.arrayBuffer()).then(result => {
const msgData = {
messageType: 1,
index: 0,
timeout: settings.store.timeout,
height: calculateHeight(cleanMessage(content)),
opacity: settings.store.opacity,
volume: settings.store.volume,
audioPath: settings.store.soundPath,
title: titleString,
content: content,
useBase64Icon: true,
icon: result,
sourceApp: "Vencord"
};
Native.sendToOverlay(msgData);
});
}
function sendOtherNotif(content: string, titleString: string) {
const msgData = {
messageType: 1,
index: 0,
timeout: settings.store.timeout,
height: calculateHeight(cleanMessage(content)),
opacity: settings.store.opacity,
volume: settings.store.volume,
audioPath: settings.store.soundPath,
title: titleString,
content: content,
useBase64Icon: false,
icon: null,
sourceApp: "Vencord"
};
Native.sendToOverlay(msgData);
}
function shouldNotify(message: Message, channel: Channel) {
const currentUser = UserStore.getCurrentUser();
if (message.author.id === currentUser.id) return false;
if (message.author.bot && settings.store.ignoreBots) return false;
if (MuteStore.allowAllMessages(channel) || message.mention_everyone && !MuteStore.isSuppressEveryoneEnabled(message.guild_id)) return true;
return message.mentions.some(m => m.id === currentUser.id);
}
function calculateHeight(content: string) {
if (content.length <= 100) return 100;
if (content.length <= 200) return 150;
if (content.length <= 300) return 200;
return 250;
}
function cleanMessage(content: string) {
return content.replace(new RegExp("<[^>]*>", "g"), "");
}

View file

@ -0,0 +1,16 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { createSocket, Socket } from "dgram";
let xsoSocket: Socket;
export function sendToOverlay(_, data: any) {
data.icon = Buffer.from(data.icon).toString("base64");
const json = JSON.stringify(data);
xsoSocket ??= createSocket("udp4");
xsoSocket.send(json, 42069, "127.0.0.1");
}

View file

@ -19,8 +19,7 @@
import * as DataStore from "@api/DataStore"; import * as DataStore from "@api/DataStore";
import { showNotification } from "@api/Notifications"; import { showNotification } from "@api/Notifications";
import { Settings } from "@api/Settings"; import { Settings } from "@api/Settings";
import { findByProps } from "@webpack"; import { OAuth2AuthorizeModal, UserStore } from "@webpack/common";
import { UserStore } from "@webpack/common";
import { Logger } from "./Logger"; import { Logger } from "./Logger";
import { openModal } from "./modal"; import { openModal } from "./modal";
@ -91,8 +90,6 @@ export async function authorizeCloud() {
return; return;
} }
const { OAuth2AuthorizeModal } = findByProps("OAuth2AuthorizeModal");
openModal((props: any) => <OAuth2AuthorizeModal openModal((props: any) => <OAuth2AuthorizeModal
{...props} {...props}
scopes={["identify"]} scopes={["identify"]}

View file

@ -78,8 +78,8 @@ export const Devs = /* #__PURE__*/ Object.freeze({
name: "Samu", name: "Samu",
id: 702973430449832038n, id: 702973430449832038n,
}, },
Animal: { Nyako: {
name: "Animal", name: "nyako",
id: 118437263754395652n id: 118437263754395652n
}, },
MaiKokain: { MaiKokain: {
@ -387,6 +387,18 @@ export const Devs = /* #__PURE__*/ Object.freeze({
name: "ant0n", name: "ant0n",
id: 145224646868860928n id: 145224646868860928n
}, },
philipbry: {
name: "philipbry",
id: 554994003318276106n
},
Korbo: {
name: "Korbo",
id: 455856406420258827n
},
maisymoe: {
name: "maisy",
id: 257109471589957632n,
},
} satisfies Record<string, Dev>); } satisfies Record<string, Dev>);
// iife so #__PURE__ works correctly // iife so #__PURE__ works correctly

View file

@ -17,14 +17,42 @@
*/ */
import { MessageObject } from "@api/MessageEvents"; import { MessageObject } from "@api/MessageEvents";
import { findByPropsLazy } from "@webpack"; import { ChannelStore, ComponentDispatch, FluxDispatcher, GuildStore, InviteActions, MaskedLink, MessageActions, ModalImageClasses, PrivateChannelsStore, RestAPI, SelectedChannelStore, SelectedGuildStore, UserProfileActions, UserProfileStore, UserSettingsActionCreators, UserUtils } from "@webpack/common";
import { ChannelStore, ComponentDispatch, FluxDispatcher, GuildStore, MaskedLink, ModalImageClasses, PrivateChannelsStore, RestAPI, SelectedChannelStore, SelectedGuildStore, UserProfileStore, UserSettingsActionCreators, UserUtils } from "@webpack/common";
import { Guild, Message, User } from "discord-types/general"; import { Guild, Message, User } from "discord-types/general";
import { ImageModal, ModalRoot, ModalSize, openModal } from "./modal"; import { ImageModal, ModalRoot, ModalSize, openModal } from "./modal";
const MessageActions = findByPropsLazy("editMessage", "sendMessage"); /**
const UserProfileActions = findByPropsLazy("openUserProfileModal", "closeUserProfileModal"); * Open the invite modal
* @param code The invite code
* @returns Whether the invite was accepted
*/
export async function openInviteModal(code: string) {
const { invite } = await InviteActions.resolveInvite(code, "Desktop Modal");
if (!invite) throw new Error("Invalid invite: " + code);
FluxDispatcher.dispatch({
type: "INVITE_MODAL_OPEN",
invite,
code,
context: "APP"
});
return new Promise<boolean>(r => {
let onClose: () => void, onAccept: () => void;
let inviteAccepted = false;
FluxDispatcher.subscribe("INVITE_ACCEPT", onAccept = () => {
inviteAccepted = true;
});
FluxDispatcher.subscribe("INVITE_MODAL_CLOSE", onClose = () => {
FluxDispatcher.unsubscribe("INVITE_MODAL_CLOSE", onClose);
FluxDispatcher.unsubscribe("INVITE_ACCEPT", onAccept);
r(inviteAccepted);
});
});
}
export function getCurrentChannel() { export function getCurrentChannel() {
return ChannelStore.getChannel(SelectedChannelStore.getChannelId()); return ChannelStore.getChannel(SelectedChannelStore.getChannelId());

View file

@ -76,7 +76,7 @@ handler.getOwnPropertyDescriptor = (target, p) => {
}; };
/** /**
* Wraps the result of {@see makeLazy} in a Proxy you can consume as if it wasn't lazy. * Wraps the result of {@link makeLazy} in a Proxy you can consume as if it wasn't lazy.
* On first property access, the lazy is evaluated * On first property access, the lazy is evaluated
* @param factory lazy factory * @param factory lazy factory
* @param attempts how many times to try to evaluate the lazy before giving up * @param attempts how many times to try to evaluate the lazy before giving up

View file

@ -4,6 +4,8 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
*/ */
import { ComponentType } from "react";
import { makeLazy } from "./lazy"; import { makeLazy } from "./lazy";
const NoopComponent = () => null; const NoopComponent = () => null;
@ -16,8 +18,12 @@ const NoopComponent = () => null;
*/ */
export function LazyComponent<T extends object = any>(factory: () => React.ComponentType<T>, attempts = 5) { export function LazyComponent<T extends object = any>(factory: () => React.ComponentType<T>, attempts = 5) {
const get = makeLazy(factory, attempts); const get = makeLazy(factory, attempts);
return (props: T) => { const LazyComponent = (props: T) => {
const Component = get() ?? NoopComponent; const Component = get() ?? NoopComponent;
return <Component {...props} />; return <Component {...props} />;
}; };
LazyComponent.$$vencordInternal = get;
return LazyComponent as ComponentType<T>;
} }

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 { findByProps, findByPropsLazy } from "@webpack"; import { findByPropsLazy, findExportedComponentLazy } from "@webpack";
import type { ComponentType, PropsWithChildren, ReactNode, Ref } from "react"; import type { ComponentType, PropsWithChildren, ReactNode, Ref } from "react";
import { LazyComponent } from "./react"; import { LazyComponent } from "./react";
@ -118,7 +118,7 @@ export type ImageModal = ComponentType<{
shouldHideMediaOptions?: boolean; shouldHideMediaOptions?: boolean;
}>; }>;
export const ImageModal = LazyComponent(() => findByProps("ImageModal").ImageModal as ImageModal); export const ImageModal = findExportedComponentLazy("ImageModal") as ImageModal;
export const ModalRoot = LazyComponent(() => Modals.ModalRoot); export const ModalRoot = LazyComponent(() => Modals.ModalRoot);
export const ModalHeader = LazyComponent(() => Modals.ModalHeader); export const ModalHeader = LazyComponent(() => Modals.ModalHeader);

View file

@ -17,7 +17,7 @@
*/ */
// eslint-disable-next-line path-alias/no-relative // eslint-disable-next-line path-alias/no-relative
import { filters, waitFor } from "@webpack"; import { filters, findByPropsLazy, waitFor } from "@webpack";
import { waitForComponent } from "./internal"; import { waitForComponent } from "./internal";
import * as t from "./types/components"; import * as t from "./types/components";
@ -55,6 +55,8 @@ export const MaskedLink = waitForComponent<t.MaskedLink>("MaskedLink", m => m?.t
export const Timestamp = waitForComponent<t.Timestamp>("Timestamp", filters.byCode(".Messages.MESSAGE_EDITED_TIMESTAMP_A11Y_LABEL.format")); export const Timestamp = waitForComponent<t.Timestamp>("Timestamp", filters.byCode(".Messages.MESSAGE_EDITED_TIMESTAMP_A11Y_LABEL.format"));
export const Flex = waitForComponent<t.Flex>("Flex", ["Justify", "Align", "Wrap"]); export const Flex = waitForComponent<t.Flex>("Flex", ["Justify", "Align", "Wrap"]);
export const { OAuth2AuthorizeModal } = findByPropsLazy("OAuth2AuthorizeModal");
waitFor(["FormItem", "Button"], m => { waitFor(["FormItem", "Button"], m => {
({ useToken, Card, Button, FormSwitch: Switch, Tooltip, TextInput, TextArea, Text, Select, SearchableSelect, Slider, ButtonLooks, TabBar, Popout, Dialog, Paginator, ScrollerThin, Clickable, Avatar } = m); ({ useToken, Card, Button, FormSwitch: Switch, Tooltip, TextInput, TextArea, Text, Select, SearchableSelect, Slider, ButtonLooks, TabBar, Popout, Dialog, Paginator, ScrollerThin, Clickable, Avatar } = m);
Forms = m; Forms = m;

View file

@ -19,9 +19,11 @@
import { LazyComponent } from "@utils/react"; import { LazyComponent } from "@utils/react";
// eslint-disable-next-line path-alias/no-relative // eslint-disable-next-line path-alias/no-relative
import { FilterFn, filters, waitFor } from "../webpack"; import { FilterFn, filters, lazyWebpackSearchHistory, waitFor } from "../webpack";
export function waitForComponent<T extends React.ComponentType<any> = React.ComponentType<any> & Record<string, any>>(name: string, filter: FilterFn | string | string[]): T { export function waitForComponent<T extends React.ComponentType<any> = React.ComponentType<any> & Record<string, any>>(name: string, filter: FilterFn | string | string[]): T {
if (IS_DEV) lazyWebpackSearchHistory.push(["waitForComponent", Array.isArray(filter) ? filter : [filter]]);
let myValue: T = function () { let myValue: T = function () {
throw new Error(`Vencord could not find the ${name} Component`); throw new Error(`Vencord could not find the ${name} Component`);
} as any; } as any;
@ -30,11 +32,13 @@ export function waitForComponent<T extends React.ComponentType<any> = React.Comp
waitFor(filter, (v: any) => { waitFor(filter, (v: any) => {
myValue = v; myValue = v;
Object.assign(lazyComponent, v); Object.assign(lazyComponent, v);
}); }, { isIndirect: true });
return lazyComponent; return lazyComponent;
} }
export function waitForStore(name: string, cb: (v: any) => void) { export function waitForStore(name: string, cb: (v: any) => void) {
waitFor(filters.byStoreName(name), cb); if (IS_DEV) lazyWebpackSearchHistory.push(["waitForStore", [name]]);
waitFor(filters.byStoreName(name), cb, { isIndirect: true });
} }

View file

@ -126,6 +126,7 @@ export type Button = ComponentType<PropsWithChildren<Omit<HTMLProps<HTMLButtonEl
buttonRef?: Ref<HTMLButtonElement>; buttonRef?: Ref<HTMLButtonElement>;
focusProps?: any; focusProps?: any;
submitting?: boolean;
submittingStartedLabel?: string; submittingStartedLabel?: string;
submittingFinishedLabel?: string; submittingFinishedLabel?: string;

View file

@ -16,14 +16,23 @@
* 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 { proxyLazy } from "@utils/lazy";
import type { Channel, User } from "discord-types/general"; import type { Channel, User } from "discord-types/general";
// eslint-disable-next-line path-alias/no-relative // eslint-disable-next-line path-alias/no-relative
import { _resolveReady, find, findByPropsLazy, findLazy, waitFor } from "../webpack"; import { _resolveReady, filters, findByCodeLazy, findByPropsLazy, findLazy, waitFor } from "../webpack";
import type * as t from "./types/utils"; import type * as t from "./types/utils";
export let FluxDispatcher: t.FluxDispatcher; export let FluxDispatcher: t.FluxDispatcher;
waitFor(["dispatch", "subscribe"], m => {
FluxDispatcher = m;
const cb = () => {
m.unsubscribe("CONNECTION_OPEN", cb);
_resolveReady();
};
m.subscribe("CONNECTION_OPEN", cb);
});
export let ComponentDispatch; export let ComponentDispatch;
waitFor(["ComponentDispatch", "ComponentDispatcher"], m => ComponentDispatch = m.ComponentDispatch); waitFor(["ComponentDispatch", "ComponentDispatcher"], m => ComponentDispatch = m.ComponentDispatch);
@ -41,7 +50,9 @@ export let SnowflakeUtils: t.SnowflakeUtils;
waitFor(["fromTimestamp", "extractTimestamp"], m => SnowflakeUtils = m); waitFor(["fromTimestamp", "extractTimestamp"], m => SnowflakeUtils = m);
export let Parser: t.Parser; export let Parser: t.Parser;
waitFor("parseTopic", m => Parser = m);
export let Alerts: t.Alerts; export let Alerts: t.Alerts;
waitFor(["show", "close"], m => Alerts = m);
const ToastType = { const ToastType = {
MESSAGE: 0, MESSAGE: 0,
@ -82,6 +93,13 @@ export const Toasts = {
} }
}; };
// This is the same module but this is easier
waitFor("showToast", m => {
Toasts.show = m.showToast;
Toasts.pop = m.popToast;
});
/** /**
* Show a simple toast. If you need more options, use Toasts.show manually * Show a simple toast. If you need more options, use Toasts.show manually
*/ */
@ -106,26 +124,16 @@ export const Clipboard: t.Clipboard = findByPropsLazy("SUPPORTS_COPY", "copy");
export const NavigationRouter: t.NavigationRouter = findByPropsLazy("transitionTo", "replaceWith", "transitionToGuild"); export const NavigationRouter: t.NavigationRouter = findByPropsLazy("transitionTo", "replaceWith", "transitionToGuild");
waitFor(["dispatch", "subscribe"], m => {
FluxDispatcher = m;
const cb = () => {
m.unsubscribe("CONNECTION_OPEN", cb);
_resolveReady();
};
m.subscribe("CONNECTION_OPEN", cb);
});
// This is the same module but this is easier
waitFor("showToast", m => {
Toasts.show = m.showToast;
Toasts.pop = m.popToast;
});
waitFor(["show", "close"], m => Alerts = m);
waitFor("parseTopic", m => Parser = m);
export let SettingsRouter: any; export let SettingsRouter: any;
waitFor(["open", "saveAccountChanges"], m => SettingsRouter = m); waitFor(["open", "saveAccountChanges"], m => SettingsRouter = m);
export const PermissionsBits: t.PermissionsBits = proxyLazy(() => find(m => typeof m.Permissions?.ADMINISTRATOR === "bigint").Permissions); export const { Permissions: PermissionsBits } = findLazy(m => typeof m.Permissions?.ADMINISTRATOR === "bigint") as { Permissions: t.PermissionsBits; };
export const zustandCreate: typeof import("zustand").default = findByCodeLazy("will be removed in v4");
const persistFilter = filters.byCode("[zustand persist middleware]");
export const { persist: zustandPersist }: typeof import("zustand/middleware") = findLazy(m => m.persist && persistFilter(m.persist));
export const MessageActions = findByPropsLazy("editMessage", "sendMessage");
export const UserProfileActions = findByPropsLazy("openUserProfileModal", "closeUserProfileModal");
export const InviteActions = findByPropsLazy("resolveInvite");

View file

@ -58,6 +58,9 @@ if (window[WEBPACK_CHUNK]) {
// normally, this is populated via webpackGlobal.push, which we patch below. // normally, this is populated via webpackGlobal.push, which we patch below.
// However, Discord has their .m prepopulated. // However, Discord has their .m prepopulated.
// Thus, we use this hack to immediately access their wreq.m and patch all already existing factories // Thus, we use this hack to immediately access their wreq.m and patch all already existing factories
//
// Update: Discord now has TWO webpack instances. Their normal one and sentry
// Sentry does not push chunks to the global at all, so this same patch now also handles their sentry modules
Object.defineProperty(Function.prototype, "m", { Object.defineProperty(Function.prototype, "m", {
set(v: any) { set(v: any) {
// When using react devtools or other extensions, we may also catch their webpack here. // When using react devtools or other extensions, we may also catch their webpack here.
@ -65,8 +68,6 @@ if (window[WEBPACK_CHUNK]) {
if (new Error().stack?.includes("discord.com")) { if (new Error().stack?.includes("discord.com")) {
logger.info("Found webpack module factory"); logger.info("Found webpack module factory");
patchFactories(v); patchFactories(v);
delete (Function.prototype as any).m;
} }
Object.defineProperty(this, "m", { Object.defineProperty(this, "m", {
@ -142,7 +143,7 @@ function patchFactories(factories: Record<string | number, (module: { exports: a
// There are (at the time of writing) 11 modules exporting the window // There are (at the time of writing) 11 modules exporting the window
// Make these non enumerable to improve webpack search performance // Make these non enumerable to improve webpack search performance
if (exports === window) { if (exports === window && require.c) {
Object.defineProperty(require.c, id, { Object.defineProperty(require.c, id, {
value: require.c[id], value: require.c[id],
enumerable: false, enumerable: false,
@ -152,11 +153,9 @@ function patchFactories(factories: Record<string | number, (module: { exports: a
return; return;
} }
const numberId = Number(id);
for (const callback of listeners) { for (const callback of listeners) {
try { try {
callback(exports, numberId); callback(exports, id);
} catch (err) { } catch (err) {
logger.error("Error in webpack listener", err); logger.error("Error in webpack listener", err);
} }
@ -166,10 +165,10 @@ function patchFactories(factories: Record<string | number, (module: { exports: a
try { try {
if (filter(exports)) { if (filter(exports)) {
subscriptions.delete(filter); subscriptions.delete(filter);
callback(exports, numberId); callback(exports, id);
} else if (exports.default && filter(exports.default)) { } else if (exports.default && filter(exports.default)) {
subscriptions.delete(filter); subscriptions.delete(filter);
callback(exports.default, numberId); callback(exports.default, id);
} }
} catch (err) { } catch (err) {
logger.error("Error while firing callback for webpack chunk", err); logger.error("Error while firing callback for webpack chunk", err);
@ -212,7 +211,7 @@ function patchFactories(factories: Record<string | number, (module: { exports: a
} }
if (patch.group) { if (patch.group) {
logger.warn(`Undoing patch ${patch.find} by ${patch.plugin} because replacement ${replacement.match} had no effect`); logger.warn(`Undoing patch group ${patch.find} by ${patch.plugin} because replacement ${replacement.match} had no effect`);
code = previousCode; code = previousCode;
mod = previousMod; mod = previousMod;
patchedBy.delete(patch.plugin); patchedBy.delete(patch.plugin);
@ -260,7 +259,7 @@ function patchFactories(factories: Record<string | number, (module: { exports: a
patchedBy.delete(patch.plugin); patchedBy.delete(patch.plugin);
if (patch.group) { if (patch.group) {
logger.warn(`Undoing patch ${patch.find} by ${patch.plugin} because replacement ${replacement.match} errored`); logger.warn(`Undoing patch group ${patch.find} by ${patch.plugin} because replacement ${replacement.match} errored`);
code = previousCode; code = previousCode;
mod = previousMod; mod = previousMod;
break; break;

View file

@ -19,6 +19,7 @@
import { proxyLazy } from "@utils/lazy"; import { proxyLazy } from "@utils/lazy";
import { LazyComponent } from "@utils/lazyReact"; import { LazyComponent } from "@utils/lazyReact";
import { Logger } from "@utils/Logger"; import { Logger } from "@utils/Logger";
import { canonicalizeMatch } from "@utils/patches";
import type { WebpackInstance } from "discord-types/other"; import type { WebpackInstance } from "discord-types/other";
import { traceFunction } from "../debug/Tracer"; import { traceFunction } from "../debug/Tracer";
@ -69,7 +70,7 @@ export const filters = {
export const subscriptions = new Map<FilterFn, CallbackFn>(); export const subscriptions = new Map<FilterFn, CallbackFn>();
export const listeners = new Set<CallbackFn>(); export const listeners = new Set<CallbackFn>();
export type CallbackFn = (mod: any, id: number) => void; export type CallbackFn = (mod: any, id: string) => void;
export function _initWebpack(instance: typeof window.webpackChunkdiscord_app) { export function _initWebpack(instance: typeof window.webpackChunkdiscord_app) {
if (cache !== void 0) throw "no."; if (cache !== void 0) throw "no.";
@ -111,12 +112,12 @@ export const find = traceFunction("find", function find(filter: FilterFn, { isIn
if (!mod?.exports) continue; if (!mod?.exports) continue;
if (filter(mod.exports)) { if (filter(mod.exports)) {
return isWaitFor ? [mod.exports, Number(key)] : mod.exports; return isWaitFor ? [mod.exports, key] : mod.exports;
} }
if (mod.exports.default && filter(mod.exports.default)) { if (mod.exports.default && filter(mod.exports.default)) {
const found = mod.exports.default; const found = mod.exports.default;
return isWaitFor ? [found, Number(key)] : found; return isWaitFor ? [found, key] : found;
} }
} }
@ -127,13 +128,6 @@ export const find = traceFunction("find", function find(filter: FilterFn, { isIn
return isWaitFor ? [null, null] : null; return isWaitFor ? [null, null] : null;
}); });
/**
* find but lazy
*/
export function findLazy(filter: FilterFn) {
return proxyLazy(() => find(filter));
}
export function findAll(filter: FilterFn) { export function findAll(filter: FilterFn) {
if (typeof filter !== "function") if (typeof filter !== "function")
throw new Error("Invalid filter. Expected a function got " + typeof filter); throw new Error("Invalid filter. Expected a function got " + typeof filter);
@ -221,18 +215,21 @@ export const findBulk = traceFunction("findBulk", function findBulk(...filterFns
}); });
/** /**
* Find the id of a module by its code * Find the id of the first module factory that includes all the given code
* @param code Code * @returns string or null
* @returns number or null
*/ */
export const findModuleId = traceFunction("findModuleId", function findModuleId(code: string) { export const findModuleId = traceFunction("findModuleId", function findModuleId(...code: string[]) {
outer:
for (const id in wreq.m) { for (const id in wreq.m) {
if (wreq.m[id].toString().includes(code)) { const str = wreq.m[id].toString();
return Number(id);
for (const c of code) {
if (!str.includes(c)) continue outer;
} }
return id;
} }
const err = new Error("Didn't find module with code:\n" + code); const err = new Error("Didn't find module with code(s):\n" + code.join("\n"));
if (IS_DEV) { if (IS_DEV) {
if (!devToolsOpen) if (!devToolsOpen)
// Strict behaviour in DevBuilds to fail early and make sure the issue is found // Strict behaviour in DevBuilds to fail early and make sure the issue is found
@ -244,6 +241,60 @@ export const findModuleId = traceFunction("findModuleId", function findModuleId(
return null; return null;
}); });
/**
* Find the first module factory that includes all the given code
* @returns The module factory or null
*/
export function findModuleFactory(...code: string[]) {
const id = findModuleId(...code);
if (!id) return null;
return wreq.m[id];
}
export const lazyWebpackSearchHistory = [] as Array<["find" | "findByProps" | "findByCode" | "findStore" | "findComponent" | "findComponentByCode" | "findExportedComponent" | "waitFor" | "waitForComponent" | "waitForStore" | "proxyLazyWebpack" | "LazyComponentWebpack" | "extractAndLoadChunks", any[]]>;
/**
* This is just a wrapper around {@link proxyLazy} to make our reporter test for your webpack finds.
*
* Wraps the result of {@link makeLazy} in a Proxy you can consume as if it wasn't lazy.
* On first property access, the lazy is evaluated
* @param factory lazy factory
* @param attempts how many times to try to evaluate the lazy before giving up
* @returns Proxy
*
* Note that the example below exists already as an api, see {@link findByPropsLazy}
* @example const mod = proxyLazy(() => findByProps("blah")); console.log(mod.blah);
*/
export function proxyLazyWebpack<T = any>(factory: () => any, attempts?: number) {
if (IS_DEV) lazyWebpackSearchHistory.push(["proxyLazyWebpack", [factory]]);
return proxyLazy<T>(factory, attempts);
}
/**
* This is just a wrapper around {@link LazyComponent} to make our reporter test for your webpack finds.
*
* A lazy component. The factory method is called on first render.
* @param factory Function returning a Component
* @param attempts How many times to try to get the component before giving up
* @returns Result of factory function
*/
export function LazyComponentWebpack<T extends object = any>(factory: () => any, attempts?: number) {
if (IS_DEV) lazyWebpackSearchHistory.push(["LazyComponentWebpack", [factory]]);
return LazyComponent<T>(factory, attempts);
}
/**
* Find the first module that matches the filter, lazily
*/
export function findLazy(filter: FilterFn) {
if (IS_DEV) lazyWebpackSearchHistory.push(["find", [filter]]);
return proxyLazy(() => find(filter));
}
/** /**
* Find the first module that has the specified properties * Find the first module that has the specified properties
*/ */
@ -255,14 +306,16 @@ export function findByProps(...props: string[]) {
} }
/** /**
* findByProps but lazy * Find the first module that has the specified properties, lazily
*/ */
export function findByPropsLazy(...props: string[]) { export function findByPropsLazy(...props: string[]) {
if (IS_DEV) lazyWebpackSearchHistory.push(["findByProps", props]);
return proxyLazy(() => findByProps(...props)); return proxyLazy(() => findByProps(...props));
} }
/** /**
* Find a function by its code * Find the first function that includes all the given code
*/ */
export function findByCode(...code: string[]) { export function findByCode(...code: string[]) {
const res = find(filters.byCode(...code), { isIndirect: true }); const res = find(filters.byCode(...code), { isIndirect: true });
@ -272,9 +325,11 @@ export function findByCode(...code: string[]) {
} }
/** /**
* findByCode but lazy * Find the first function that includes all the given code, lazily
*/ */
export function findByCodeLazy(...code: string[]) { export function findByCodeLazy(...code: string[]) {
if (IS_DEV) lazyWebpackSearchHistory.push(["findByCode", code]);
return proxyLazy(() => findByCode(...code)); return proxyLazy(() => findByCode(...code));
} }
@ -289,9 +344,11 @@ export function findStore(name: string) {
} }
/** /**
* findStore but lazy * Find a store by its displayName, lazily
*/ */
export function findStoreLazy(name: string) { export function findStoreLazy(name: string) {
if (IS_DEV) lazyWebpackSearchHistory.push(["findStore", [name]]);
return proxyLazy(() => findStore(name)); return proxyLazy(() => findStore(name));
} }
@ -309,28 +366,108 @@ export function findComponentByCode(...code: string[]) {
* Finds the first component that matches the filter, lazily. * Finds the first component that matches the filter, lazily.
*/ */
export function findComponentLazy<T extends object = any>(filter: FilterFn) { export function findComponentLazy<T extends object = any>(filter: FilterFn) {
return LazyComponent<T>(() => find(filter)); if (IS_DEV) lazyWebpackSearchHistory.push(["findComponent", [filter]]);
return LazyComponent<T>(() => {
const res = find(filter, { isIndirect: true });
if (!res)
handleModuleNotFound("findComponent", filter);
return res;
});
} }
/** /**
* Finds the first component that includes all the given code, lazily * Finds the first component that includes all the given code, lazily
*/ */
export function findComponentByCodeLazy<T extends object = any>(...code: string[]) { export function findComponentByCodeLazy<T extends object = any>(...code: string[]) {
return LazyComponent<T>(() => findComponentByCode(...code)); if (IS_DEV) lazyWebpackSearchHistory.push(["findComponentByCode", code]);
return LazyComponent<T>(() => {
const res = find(filters.componentByCode(...code), { isIndirect: true });
if (!res)
handleModuleNotFound("findComponentByCode", ...code);
return res;
});
} }
/** /**
* Finds the first component that is exported by the first prop name, lazily * Finds the first component that is exported by the first prop name, lazily
*/ */
export function findExportedComponentLazy<T extends object = any>(...props: string[]) { export function findExportedComponentLazy<T extends object = any>(...props: string[]) {
return LazyComponent<T>(() => findByProps(...props)?.[props[0]]); if (IS_DEV) lazyWebpackSearchHistory.push(["findExportedComponent", props]);
return LazyComponent<T>(() => {
const res = find(filters.byProps(...props), { isIndirect: true });
if (!res)
handleModuleNotFound("findExportedComponent", ...props);
return res[props[0]];
});
}
/**
* Extract and load chunks using their entry point
* @param code An array of all the code the module factory containing the entry point (as of using it to load chunks) must include
* @param matcher A RegExp that returns the entry point id as the first capture group. Defaults to a matcher that captures the first entry point found in the module factory
*/
export async function extractAndLoadChunks(code: string[], matcher: RegExp = /\.el\("(.+?)"\)(?<=(\i)\.el.+?)\.then\(\2\.bind\(\2,"\1"\)\)/) {
const module = findModuleFactory(...code);
if (!module) {
const err = new Error("extractAndLoadChunks: Couldn't find module factory");
logger.warn(err, "Code:", code, "Matcher:", matcher);
return;
}
const match = module.toString().match(canonicalizeMatch(matcher));
if (!match) {
const err = new Error("extractAndLoadChunks: Couldn't find entry point id in module factory code");
logger.warn(err, "Code:", code, "Matcher:", matcher);
// Strict behaviour in DevBuilds to fail early and make sure the issue is found
if (IS_DEV && !devToolsOpen)
throw err;
return;
}
const [, id] = match;
if (!id || !Number(id)) {
const err = new Error("extractAndLoadChunks: Matcher didn't return a capturing group with the entry point, or the entry point returned wasn't a number");
logger.warn(err, "Code:", code, "Matcher:", matcher);
// Strict behaviour in DevBuilds to fail early and make sure the issue is found
if (IS_DEV && !devToolsOpen)
throw err;
return;
}
await (wreq as any).el(id);
return wreq(id as any);
}
/**
* This is just a wrapper around {@link extractAndLoadChunks} to make our reporter test for your webpack finds.
*
* Extract and load chunks using their entry point
* @param code An array of all the code the module factory containing the entry point (as of using it to load chunks) must include
* @param matcher A RegExp that returns the entry point id as the first capture group. Defaults to a matcher that captures the first entry point found in the module factory
* @returns A function that loads the chunks on first call
*/
export function extractAndLoadChunksLazy(code: string[], matcher: RegExp = /\.el\("(.+?)"\)(?<=(\i)\.el.+?)\.then\(\2\.bind\(\2,"\1"\)\)/) {
if (IS_DEV) lazyWebpackSearchHistory.push(["extractAndLoadChunks", [code, matcher]]);
return () => extractAndLoadChunks(code, matcher);
} }
/** /**
* Wait for a module that matches the provided filter to be registered, * Wait for a module that matches the provided filter to be registered,
* then call the callback with the module as the first argument * then call the callback with the module as the first argument
*/ */
export function waitFor(filter: string | string[] | FilterFn, callback: CallbackFn) { export function waitFor(filter: string | string[] | FilterFn, callback: CallbackFn, { isIndirect = false }: { isIndirect?: boolean; } = {}) {
if (IS_DEV && !isIndirect) lazyWebpackSearchHistory.push(["waitFor", Array.isArray(filter) ? filter : [filter]]);
if (typeof filter === "string") if (typeof filter === "string")
filter = filters.byProps(filter); filter = filters.byProps(filter);
else if (Array.isArray(filter)) else if (Array.isArray(filter))
@ -383,7 +520,7 @@ export function search(...filters: Array<string | RegExp>) {
* so putting breakpoints or similar will have no effect. * so putting breakpoints or similar will have no effect.
* @param id The id of the module to extract * @param id The id of the module to extract
*/ */
export function extract(id: number) { export function extract(id: string | number) {
const mod = wreq.m[id] as Function; const mod = wreq.m[id] as Function;
if (!mod) return null; if (!mod) return null;

View file

@ -2,6 +2,7 @@
"compilerOptions": { "compilerOptions": {
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true,
"lib": [ "lib": [
"DOM", "DOM",
"DOM.Iterable", "DOM.Iterable",