Compare commits

..

98 commits

Author SHA1 Message Date
OTKUSteyler
1547421074
Update index.ts
Some checks failed
Node.js CI / CD (Bun + Forgejo) / buildAndRelease (push) Has been cancelled
2026-09-12 10:39:07 +02:00
OTKUSteyler
f3b7935ab0
Update index.ts 2026-09-12 10:36:20 +02:00
OTKUSteyler
2fca83c60a
Merge branch 'kmmiio99o:main' into main 2026-09-11 09:31:49 +02:00
kmmiio99o
c663a3724c
Merge pull request #14 from ItsTripleSix/fix/account-switch-splash-hang
fix: prevent account-switch cold launch splash hang
2026-09-11 08:48:19 +02:00
OTKUSteyler
94e651a50e
Update index.ts 2026-09-11 08:28:51 +02:00
OTKUSteyler
40726d5b9a
Delete src/core/plugins/CustomBackgroundMusic directory 2026-09-11 08:27:50 +02:00
OTKUSteyler
d8b35b5501
Update updater.ts 2026-09-11 08:26:59 +02:00
OTKUSteyler
32d32d3f02
Update index.ts 2026-09-11 08:02:23 +02:00
OTKUSteyler
3914eb8e11
Create Settings.tsx 2026-09-11 08:00:13 +02:00
OTKUSteyler
ad9963ade2
Create index.ts 2026-09-11 07:59:27 +02:00
OTKUSteyler
363f9984f6
Delete src/core/plugins/custom background music directory 2026-09-11 07:58:22 +02:00
OTKUSteyler
f5c9edd1fd
Rename Index.ts to index.ts 2026-09-11 07:56:06 +02:00
OTKUSteyler
c1b2498009
Update index.ts 2026-09-11 07:43:27 +02:00
OTKUSteyler
1e381e4940
Update Settings.tsx 2026-09-11 07:38:52 +02:00
OTKUSteyler
e87aff3801
Update Index.ts 2026-09-11 07:38:00 +02:00
OTKUSteyler
0ed3508d7c
Update Index.ts 2026-09-11 07:23:46 +02:00
OTKUSteyler
c1b8f5969e
Create Settings.tsx 2026-09-11 07:22:42 +02:00
OTKUSteyler
24ebe343bd
Create Index.ts 2026-09-11 07:21:18 +02:00
ItsTripleSix
d0b633fa17
chore: bump ShiggyCord version to 1.4.1.8 2026-09-10 16:10:48 -06:00
ItsTripleSix
9dfe5ca671
fix: prevent account-switch cold launch splash hang 2026-09-10 16:09:53 -06:00
OTKUSteyler
bdb448eb94
Update index.ts 2026-09-09 08:39:24 +02:00
OTKUSteyler
2c91da18ea
Update index.ts 2026-09-09 08:38:39 +02:00
OTKUSteyler
1d630e1e3a
Update PluginCard.tsx 2026-09-05 16:47:28 +02:00
OTKUSteyler
2c0c24cb86
Update index.ts 2026-09-04 20:36:27 +02:00
OTKUSteyler
a42fb7ce36
Update About.tsx 2026-09-04 15:17:42 +02:00
OTKUSteyler
f48aea843f
Update debug.ts 2026-09-03 21:50:47 +02:00
OTKUSteyler
8430031566
Update reload.ts 2026-09-03 21:10:07 +02:00
OTKUSteyler
fa8ebe30bd
Update debug.ts 2026-09-03 21:09:44 +02:00
OTKUSteyler
e6e5728d25
Update index.ts 2026-09-03 20:43:33 +02:00
OTKUSteyler
5f41539026
Update index.tsx 2026-09-03 20:37:21 +02:00
OTKUSteyler
1b99480b82
Update build.mjs 2026-09-03 18:03:48 +02:00
OTKUSteyler
151b07434e
Update package.json 2026-09-03 18:02:28 +02:00
OTKUSteyler
11a6e4d1c4
Update package.json 2026-09-03 18:00:03 +02:00
OTKUSteyler
cbcbd53cc0
Add files via upload 2026-09-03 17:15:26 +02:00
OTKUSteyler
d26de6be8e
Update index.ts 2026-09-03 17:14:22 +02:00
OTKUSteyler
b51ae0677d
Update index.tsx 2026-09-03 17:13:38 +02:00
OTKUSteyler
5cdb7d9969
Update index.ts 2026-09-03 17:13:09 +02:00
OTKUSteyler
687f47318b
Update default.json 2026-09-03 17:12:03 +02:00
OTKUSteyler
1071349bbc
Add files via upload 2026-09-03 17:10:56 +02:00
OTKUSteyler
72ce900cd8
Update README.md 2026-09-03 17:09:30 +02:00
OTKUSteyler
ecef05061b
Update release.yml 2026-09-03 16:04:20 +02:00
kmmiio99o
15915a08b9
chore: update embed URLs for Instagram and Reddit 2026-09-02 19:41:07 +00:00
kmmiio99o
ad46e065c9
Merge branch 'C0C0B01:main' into main 2026-08-27 13:23:18 +02:00
cocobo1
f5bbcecade Merge pull request 'fix: properly resolve design components' (#31) from Purple-EyeZ/Kettu:dev into dev
Reviewed-on: https://codeberg.org/cocobo1/Kettu/pulls/31
2026-08-27 09:08:29 +02:00
kmmiio99o
3f5643931c
Merge branch 'dev' 2026-08-27 00:39:25 +02:00
Purple_Ξye™
28fe79ade4
fix: properly resolve design components (#12) 2026-08-27 00:33:00 +02:00
Purple_Ξye™
5858340d04 fix: properly resolve design components 2026-08-26 23:36:44 +02:00
kmmiio99o
36e4bccc6a
Merge branch 'dev' 2026-08-13 11:43:59 +02:00
kmmiio99o
1d73ac91e5
chore: bump to 1.4.1.7 2026-08-09 23:47:02 +02:00
kmmiio99o
1f3ad73135
fix(ErrorBoundaryScreen): text color 2026-08-09 23:44:54 +02:00
kmmiio99o
4bb190b81a
fix(General): strings 2026-08-09 23:41:35 +02:00
kmmiio99o
723815e4c5
fix(band-aid): crashing in settings pages 2026-08-09 23:41:24 +02:00
kmmiio99o
4b64d99ac4
Merge branch 'dev' 2026-07-31 11:48:38 +02:00
kmmiio99o
cb877a2a99
feat: iOS Tweak support
because we are basing it off RainTweak, we need to add such stuff
2026-07-31 10:51:51 +02:00
kmmiio99o
a60f527f3c
fix: pin @gullerya/object-observer alias to bypass broken
exports.require in CI
2026-07-30 19:54:18 +02:00
kmmiio99o
fc6e5bccfd
chore: bump to 1.4.1.6 2026-07-30 19:51:16 +02:00
kmmiio99o
185f4ab6f3
fix: loading plugin's on 341.3+ 2026-07-30 19:49:37 +02:00
kmmiio99o
0dc4e8cdf7
chore: bump dependencies 2026-07-30 19:46:18 +02:00
kmmiio99o
a628abc128
chore: bump to 1.4.1.5 2026-07-27 22:40:01 +02:00
kmmiio99o
01a00fc98d
Merge remote-tracking branch 'upstream/dev' into dev 2026-07-27 22:38:03 +02:00
kmmiio99o
4c2c6a3058
Merge remote-tracking branch 'upstream' into dev 2026-07-27 22:21:29 +02:00
cocobo1
4f4a84ebce fix: remove broken settings section on android and broken device info command 2026-07-27 16:50:02 +00:00
cocobo1
791956daac chore: bump version 2026-07-27 16:38:25 +00:00
cocobo1
3c009cc46e feat: fix colouring on ui text 2026-07-27 16:37:42 +00:00
cocobo1
e345bb14d3 Merge branch 'dev' into github 2026-07-04 08:48:00 +00:00
kmmiio99o
3637ba9963
fix: crash in Themes page
when using other languages than english, it was just crashing
2026-05-29 18:44:58 +02:00
kmmiio99o
2155a2a055
feat: fix crash in Themes page 2026-05-15 21:41:32 +02:00
kmmiio99o
dd91831481
Merge remote-tracking branch 'upstream/main' 2026-05-15 21:37:50 +02:00
cocobo1
61751664c5 fix: settings on alpha 330.1+ 2026-05-14 20:06:34 +00:00
kmmiio99o
06c8853399
fix crashing in Themes tab
don't ask me how it did it
2026-05-09 15:24:22 +02:00
cocobo1
727603ff46 fix: settings on 329.3 2026-05-09 08:09:09 +00:00
kmmiio99o
de84964ff1
bump to 1.4.1.4 2026-05-07 14:38:05 +02:00
kmmiio99o
03ccabfb5c
fix: settings crashing when opening them
happens on 329203+ versions
2026-05-07 14:34:03 +02:00
kmmiio99o
752d1c9b11
feat: remove messagefix
was causing issues with freemoji plugin
2026-05-07 13:32:11 +02:00
kmmiio99o
af64cda7fb
fix icon in settings 2026-04-26 11:32:22 +02:00
kmmiio99o
d697150982
Revert "feat: drop fixembed plugin and fix icon in main settings"
This reverts commit 84f6592ecf.
2026-04-26 11:28:06 +02:00
kmmiio99o
4587de15b0
bump to 1.4.1.3 2026-04-25 20:54:53 +02:00
kmmiio99o
ffd84c017e
small fixes 2026-04-25 20:54:18 +02:00
kmmiio99o
84f6592ecf
feat: drop fixembed plugin and fix icon in main settings 2026-04-25 20:45:06 +02:00
kmmiio99o
676f1a19ba
feat: some speedups 2026-04-05 21:20:30 +02:00
kmmiio99o
18dccc9e17
chore: bump dependencies 2026-04-05 21:20:20 +02:00
kmmiio99o
63bfe21eb7
Merge remote-tracking branch 'upstream/dev' into dev 2026-03-21 13:08:05 +01:00
cocobo1
d31739d8d8 Merge branch 'dev' into github 2026-03-18 22:13:16 +02:00
cocobo1
1cf2787f3a fix: make badges work again 2026-03-18 22:12:52 +02:00
cocobo1
ec97bbb3be Merge branch 'dev' into github 2026-03-18 17:54:03 +02:00
cocobo1
cb6a242fe8 feat: make i18n work (thanks serstars) 2026-03-18 17:53:26 +02:00
cocobo1
d9ba90e6ef Merge branch 'main' into github 2026-03-08 18:47:36 +02:00
cocobo1
43a4a5b665 docs: update readme 2026-03-08 18:45:16 +02:00
kmmiio99o
612483fbea
feat: Optimize object iteration and module counting 2026-03-06 18:32:05 +01:00
kmmiio99o
ad93bfa6c6
fix: build file 2026-03-06 18:09:13 +01:00
kmmiio99o
2f1702f5a4
Merge remote-tracking branch 'upstream/dev' into dev 2026-03-06 18:06:55 +01:00
cocobo1
1876c4a4a6 fix: make themes work 2026-03-06 18:51:46 +02:00
kmmiio99o
1fa558e6a0
fix: crashing on chat 2026-02-27 16:43:24 +01:00
kmmiio99o
a4c079ac30
fix: backgrounds not working 2026-02-25 23:48:29 +01:00
kmmiio99o
78bc82873b
chore: bump to 1.4.1.2 2026-02-23 17:29:14 +01:00
kmmiio99o
98e65fcefe
feat: Lazy-load chroma/fuzzysort and add memory backend
Remove Shiggyenhancements plugin

Replace eager chroma and fuzzysort imports with lazy require/cached
loads to reduce startup bundle and runtime cost. Add createMemoryBackend
(an in-memory storage with debounced persistence) and use it for fonts
to accelerate startup. Add per-store write debouncers and change lodash
import to a type-only import
2026-02-23 17:28:55 +01:00
kmmiio99o
8857eaf144
chore: bump dependencies 2026-02-23 16:31:11 +01:00
cocobo1
3bae44e833 Merge branch 'dev' into github 2025-12-12 20:09:13 +02:00
53 changed files with 1925 additions and 2461 deletions

View file

@ -53,4 +53,4 @@ jobs:
git fetch origin git fetch origin
git checkout github || git checkout -b github git checkout github || git checkout -b github
git merge origin/main --no-edit --allow-unrelated-histories git merge origin/main --no-edit --allow-unrelated-histories
git push --force origin github git push --force origin github

View file

@ -1,7 +1,6 @@
# GoonCord [![Discord](https://img.shields.io/discord/1427396621905432699?style=social&logo=discord&label=ShiggyCord)](https://discord.gg/nQykFF9Ud6) # GoonCord [![Discord](https://img.shields.io/discord/1427396621905432699?style=social&logo=discord&label=ShiggyCord)](https://discord.shiggycord.dev)
An unofficial fork of ShiggyGord, made just for fun Bundle based on ShiggyCord, made just for fun
Idea taken from me
## Installing ## Installing
@ -9,7 +8,7 @@ Idea taken from me
- Rooted: [Goon Xposed](https://github.com/OTKUSteyler/GoonXposed) - Rooted: [Goon Xposed](https://github.com/OTKUSteyler/GoonXposed)
- Non-Rooted: [Goon Manger](https://github.com/OTKUSteyler/GoonManager) - Non-Rooted: [Goon Manager](https://github.com/OTKUSteyler/GoonManager)
### Android/iOS ### Android/iOS
- **Injecting bundle:** - **Injecting bundle:**
@ -22,7 +21,7 @@ Idea taken from me
1. Go to Settings > General and enable Developer Settings. 1. Go to Settings > General and enable Developer Settings.
1. Clone the repo: 1. Clone the repo:
``` ```
git clone https://github.com/OTKUSteyler/GoonCord.git git clone https://github.com/OTKUSteyler/gooncord.git
``` ```
1. Install dependencies: 1. Install dependencies:
``` ```
@ -38,3 +37,5 @@ Idea taken from me
1. Make your changes, rebuild, reload, go wild! 1. Make your changes, rebuild, reload, go wild!
Alternatively, you can directly *serve* the bundled code by running `bun run serve`. `gooncord.js` will be served on your local address under the port 4040. You will then insert `http://<local ip address>:4040/gooncord.js` as a custom url and reload. Whenever you restart your mobile client, the script will rebuild the bundle as your client fetches it. Alternatively, you can directly *serve* the bundled code by running `bun run serve`. `gooncord.js` will be served on your local address under the port 4040. You will then insert `http://<local ip address>:4040/gooncord.js` as a custom url and reload. Whenever you restart your mobile client, the script will rebuild the bundle as your client fetches it.
![OTKUSteyler](https://count.getloli.com/@OTKUSteyler?name=OTKUSteyler&theme=miku&padding=7&offset=0&align=top&scale=1&pixelated=1&darkmode=1)

1955
bun.lock

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,7 @@
"private": true, "private": true,
"author": { "author": {
"name": "OTKUSteyler", "name": "OTKUSteyler",
"url": "https://github.com/OTKUSteyler/GoonCord" "url": "https://github.com/OTKUSteyler"
}, },
"scripts": { "scripts": {
"build": "node scripts/build.mjs", "build": "node scripts/build.mjs",
@ -13,45 +13,40 @@
}, },
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"devDependencies": { "devDependencies": {
"@saithodev/semantic-release-backmerge": "^4.0.1", "@swc/core": "^1.15.47",
"@semantic-release/changelog": "^6.0.3", "@swc/helpers": "^0.5.23",
"@semantic-release/exec": "^7.1.0", "@types/node": "^26.1.2",
"@semantic-release/git": "^10.0.1",
"semantic-release": "^25.0.2",
"@swc/core": "^1.13.1",
"@swc/helpers": "^0.5.17",
"@types/node": "^25.0.3",
"@types/yargs-parser": "^21.0.3", "@types/yargs-parser": "^21.0.3",
"@typescript-eslint/eslint-plugin": "^8.50.1", "@typescript-eslint/eslint-plugin": "^8.65.0",
"@typescript-eslint/parser": "^8.50.1", "@typescript-eslint/parser": "^8.65.0",
"@typescript-eslint/typescript-estree": "^8.50.1", "@typescript-eslint/typescript-estree": "^8.65.0",
"chalk": "^5.6.2", "chalk": "^6.0.0",
"esbuild": "^0.27.2", "esbuild": "^0.28.1",
"esbuild-plugin-globals": "^0.2.0", "esbuild-plugin-globals": "^0.2.0",
"eslint": "^9.39.2", "eslint": "^10.8.0",
"eslint-plugin-import-alias": "^1.2.0", "eslint-plugin-import-alias": "^1.2.0",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-simple-import-sort": "^14.0.0",
"eslint-plugin-unused-imports": "^4.3.0", "eslint-plugin-unused-imports": "^4.4.1",
"typescript": "^5.9.3", "typescript": "^7.0.2",
"yargs-parser": "^22.0.0" "yargs-parser": "^22.0.0"
}, },
"dependencies": { "dependencies": {
"@gullerya/object-observer": "^6.1.4", "@gullerya/object-observer": "^7.0.0",
"@tanstack/react-query": "^5.90.12", "@tanstack/react-query": "^5.101.4",
"@types/chroma-js": "~3.1.2", "@types/chroma-js": "~3.1.2",
"@types/lodash": "~4.17.21", "@types/lodash": "~4.17.24",
"@types/react": "19.2.7", "@types/react": "19.2.17",
"es-toolkit": "^1.43.0", "es-toolkit": "^1.50.0",
"fuzzysort": "^3.1.0", "fuzzysort": "^3.1.0",
"intl-messageformat": "^11.0.5", "intl-messageformat": "^11.2.12",
"moment": "2.30.1", "moment": "2.30.1",
"react-devtools": "^7.0.1", "react-devtools": "^7.0.1",
"react-native": "0.83.1", "react-native": "0.86.2",
"react-native-reanimated": "^4.2.1", "react-native-reanimated": "^4.5.3",
"react-native-webview": "^13.16.0", "react-native-webview": "^14.0.1",
"spitroast": "^2.1.6", "spitroast": "^2.1.6",
"type-fest": "^5.3.1" "type-fest": "^5.8.0"
}, },
"trustedDependencies": [ "trustedDependencies": [
"@swc/core", "@swc/core",

View file

@ -56,6 +56,8 @@ const config = {
"!ShiggyCord-deps-shim!": "./shims/depsModule.ts", "!ShiggyCord-deps-shim!": "./shims/depsModule.ts",
spitroast: "./node_modules/spitroast", spitroast: "./node_modules/spitroast",
"react/jsx-runtime": "./shims/jsxRuntime", "react/jsx-runtime": "./shims/jsxRuntime",
"@gullerya/object-observer":
path.resolve("./node_modules/@gullerya/object-observer/dist/object-observer.min.js"),
}, },
plugins: [ plugins: [
globalPlugin({ globalPlugin({
@ -75,7 +77,7 @@ const config = {
constModules: { constModules: {
globals: { globals: {
"bunny-build-info": { "bunny-build-info": {
version: `"1.4.1.1"`, version: `"1.4.1.8"`,
}, },
}, },
}, },

View file

@ -19,7 +19,7 @@ export function serve(options) {
if (pathname?.endsWith(".js")) { if (pathname?.endsWith(".js")) {
try { try {
let fileToServe; let fileToServe;
if (pathname === "/gooncord.min.js") { if (pathname === "/shiggycord.min.js") {
const { config, context, timeTook } = await buildBundle({ const { config, context, timeTook } = await buildBundle({
minify: true, minify: true,
}); });
@ -30,7 +30,7 @@ export function serve(options) {
true, true,
); );
fileToServe = config.outfile.replace(/\.js$/, ".min.js"); fileToServe = config.outfile.replace(/\.js$/, ".min.js");
} else if (pathname === "/gooncord.js") { } else if (pathname === "/shiggycord.js") {
const { config, context, timeTook } = await buildBundle(); const { config, context, timeTook } = await buildBundle();
printBuildSuccess(context.hash, args["release-branch"], timeTook); printBuildSuccess(context.hash, args["release-branch"], timeTook);
fileToServe = config.outfile; fileToServe = config.outfile;
@ -55,7 +55,7 @@ export function serve(options) {
server.listen(args.port ?? 4040); server.listen(args.port ?? 4040);
console.info(chalk.bold.yellowBright("Serving GoonCord on:")); console.info(chalk.bold.yellowBright("Serving ShiggyCord on:"));
const netInterfaces = os.networkInterfaces(); const netInterfaces = os.networkInterfaces();
for (const netinterfaces of Object.values(netInterfaces)) { for (const netinterfaces of Object.values(netInterfaces)) {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -18,24 +18,26 @@ export default () =>
}, },
], ],
execute([ephemeral], ctx) { execute([ephemeral], ctx) {
const info = getDebugInfo(); const info = getDebugInfo();
const content = [
"**💦GoonCord Goon Info💦**",
`> GoonCord: ${info.bunny.version} (${info.bunny.loader.name} ${info.bunny.loader.version})`,
`> Discord: ${info.discord.version} (${info.discord.build})`,
`> React: ${info.react.version} (RN ${info.react.nativeVersion})`,
`> Hermes: ${info.hermes.version} (bcv${info.hermes.bytecodeVersion})`,
`> System: ${info.os.name} ${info.os.version} ${info.os.sdk ? `(SDK ${info.os.sdk})` : ""}`.trimEnd(),
`> Device: ${info.device.model}`,
].join("\n");
if (ephemeral?.value) { if(info.device.codename === undefined) { info.device.codename = ""}
messageUtil.sendBotMessage(ctx.channel.id, content); else{ info.device.codename = "(" + info.device.codename + ")" }
} else {
const fixNonce = Date.now().toString(); const content = [
messageUtil.sendMessage(ctx.channel.id, { content }, void 0, { "**💦GoonCord💦 Debug Info**",
nonce: fixNonce, `> GoonCord: ${info.bunny.version} (${info.bunny.loader.name} ${info.bunny.loader.version})`,
}); `> Discord: ${info.discord.version} (${info.discord.build})`,
} `> React: ${info.react.version} (RN ${info.react.nativeVersion})`,
}, `> Hermes: ${info.hermes.version} (bcv${info.hermes.bytecodeVersion})`,
}; `> System: ${info.os.name} ${info.os.version} ${info.os.sdk ? `(SDK ${info.os.sdk})` : ""}`.trimEnd(),
`> Device: ${info.device.model} ${info.device.codename}`,
].join("\n");
if (ephemeral?.value) {
messageUtil.sendBotMessage(ctx.channel.id, content);
} else {
const fixNonce = (BigInt(Date.now() - 1420070400000) << 22n).toString();
messageUtil.sendMessage(ctx.channel.id, { content }, void 0, {nonce:fixNonce});
}
}
};

View file

@ -190,7 +190,7 @@ export default () =>
if (ephemeral?.value) { if (ephemeral?.value) {
messageUtil.sendBotMessage(ctx.channel.id, content); messageUtil.sendBotMessage(ctx.channel.id, content);
} else { } else {
const fixNonce = Date.now().toString(); const fixNonce = (BigInt(Date.now() - 1420070400000) << 22n).toString();
messageUtil.sendMessage(ctx.channel.id, { content }, void 0, { messageUtil.sendMessage(ctx.channel.id, { content }, void 0, {
nonce: fixNonce, nonce: fixNonce,
}); });

View file

@ -4,12 +4,14 @@
"ARE_YOU_SURE_TO_CLEAR_DATA": "Are you sure you wish to clear the data of {name}?", "ARE_YOU_SURE_TO_CLEAR_DATA": "Are you sure you wish to clear the data of {name}?",
"ARE_YOU_SURE_TO_DELETE_PLUGIN": "Are you sure you wish to delete {name}? This will clear all of the plugin's data.", "ARE_YOU_SURE_TO_DELETE_PLUGIN": "Are you sure you wish to delete {name}? This will clear all of the plugin's data.",
"ARE_YOU_SURE_TO_DELETE_THEME": "Are you sure you wish to delete {name}?", "ARE_YOU_SURE_TO_DELETE_THEME": "Are you sure you wish to delete {name}?",
"AUTO_DEBUGGER": "Automatically connect to Debugger",
"AUTO_DEVTOOLS": "Automatically connect to Devtools",
"ASSET_BROWSER": "Asset Browser", "ASSET_BROWSER": "Asset Browser",
"ASSET_TYPES": "Asset Types", "ASSET_TYPES": "Asset Types",
"IMAGE_FILES": "Image Files", "IMAGE_FILES": "Image Files",
"TEXT_FILES": "Text Files", "TEXT_FILES": "Text Files",
"BRAND": "Brand", "BRAND": "Brand",
"SHIGGYCORD": "💦GoonCord💦", "SHIGGYCORD": "GoonCord",
"SHIGGYCORD_URL": "GoonCord URL", "SHIGGYCORD_URL": "GoonCord URL",
"BROWSER": "Browser", "BROWSER": "Browser",
"BYTECODE": "Bytecode", "BYTECODE": "Bytecode",
@ -22,13 +24,14 @@
"CLEAR_DATA_SUCCESSFUL": "Cleared data for {name}", "CLEAR_DATA_SUCCESSFUL": "Cleared data for {name}",
"CODEBERG": "Codeberg", "CODEBERG": "Codeberg",
"CODENAME": "Codename", "CODENAME": "Codename",
"COMMAND_DEBUG_DESC": "Send GoonCord debug info.", "COMMAND_DEBUG_DESC": "Send 💦GoonCord💦 debug info.",
"COMMAND_DEBUG_OPT_EPHEMERALLY": "Send debug info ephemerally.", "COMMAND_DEBUG_OPT_EPHEMERALLY": "Send debug info ephemerally.",
"COMMAND_EVAL_DESC": "Evaluate JavaScript code.", "COMMAND_EVAL_DESC": "Evaluate JavaScript code.",
"COMMAND_EVAL_OPT_ASYNC": "Whether to support 'await' in code. Must explicitly return for result (default: false)", "COMMAND_EVAL_OPT_ASYNC": "Whether to support 'await' in code. Must explicitly return for result (default: false)",
"COMMAND_EVAL_OPT_CODE": "The code to evaluate.", "COMMAND_EVAL_OPT_CODE": "The code to evaluate.",
"COMMAND_PLUGINS_DESC": "Send list of installed plugins.", "COMMAND_PLUGINS_DESC": "Send list of installed plugins.",
"COMMAND_PLUGINS_OPT_EPHEMERALLY": "Send plugins list ephemerally.", "COMMAND_PLUGINS_OPT_EPHEMERALLY": "Send plugins list ephemerally.",
"COMMAND_RELOAD_DESC": "Reload Discord.",
"COMPONENT": "Component", "COMPONENT": "Component",
"CONFIRMATION_LINK_IS_A_TYPE": "This link is a **{urlType, select, plugin {Plugin} theme {Theme} other {Add-on}}**, would you like to install it?", "CONFIRMATION_LINK_IS_A_TYPE": "This link is a **{urlType, select, plugin {Plugin} theme {Theme} other {Add-on}}**, would you like to install it?",
"CONNECT_TO_DEBUG_WEBSOCKET": "Connect to debug WebSocket", "CONNECT_TO_DEBUG_WEBSOCKET": "Connect to debug WebSocket",
@ -39,6 +42,7 @@
"DEBUG": "Debug", "DEBUG": "Debug",
"DEBUGGER_URL": "Debugger URL", "DEBUGGER_URL": "Debugger URL",
"DELETE": "Delete", "DELETE": "Delete",
"DEVTOOLS_URL": "DevTools URL",
"DESC_EXTRACT_FONTS_FROM_THEME": "Looks out for \"fonts\" field in your currently applied theme and install it.", "DESC_EXTRACT_FONTS_FROM_THEME": "Looks out for \"fonts\" field in your currently applied theme and install it.",
"DEVELOPER": "Developer", "DEVELOPER": "Developer",
"DEVELOPER_SETTINGS": "Developer Settings", "DEVELOPER_SETTINGS": "Developer Settings",
@ -68,12 +72,12 @@
"LOAD_FROM_CUSTOM_URL": "Load from custom URL", "LOAD_FROM_CUSTOM_URL": "Load from custom URL",
"LOAD_FROM_CUSTOM_URL_DEC": "Load GoonCord from a custom endpoint.", "LOAD_FROM_CUSTOM_URL_DEC": "Load GoonCord from a custom endpoint.",
"LOAD_REACT_DEVTOOLS": "Load React DevTools", "LOAD_REACT_DEVTOOLS": "Load React DevTools",
"SHIGGY_URL": "ShiggyCord URL",
"LOADER": "Loader", "LOADER": "Loader",
"MACHINE_ID": "Machine ID", "MACHINE_ID": "Machine ID",
"MANUFACTURER": "Manufacturer", "MANUFACTURER": "Manufacturer",
"MESSAGE": "Message", "MESSAGE": "Message",
"MISCELLANEOUS": "Miscellaneous", "MISCELLANEOUS": "Miscellaneous",
"MODAL_RELOAD_THEME_CHANGE": "Reload the app to apply the theme change?",
"MODAL_RELOAD_REQUIRED": "Reload app?", "MODAL_RELOAD_REQUIRED": "Reload app?",
"MODAL_RELOAD_REQUIRED_DESC": "A reload is required to see the changes. Do you want to reload now?", "MODAL_RELOAD_REQUIRED_DESC": "A reload is required to see the changes. Do you want to reload now?",
"MODAL_THEME_REFETCHED": "Theme refetched", "MODAL_THEME_REFETCHED": "Theme refetched",

View file

@ -1,11 +1,12 @@
import { logger } from "@lib/utils/logger";
import { FluxDispatcher } from "@metro/common"; import { FluxDispatcher } from "@metro/common";
import { findByNameLazy } from "@metro/wrappers"; import { findByNameLazy, findByStoreName } from "@metro/wrappers";
import { PrimitiveType } from "intl-messageformat"; import { PrimitiveType } from "intl-messageformat";
import langDefault from "./default.json"; import langDefault from "./default.json";
const IntlMessageFormat = findByNameLazy( const IntlMessageFormat = findByNameLazy(
"MessageFormat", "MessageFormat",
) as typeof import("intl-messageformat").default; ) as typeof import("intl-messageformat").default;
type I18nKey = keyof typeof langDefault; type I18nKey = keyof typeof langDefault;
@ -17,61 +18,133 @@ const _loadedLocale = new Set<string>();
const _loadedStrings = {} as Record<string, typeof langDefault>; const _loadedStrings = {} as Record<string, typeof langDefault>;
export const Strings = new Proxy( export const Strings = new Proxy(
{}, {},
{ {
get: (_t, prop: keyof typeof langDefault) => { get: (_t, prop: keyof typeof langDefault) => {
if (_currentLocale && _loadedStrings[_currentLocale]?.[prop]) { if (_currentLocale && _loadedStrings[_currentLocale]?.[prop]) {
return _loadedStrings[_currentLocale]?.[prop]; return _loadedStrings[_currentLocale]?.[prop];
} }
return langDefault[prop]; return langDefault[prop];
},
}, },
},
) as Record<I18nKey, string>; ) as Record<I18nKey, string>;
export function initFetchI18nStrings() { const languageMap: Record<string, string> = {
const cb = ({ locale }: { locale: string }) => { "ar-SA": "ar",
const languageMap = { "bn-BD": "bn",
"es-ES": "es", "ca-ES": "ca",
"es-419": "es_419", "de-DE": "de",
"zh-TW": "zh-Hant", "es-ES": "es",
"zh-CN": "zh-Hans", "es-419": "es",
"pt-PT": "pt", "fa-IR": "fa",
"pt-BR": "pt_BR", "fi-FI": "fi",
"sv-SE": "sv", "fr-FR": "fr",
} as Record<string, string>; "hi-IN": "hi",
"hr-HR": "hr",
"hu-HU": "hu",
"id-ID": "id",
"it-IT": "it",
"ja-JP": "ja",
"pl-PL": "pl",
"pt-BR": "pt_BR",
"ru-RU": "ru",
"sk-SK": "sk",
"sv-SE": "sv",
"tr-TR": "tr",
"vi-VN": "vi",
};
function fetchLocale(locale: string) {
const resolvedLocale = (_lastSetLocale = languageMap[locale] ?? locale); const resolvedLocale = (_lastSetLocale = languageMap[locale] ?? locale);
if (resolvedLocale.startsWith("en-")) { logger.log("[i18n] fetchLocale called:", locale, "->", resolvedLocale);
_currentLocale = null;
return;
}
if (!_loadedLocale.has(resolvedLocale)) { if (!_loadedLocale.has(resolvedLocale)) {
_loadedLocale.add(resolvedLocale); _loadedLocale.add(resolvedLocale);
fetch( if (resolvedLocale.toLowerCase().startsWith("en")) {
`https://raw.githubusercontent.com/OTKUSteyler/i18n/main/resources/${resolvedLocale}/gooncord.json`, // use local default.json for english
) logger.log("[i18n] Using local default.json for English locale");
.then((r) => r.json()) _loadedStrings[resolvedLocale] = langDefault;
.then((strings) => (_loadedStrings[resolvedLocale] = strings)) _currentLocale = resolvedLocale;
.then( } else {
() => fetch(`https://codeberg.org/cocobo1/kettu-i18n/raw/branch/main/base/${resolvedLocale}.json`)
resolvedLocale === _lastSetLocale && .then(r => r.json())
(_currentLocale = resolvedLocale), .then(strings => {
) logger.log("[i18n] Loaded strings for:", resolvedLocale);
.catch((e) => _loadedStrings[resolvedLocale] = strings;
console.error( _currentLocale = resolvedLocale;
`An error occured while fetching strings for ${resolvedLocale}: ${e}`, })
), .catch(e =>
); logger.error(`[i18n] Error fetching strings for ${resolvedLocale}: ${e}`),
);
}
} else { } else {
_currentLocale = resolvedLocale; _currentLocale = resolvedLocale;
} }
}; }
FluxDispatcher.subscribe("I18N_LOAD_SUCCESS", cb); export function initFetchI18nStrings() {
return () => FluxDispatcher.unsubscribe("I18N_LOAD_SUCCESS", cb); let attempts = 0;
const checkAndFetch = () => {
attempts++;
try {
const LocaleStore = findByStoreName("LocaleStore");
logger.log("[i18n] Attempt", attempts, "- LocaleStore:", !!LocaleStore);
if (!LocaleStore) {
logger.log("[i18n] LocaleStore not found yet");
return false;
}
if (LocaleStore?._isInitialized !== true) {
logger.log("[i18n] LocaleStore not initialized yet");
return false;
}
const locale = LocaleStore.locale;
if (locale) {
logger.log("[i18n] Using LocaleStore:", locale);
fetchLocale(locale);
return true;
}
} catch (e) {
logger.log("[i18n] Error:", e);
}
return false;
};
const tryTimes = () => {
if (checkAndFetch()) return;
if (attempts < 15) {
setTimeout(tryTimes, 500);
}
};
tryTimes();
const cb = (e: any) => {
if (e?.settings?.changes?.loading) {
logger.log("[i18n] Settings loading, skipping...");
return;
}
const locale = e?.settings?.changes?.protoToSave?.localization?.locale?.value;
logger.log("[i18n] Locale changed:", locale);
if (locale) {
logger.log("[i18n] Found locale in event:", locale);
fetchLocale(locale);
}
};
FluxDispatcher.subscribe("USER_SETTINGS_PROTO_UPDATE_EDIT_INFO", cb);
return () => {
FluxDispatcher.unsubscribe("USER_SETTINGS_PROTO_UPDATE_EDIT_INFO", cb);
};
} }
type FormatStringRet<T> = T extends PrimitiveType type FormatStringRet<T> = T extends PrimitiveType
@ -79,10 +152,20 @@ type FormatStringRet<T> = T extends PrimitiveType
: string | T | (string | T)[]; : string | T | (string | T)[];
export function formatString<T = void>( export function formatString<T = void>(
key: I18nKey, key: I18nKey,
val: Record<string, T>, val: Record<string, T>,
): FormatStringRet<T> { ): FormatStringRet<T> {
const str = Strings[key]; const str = Strings[key];
// @ts-ignore if (!str) return str as FormatStringRet<T>;
return new IntlMessageFormat(str).format(val); try {
// @ts-ignore
return new IntlMessageFormat(str).format(val);
} catch (e: any) {
if (e?.name === "SyntaxError") {
logger.warn(`[i18n] Failed to parse message for key "${String(key)}":`, e.message);
return str as FormatStringRet<T>;
}
logger.warn(`[i18n] Failed to format message for key "${String(key)}":`, e?.message ?? e);
return str as FormatStringRet<T>;
}
} }

View file

@ -1,94 +1,201 @@
import { after } from "@lib/api/patcher"; import { after } from "@lib/api/patcher";
import { onJsxCreate } from "@lib/api/react/jsx"; import { onJsxCreate } from "@lib/api/react/jsx";
import { findByName } from "@metro"; import { findByNameLazy } from "@metro";
import { useEffect, useState } from "react";
import { defineCorePlugin } from ".."; import { defineCorePlugin } from "..";
import { FluxDispatcher } from "@metro/common";
interface BunnyBadge { interface Badge {
label: string; label: string;
url: string; url: string;
} }
const useBadgesModule = findByName("useBadges", false); interface UserBadgeData {
roles?: string[];
custom?: Badge[];
}
interface BadgeData {
[userId: string]: UserBadgeData;
}
interface RoleData {
label: string;
url: string;
}
interface RolesData {
[roleName: string]: RoleData;
}
interface EquicordBadge {
tooltip: string;
badge: string;
}
const useBadgesModule = findByNameLazy("useBadges", false);
const badgesCache = new Map<string, Badge[]>();
const badgeProps = new Map<string, Record<string, any>>();
const pendingRequests = new Set<string>();
export default defineCorePlugin({ export default defineCorePlugin({
manifest: { manifest: {
id: "bunny.badges", id: "bunny.badges",
version: "1.1.0", version: "1.2.0",
type: "plugin", type: "plugin",
spec: 3, spec: 3,
main: "", main: "",
display: { display: {
name: "Badges", name: "Badges",
description: "Adds badges to user's profile", description: "Adds badges to user's profile",
authors: [{ name: "cocobo1" }, { name: "pylixonly" }], authors: [{ name: "cocobo1" }, { name: "pylixonly" }],
},
}, },
},
start() { start() {
let allBadges: { [x: string]: any } | null = null; onJsxCreate("ProfileBadge", (component, ret) => {
const badgeProps = {} as Record<string, any>; if (ret.props.id?.startsWith("rain-")) {
const cachedProps = badgeProps.get(ret.props.id);
onJsxCreate("ProfileBadge", (component, ret) => { if (cachedProps) {
if (ret.props.id?.startsWith("bunny-")) { ret.props.source = cachedProps.source;
const cachedProps = badgeProps[ret.props.id]; ret.props.label = cachedProps.label;
if (cachedProps) { ret.props.id = cachedProps.id;
ret.props.source = cachedProps.source; }
ret.props.label = cachedProps.label; }
ret.props.id = cachedProps.id;
}
}
});
onJsxCreate("RenderedBadge", (component, ret) => {
if (ret.props.id?.startsWith("bunny-")) {
const cachedProps = badgeProps[ret.props.id];
if (cachedProps) {
Object.assign(ret.props, cachedProps);
}
}
});
after("default", useBadgesModule, ([user], result) => {
const [badges, setBadges] = useState<BunnyBadge[]>([]);
useEffect(() => {
if (!user) return;
if (!allBadges) {
fetch(
"https://codeberg.org/raincord/badges/raw/branch/main/badges.json",
)
.then((r) => r.json())
.then((data) => {
allBadges = data;
//@ts-expect-error
setBadges(allBadges[user.userId] || []);
});
} else {
//user has no badges, but maybe they should get some by contributing
setBadges(allBadges[user.userId] || []);
}
}, [user?.userId]);
if (user && badges.length > 0) {
badges.forEach((badge, i) => {
const badgeId = `bunny-${user.userId}-${i}`;
badgeProps[badgeId] = {
id: badgeId,
source: { uri: badge.url },
label: badge.label,
userId: user.userId,
};
result.push({
id: badgeId,
description: badge.label,
icon: " _",
});
}); });
}
}); onJsxCreate("RenderedBadge", (component, ret) => {
}, if (ret.props.id?.startsWith("rain-")) {
const cachedProps = badgeProps.get(ret.props.id);
if (cachedProps) {
Object.assign(ret.props, cachedProps);
}
}
});
// Small helper so one failing/optional source can't take down the others.
const safeFetchJson = async <T,>(url: string, fallback: T): Promise<T> => {
try {
const res = await fetch(url);
if (!res.ok) return fallback;
return (await res.json()) as T;
} catch {
return fallback;
}
};
const fetchAndProcessBadges = async (userId: string) => {
if (pendingRequests.has(userId)) return;
pendingRequests.add(userId);
try {
const [
goonBadgesData,
goonRolesData,
rainBadgesData,
rainRolesData,
equicordData,
] = await Promise.all([
safeFetchJson<BadgeData>(
"https://codeberg.org/chocomint-chan/GoonCord_Badges/raw/branch/main/badges.json",
{}
),
safeFetchJson<RolesData>(
"https://codeberg.org/chocomint-chan/GoonCord_Badges/raw/branch/main/assets/roles/roles.json",
{}
),
safeFetchJson<BadgeData>(
"https://codeberg.org/raincord/badges/raw/branch/main/badges.json",
{}
),
safeFetchJson<RolesData>(
"https://codeberg.org/raincord/badges/raw/branch/main/assets/roles/roles.json",
{}
),
safeFetchJson<Record<string, EquicordBadge[]>>(
"https://badge.equicord.org/badges.json",
{}
),
]);
const allBadges: Badge[] = [];
// GoonCord and raincord badge/role sets are keyed independently,
// so each source is resolved against its own roles file.
const badgeSources: Array<{ badges: BadgeData; roles: RolesData }> = [
{ badges: goonBadgesData, roles: goonRolesData },
{ badges: rainBadgesData, roles: rainRolesData },
];
badgeSources.forEach(({ badges, roles }) => {
const userBadgeData = badges[userId];
if (!userBadgeData) return;
// process role badges
userBadgeData.roles?.forEach(roleName => {
const roleData = roles[roleName];
if (roleData) {
allBadges.push({
label: roleData.label,
url: roleData.url,
});
}
});
// process custom badges
if (userBadgeData.custom) {
allBadges.push(...userBadgeData.custom);
}
});
// process equicord badges
const equicordUserBadges = equicordData[userId] ?? [];
equicordUserBadges.forEach(b => {
allBadges.push({ label: b.tooltip, url: b.badge });
});
badgesCache.set(userId, allBadges);
allBadges.forEach((badge, i) => {
const badgeId = `rain-${userId}-${i}`;
badgeProps.set(badgeId, {
id: badgeId,
source: { uri: badge.url },
label: badge.label,
userId,
});
});
FluxDispatcher.dispatch({ type: "USER_UPDATE", user: { id: userId } });
} catch (err) {
console.error("[bunny.badges] Failed to fetch/process badges:", err);
} finally {
pendingRequests.delete(userId);
}
};
return after("default", useBadgesModule, ([user], result) => {
if (!user) return;
const userId = user.userId;
const cached = badgesCache.get(userId);
if (!cached) {
if (!pendingRequests.has(userId)) {
fetchAndProcessBadges(userId);
}
return;
}
cached.forEach((badge, i) => {
const badgeId = `rain-${userId}-${i}`;
result.unshift({
id: badgeId,
description: badge.label,
icon: " _",
});
});
});
},
}); });

View file

@ -0,0 +1,13 @@
import { Forms } from "@vendetta/ui/components";
const { FormSection, FormText } = Forms;
export default () => (
<FormSection title="FavouriteAnything">
<FormText>
Adds the favourite button to ALL media in the image viewer, not just GIFs!
</FormText>
<FormText style={{ marginTop: 8, color: "#b9bbbe" }}>
Images and videos can now be saved to your GIF favourites picker. Works by patching Discord's GIFFavButton to accept all media types.
</FormText>
</FormSection>
)

View file

@ -0,0 +1,169 @@
// src/core/plugins/favouriteanything/index.ts
import { defineCorePlugin } from "..";
import { findByProps } from "@metro";
import { before } from "@lib/api/patcher";
import { logger } from "@lib/utils/logger";
type PatchCleanupFn = () => void;
let origType: Function | null = null;
let memoWrapper: any = null;
let retryTimeout: ReturnType<typeof setTimeout> | null = null;
let unpatchAddFavorite: PatchCleanupFn | null = null;
let origUseFavoriteGIFsMobile: Function | null = null;
let favMobileModule: any = null;
const VIDEO_EXT = [".mp4", ".webm", ".mov", ".avi", ".mkv", ".flv", ".wmv", ".m4v", ".gifv"];
const processed = new WeakSet<object>();
function isVideo(url: string): boolean {
if (!url) return false;
const path = url.split("?")[0].toLowerCase();
return VIDEO_EXT.some(e => path.endsWith(e));
}
function makeVideoThumbnail(url: string): string {
if (!url) return url;
let out = url.replace("cdn.discordapp.com", "media.discordapp.net");
if (out.includes("media.discordapp.net") || out.includes("images-ext")) {
return out + (out.includes("?") ? "&" : "?") + "format=jpeg";
}
return url;
}
function findGIFFavButton(): any {
const modules = (globalThis as any).__bunny?.metro?.modules ?? (globalThis as any).modules;
if (!modules) return null;
for (const id in modules) {
try {
const mod = modules[id]?.publicModule?.exports;
if (!mod) continue;
const def = mod.default;
if (def?.$$typeof?.toString().includes("memo") && def.type) {
if (def.type.displayName === "GIFFavButton" || def.type.name === "GIFFavButton") return def;
}
if (typeof mod === "function" && (mod.displayName === "GIFFavButton" || mod.name === "GIFFavButton")) return mod;
} catch {}
}
return null;
}
function patchSource(source: any): any {
if (source.isGIFV) return source;
return {
...source,
isGIFV: true,
embedURI: source.embedURI || source.sourceURI || source.uri,
videoURI: source.videoURI || source.uri,
embedProviderName: source.embedProviderName || "",
};
}
function applyPatch(): boolean {
memoWrapper = findGIFFavButton();
if (!memoWrapper) return false;
origType = memoWrapper.type;
memoWrapper.type = function PatchedGIFFavButton(props: any) {
const p = { ...props };
if (p.source && !p.source.isGIFV) p.source = patchSource(p.source);
return (origType as Function)(p);
};
(memoWrapper.type as any).displayName = "GIFFavButton";
return true;
}
function patchAddFavorite(): void {
const favModule = findByProps("addFavoriteGIF");
if (!favModule) return;
unpatchAddFavorite = before("addFavoriteGIF", favModule, (args: any[]) => {
const data = args[0];
if (!data || typeof data !== "object") return;
const url = data.url || "";
const src = data.src || "";
if (isVideo(url) || isVideo(src)) {
data.format = 2;
} else if (data.format === 2) {
data.format = 1;
}
});
}
function patchMobileFavorites(): void {
const mod = findByProps("useFavoriteGIFsMobile");
if (!mod) return;
favMobileModule = mod;
origUseFavoriteGIFsMobile = mod.useFavoriteGIFsMobile;
let lastFavs: any = null;
mod.useFavoriteGIFsMobile = function (...args: any[]) {
const result = (origUseFavoriteGIFsMobile as Function).apply(this, args);
if (result?.favorites && Array.isArray(result.favorites) && result.favorites !== lastFavs) {
lastFavs = result.favorites;
for (const item of result.favorites) {
if (!item || processed.has(item)) continue;
processed.add(item);
if (isVideo(item.url) || isVideo(item.src)) {
item.src = makeVideoThumbnail(item.src || item.url);
}
}
}
return result;
};
}
export default defineCorePlugin({
manifest: {
id: "bunny.favouriteanything",
version: "1.0.0",
type: "plugin",
spec: 3,
main: "",
display: {
name: "FavouriteAnything",
description: "Adds the favourite button to ALL media, not just GIFs",
authors: [{ name: "TheUnrealZaka" }],
},
},
start() {
if (applyPatch()) {
logger.log("[FavouriteAnything] Patched GIFFavButton.");
} else {
let retries = 0;
const tryPatch = () => {
if (applyPatch()) {
logger.log("[FavouriteAnything] Patched GIFFavButton (after retry).");
retryTimeout = null;
} else if (retries++ < 50) {
retryTimeout = setTimeout(tryPatch, 300);
} else {
logger.error("[FavouriteAnything] GIFFavButton not found after retries.");
}
};
retryTimeout = setTimeout(tryPatch, 300);
}
patchAddFavorite();
patchMobileFavorites();
},
stop() {
if (retryTimeout) { clearTimeout(retryTimeout); retryTimeout = null; }
if (memoWrapper && origType) {
memoWrapper.type = origType;
origType = null;
memoWrapper = null;
}
if (unpatchAddFavorite) { unpatchAddFavorite(); unpatchAddFavorite = null; }
if (favMobileModule && origUseFavoriteGIFsMobile) {
favMobileModule.useFavoriteGIFsMobile = origUseFavoriteGIFsMobile;
origUseFavoriteGIFsMobile = null;
favMobileModule = null;
}
},
});

View file

@ -34,9 +34,12 @@ export const getCorePlugins = (): CorePluginRecord => ({
"bunny.quickinstall": makeLazyPlugin(() => require("./quickinstall"), true), "bunny.quickinstall": makeLazyPlugin(() => require("./quickinstall"), true),
"bunny.badges": makeLazyPlugin(() => require("./badges")), "bunny.badges": makeLazyPlugin(() => require("./badges")),
"bunny.notrack": makeLazyPlugin(() => require("./notrack")), "bunny.notrack": makeLazyPlugin(() => require("./notrack")),
"bunny.messagefix": makeLazyPlugin(() => require("./messagefix")),
"bunny.fixembed": makeLazyPlugin(() => require("./fixembed")), "bunny.fixembed": makeLazyPlugin(() => require("./fixembed")),
"bunny.enhancements": makeLazyPlugin(() => require("./shiggyenhancements")), "bunny.favouriteanything": makeLazyPlugin(() => require("./favouriteanything")),
"bunny.nodelete": makeLazyPlugin(() => require("./nodelete")),
"bunny.maxverstappen": makeLazyPlugin(() => require("./maxverstappen")),
}); });
/** /**

Binary file not shown.

View file

@ -0,0 +1,145 @@
import { defineCorePlugin } from "..";
import { findByProps } from "@metro";
import { logger } from "@lib/utils/logger";
const { ReactNative } = window as any;
const { DCDSoundManager } = ReactNative?.NativeModules || {};
const AUDIO_URL = "https://github.com/OTKUSteyler/GoonCord/raw/refs/heads/main/src/core/plugins/maxverstappen/Max%20Verstappen.mp3";
const SOUND_ID = 6972;
const TRIGGER_CHANCE = 0.33;
const MAX_PATTERN = /\bmax\s*verst[ae]pp?en\b/i;
let isPlaying = false;
let isPrepared = false;
let isPreparing = false;
let soundDuration = 30000;
let timeoutId: NodeJS.Timeout | null = null;
let onMessageCreate: ((e: any) => void) | null = null;
let onMessageSend: ((e: any) => void) | null = null;
function shouldTrigger(): boolean {
return Math.random() < TRIGGER_CHANCE;
}
function checkText(text: string): boolean {
if (!text) return false;
return MAX_PATTERN.test(text) && shouldTrigger();
}
function ensurePrepared(): Promise<number> {
return new Promise((resolve) => {
if (isPrepared) return resolve(soundDuration);
if (isPreparing) return resolve(-1);
isPreparing = true;
DCDSoundManager.prepare(AUDIO_URL, "music", SOUND_ID, (error: any, sound: any) => {
isPreparing = false;
if (error) {
logger.error("[MaxVerstappen] Failed to prepare:", error);
return resolve(-1);
}
isPrepared = true;
soundDuration = sound?.duration || 30000;
resolve(soundDuration);
});
});
}
async function playAudio() {
if (!DCDSoundManager) return;
if (isPlaying) return;
const duration = await ensurePrepared();
if (duration === -1) return;
isPlaying = true;
try {
await DCDSoundManager.play(SOUND_ID);
timeoutId = setTimeout(() => {
DCDSoundManager.stop(SOUND_ID);
isPlaying = false;
timeoutId = null;
}, soundDuration);
} catch (e) {
logger.error("[MaxVerstappen] Playback error:", e);
isPlaying = false;
}
}
export default defineCorePlugin({
manifest: {
id: "bunny.maxverstappen",
version: "1.0.0",
type: "plugin",
spec: 3,
main: "",
display: {
name: "Max Verstappen",
description: "Plays audio when Max Verstappen is mentioned (33% chance)",
authors: [{ name: "GoonCord Team" }],
},
},
start() {
if (!DCDSoundManager) {
logger.error("[MaxVerstappen] DCDSoundManager not found");
return;
}
const FluxDispatcher = findByProps("dispatch", "subscribe");
if (!FluxDispatcher) {
logger.error("[MaxVerstappen] FluxDispatcher not found");
return;
}
onMessageCreate = ({ message }: any) => {
try {
if (!message?.content) return;
if (checkText(message.content)) playAudio();
} catch (e) {
logger.error("[MaxVerstappen] MESSAGE_CREATE error:", e);
}
};
onMessageSend = ({ message }: any) => {
try {
if (!message?.content) return;
if (checkText(message.content)) playAudio();
} catch (e) {
logger.error("[MaxVerstappen] MESSAGE_SEND error:", e);
}
};
FluxDispatcher.subscribe("MESSAGE_CREATE", onMessageCreate);
FluxDispatcher.subscribe("MESSAGE_SEND", onMessageSend);
logger.log("[MaxVerstappen] Enabled");
},
stop() {
if (DCDSoundManager && isPlaying) {
DCDSoundManager.stop(SOUND_ID);
isPlaying = false;
}
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
const FluxDispatcher = findByProps("dispatch", "subscribe");
if (FluxDispatcher) {
if (onMessageCreate) FluxDispatcher.unsubscribe("MESSAGE_CREATE", onMessageCreate);
if (onMessageSend) FluxDispatcher.unsubscribe("MESSAGE_SEND", onMessageSend);
}
onMessageCreate = null;
onMessageSend = null;
isPrepared = false;
isPreparing = false;
soundDuration = 30000;
logger.log("[MaxVerstappen] Disabled");
},
});

View file

@ -1,56 +0,0 @@
import { defineCorePlugin } from "..";
import { findByProps } from "@metro";
import { logger } from "@lib/utils/logger";
// Find the MessageActions module
const MessageActions = findByProps("sendMessage");
let originalSendMessage: Function;
export default defineCorePlugin({
manifest: {
id: "bunny.messagefix",
version: "1.0.0",
type: "plugin",
spec: 3,
main: "",
display: {
name: "MessageFix",
description: "Ensures messages include the required nonce parameter",
authors: [{ name: "Win8.1VMUser (plugin by ShiggyCord Team)" }],
},
},
start() {
// Save original function
originalSendMessage = MessageActions.sendMessage;
// Replace with our implementation
MessageActions.sendMessage = function (
channelId,
message,
replyRef,
options,
) {
// Ensure options exists and has a nonce
options = options || {};
options.nonce = options.nonce || Date.now().toString();
// Call original with fixed parameters
return originalSendMessage.call(
this,
channelId,
message,
replyRef,
options,
);
};
logger.log("MessageFix: Enabled - adding nonce to all messages");
},
stop() {
// Restore original function
if (originalSendMessage) MessageActions.sendMessage = originalSendMessage;
logger.log("MessageFix: Disabled");
},
});

View file

@ -0,0 +1,76 @@
// src/core/plugins/NoDelete/index.ts
import { defineCorePlugin } from "..";
import { findByStoreName } from "@metro";
import { before } from "@lib/api/patcher";
import { logger } from "@lib/utils/logger";
import { FluxDispatcher } from "@metro/common";
type PatchCleanupFn = () => void;
let patches: PatchCleanupFn[] = [];
let MessageStore: any = null;
let deleteable: string[] = [];
function patchDispatcher(): PatchCleanupFn {
return before("dispatch", FluxDispatcher, (args: any[]) => {
try {
if (!MessageStore) MessageStore = findByStoreName("MessageStore");
const event = args[0];
if (!event || event?.type !== "MESSAGE_DELETE") return;
if (!event?.id || !event?.channelId) return;
if (deleteable.includes(event.id)) {
deleteable.splice(deleteable.indexOf(event.id), 1);
return;
}
deleteable.push(event.id);
args[0] = {
type: "MESSAGE_EDIT_FAILED_AUTOMOD",
messageData: {
type: 1,
message: {
channelId: event.channelId,
messageId: event.id,
},
},
errorResponseBody: {
code: 200000,
message: "[deleted]",
},
};
} catch (e) {
logger.error("NoDelete: dispatcher patch failed", e);
}
});
}
export default defineCorePlugin({
manifest: {
id: "bunny.nodelete",
version: "1.0.0",
type: "plugin",
spec: 3,
main: "",
display: {
name: "NoDelete",
description: "Prevents messages from being deleted from your view",
authors: [{ name: "meqativ" }],
},
},
start() {
patches = [patchDispatcher()].filter(Boolean);
logger.log("NoDelete: Enabled");
},
stop() {
patches.forEach((p) => p?.());
patches = [];
deleteable = [];
MessageStore = null;
logger.log("NoDelete: Disabled");
},
});

View file

@ -0,0 +1,90 @@
import { React, ReactNative } from "@vendetta/metro/common";
import { storage } from "@vendetta/plugin";
import { useProxy } from "@vendetta/storage";
import { showConfirmationAlert } from "@vendetta/ui/alerts";
import { Forms } from "@vendetta/ui/components";
import { getAssetIDByName } from "@vendetta/ui/assets";
import { getTranslation } from "./translations.js";
import ItemWithRemove from "../../common/ui/ItemWithRemove.jsx";
import { findByStoreName, findByProps } from "@vendetta/metro";
let UserStore, UncachedUserManager, Profiles;
export default (props) => {
UserStore ??= findByStoreName("UserStore");
UncachedUserManager ??= findByProps("fetchProfile", "getUser", "setFlag");
Profiles ??= findByProps("showUserProfile");
async function openProfile(userId) {
const show = Profiles.showUserProfile;
UserStore.getUser(userId) ? show({ userId }) : UncachedUserManager.getUser(userId).then(({ id }) => show({ userId: id }));
}
useProxy(storage);
const [users, setUsers] = React.useState(storage["ignore"]["users"]);
const handleRemoveUser = (userId) => {
const newArr = users.filter((id) => id !== userId);
storage["ignore"].users = newArr;
setUsers(newArr);
};
const handleClearUsers = () => {
storage["ignore"].users = [];
setUsers([]);
};
let uncached = 0;
return (
<ReactNative.ScrollView style={{ flex: 1 }}>
<Forms.FormSection title={getTranslation("settings.titles.settings")} titleStyleType="no_border">
<Forms.FormRow label={getTranslation("settings.showTimestamps")} trailing={<Forms.FormSwitch value={storage.timestamps} onValueChange={(v) => (storage.timestamps = v)} />} />
<Forms.FormRow label={getTranslation("settings.ewTimestampFormat")} trailing={<Forms.FormSwitch value={storage["ew"]} onValueChange={(v) => (storage.ew = v)} />} />
<Forms.FormDivider />
<Forms.FormRow label={getTranslation("settings.youDeletedItWarning")} />
</Forms.FormSection>
<Forms.FormSection title={getTranslation("settings.titles.filters")}>
<Forms.FormRow label={getTranslation("settings.ignoreBots")} trailing={<Forms.FormSwitch value={storage["ignore"].bots} onValueChange={(value) => (storage["ignore"].bots = value)} />} />
<Forms.FormRow
label={getTranslation("settings.clearUsersLabel", true)?.make?.(users.length)}
trailing={<Forms.FormRow.Icon source={getAssetIDByName("ic_trash_24px")} />}
onPress={() => {
if (users.length !== 0)
showConfirmationAlert({
title: getTranslation("settings.confirmClear.title"),
content: getTranslation("settings.confirmClear.description", true)?.make?.(users.length),
confirmText: getTranslation("settings.confirmClear.yes"),
cancelText: getTranslation("settings.confirmClear.no"),
confirmColor: "brand",
onConfirm: handleClearUsers,
});
}}
/>
<ReactNative.ScrollView style={{ flex: 1, gap: 3, marginLeft: 15 }}>
{users.map((id) => {
const User = UserStore.getUser(id) ?? {};
let pfp = User?.getAvatarURL?.(null,26)?.replace?.(/\.(gif|webp)/, ".png");
if (!pfp) {
pfp = "https://cdn.discordapp.com/embed/avatars/1.png?size=48";
User.username = `${id} Uncached`;
User.discriminator = "0";
if (uncached === 0) User.username += ", press the avatar";
uncached++;
}
return (
<ItemWithRemove
imageSource={{ uri: pfp }}
onImagePress={() => {
openProfile(id);
}}
onRemove={() => handleRemoveUser(id)}
label={User.username + (User.discriminator == 0 ? "" : `#${User.discriminator}`)}
labelRemove={getTranslation("settings.removeUserButton")}
/>
);
})}
</ReactNative.ScrollView>
<Forms.FormDivider />
<Forms.FormRow label={getTranslation("settings.addUsersInfo")} />
</Forms.FormSection>
</ReactNative.ScrollView>
);
};

View file

@ -0,0 +1,158 @@
function slavicNoun(number, $1, $2, $5) {
let n = Math.abs(number);
n %= 100;
if (n >= 5 && n <= 20) return $5;
n %= 10;
if (n === 1) return $1;
if (n >= 2 && n <= 4) return $2;
return $5;
}
function latinNoun(number, $1, $x) {
return number === 1 ? $1 : $x;
}
export let massive = {};
massive = {
settings: {
titles: {
settings: {
"en-GB": "Settings",
uk: "Налаштування",
ru: "Настройки",
},
filters: {
"en-GB": "Filters",
uk: "Фільтри",
ru: "Фильтры",
},
},
showTimestamps: {
"en-GB": "Show the time of deletion",
uk: "Показувати час видалення",
ru: "Показывать время удаления",
},
ewTimestampFormat: {
"en-GB": "Use 12-hour format",
uk: "Використовувати 12-годинний формат",
ru: "Использовать 12-часовой формат",
},
youDeletedItWarning: {
"en-GB": "The messages YOU deleted - are not saved",
uk: "Повідомлення які видалили ВИ - не зберігаются",
ru: "Сообщения удаленные ВАМИ - не сохраняются",
},
addUsersInfo: {
"en-GB": () =>
`To add or remove users from the ignore list, follow these steps:\n` +
`1. open their profile\n` +
`2. press the •••\n` +
`3. press "${massive.optionLabels[0]["en-GB"]}"\n` +
`4. 🎉`,
uk: () =>
`Щоб добавити когось до списку ігнорованих користувачів, виконайте ці дії:\n` +
`1. відкрите їх профіль\n` +
`2. натисніть •••\n` +
`3. натисніть "${massive.optionLabels[0]["uk"]}"\n` +
`4. 🎉`,
ru: () =>
`Чтобы добавить кого-то в список игнорированных пользователей - следуйте этим шагам\n` +
`1. откройте их профиль\n` +
`2. нажмите •••\n` +
`3. нажмите "${massive.optionLabels[0]["ru"]}"\n` +
`4. 🎉`,
},
ignoreBots: {
"en-GB": "Ignore bots",
uk: "Ігнорувати ботів",
ru: "Игнорировать ботов",
},
clearUsersLabel: {
"en-GB": (amount) =>
`You have ${amount} user${latinNoun(amount, "", "s")} in the ignored users list`,
uk: (amount) =>
`Ви маєте ${amount} користувач${slavicNoun(amount, "а", "а", "ів")} у списку ігнорованих користувачів`,
ru: (amount) =>
`Вы имеете ${amount} пользовател${slavicNoun(amount, "я", "я", "ей")} в списке игнорированных пользователей`,
},
confirmClear: {
title: {
"en-GB": "Hold on!",
uk: "Почекай-но!",
ru: "Положди-ка!",
},
description: {
"en-GB": (amount) =>
`This will remove ${amount} user${latinNoun(amount, "", "s")} from the ignored users list.\nDo you you really want to do that?`,
uk: (amount) =>
`Це прибере ${amount} користувач${slavicNoun(amount, "а", "а", "ів")} зі списку ігнорованих користувачів.\nВи дійсно хочете продовжити?`,
ru: (amount) =>
`Это уберет ${amount} пользовател${slavicNoun(amount, "я", "я", "ей")} из списка игнорированных пользователей.\nВы действительно хотите продолжить?`,
},
yes: {
"en-GB": "Yes",
uk: "Так",
ru: "Да",
},
no: {
"en-GB": "Cancel",
uk: "Відминити",
ru: "Отменить",
},
},
removeUserButton: {
"en-GB": "REMOVE",
uk: "ПРИБРАТИ",
ru: "УБРАТЬ",
},
},
optionLabels: [
{
"en-GB": "Add to NoDelete ignored users list",
uk: "Добавити до списку ігнорованих користувачів у NoDelete",
ru: "Добавить в список игнорированных пользователей в NoDelete",
},
{
"en-GB": "Remove from the NoDelete ignore list",
uk: "Прибрати з списку ігнорованих користувачів у NoDelete",
ru: "Убрать из списка игнорированных пользователей в NoDelete",
},
],
toastLabels: [
{
"en-GB": "Added {user} to the ignored users list",
uk: "{user} добавлено до списку ігнорованих користувачів",
ru: "{user} добавлены в список игнорированных пользователей",
},
{
"en-GB": "Removed {user} from the ignored users list",
uk: "{user} прибрано зі списку ігнорованих користувачів",
ru: "{user} убраны из списка игнорированных пользователей",
},
],
thisMessageWasDeleted: {
"en-GB": "This message was deleted",
uk: "Це повідомлення було видалено",
ru: "Это сообщение было удалено",
},
};
export const locale = () =>
vendetta.metro.findByStoreName("LocaleStore").locale;
const defaultLocale = "en-GB";
export function getTranslation(find, fn) {
let value = massive;
for (const key of find.split(".")) {
// biome-ignore lint/suspicious/noPrototypeBuiltins: meow
if (value?.hasOwnProperty(key)) {
value = value[key];
} else {
value = massive;
}
}
if (value === massive) return find;
let localized = value[locale()] ?? value[defaultLocale] ?? find;
if (typeof localized === "function" && !fn) localized = localized();
return fn ? { make: localized } : localized;
}

View file

@ -1,301 +0,0 @@
import { defineCorePlugin } from "..";
import { findByProps } from "@metro";
import { logger } from "@lib/utils/logger";
import { settings } from "@lib/api/settings";
import { React } from "@metro/common";
const { TableRowGroup, TableRow, TableSwitchRow, Stack } = findByProps(
"TableRowGroup",
"TableRow",
"TableSwitchRow",
"Stack",
);
const { ScrollView } = require("react-native");
// Get ReactNative for NativeModules
const { ReactNative } = window as any;
const { DCDSoundManager } = ReactNative?.NativeModules || {};
// Sound settings type
type SoundSettings = {
enabled: boolean;
probability: number;
};
declare module "@lib/api/settings" {
interface Settings {
startupSound?: SoundSettings;
}
}
const soundUrl =
"https://github.com/OTKUSteyler/GoonCord/raw/refs/heads/main/Stuff/All%20I%20Want%20For%20Christmas%20(Is%20Fark).mp3";
const soundId = 6971;
let soundDuration = -1;
let timeoutId: NodeJS.Timeout | null = null;
let isPlaying = false;
let isPrepared = false;
// Function to prepare the sound
const prepareSound = function (): Promise<any> {
return new Promise(function (resolve) {
if (!DCDSoundManager) {
logger.error("DCDSoundManager not available");
resolve(null);
return;
}
DCDSoundManager.prepare(
soundUrl,
"music",
soundId,
function (error: any, sound: any) {
if (error) {
logger.error("Failed to prepare sound:", error);
resolve(null);
} else {
resolve(sound);
}
},
);
});
};
// Function to play the sound
async function playSound() {
if (!DCDSoundManager) {
logger.error("DCDSoundManager not available for playback");
return;
}
if (isPlaying) {
if (timeoutId != null) clearTimeout(timeoutId);
DCDSoundManager.stop(soundId);
isPlaying = false;
}
isPlaying = true;
try {
await DCDSoundManager.play(soundId);
if (soundDuration > 0) {
timeoutId = setTimeout(function () {
isPlaying = false;
DCDSoundManager.stop(soundId);
timeoutId = null;
}, soundDuration);
}
} catch (error) {
logger.error("Error playing sound:", error);
isPlaying = false;
}
}
// Check if we should play based on probability
function shouldPlaySound(): boolean {
const probability = settings.startupSound?.probability ?? 2;
const random = Math.random() * 100;
return random <= probability;
}
export default defineCorePlugin({
manifest: {
id: "bunny.enhancements",
version: "0.1.0",
type: "plugin",
spec: 3,
main: "",
display: {
name: "Shiggy Enhancements",
description:
"Fixes common discord bugs because discord wont, and adds some features. (Originally Kettu Enhancements)",
authors: [{ name: "cocobo1" }, { name: "Shiggy Team" }],
},
},
SettingsComponent() {
const { useState, useEffect } = React;
const [config, setConfig] = useState<SoundSettings>({
enabled: settings.startupSound?.enabled ?? true,
probability: settings.startupSound?.probability ?? 2,
});
useEffect(() => {
settings.startupSound = config;
}, [config]);
const updateConfig = (
key: keyof SoundSettings,
value: boolean | number,
) => {
setConfig((prev) => ({ ...prev, [key]: value }));
};
// Get UI components including TextInput
const {
TableRowGroup: _TRG,
TableRow: _TR,
TableSwitchRow: _TSR,
Stack: _S,
TextInput: _TextInput,
} = findByProps(
"TableRowGroup",
"TableRow",
"TableSwitchRow",
"Stack",
"TextInput",
);
// Fallback if table components aren't available
if (!_TRG || !_TSR || !_TR || !_S) {
const FallbackText =
"Startup Sound UI unavailable (missing TableRow components).";
return React.createElement(
ScrollView,
{ style: { flex: 1, padding: 12 } },
FallbackText,
);
}
const TableRowGroup = _TRG;
const TableRow = _TR;
const TableSwitchRow = _TSR;
const Stack = _S;
const TextInput = _TextInput;
// Handle probability input change
const handleProbabilityChange = (v: string) => {
const num = parseInt(v);
if (!isNaN(num) && num >= 0 && num <= 100) {
updateConfig("probability", num);
} else if (v === "") {
updateConfig("probability", 0);
}
};
// Create settings elements
const settingsElements = [];
// Startup Sound toggle with probability display
settingsElements.push(
React.createElement(
TableRowGroup,
{ title: "Startup Sound", key: "startup-sound" },
React.createElement(TableSwitchRow, {
label: "Enable Startup Sound",
value: config.enabled,
onValueChange: (v: boolean) => updateConfig("enabled", v),
}),
React.createElement(TableRow, {
label: "Play Chance",
subLabel: `Current: ${config.probability}% chance to play on startup`,
}),
),
);
// Probability input in its own invisible group
if (TextInput) {
settingsElements.push(
React.createElement(
TableRowGroup,
{ title: "", key: "probability-input-group" },
React.createElement(TextInput, {
placeholder: "Enter probability (0-100)",
value: config.probability.toString(),
onChange: handleProbabilityChange,
key: "probability-input",
}),
),
);
}
// About section
settingsElements.push(
React.createElement(
TableRowGroup,
{ title: "About", key: "about" },
React.createElement(TableRow, {
label: "Description",
subLabel:
"Shiggy Enhancements, more options, more features. Originally Kettu Enhancements with additinal features.",
disabled: true,
}),
),
);
return React.createElement(
ScrollView,
{ style: { flex: 1 } },
React.createElement(
Stack,
{ spacing: 8, style: { padding: 10 } },
...settingsElements,
),
);
},
start() {
logger.log("Shiggy Enhancements: Starting plugin");
// Initialize sound settings
settings.startupSound = settings.startupSound || {
enabled: false,
probability: 2,
};
// Check if DCDSoundManager is available
if (!DCDSoundManager) {
logger.error("DCDSoundManager not found - startup sound disabled");
return;
}
// Play startup sound if enabled and probability check passes
if (settings.startupSound.enabled && shouldPlaySound()) {
logger.log(
`Attempting to play startup sound (${settings.startupSound.probability}% chance)`,
);
// Always prepare the sound fresh each time
prepareSound()
.then(function (sound) {
if (sound) {
isPrepared = true;
soundDuration = sound.duration || 5000;
logger.log(`Sound prepared, duration: ${soundDuration}ms`);
playSound();
logger.log(
`Startup sound played (${settings.startupSound?.probability}% chance)`,
);
} else {
logger.error("Failed to prepare sound");
}
})
.catch((error) => {
logger.error("Error in sound preparation:", error);
});
} else if (settings.startupSound.enabled) {
logger.log(
`Startup sound skipped due to probability (${settings.startupSound?.probability}% chance)`,
);
}
},
stop() {
logger.log("Shiggy Enhancements: Stopping plugin");
// Stop and cleanup audio
if (DCDSoundManager && isPlaying) {
DCDSoundManager.stop(soundId);
isPlaying = false;
}
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
// Reset preparation state so it can play again next time
isPrepared = false;
},
});

View file

@ -23,51 +23,54 @@ const { showSimpleActionSheet } = lazyDestructure(() =>
// TODO: These styles work weirdly. Low DPI Has cramped text. Fix? // TODO: These styles work weirdly. Low DPI Has cramped text. Fix?
const useStyles = createStyles({ const useStyles = createStyles({
card: { card: {
backgroundColor: semanticColors?.CARD_SECONDARY_BG, backgroundColor: semanticColors?.CARD_SECONDARY_BG,
borderRadius: 12, borderRadius: 12,
overflow: "hidden", overflow: "hidden"
}, },
header: { header: {
padding: 0, padding: 0,
}, },
headerLeading: { headerLeading: {
flexDirection: "column", flex: 1,
justifyContent: "center", flexDirection: "column",
scale: 1.2, justifyContent: "center",
}, scale: 1.2
headerTrailing: { },
display: "flex", headerTrailing: {
flexDirection: "row", display: "flex",
gap: 15, flexDirection: "row",
alignItems: "center", gap: 15,
}, alignItems: "center"
headerLabel: { },
...TextStyleSheet["heading-md/semibold"], headerLabel: {
color: semanticColors.MOBILE_TEXT_HEADING_PRIMARY, ...TextStyleSheet["heading-lg/semibold"],
}, },
headerSubtitle: { headerLabelCompact: {
...TextStyleSheet["text-md/semibold"], ...TextStyleSheet["text-md/semibold"],
color: semanticColors.TEXT_MUTED, },
}, headerSubtitle: {
descriptionLabel: { ...TextStyleSheet["text-sm/semibold"],
...TextStyleSheet["text-md/semibold"], color: semanticColors.TEXT_MUTED,
color: semanticColors.TEXT_NORMAL, },
}, descriptionLabel: {
actions: { ...TextStyleSheet["text-md/semibold"],
flexDirection: "row-reverse", color: "text-strong",
alignItems: "center", },
gap: 5, actions: {
}, flexDirection: "row-reverse",
iconStyle: { alignItems: "center",
tintColor: semanticColors.INTERACTIVE_ICON_DEFAULT, gap: 5
opacity: 0.2, },
height: 64, iconStyle: {
width: 64, tintColor: semanticColors.LOGO_PRIMARY,
left: void 0, opacity: 0.2,
right: "30%", height: 64,
top: "-10%", width: 64,
}, left: void 0,
right: "30%",
top: "-10%"
}
}); });
interface Action { interface Action {
@ -108,9 +111,9 @@ export default function AddonCard(props: CardProps) {
<Stack spacing={16}> <Stack spacing={16}>
<View style={{ flexDirection: "row", alignItems: "center" }}> <View style={{ flexDirection: "row", alignItems: "center" }}>
<View style={styles.headerLeading}> <View style={styles.headerLeading}>
<Text style={styles.headerLabel}>{props.headerLabel}</Text> <Text numberOfLines={1} ellipsizeMode="tail" style={styles.headerLabel}>{props.headerLabel}</Text>
{props.headerSublabel && ( {props.headerSublabel && (
<Text style={styles.headerSubtitle}>{props.headerSublabel}</Text> <Text numberOfLines={1} ellipsizeMode="tail" style={styles.headerSubtitle}>{props.headerSublabel}</Text>
)} )}
</View> </View>
<View style={[styles.headerTrailing, { marginLeft: "auto" }]}> <View style={[styles.headerTrailing, { marginLeft: "auto" }]}>

View file

@ -22,7 +22,6 @@ import {
} from "@metro/common/components"; } from "@metro/common/components";
import { ErrorBoundary, Search } from "@ui/components"; import { ErrorBoundary, Search } from "@ui/components";
import { isNotNil } from "es-toolkit"; import { isNotNil } from "es-toolkit";
import fuzzysort from "fuzzysort";
import { import {
ComponentType, ComponentType,
ReactNode, ReactNode,
@ -181,6 +180,27 @@ export default function AddonPage<T extends object>({
const { bottom: bottomInset } = useSafeAreaInsets(); const { bottom: bottomInset } = useSafeAreaInsets();
const navigation = NavigationNative.useNavigation(); const navigation = NavigationNative.useNavigation();
// Lazy-load fuzzysort to reduce initial bundle size
const fuzzysortRef = React.useRef<any>(null);
const [fuzzysortReady, setFuzzysortReady] = React.useState(false);
React.useEffect(() => {
let mounted = true;
import("fuzzysort")
.then((mod) => {
const f = (mod as any).default ?? mod;
if (mounted) {
fuzzysortRef.current = f;
setFuzzysortReady(true);
}
})
.catch(() => {
// ignore load errors
});
return () => {
mounted = false;
};
}, []);
useEffect(() => { useEffect(() => {
if (props.OptionsActionSheetComponent) { if (props.OptionsActionSheetComponent) {
navigation.setOptions({ navigation.setOptions({
@ -205,11 +225,13 @@ export default function AddonPage<T extends object>({
const items = values.filter((i) => isNotNil(i) && typeof i === "object"); const items = values.filter((i) => isNotNil(i) && typeof i === "object");
if (!search && sortFn) items.sort(sortFn); if (!search && sortFn) items.sort(sortFn);
return fuzzysort.go(search, items, { if (!fuzzysortReady || !fuzzysortRef.current) return [] as any[];
return fuzzysortRef.current.go(search, items, {
keys: props.searchKeywords, keys: props.searchKeywords,
all: true, all: true,
}); });
}, [props.items, sortFn, search]); }, [props.items, sortFn, search, fuzzysortReady]);
const onInstallPress = useCallback(() => { const onInstallPress = useCallback(() => {
if (!props.installAction) return () => {}; if (!props.installAction) return () => {};

View file

@ -19,7 +19,6 @@ import {
TableSwitchRow, TableSwitchRow,
Stack, Stack,
} from "@metro/common/components"; } from "@metro/common/components";
import { semanticColors } from "@ui/color";
import { ScrollView, View } from "react-native"; import { ScrollView, View } from "react-native";
import ErrorComponentStackCard from "./ErrorComponentStackCard"; import ErrorComponentStackCard from "./ErrorComponentStackCard";
@ -74,8 +73,8 @@ export default function ErrorBoundaryScreen(props: {
<ScrollView fadingEdgeLength={64} contentContainerStyle={{ gap: 12 }}> <ScrollView fadingEdgeLength={64} contentContainerStyle={{ gap: 12 }}>
<Text <Text
variant="heading-md/extrabold" variant="heading-md/extrabold"
color="text-default"
style={{ style={{
color: semanticColors.HEADER_PRIMARY,
marginBottom: 8, marginBottom: 8,
textAlign: "center", textAlign: "center",
}} }}

View file

@ -11,7 +11,7 @@ export { shiggycordIcon };
export default function initSettings() { export default function initSettings() {
registerSection({ registerSection({
name: "💦GoonCord💦", name: "GoonCord",
items: [ items: [
{ {
key: "SHIGGYCORD", key: "SHIGGYCORD",

View file

@ -97,7 +97,7 @@ export default function About() {
variant="text-md/medium" variant="text-md/medium"
style={{ textAlign: "center", marginTop: 4 }} style={{ textAlign: "center", marginTop: 4 }}
> >
Goon your discord client! 💦Goon💦 your discord client!
</Text> </Text>
</View> </View>

View file

@ -32,7 +32,19 @@ export default function General() {
const navigation = NavigationNative.useNavigation(); const navigation = NavigationNative.useNavigation();
// Custom Community Card Button // Custom Community Card Button
const CommunityCardButton = ({ icon, label, subLabel, color, onPress }) => ( const CommunityCardButton = ({
icon,
label,
subLabel,
color,
onPress,
}: {
icon?: number | { uri: string };
label: string;
subLabel?: string | null;
color?: string;
onPress?: () => void;
}) => (
<TouchableOpacity style={{ flex: 1 }} onPress={onPress} activeOpacity={0.7}> <TouchableOpacity style={{ flex: 1 }} onPress={onPress} activeOpacity={0.7}>
<Card <Card
style={{ style={{
@ -60,7 +72,7 @@ export default function General() {
}} }}
> >
<TableRow.Icon <TableRow.Icon
source={icon} source={icon as any}
style={{ style={{
tintColor: "#FFFFFF", tintColor: "#FFFFFF",
width: 24, width: 24,
@ -113,8 +125,8 @@ export default function General() {
> >
<TableRowGroup title="App Information"> <TableRowGroup title="App Information">
<TableRow <TableRow
label="💦GoonCord💦" label="GoonCord"
icon={<TableRow.Icon source={{ uri: shiggycordIcon }} />} icon={<TableRow.Icon source={{ uri: shiggycordIcon ?? "" }} />}
trailing={<TableRow.TrailingText text={debugInfo.bunny.version} />} trailing={<TableRow.TrailingText text={debugInfo.bunny.version} />}
/> />
<TableRow <TableRow
@ -132,7 +144,7 @@ export default function General() {
subLabel={`${debugInfo.bunny.loader.name} loader`} subLabel={`${debugInfo.bunny.loader.name} loader`}
icon={<TableRow.Icon source={findAssetId("DownloadIcon")} />} icon={<TableRow.Icon source={findAssetId("DownloadIcon")} />}
trailing={ trailing={
<TableRow.TrailingText text={debugInfo.bunny.loader.version} /> <TableRow.TrailingText text={debugInfo.bunny.loader.version ?? ""} />
} }
/> />
</TableRowGroup> </TableRowGroup>
@ -188,7 +200,7 @@ export default function General() {
<TableSwitchRow <TableSwitchRow
label={Strings.SETTINGS_ACTIVATE_DISCORD_EXPERIMENTS} label={Strings.SETTINGS_ACTIVATE_DISCORD_EXPERIMENTS}
subLabel={Strings.SETTINGS_ACTIVATE_DISCORD_EXPERIMENTS_DESC} subLabel={Strings.SETTINGS_ACTIVATE_DISCORD_EXPERIMENTS_DESC}
icon={<TableRow.Icon source={findAssetId("FlaskIcon")} />} icon={<TableRow.Icon source={findAssetId("StaffBadgeIcon")} />}
value={settings.enableDiscordDeveloperSettings} value={settings.enableDiscordDeveloperSettings}
onValueChange={(v: boolean) => { onValueChange={(v: boolean) => {
settings.enableDiscordDeveloperSettings = v; settings.enableDiscordDeveloperSettings = v;

View file

@ -11,10 +11,11 @@ import {
Text, Text,
} from "@metro/common/components"; } from "@metro/common/components";
import { showSheet } from "@ui/sheets"; import { showSheet } from "@ui/sheets";
import chroma from "chroma-js";
import { createContext, useContext, useMemo } from "react"; import { createContext, useContext, useMemo } from "react";
import { Image, View } from "react-native"; import { Image, View } from "react-native";
const getChroma = () => require("chroma-js") as typeof import("chroma-js").default;
const CardContext = createContext<{ const CardContext = createContext<{
plugin: UnifiedPluginModel; plugin: UnifiedPluginModel;
result: Fuzzysort.KeysResult<UnifiedPluginModel>; result: Fuzzysort.KeysResult<UnifiedPluginModel>;
@ -22,7 +23,7 @@ const CardContext = createContext<{
const useCardContext = () => useContext(CardContext); const useCardContext = () => useContext(CardContext);
function getHighlightColor(): import("react-native").ColorValue { function getHighlightColor(): import("react-native").ColorValue {
return chroma(tokens.unsafe_rawColors.YELLOW_300).alpha(0.3).hex(); return getChroma()(tokens.unsafe_rawColors.YELLOW_300).alpha(0.3).hex();
} }
function Title() { function Title() {

View file

@ -702,7 +702,7 @@ export default function Plugins() {
// Use the insertion order of pluginSettings so newly-installed plugins // Use the insertion order of pluginSettings so newly-installed plugins
// (which add entries to pluginSettings) appear first. // (which add entries to pluginSettings) appear first.
const installedBnIds = Object.keys(pluginSettings || {}) const installedBnIds = Object.keys(pluginSettings || {})
.filter((id) => registeredPlugins.has(id) && !isCorePlugin(id)) .filter((id) => registeredPlugins.has(id))
.slice() .slice()
.reverse(); .reverse();
@ -710,12 +710,12 @@ export default function Plugins() {
.map((id) => registeredPlugins.get(id)!) .map((id) => registeredPlugins.get(id)!)
.map(unifyBunnyPlugin); .map(unifyBunnyPlugin);
// Merge lists: show Vendetta-managed plugins first (recent first), // Merge lists: show Vendetta-managed plugins first,
// then Bunny-installed externals (recent first). // then Bunny-installed externals (recent first).
const allPlugins = [...vdPlugins, ...bnPlugins]; const allPlugins = [...vdPlugins, ...bnPlugins];
// Filter out core plugins from the list // Include core plugins in the list
return allPlugins.filter((plugin) => !isCorePlugin(plugin.id)); return allPlugins;
}} }}
ListHeaderComponent={() => null} ListHeaderComponent={() => null}
installAction={{ installAction={{

View file

@ -33,8 +33,6 @@ import {
Text, Text,
} from "@metro/common/components"; } from "@metro/common/components";
import { View, Image } from "react-native"; import { View, Image } from "react-native";
import { showConfirmationAlert } from "@core/vendetta/alerts";
import { BundleUpdaterManager } from "@lib/api/native/modules";
const { useReducer } = React; const { useReducer } = React;
@ -352,58 +350,12 @@ function BrowseThemePage(props: BrowseThemePageProps) {
); );
} }
/**
* Theme options have been changed from radio groups to individual switches
* for better UX. Each option can now be toggled independently.
*/
export default function Themes() { export default function Themes() {
useProxy(settings); useProxy(settings);
useProxy(themes); useProxy(themes);
const navigation = NavigationNative.useNavigation(); const navigation = NavigationNative.useNavigation();
const [mode, setMode] = React.useState<"installed" | "browse">("installed"); const [mode, setMode] = React.useState<"installed" | "browse">("installed");
// Track the theme that was active when the user entered the Themes page.
// If it changes while the user stays on this page, we'll prompt to reload when they leave.
const initialThemeIdRef = React.useRef<string | null>(getCurrentTheme()?.id ?? null);
React.useEffect(() => {
const beforeRemoveHandler = (e: any) => {
try {
const currentId = getCurrentTheme()?.id ?? null;
if (currentId !== initialThemeIdRef.current) {
// Notify the user that a reload is required for full application of theme changes
showConfirmationAlert({
title: Strings.MODAL_RELOAD_THEME_CHANGE,
content: Strings.MODAL_THEME_REFETCHED_DESC,
confirmText: Strings.RELOAD,
cancelText: Strings.CANCEL,
confirmColor: "red",
onConfirm: () => {
try {
BundleUpdaterManager.reload();
} catch (err) {
try {
(global as any).NativeModules?.BundleUpdaterManager?.reload?.();
} catch (_) {}
}
},
});
}
} catch (_) {
// swallow any unexpected errors — we don't want to block navigation
}
};
const unsub = navigation?.addListener ? navigation.addListener("beforeRemove", beforeRemoveHandler) : undefined;
return () => {
try {
if (unsub && typeof unsub === "function") unsub();
else if (unsub && typeof unsub.remove === "function") unsub.remove();
} catch (_) {}
};
}, [navigation]);
const [themesList, setThemesList] = React.useState<ThemeData[]>([]); const [themesList, setThemesList] = React.useState<ThemeData[]>([]);
const [loading, setLoading] = React.useState(false); const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);

View file

@ -147,7 +147,12 @@ export const initVendettaObject = (): any => {
return require("moment"); return require("moment");
}, },
get chroma() { get chroma() {
return require("chroma-js"); // Lazy-load chroma-js to reduce startup cost
const cached = (this as any)._chroma;
if (cached) return cached;
const loaded = require("chroma-js");
(this as any)._chroma = loaded;
return loaded;
}, },
get lodash() { get lodash() {
return require("lodash"); return require("lodash");

View file

@ -153,6 +153,8 @@ export interface StorageBackend {
} }
const ILLEGAL_CHARS_REGEX = /[<>:"/\\|?*]/g; const ILLEGAL_CHARS_REGEX = /[<>:"/\\|?*]/g;
// Debouncer map for per-store writes to disk
const _writeDebouncers = new Map<string, any>();
const filePathFixer = (file: string): string => Platform.select({ const filePathFixer = (file: string): string => Platform.select({
default: file, default: file,
@ -215,6 +217,71 @@ export const createMMKVBackend = (store: string, defaultData = {}) => {
})()); })());
}; };
// Lightweight in-memory backend to accelerate startup and avoid blocking IO
// while still providing eventual persistence to the native storage.
export const createMemoryBackend = (
store: string,
defaultData: any = {},
) => {
const mmkvPath = getMMKVPath(store);
const defaultStr = JSON.stringify(defaultData);
// In-memory backing object used by the proxy immediately
const memory: any = { ...defaultData };
// Load persisted data in background without blocking startup
(async () => {
try {
const cached = await NativeCacheModule.getItem(store);
if (cached) {
const parsed = JSON.parse(cached);
Object.assign(memory, parsed);
}
} catch {
// ignore
}
try {
const path = `${NativeFileModule.getConstants().DocumentsDirPath}/${mmkvPath}`;
if (await NativeFileModule.fileExists(path)) {
const content = await NativeFileModule.readFile(path, "utf8");
const data = JSON.parse(content);
Object.assign(memory, data);
}
} catch {
// ignore
}
})();
// Hydration is intentionally omitted for stability in this rewrite
// Local path helper (inlined to avoid hoisting concerns)
const fixPath = (file: string) =>
Platform.select({ default: file, ios: NativeFileModule.saveFileToGallery ? file : `Documents/${file}` });
return {
get: async () => memory,
set: async (data: any) => {
Object.assign(memory, data);
// Debounced persistence to disk to avoid IO storms
try {
const existing = _writeDebouncers.get(store);
if (existing) clearTimeout(existing);
const timeout = setTimeout(async () => {
try {
await NativeFileModule.writeFile("documents", fixPath(mmkvPath), JSON.stringify(memory), "utf8");
} catch {
// ignore persistence errors to keep startup fast
}
}, 120);
_writeDebouncers.set(store, timeout);
} catch {
// ignore
}
},
} as unknown as StorageBackend;
};
export const createFileBackend = (file: string, defaultData = {}, migratePromise?: Promise<void>): StorageBackend => { export const createFileBackend = (file: string, defaultData = {}, migratePromise?: Promise<void>): StorageBackend => {
return { return {
get: async () => { get: async () => {
@ -239,4 +306,3 @@ export const createFileBackend = (file: string, defaultData = {}, migratePromise
} }
}; };
}; };

View file

@ -11,7 +11,7 @@ async function initializeShiggyCord() {
Object.freeze = Object.seal = Object; Object.freeze = Object.seal = Object;
await require("@metro/internals/caches").initMetroCache(); await require("@metro/internals/caches").initMetroCache();
await require(".").default(); require(".").default();
} catch (e) { } catch (e) {
const { ClientInfoManager } = require("@lib/api/native/modules"); const { ClientInfoManager } = require("@lib/api/native/modules");
const stack = e instanceof Error ? e.stack : undefined; const stack = e instanceof Error ? e.stack : undefined;

View file

@ -11,13 +11,8 @@ import { injectFluxInterceptor } from "@lib/api/flux";
import { patchJsx } from "@lib/api/react/jsx"; import { patchJsx } from "@lib/api/react/jsx";
import { logger } from "@lib/utils/logger"; import { logger } from "@lib/utils/logger";
import { patchSettings } from "@ui/settings"; import { patchSettings } from "@ui/settings";
import { updaterSettings } from "@lib/api/settings";
import { InteractionManager } from "react-native"; import { InteractionManager } from "react-native";
import { getDebugInfo, initDebugger } from "@lib/api/debug"; import { getDebugInfo, initDebugger } from "@lib/api/debug";
// Debug toggle helper (temporary runtime fallback). The helper is dynamically
// imported when needed (to avoid bundling it permanently) and removed after use.
import * as lib from "./lib"; import * as lib from "./lib";
import { timings } from "@lib/utils/timings"; import { timings } from "@lib/utils/timings";
@ -66,52 +61,19 @@ export default async () => {
const { VdPluginManager } = await import("@core/vendetta/plugins"); const { VdPluginManager } = await import("@core/vendetta/plugins");
const { initPlugins, updatePlugins } = await import("@lib/addons/plugins"); const { initPlugins, updatePlugins } = await import("@lib/addons/plugins");
// Initialize Vendetta plugins (may start many plugins) — do not block UI. await Promise.all([
VdPluginManager.initPlugins() VdPluginManager.initPlugins()
.then((u) => lib.unload.push(u)) .then((u) => u && lib.unload.push(u))
.catch((e) => logger.log("Vendetta init failed:", e)); .catch((e) => logger.log("Vendetta init failed:", e)),
initPlugins()
.catch((e) => logger.log("initPlugins failed:", e)),
]);
// Start ShiggyCord (Bunny) plugins now without forcing repository updates. updatePlugins()
// Plugin repository fetching is deferred so the app can finish launching first. .catch((e) => logger.log("updatePlugins failed:", e));
// Stagger plugin startup to reduce CPU/memory spikes: use smaller batches and a small interval.
// This keeps the UI responsive while plugins initialize in the background.
initPlugins({ staggerInterval: 500, batchSize: 2 });
// Attempt a lightweight recovery toggle if some core plugins failed to start.
try {
// Dynamically import the helper (if present) but suppress verbose errors.
const mod = await import("@core/debug/toggleCorePlugins").catch(
() => null,
);
if (mod?.default) {
// Run helper with minimal noise; ignore failures.
mod.default({ offDuration: 1500 }).catch(() => {});
}
// Try to remove the helper source file (best-effort, ignore failures).
await import("@lib/api/native/fs")
.then((fs) => fs.removeFile("src/core/debug/toggleCorePlugins.ts"))
.catch(() => {});
} catch {
// suppressed
}
// Update fonts in background // Update fonts in background
updateFonts().catch((e) => logger.log("updateFonts failed:", e)); updateFonts().catch((e) => logger.log("updateFonts failed:", e));
// Schedule plugin repository update after a delay (5 minutes) so update work
// does not impact initial launch performance.
setTimeout(
() => {
updatePlugins().catch((e) =>
logger.log("updatePlugins failed (deferred 5min):", e),
);
},
5 * 60 * 1000,
);
// Note: we intentionally moved the call to `updatePlugins()` above so core
// plugins are registered prior to calling `initPlugins()`.
}; };
// Preferred: wait until interactions finish (animations / navigation). // Preferred: wait until interactions finish (animations / navigation).
@ -126,5 +88,5 @@ export default async () => {
} }
// Final ready log for basic UI availability. // Final ready log for basic UI availability.
logger.log("GoonCord is ready."); logger.log("ShiggyCord is ready.");
}; };

View file

@ -1,4 +1,4 @@
import { awaitStorage, createMMKVBackend, createStorage, wrapSync } from "@core/vendetta/storage"; import { awaitStorage, createMemoryBackend, createStorage, wrapSync } from "@core/vendetta/storage";
import { clearFolder, downloadFile, fileExists, removeFile, writeFile } from "@lib/api/native/fs"; import { clearFolder, downloadFile, fileExists, removeFile, writeFile } from "@lib/api/native/fs";
import { safeFetch } from "@lib/utils"; import { safeFetch } from "@lib/utils";
@ -13,7 +13,8 @@ export type FontDefinition = {
} }
type FontStorage = Record<string, FontDefinition> & { __selected?: string; }; type FontStorage = Record<string, FontDefinition> & { __selected?: string; };
export const fonts = wrapSync(createStorage<FontStorage>(createMMKVBackend("BUNNY_FONTS"))); // Use a memory-backed storage for faster startup; persistence is still written back to disk
export const fonts = wrapSync(createStorage<FontStorage>(createMemoryBackend("BUNNY_FONTS", {})));
async function writeFont(font: FontDefinition | null) { async function writeFont(font: FontDefinition | null) {
if (!font && font !== null) throw new Error("Arg font must be a valid object or null"); if (!font && font !== null) throw new Error("Arg font must be a valid object or null");

View file

@ -752,27 +752,54 @@ export async function updatePlugins() {
}), }),
); );
} }
const enabledIds = [...registeredPlugins.keys()].filter(
(id) => isPluginEnabled(id) && !pluginInstances.has(id),
);
await Promise.allSettled(
enabledIds.map(async (id) => {
try {
await startPlugin(id).catch((e) => {
console.error(`Failed to start plugin ${id}:`, e);
});
} catch (error) {
console.error(`Unexpected error while starting plugin ${id}:`, error);
}
}),
);
} }
/** /**
* Initialize plugins but stagger startup to avoid CPU spikes during app start. * Register all installed plugins from local storage manifests without any
* network fetch. This lets initPlugins() start plugins immediately using
* previously-cached scripts, keeping startup snappy.
* *
* This replaces the original "start all enabled plugins in parallel" approach * Safe to call multiple times — skips already-registered plugins.
* with a configurable staggered startup: plugins are started in small batches and
* we await a small interval between batches. This reduces short-lived CPU and I/O contention
* that causes jank on low-end devices.
*
* Backwards-compatible: callers can still call initPlugins() with no arguments.
*
* Options:
* - staggerInterval (ms) - wait time between batches (default: 100)
* - batchSize - how many plugins to start concurrently in a single batch (default: 3)
*/ */
export async function initPlugins( export async function registerInstalledPlugins() {
options: { staggerInterval?: number; batchSize?: number } = {}, await awaitStorage(pluginRepositories, pluginSettings);
) {
const { staggerInterval = 100, batchSize = 3 } = options;
const ids = Object.keys(pluginSettings ?? {});
if (ids.length === 0) return;
// Load all manifest files from local storage in parallel.
const results = await Promise.allSettled(
ids.map(async (id) => {
if (registeredPlugins.has(id) || isCorePlugin(id)) return;
const loaded = await preloadStorageIfExists(`plugins/manifests/${id}.json`);
if (loaded) {
const manifest = getPreloadedStorage<t.BunnyPluginManifest>(
`plugins/manifests/${id}.json`,
);
if (manifest) {
registeredPlugins.set(id, manifest);
}
}
}),
);
}
export async function initPlugins() {
// Ensure core plugins are registered prior to any plugin startup logic so // Ensure core plugins are registered prior to any plugin startup logic so
// settings pages and UI that read registered/core plugins show them. // settings pages and UI that read registered/core plugins show them.
// registerCorePlugins is synchronous and idempotent, but call it here as a // registerCorePlugins is synchronous and idempotent, but call it here as a
@ -784,6 +811,7 @@ export async function initPlugins(
} }
await awaitStorage(pluginRepositories, pluginSettings); await awaitStorage(pluginRepositories, pluginSettings);
await registerInstalledPlugins();
// Collect enabled plugin ids // Collect enabled plugin ids
const enabledIds = [...registeredPlugins.keys()].filter((id) => const enabledIds = [...registeredPlugins.keys()].filter((id) =>
@ -792,32 +820,18 @@ export async function initPlugins(
if (enabledIds.length === 0) return; if (enabledIds.length === 0) return;
// Helper sleep // Start all enabled plugins in parallel
const sleep = (ms: number) => new Promise<void>((res) => setTimeout(res, ms)); await Promise.allSettled(
enabledIds.map(async (id) => {
// Start plugins in batches to avoid starting all at once. try {
for (let i = 0; i < enabledIds.length; i += batchSize) { // Only attempt to start if still enabled and not already started
const batch = enabledIds.slice(i, i + batchSize); if (!isPluginEnabled(id) || pluginInstances.has(id)) return;
await startPlugin(id).catch((e) => {
// Start the batch in parallel but don't let exceptions bubble to halt everything. console.error(`Failed to start plugin ${id}:`, e);
await Promise.allSettled( });
batch.map(async (id) => { } catch (error) {
try { console.error(`Unexpected error while starting plugin ${id}:`, error);
// Only attempt to start if still enabled and not already started }
if (!isPluginEnabled(id) || pluginInstances.has(id)) return; }),
await startPlugin(id).catch((e) => { );
// startPlugin may throw; ensure it doesn't crash the whole batch
console.error(`Failed to start plugin ${id}:`, e);
});
} catch (error) {
console.error(`Unexpected error while starting plugin ${id}:`, error);
}
}),
);
// If there are more plugins to start, wait before starting the next batch.
if (i + batchSize < enabledIds.length) {
await sleep(staggerInterval);
}
}
} }

View file

@ -1,8 +1,9 @@
import { findByProps } from "@metro"; import { findByProps } from "@metro";
import chroma from "chroma-js";
import { omit } from "es-toolkit"; import { omit } from "es-toolkit";
import { Platform, processColor } from "react-native"; import { Platform, processColor } from "react-native";
const getChroma = () => require("chroma-js") as typeof import("chroma-js").default;
import { colorsPref } from "./preferences"; import { colorsPref } from "./preferences";
import { ColorManifest, InternalColorDefinition } from "./types"; import { ColorManifest, InternalColorDefinition } from "./types";
@ -33,7 +34,7 @@ export function parseColorManifest(manifest: ColorManifest): InternalColorDefini
} else if (typeof semanticColorValue === "string") { } else if (typeof semanticColorValue === "string") {
if (semanticColorValue.startsWith("#")) { if (semanticColorValue.startsWith("#")) {
semanticColorDefinitions[semanticColorKey] = { semanticColorDefinitions[semanticColorKey] = {
value: chroma(semanticColorValue).hex(), value: getChroma()(semanticColorValue).hex(),
opacity: 1, opacity: 1,
}; };
} else { } else {
@ -208,7 +209,7 @@ export function applyAndroidAlphaKeys(rawColors?: Record<string, string>) {
for (const key in alphaMap) { for (const key in alphaMap) {
const [colorKey, alpha] = alphaMap[key]; const [colorKey, alpha] = alphaMap[key];
if (!rawColors[colorKey]) continue; if (!rawColors[colorKey]) continue;
rawColors[key] = chroma(rawColors[colorKey]).alpha(alpha).hex(); rawColors[key] = getChroma()(rawColors[colorKey]).alpha(alpha).hex();
} }
return rawColors; return rawColors;
@ -221,11 +222,11 @@ export function normalizeToHex(colorString: string | undefined): string | undefi
return "#00000000"; return "#00000000";
} }
if (chroma.valid(colorString)) return chroma(colorString).hex(); if (getChroma().valid(colorString)) return getChroma()(colorString).hex();
const color = Number(processColor(colorString)); const color = Number(processColor(colorString));
return chroma.rgb( return getChroma().rgb(
color >> 16 & 0xff, // red color >> 16 & 0xff, // red
color >> 8 & 0xff, // green color >> 8 & 0xff, // green
color & 0xff, // blue color & 0xff, // blue

View file

@ -4,7 +4,7 @@ import { after } from "@lib/api/patcher";
import { useObservable } from "@lib/api/storage"; import { useObservable } from "@lib/api/storage";
import { findInReactTree } from "@lib/utils"; import { findInReactTree } from "@lib/utils";
import { findByFilePathLazy } from "@metro"; import { findByFilePathLazy } from "@metro";
import chroma from "chroma-js"; const getChroma = () => require("chroma-js") as typeof import("chroma-js").default;
import { ImageBackground, StyleSheet } from "react-native"; import { ImageBackground, StyleSheet } from "react-native";
import { logger } from "@lib/utils/logger"; import { logger } from "@lib/utils/logger";
@ -13,53 +13,77 @@ const Messages = findByFilePathLazy("modules/messages/native/Messages.tsx", true
function ThemeBackground({ children }: { children: React.ReactNode; }) { function ThemeBackground({ children }: { children: React.ReactNode; }) {
useObservable([colorsPref]); useObservable([colorsPref]);
if (!_colorRef.current const background = _colorRef.current?.background;
|| colorsPref.customBackground === "hidden" const url = background?.url;
|| !_colorRef.current.background?.url const blur = background?.blur;
|| _colorRef.current.background?.blur && (typeof _colorRef.current.background?.blur !== "number")
if (
!_colorRef.current ||
colorsPref.customBackground === "hidden" ||
!url
) { ) {
return children; return children;
} }
return <ImageBackground if (blur !== undefined && typeof blur !== "number") {
style={{ flex: 1, height: "100%" }} return children;
source={{ uri: _colorRef.current.background?.url }} }
blurRadius={_colorRef.current.background?.blur}
> return (
{children} <ImageBackground
</ImageBackground>; style={{ flex: 1, height: "100%" }}
source={{ uri: url }}
blurRadius={typeof blur === "number" ? blur : 0}
>
{children}
</ImageBackground>
);
} }
export default function patchChatBackground() { export default function patchChatBackground() {
try { try {
if (!Messages) return;
const patches = [ const patches = [
after("render", Messages, (_, ret) => { after("render", Messages, (_, ret) => {
if (!_colorRef.current || !_colorRef.current.background?.url) return; if (!ret) return ret;
const messagesComponent = findInReactTree( try {
ret, if (!_colorRef.current || !_colorRef.current.background?.url) return ret;
x => x && "HACK_fixModalInteraction" in x.props && x?.props?.style
);
if (messagesComponent) { const messagesComponent = findInReactTree(
const flattened = StyleSheet.flatten(messagesComponent.props.style); ret,
const backgroundColor = chroma( x => x && "HACK_fixModalInteraction" in (x.props ?? {}) && x?.props?.style
flattened.backgroundColor || "black" );
).alpha(
1 - (_colorRef.current.background?.opacity ?? 1)
).hex();
messagesComponent.props.style = StyleSheet.flatten([ if (messagesComponent) {
messagesComponent.props.style, try {
{ backgroundColor } const flattened = StyleSheet.flatten(messagesComponent.props.style);
]); const backgroundColor = getChroma()(
flattened.backgroundColor || "black"
).alpha(
1 - (_colorRef.current.background?.opacity ?? 1)
).hex();
messagesComponent.props.style = StyleSheet.flatten([
messagesComponent.props.style,
{ backgroundColor }
]);
} catch (e) {
logger.warn("Background patch: failed to modify style", e);
}
}
} catch (e) {
logger.warn("Background patch: render error", e);
return ret;
} }
return <ThemeBackground>{ret}</ThemeBackground>; return <ThemeBackground>{ret}</ThemeBackground>;
}) })
]; ];
return () => patches.forEach(x => x()); return () => patches.forEach(x => x?.());
} catch (e) { } catch (e) {
logger.error("Background patch: failed to initialize", e);
} }
} }

View file

@ -2,12 +2,14 @@ import { _colorRef } from "@lib/addons/themes/colors/updater";
import { NativeThemeModule } from "@lib/api/native/modules"; import { NativeThemeModule } from "@lib/api/native/modules";
import { before, instead } from "@lib/api/patcher"; import { before, instead } from "@lib/api/patcher";
import { findByProps } from "@metro"; import { findByProps } from "@metro";
import { byMutableProp } from "@metro/filters";
import { createLazyModule } from "@metro/lazy";
import chroma from "chroma-js"; import chroma from "chroma-js";
const tokenReference = findByProps("SemanticColor"); const tokenReference = findByProps("SemanticColor");
const isThemeModule = createLazyModule(byMutableProp("isThemeDark")); const themeTypes = findByProps("ThemeTypes")?.ThemeTypes;
const origRawColor = { ...tokenReference.RawColor };
const origDarker = themeTypes.DARKER as string;
const origLight = themeTypes.LIGHT as string;
const SEMANTIC_FALLBACK_MAP: Record<string, string> = { const SEMANTIC_FALLBACK_MAP: Record<string, string> = {
"BG_BACKDROP": "BACKGROUND_FLOATING", "BG_BACKDROP": "BACKGROUND_FLOATING",
@ -22,13 +24,19 @@ const SEMANTIC_FALLBACK_MAP: Record<string, string> = {
"BG_SURFACE_RAISED": "BACKGROUND_MOBILE_PRIMARY" "BG_SURFACE_RAISED": "BACKGROUND_MOBILE_PRIMARY"
}; };
const origRawColor = { ...tokenReference.RawColor };
export default function patchDefinitionAndResolver() { export default function patchDefinitionAndResolver() {
const callback = ([theme]: any[]) => { const callback = ([theme]: any[]) => theme === _colorRef.key ? [_colorRef.current!.reference] : void 0;
if (!_colorRef.current) return void 0;
return theme === _colorRef.key ? [_colorRef.current.reference] : void 0; Object.defineProperty(themeTypes, "DARKER", {
}; configurable: true,
enumerable: true,
get: () => _colorRef.current?.reference === "darker" ? _colorRef.key : origDarker,
});
Object.defineProperty(themeTypes, "LIGHT", {
configurable: true,
enumerable: true,
get: () => _colorRef.current?.reference === "light" ? _colorRef.key : origLight,
});
Object.keys(tokenReference.RawColor).forEach(key => { Object.keys(tokenReference.RawColor).forEach(key => {
Object.defineProperty(tokenReference.RawColor, key, { Object.defineProperty(tokenReference.RawColor, key, {
@ -37,50 +45,48 @@ export default function patchDefinitionAndResolver() {
get: () => { get: () => {
const ret = _colorRef.current?.raw[key]; const ret = _colorRef.current?.raw[key];
if (ret) return ret; if (ret) return ret;
return origRawColor[key]; return origRawColor[key];
} }
}); });
}); });
const unpatches = [ const unpatches = [
before("isThemeDark", isThemeModule, callback),
before("isThemeLight", isThemeModule, callback),
before("updateTheme", NativeThemeModule, callback), before("updateTheme", NativeThemeModule, callback),
instead("resolveSemanticColor", tokenReference.default.meta ?? tokenReference.default.internal, (args: any[], orig: any) => { instead("resolveSemanticColor", tokenReference.default.meta ?? tokenReference.default.internal, (args: any[], orig: any) => {
if (!_colorRef.current) return orig(...args); if (!_colorRef.current) return orig(...args);
const currentRef = _colorRef.current;
if (args[0] !== _colorRef.key) return orig(...args); if (args[0] !== _colorRef.key) return orig(...args);
// Use the captured reference value args[0] = _colorRef.current.reference;
args[0] = currentRef.reference;
const [name, colorDef] = extractInfo(currentRef.reference, args[1]); const [name, colorDef] = extractInfo(_colorRef.current!.reference, args[1]);
// If extractInfo couldn't resolve a color definition, fall back to the original resolver let semanticDef = _colorRef.current.semantic[name];
if (!name || !colorDef) return orig(...args); if (!semanticDef && _colorRef.current.spec === 2 && name in SEMANTIC_FALLBACK_MAP) {
semanticDef = _colorRef.current.semantic[SEMANTIC_FALLBACK_MAP[name]];
let semanticDef = currentRef.semantic[name];
if (!semanticDef && currentRef.spec === 2 && name in SEMANTIC_FALLBACK_MAP) {
semanticDef = currentRef.semantic[SEMANTIC_FALLBACK_MAP[name]];
} }
if (semanticDef?.value) { if (semanticDef?.value) {
if (semanticDef.opacity === 1) return semanticDef.value; return semanticDef.opacity === 1
return chroma(semanticDef.value).alpha(semanticDef.opacity).hex(); ? semanticDef.value
: chroma(semanticDef.value).alpha(semanticDef.opacity).hex();
} }
const rawKey = colorDef.raw; const rawValue = _colorRef.current.raw[colorDef.raw];
const rawValue = rawKey ? currentRef.raw[rawKey] : undefined;
if (rawValue) { if (rawValue) {
// Set opacity if needed // Set opacity if needed
return (colorDef.opacity === 1) ? rawValue : chroma(rawValue).alpha(colorDef.opacity).hex(); return colorDef.opacity === 1 ? rawValue : chroma(rawValue).alpha(colorDef.opacity).hex();
} }
// Fallback to default // Fallback to default
return orig(...args); return orig(...args);
}), }),
() => { () => {
Object.defineProperty(themeTypes, "DARKER", {
configurable: true, writable: true, value: origDarker
});
Object.defineProperty(themeTypes, "LIGHT", {
configurable: true, writable: true, value: origLight
});
Object.defineProperty(tokenReference, "RawColor", { Object.defineProperty(tokenReference, "RawColor", {
configurable: true, configurable: true,
writable: true, writable: true,
@ -95,13 +101,7 @@ export default function patchDefinitionAndResolver() {
function extractInfo(themeName: string, colorObj: any): [name: string, colorDef: any] { function extractInfo(themeName: string, colorObj: any): [name: string, colorDef: any] {
// @ts-ignore - assigning to extractInfo._sym // @ts-ignore - assigning to extractInfo._sym
const propName = colorObj[extractInfo._sym ??= Object.getOwnPropertySymbols(colorObj)[0]]; const propName = colorObj[extractInfo._sym ??= Object.getOwnPropertySymbols(colorObj)[0]];
const entry = tokenReference.SemanticColor[propName]; const colorDef = tokenReference.SemanticColor[propName];
if (!entry || typeof entry !== "object") { return [propName, colorDef[themeName]];
return [propName, undefined];
}
const colorDef = Object.prototype.hasOwnProperty.call(entry, themeName) ? entry[themeName] : undefined;
return [propName, colorDef];
} }

View file

@ -12,39 +12,22 @@ const mmkvStorage = proxyLazy(() => {
export default function patchStorage() { export default function patchStorage() {
const patchedKeys = new Set(["ThemeStore", "SelectivelySyncedUserSettingsStore"]); const patchedKeys = new Set(["ThemeStore", "SelectivelySyncedUserSettingsStore"]);
const sanitizedKeys = new Set<string>();
const patches = [ const patches = [
after("get", mmkvStorage, ([key], ret) => { after("get", mmkvStorage, ([key], ret) => {
if (!patchedKeys.has(key)) return; if (!_colorRef.current || !patchedKeys.has(key)) return;
const state = findInTree(ret._state, s => typeof s.theme === "string"); const state = findInTree(ret._state, s => typeof s.theme === "string");
if (!state) return; if (state) state.theme = _colorRef.key;
const lastSetDiscordTheme = _colorRef.lastSetDiscordTheme ?? "darker";
if (/^bn-theme-\d+$/.test(state.theme) && !/^bn-theme-\d+$/.test(lastSetDiscordTheme) && !sanitizedKeys.has(key)) {
try {
const newVal = { ...ret._state, theme: lastSetDiscordTheme };
mmkvStorage.set(key, newVal);
sanitizedKeys.add(key);
} catch (e) {
}
state.theme = lastSetDiscordTheme;
return;
}
if (_colorRef.current) state.theme = _colorRef.key;
}), }),
before("set", mmkvStorage, ([key, value]) => { before("set", mmkvStorage, ([key, value]) => {
if (!patchedKeys.has(key)) return; if (!patchedKeys.has(key)) return;
const json = JSON.stringify(value); const json = JSON.stringify(value);
const lastSetDiscordTheme = _colorRef.lastSetDiscordTheme ?? "darker"; const lastSetDiscordTheme = _colorRef.lastSetDiscordTheme ?? "darker";
const replacementTheme = /^bn-theme-\d+$/.test(lastSetDiscordTheme) ? "darker" : lastSetDiscordTheme;
const replaced = json.replace( const replaced = json.replace(
/"theme":"bn-theme-\d+"/, /"theme":"bn-theme-\d+"/,
`"theme":${JSON.stringify(replacementTheme)}` `"theme":${JSON.stringify(lastSetDiscordTheme)}`
); );
return [key, JSON.parse(replaced)]; return [key, JSON.parse(replaced)];

View file

@ -24,46 +24,71 @@ export const _colorRef: InternalColorRef = {
current: null, current: null,
key: `bn-theme-${_inc}`, key: `bn-theme-${_inc}`,
origRaw: origRawColor, origRaw: origRawColor,
lastSetDiscordTheme: "dark" lastSetDiscordTheme: "darker"
}; };
export function updateBunnyColor(colorManifest: ColorManifest | null, { update = true } ) { export function updateBunnyColor(colorManifest: ColorManifest | null, { update = true }) {
if (settings.safeMode?.enabled) return; if (settings.safeMode?.enabled) return;
const internalDef = colorManifest ? parseColorManifest(colorManifest) : null; const internalDef = colorManifest ? parseColorManifest(colorManifest) : null;
const ref = Object.assign(_colorRef, {
current: internalDef,
key: `bn-theme-${++_inc}`,
lastSetDiscordTheme: !ThemeStore.theme.startsWith("bn-theme-")
? ThemeStore.theme
: _colorRef.lastSetDiscordTheme
});
const keyName = internalDef && internalDef.reference === "light" ? `bn-theme-${++_inc}` : `dark`; if (internalDef != null) {
// Register the synthetic key BEFORE calling native, and verify it actually
const tsTheme = typeof ThemeStore.theme === "string" ? ThemeStore.theme : undefined; // landed in every table native reads from. If any registration step fails,
const currentDiscordTheme = tsTheme === "darker" ? "dark" : (tsTheme ?? ""); // bail out to a known-good theme instead of handing native an unknown key.
try {
const lastSet = (currentDiscordTheme && !currentDiscordTheme.startsWith("bn-theme-") && currentDiscordTheme !== keyName)
? currentDiscordTheme
: _colorRef.lastSetDiscordTheme;
var ref = Object.assign(_colorRef, {
current: internalDef,
key: keyName,
lastSetDiscordTheme: lastSet
});
if (internalDef != null) {
tokenRef.Theme[ref.key.toUpperCase()] = ref.key; tokenRef.Theme[ref.key.toUpperCase()] = ref.key;
if (!FormDivider.DIVIDER_COLORS[ref.current!.reference]) {
throw new Error(`Missing divider color reference: ${ref.current!.reference}`);
}
FormDivider.DIVIDER_COLORS[ref.key] = FormDivider.DIVIDER_COLORS[ref.current!.reference]; FormDivider.DIVIDER_COLORS[ref.key] = FormDivider.DIVIDER_COLORS[ref.current!.reference];
Object.keys(tokenRef.Shadow).forEach(k => tokenRef.Shadow[k][ref.key] = tokenRef.Shadow[k][ref.current!.reference]); Object.keys(tokenRef.Shadow).forEach(k => {
if (!(ref.current!.reference in tokenRef.Shadow[k])) {
throw new Error(`Missing shadow reference: ${ref.current!.reference}`);
}
tokenRef.Shadow[k][ref.key] = tokenRef.Shadow[k][ref.current!.reference];
});
Object.keys(tokenRef.SemanticColor).forEach(k => { Object.keys(tokenRef.SemanticColor).forEach(k => {
if (!(ref.current!.reference in tokenRef.SemanticColor[k])) {
throw new Error(`Missing semantic color reference: ${ref.current!.reference}`);
}
tokenRef.SemanticColor[k][ref.key] = { tokenRef.SemanticColor[k][ref.key] = {
...tokenRef.SemanticColor[k][ref.current!.reference] ...tokenRef.SemanticColor[k][ref.current!.reference]
}; };
}); });
} catch (e) {
console.error("Failed to register custom theme key, falling back", e);
ref.current = null; // force fallback path below
} }
}
if (update) { if (update) {
AppearanceManager.setShouldSyncAppearanceSettings(false); AppearanceManager.setShouldSyncAppearanceSettings(false);
const fallback = (_colorRef.lastSetDiscordTheme && !_colorRef.lastSetDiscordTheme.startsWith("bn-theme-"))
? _colorRef.lastSetDiscordTheme const targetTheme = ref.current != null ? ref.key : ref.lastSetDiscordTheme;
: "dark";
AppearanceManager.updateTheme(internalDef != null ? ref.key : fallback); // This is the actual crash site: a raw call into native code with no
// guard. Wrap it so a native-side rejection can't take the whole
// bridge/thread down — fall back to the last known-good Discord theme.
try {
AppearanceManager.updateTheme(targetTheme);
} catch (e) {
console.error(`Native rejected theme "${targetTheme}", reverting to "${ref.lastSetDiscordTheme}"`, e);
try {
AppearanceManager.updateTheme(ref.lastSetDiscordTheme);
} catch (e2) {
console.error("Fallback theme also failed to apply", e2);
}
}
} }
} }

View file

@ -141,7 +141,7 @@ export function getCurrentTheme() {
* @internal * @internal
*/ */
export function getThemeFromLoader(): VdThemeInfo | null { export function getThemeFromLoader(): VdThemeInfo | null {
return getStoredTheme(); return getStoredTheme() ?? getCurrentTheme();
} }
/** /**
@ -157,6 +157,7 @@ export async function initThemes() {
} }
await newAwaitStorage(colorsPref); await newAwaitStorage(colorsPref);
await awaitStorage(themes);
const currentTheme = getThemeFromLoader(); const currentTheme = getThemeFromLoader();
initColors(currentTheme?.data ?? null); initColors(currentTheme?.data ?? null);

View file

@ -401,4 +401,4 @@ export function initDebugger() {
logger.error("Failed to connect to ReactDevTools during startup:", e); logger.error("Failed to connect to ReactDevTools during startup:", e);
} }
} }
} }

View file

@ -8,6 +8,8 @@ export const dispatcher = FluxDispatcher;
type Intercept = (payload: Record<string, any> & { type: string; }) => any; type Intercept = (payload: Record<string, any> & { type: string; }) => any;
let intercepts: Intercept[] = []; let intercepts: Intercept[] = [];
const interceptIndex = new Map<Intercept, number>();
let nextInterceptId = 0;
/** /**
* @internal * @internal
@ -33,7 +35,14 @@ export function injectFluxInterceptor() {
(dispatcher._interceptors ??= []).unshift(cb); (dispatcher._interceptors ??= []).unshift(cb);
return () => dispatcher._interceptors &&= dispatcher._interceptors.filter(v => v !== cb); return () => {
const idx = interceptIndex.get(cb);
if (idx !== undefined) {
intercepts.splice(idx, 1);
interceptIndex.delete(cb);
intercepts.forEach((intercept, i) => interceptIndex.set(intercept, i));
}
};
} }
/** /**
@ -41,9 +50,16 @@ export function injectFluxInterceptor() {
* nullish -> nothing, falsy -> block, object -> modify * nullish -> nothing, falsy -> block, object -> modify
*/ */
export function intercept(cb: Intercept) { export function intercept(cb: Intercept) {
const id = nextInterceptId++;
interceptIndex.set(cb, id);
intercepts.push(cb); intercepts.push(cb);
return () => { return () => {
intercepts = intercepts.filter(i => i !== cb); const idx = interceptIndex.get(cb);
if (idx !== undefined) {
intercepts.splice(idx, 1);
interceptIndex.delete(cb);
intercepts.forEach((intercept, i) => interceptIndex.set(intercept, i));
}
}; };
} }

View file

@ -10,6 +10,9 @@ const rainLoaderIdentity = globalThis.__RAIN_LOADER__;
// @ts-ignore // @ts-ignore
const vendettaLoaderIdentity = globalThis.__vendetta_loader; const vendettaLoaderIdentity = globalThis.__vendetta_loader;
// @ts-ignore
const shiggyLoaderIdentity = globalThis.__SHIGGY_LOADER__;
export interface VendettaLoaderIdentity { export interface VendettaLoaderIdentity {
name: string; name: string;
features: { features: {
@ -36,6 +39,10 @@ export function isRa1nLoader() {
return rainLoaderIdentity != null; return rainLoaderIdentity != null;
} }
export function isShiggyLoader() {
return shiggyLoaderIdentity != null;
}
function polyfillVendettaLoaderIdentity() { function polyfillVendettaLoaderIdentity() {
if (!isPyonLoader() || isVendettaLoader() || !isRa1nLoader()) return null; if (!isPyonLoader() || isVendettaLoader() || !isRa1nLoader()) return null;
@ -98,6 +105,8 @@ export function getLoaderIdentity() {
return getVendettaLoaderIdentity(); return getVendettaLoaderIdentity();
} else if (isRa1nLoader()) { } else if (isRa1nLoader()) {
return rainLoaderIdentity(); return rainLoaderIdentity();
} else if (isShiggyLoader()) {
return shiggyLoaderIdentity;
} }
return null; return null;
@ -116,6 +125,7 @@ export function getLoaderName() {
if (isPyonLoader()) return pyonLoaderIdentity.loaderName; if (isPyonLoader()) return pyonLoaderIdentity.loaderName;
else if (isRa1nLoader()) return rainLoaderIdentity.loadername; else if (isRa1nLoader()) return rainLoaderIdentity.loadername;
else if (isVendettaLoader()) return vendettaLoaderIdentity.name; else if (isVendettaLoader()) return vendettaLoaderIdentity.name;
else if (isShiggyLoader()) return shiggyLoaderIdentity.loaderName;
return "Unknown"; return "Unknown";
} }
@ -123,6 +133,7 @@ export function getLoaderName() {
export function getLoaderVersion(): string | null { export function getLoaderVersion(): string | null {
if (isPyonLoader()) return pyonLoaderIdentity.loaderVersion; if (isPyonLoader()) return pyonLoaderIdentity.loaderVersion;
else if (isRa1nLoader()) return rainLoaderIdentity.loaderVersion; else if (isRa1nLoader()) return rainLoaderIdentity.loaderVersion;
else if (isShiggyLoader()) return shiggyLoaderIdentity.loaderVersion;
return null; return null;
} }
@ -144,7 +155,9 @@ export function isThemeSupported() {
} else if (isVendettaLoader()) { } else if (isVendettaLoader()) {
return vendettaLoaderIdentity!!.features.themes != null; return vendettaLoaderIdentity!!.features.themes != null;
} else if (isRa1nLoader()) { } else if (isRa1nLoader()) {
return false; // Ra1n has theme support disabled, this is here just to make sure it doesnt think it does return false; // Rain has theme support disabled, this is here just to make sure it doesnt think it does
} else if (isShiggyLoader()) {
return shiggyLoaderIdentity.hasThemeSupport;
} }
return false; return false;
@ -158,6 +171,8 @@ export function getStoredTheme(): VdThemeInfo | null {
if (!themeProp) return null; if (!themeProp) return null;
// @ts-ignore // @ts-ignore
return globalThis[themeProp] || null; return globalThis[themeProp] || null;
} else if (isShiggyLoader()) {
return shiggyLoaderIdentity.storedTheme || null;
} }
return null; return null;
@ -168,6 +183,8 @@ export function getThemeFilePath() {
return "pyoncord/current-theme.json"; return "pyoncord/current-theme.json";
} else if (isVendettaLoader()) { } else if (isVendettaLoader()) {
return "vendetta_theme.json"; return "vendetta_theme.json";
} else if (isShiggyLoader()) {
return "ShiggyCord/current-theme.json";
} }
return null; return null;
@ -241,6 +258,7 @@ export function getLoaderConfigPath() {
export function isFontSupported() { export function isFontSupported() {
if (isPyonLoader()) return pyonLoaderIdentity.fontPatch === 2; if (isPyonLoader()) return pyonLoaderIdentity.fontPatch === 2;
else if (isShiggyLoader()) return shiggyLoaderIdentity.fontPatch === 2;
return false; return false;
} }
@ -248,4 +266,4 @@ export function isFontSupported() {
export async function clearBundle() { export async function clearBundle() {
// TODO: This should be not be hardcoded, maybe put in loader.json? // TODO: This should be not be hardcoded, maybe put in loader.json?
return void await removeCacheFile("bundle.js"); return void await removeCacheFile("bundle.js");
} }

View file

@ -5,15 +5,14 @@ import type { ImageURISource } from "react-native";
import { patchTabsUI } from "./patches/tabs"; import { patchTabsUI } from "./patches/tabs";
export interface RowConfig { export interface RowConfig {
key: string; key: string;
title: () => string; title: () => string;
onPress?: () => any; onPress?: () => any;
render?: Parameters<typeof lazy>[0]; render?: Parameters<typeof lazy>[0];
icon?: ImageURISource | number; icon?: ImageURISource | number;
IconComponent?: React.ReactNode; IconComponent?: React.ReactNode,
usePredicate?: () => boolean; usePredicate?: () => boolean,
useTrailing?: () => string | JSX.Element; useTrailing?: () => string | JSX.Element
rawTabsConfig?: Record<string, any>;
} }
export const registeredSections = {} as { export const registeredSections = {} as {

View file

@ -57,15 +57,15 @@ export function patchPanelUI(unpatches: (() => void | boolean)[]) {
after("default", findByNameLazy("getScreens", false), (_a, screens) => ({ after("default", findByNameLazy("getScreens", false), (_a, screens) => ({
...screens, ...screens,
VendettaCustomPage: { VendettaCustomPage: {
title: "GoonCord", title: "ShiggyCord",
render: () => <CustomPageRenderer />, render: () => <CustomPageRenderer />,
}, },
BUNNY_CUSTOM_PAGE: { BUNNY_CUSTOM_PAGE: {
title: "GoonCord", title: "ShiggyCord",
render: () => <CustomPageRenderer />, render: () => <CustomPageRenderer />,
}, },
SHIGGYCORD_CUSTOM_PAGE: { SHIGGYCORD_CUSTOM_PAGE: {
title: "GoonCord", title: "ShiggyCord",
render: () => <CustomPageRenderer />, render: () => <CustomPageRenderer />,
}, },
})), })),

View file

@ -2,7 +2,6 @@ import { after } from "@lib/api/patcher";
import { TableRow } from "@metro/common/components"; import { TableRow } from "@metro/common/components";
import { findByNameLazy, findByPropsLazy } from "@metro/wrappers"; import { findByNameLazy, findByPropsLazy } from "@metro/wrappers";
import { registeredSections } from "@ui/settings"; import { registeredSections } from "@ui/settings";
import { Strings } from "@core/i18n";
import { CustomPageRenderer, wrapOnPress } from "./shared"; import { CustomPageRenderer, wrapOnPress } from "./shared";
import { findInReactTree } from "@lib/utils"; import { findInReactTree } from "@lib/utils";
@ -29,9 +28,9 @@ export function patchTabsUI(unpatches: (() => void | boolean)[]) {
IconComponent: () => <TableRow.Icon source={row.icon} />, IconComponent: () => <TableRow.Icon source={row.icon} />,
usePredicate: row.usePredicate, usePredicate: row.usePredicate,
useTrailing: row.useTrailing, useTrailing: row.useTrailing,
useTitle: row.title,
onPress: wrapOnPress(row.onPress, null, row.render, row.title()), onPress: wrapOnPress(row.onPress, null, row.render, row.title()),
withArrow: true, withArrow: true,
...row.rawTabsConfig,
}, },
})), })),
) )
@ -48,6 +47,7 @@ export function patchTabsUI(unpatches: (() => void | boolean)[]) {
VendettaCustomPage: { VendettaCustomPage: {
type: "route", type: "route",
title: () => "ShiggyCord", title: () => "ShiggyCord",
useTitle: () => "ShiggyCord",
screen: { screen: {
route: "VendettaCustomPage", route: "VendettaCustomPage",
getComponent: () => CustomPageRenderer, getComponent: () => CustomPageRenderer,
@ -56,6 +56,7 @@ export function patchTabsUI(unpatches: (() => void | boolean)[]) {
SHIGGYCORD_CUSTOM_PAGE: { SHIGGYCORD_CUSTOM_PAGE: {
type: "route", type: "route",
title: () => "ShiggyCord", title: () => "ShiggyCord",
useTitle: () => "ShiggyCord",
screen: { screen: {
route: "SHIGGYCORD_CUSTOM_PAGE", route: "SHIGGYCORD_CUSTOM_PAGE",
getComponent: () => CustomPageRenderer, getComponent: () => CustomPageRenderer,
@ -64,6 +65,7 @@ export function patchTabsUI(unpatches: (() => void | boolean)[]) {
BUNNY_CUSTOM_PAGE: { BUNNY_CUSTOM_PAGE: {
type: "route", type: "route",
title: () => "ShiggyCord", title: () => "ShiggyCord",
useTitle: () => "ShiggyCord",
screen: { screen: {
route: "BUNNY_CUSTOM_PAGE", route: "BUNNY_CUSTOM_PAGE",
getComponent: () => CustomPageRenderer, getComponent: () => CustomPageRenderer,

View file

@ -4,13 +4,13 @@
export type SearchTree = Record<string, any>; export type SearchTree = Record<string, any>;
export type SearchFilter = (tree: SearchTree) => boolean; export type SearchFilter = (tree: SearchTree) => boolean;
export interface FindInTreeOptions { export interface FindInTreeOptions {
walkable?: string[]; walkable?: string[] | Set<string>;
ignore?: string[]; ignore?: string[] | Set<string>;
maxDepth?: number; maxDepth?: number;
} }
function treeSearch(tree: SearchTree, filter: SearchFilter, opts: Required<FindInTreeOptions>, depth: number): any { function treeSearch(tree: SearchTree, filter: SearchFilter, walkable: Set<string>, ignore: Set<string>, maxDepth: number, depth: number): any {
if (depth > opts.maxDepth) return; if (depth > maxDepth) return;
if (!tree) return; if (!tree) return;
try { try {
@ -22,18 +22,19 @@ function treeSearch(tree: SearchTree, filter: SearchFilter, opts: Required<FindI
if (typeof item !== "object" || item === null) continue; if (typeof item !== "object" || item === null) continue;
try { try {
const found = treeSearch(item, filter, opts, depth + 1); const found = treeSearch(item, filter, walkable, ignore, maxDepth, depth + 1);
if (found) return found; if (found) return found;
} catch { } } catch { }
} }
} else if (typeof tree === "object") { } else if (typeof tree === "object") {
for (const key of Object.keys(tree)) { for (const key in tree) {
if (Object.prototype.hasOwnProperty.call(tree, key) === false) continue;
if (typeof tree[key] !== "object" || tree[key] === null) continue; if (typeof tree[key] !== "object" || tree[key] === null) continue;
if (opts.walkable.length && !opts.walkable.includes(key)) continue; if (walkable.size && !walkable.has(key)) continue;
if (opts.ignore.includes(key)) continue; if (ignore.size && ignore.has(key)) continue;
try { try {
const found = treeSearch(tree[key], filter, opts, depth + 1); const found = treeSearch(tree[key], filter, walkable, ignore, maxDepth, depth + 1);
if (found) return found; if (found) return found;
} catch { } } catch { }
} }
@ -49,5 +50,5 @@ export default function findInTree(
maxDepth = 100 maxDepth = 100
}: FindInTreeOptions = {}, }: FindInTreeOptions = {},
): any | undefined { ): any | undefined {
return treeSearch(tree, filter, { walkable, ignore, maxDepth }, 0); return treeSearch(tree, filter, new Set(walkable), new Set(ignore), maxDepth, 0);
} }

View file

@ -1,6 +1,6 @@
import { Nullish } from "@lib/utils/types"; import { Nullish } from "@lib/utils/types";
import { TextStyles, ThemeColors } from "@ui/types"; import { TextStyles, ThemeColors } from "@ui/types";
import { Falsey } from "lodash"; import type { Falsey } from "lodash";
import { FC, MutableRefObject, PropsWithoutRef, ReactNode, RefObject } from "react"; import { FC, MutableRefObject, PropsWithoutRef, ReactNode, RefObject } from "react";
import type * as RN from "react-native"; import type * as RN from "react-native";
import { ImageSourcePropType, PressableProps } from "react-native"; import { ImageSourcePropType, PressableProps } from "react-native";

View file

@ -16,23 +16,17 @@ let _metroCache = null as unknown as ReturnType<typeof buildInitCache>;
export const getMetroCache = () => _metroCache; export const getMetroCache = () => _metroCache;
function buildInitCache() { function buildInitCache() {
const modulesCount = Object.keys(window.modules).length;
const cache = { const cache = {
_v: CACHE_VERSION, _v: CACHE_VERSION,
_buildNumber: NativeClientInfoModule.getConstants().Build, _buildNumber: NativeClientInfoModule.getConstants().Build,
_modulesCount: Object.keys(window.modules).length, _modulesCount: modulesCount,
flagsIndex: {} as Record<string, number>, flagsIndex: {} as Record<string, number>,
findIndex: {} as Record<string, ModulesMap | undefined>, findIndex: {} as Record<string, ModulesMap | undefined>,
polyfillIndex: {} as Record<string, ModulesMap | undefined> polyfillIndex: {} as Record<string, ModulesMap | undefined>
} as const; } as const;
// Force load all modules so useful modules are pre-cached. Add a minor
// delay so the cache is initialized before the modules are loaded.
setTimeout(() => {
for (const id in window.modules) {
require("./modules").requireModule(id);
}
}, 100);
_metroCache = cache; _metroCache = cache;
return cache; return cache;
} }
@ -52,7 +46,8 @@ export async function initMetroCache() {
_metroCache = null!; _metroCache = null!;
throw "cache invalidated; version mismatch"; throw "cache invalidated; version mismatch";
} }
if (_metroCache._modulesCount !== Object.keys(window.modules).length) { let currentCount = Object.keys(window.modules).length;
if (_metroCache._modulesCount !== currentCount) {
_metroCache = null!; _metroCache = null!;
throw "cache invalidated; modules count mismatch"; throw "cache invalidated; modules count mismatch";
} }

View file

@ -117,17 +117,13 @@ for (const [id, moduleExports] of cacher.getModules()) {
} }
const exportsKeysLength = Reflect.ownKeys(actualExports).length; const exportsKeysLength = Reflect.ownKeys(actualExports).length;
if (_source[prop] && exportsKeysLength >= _source[prop]) { if (_source[prop] && exportsKeysLength <= _source[prop]) {
continue; continue;
} }
_module[prop] = actualExports[prop]; _module[prop] = actualExports[prop];
_source[prop] = Reflect.ownKeys(actualExports).length; _source[prop] = Reflect.ownKeys(actualExports).length;
cacher.cacheId(id); cacher.cacheId(id);
if (exportsKeysLength === 1) {
redesignProps.delete(prop);
}
} }
} }