Compare commits

...

135 commits
1.4.1 ... main

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
kmmiio99o
da5f354cb7
Merge pull request #6 from meqativ/main
feat: better error logging in debugger
2026-02-07 12:48:55 +01:00
meqativ
6f8604d6a3
feat: better error logging in debugger 2026-02-07 13:31:46 +02:00
kmmiio99o
b8c7519519
feat: change Sheet Menu look totally in Asset Browser 2026-02-06 20:33:33 +01:00
kmmiio99o
4b0f64131c
feat: update Asset Browser file sorting 2026-02-06 20:23:01 +01:00
kmmiio99o
f7d4061b86
feat: fix buttons in Developer settings looking bad 2026-02-06 20:13:55 +01:00
kmmiio99o
5bdc55a8b0
feat: bump to 1.4.1.1 2026-02-06 19:33:37 +01:00
kmmiio99o
bd7b595d1c
fix: debugger connection in Developer options and React DevTools 2026-02-06 19:21:56 +01:00
kmmiio99o
afed7edd04
Merge remote-tracking branch 'upstream/dev' into dev 2026-02-06 19:11:32 +01:00
cocobo1
23e5efc446 fix: update the removed activity icon 2026-02-03 22:35:22 +02:00
kmmiio99o
ed3570166c
chore: update discord URL 2026-02-01 13:11:45 +01:00
kmmiio99o
aa053d04c2
feat: change icon from Activities to PuzzlePiece icon 2026-01-31 18:34:27 +01:00
kmmiio99o
bccac8377c Merge remote-tracking branch 'upstream/dev' into dev 2026-01-25 16:35:41 +01:00
cocobo1
278e75a7df fix: revert theme system 2026-01-25 15:21:34 +02:00
kmmiio99o
a59b3b95e9
feat: Add Kettu constants 2026-01-25 12:37:19 +01:00
kmmiio99o
63994f9caf
chore: 1.4.1.1 -> 1.4.1 2026-01-24 15:47:01 +01:00
kmmiio99o
292f5de9e3
chore: bump to 1.4.1.1 2026-01-24 14:16:25 +01:00
kmmiio99o
a695ce6356
feat: add reload request Alert when changing theme
Made it with purpose of working themes and no random light mode parts
2026-01-23 21:51:04 +01:00
kmmiio99o
ea503dbaa2
fix: themes falling back to light mode
Also known as light mode bug
2026-01-23 21:38:24 +01:00
kmmiio99o
7e8cdfe042
fix: coloring status bar to black
I dunno if itll fix the issue, but I hope it does

taken from Firecord tho, so AI slop
2026-01-23 20:16:27 +01:00
kmmiio99o
554f50e235
feat(src/core/plugins/index.ts): LazyLoad core plugins for slightly
faster startup
2026-01-23 20:08:26 +01:00
kmmiio99o
bbc3b9bcb7
Fix: failed merge 2026-01-16 22:14:58 +01:00
kmmiio99o
9b2a7ab0ba
Merge remote-tracking branch 'upstream/dev' 2026-01-16 21:52:55 +01:00
cocobo1
8c6932c193 Merge pull request 'feat: update info action sheet menu to match the plugins one and use Checkboxes for Options menu in Themes page' (#25) from kmmiio99o/Kettu:dev into dev
Reviewed-on: https://codeberg.org/cocobo1/Kettu/pulls/25
2026-01-16 18:51:18 +01:00
kmmiio99o
f47e4c2a9a Merge branch 'dev' into dev 2026-01-16 18:46:02 +01:00
kmmiio99o
53f2425f65
feat: use Checkboxes for Options menu in Themes page 2026-01-16 18:34:14 +01:00
kmmiio99o
00db6c11f2 Jolly -> Shiggy 2026-01-13 20:58:31 +01:00
kmmiio99o
a4a0dfbe5f Merge branch 'dev' 2026-01-13 20:53:47 +01:00
kmmiio99o
c2b3cd5b2d chore: rename back to ShiggyCord 2026-01-13 20:50:55 +01:00
kmmiio99o
20387f26f3
Merge branch 'dev' 2026-01-10 17:32:28 +01:00
kmmiio99o
0e7283aece
fix: url.parse is deprecate 2026-01-05 01:04:59 +01:00
kmmiio99o
937144b45f
fix: Property hex does not exist on type ChromaStatic 2026-01-05 01:01:27 +01:00
Bwlok
fb600f9fe7
Add support for spec 1 themes 2026-01-05 00:59:48 +01:00
kmmiio99o
845e14869b
fix: errors on community cards 2026-01-02 14:44:56 +01:00
Kernel Builder
ade7693b9d
Merge remote-tracking branch 'upstream/dev' into dev 2026-01-01 19:34:19 +01:00
Kernel Builder
bea629bf78
fix version 2026-01-01 18:28:17 +01:00
Kernel Builder
1712844313
fix release file 2026-01-01 18:25:54 +01:00
kmmiio99o
92f35554ab Make Themes Action Sheet menu look the same way as Plugins Action Sheet
menu
2025-12-20 23:45:55 +01:00
cocobo1
3bae44e833 Merge branch 'dev' into github 2025-12-12 20:09:13 +02:00
61 changed files with 2493 additions and 2823 deletions

View file

@ -42,14 +42,14 @@ jobs:
echo '--- scripts/build.mjs (snippet) ---' >&2
sed -n '1,200p' scripts/build.mjs >&2 || true
# Primary extraction: find the first quoted semver (may include prerelease suffix, but we strip it)
raw=$(grep -m1 -oE '\"[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?\"' scripts/build.mjs | tr -d '\"' | head -n1 || true)
# Primary extraction: find the first quoted version (supports major.minor.patch or major.minor.patch.patch formats, may include prerelease suffix)
raw=$(grep -m1 -oE '\"[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?(-[0-9A-Za-z.-]+)?\"' scripts/build.mjs | tr -d '\"' | head -n1 || true)
# Strip any prerelease suffix, keep only major.minor.patch
version=$(printf '%s' "$raw" | sed -E 's/^([0-9]+\.[0-9]+\.[0-9]+).*/\1/')
# Strip any prerelease suffix, keep only major.minor.patch or major.minor.patch.patch
version=$(printf '%s' "$raw" | sed -E 's/^([0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?).*/\1/')
# Validate final result is exactly major.minor.patch
if ! printf '%s' "$version" | grep -xE '^[0-9]+\.[0-9]+\.[0-9]+$' >/dev/null; then
# Validate final result is either major.minor.patch or major.minor.patch.patch
if ! printf '%s' "$version" | grep -xE '^[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?$' >/dev/null; then
echo "Failed to extract a valid base version from scripts/build.mjs" >&2
sed -n '1,200p' scripts/build.mjs >&2 || true
exit 1
@ -98,7 +98,7 @@ jobs:
tag_name: ${{ steps.prepare.outputs.tag }}
name: Release ${{ steps.prepare.outputs.tag }}
files: |
dist/shiggycord.js
dist/shiggycord.min.js
dist/gooncord.js
dist/gooncord.min.js
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -1,39 +1,41 @@
# ShiggyCord [![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)
Bundle based on Kettu, made just for fun
Bundle based on ShiggyCord, made just for fun
## Installing
### Android
- Rooted: [Shiggy Xposed](https://github.com/kmmiio99o/ShiggyXposed)
- Rooted: [Goon Xposed](https://github.com/OTKUSteyler/GoonXposed)
- Non-Rooted: [Shiggy Manager](https://github.com/kmmiio99o/ShiggyManager)
- Non-Rooted: [Goon Manager](https://github.com/OTKUSteyler/GoonManager)
### Android/iOS
- **Injecting bundle:**
```url
https://github.com/kmmiio99o/ShiggyCord/releases/latest/download/shiggycord.js
https://github.com/OTKUSteyler/GoonCord/releases/latest/download/gooncord.js
```
## Building
1. Install a ShiggyCord loader with loader config support (any mentioned in the [Installing](#installing) section).
1. Install a GoonCord loader with loader config support (any mentioned in the [Installing](#installing) section).
1. Go to Settings > General and enable Developer Settings.
1. Clone the repo:
```
git clone https://github.com/kmmiio99o/Shiggycord.git
git clone https://github.com/OTKUSteyler/gooncord.git
```
1. Install dependencies:
```
bun i
```
1. Build ShiggyCord's code:
1. Build GoonCord's code:
```
bun run build
```
1. In the newly created `dist` directory, run a HTTP server. I recommend [http-server](https://www.npmjs.com/package/http-server).
1. Go to Settings > Developer enabled earlier. Enable `Load from custom url` and input the IP address and port of the server (e.g. `http://192.168.1.236:4040/shiggycord.js`) in the new input box labelled `ShiggyCord URL`.
1. Restart Discord. Upon reload, you should notice that your device will download ShiggyCord's bundled code from your server, rather than GitHub.
1. Go to Settings > Developer enabled earlier. Enable `Load from custom url` and input the IP address and port of the server (e.g. `http://192.168.1.236:4040/gooncord.js`) in the new input box labelled `GoonCord URL`.
1. Restart Discord. Upon reload, you should notice that your device will download GoonCord's bundled code from your server, rather than GitHub.
1. Make your changes, rebuild, reload, go wild!
Alternatively, you can directly *serve* the bundled code by running `bun run serve`. `shiggycord.js` will be served on your local address under the port 4040. You will then insert `http://<local ip address>:4040/shiggycord.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

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

View file

@ -30,7 +30,7 @@ let context = null;
const config = {
entryPoints: ["src/entry.ts"],
bundle: true,
outfile: "dist/shiggycord.js",
outfile: "dist/gooncord.js",
format: "iife",
splitting: false,
// Enable minification by default for release builds (when a release-branch is provided).
@ -56,6 +56,8 @@ const config = {
"!ShiggyCord-deps-shim!": "./shims/depsModule.ts",
spitroast: "./node_modules/spitroast",
"react/jsx-runtime": "./shims/jsxRuntime",
"@gullerya/object-observer":
path.resolve("./node_modules/@gullerya/object-observer/dist/object-observer.min.js"),
},
plugins: [
globalPlugin({
@ -75,7 +77,7 @@ const config = {
constModules: {
globals: {
"bunny-build-info": {
version: `"1.4.1"`,
version: `"1.4.1.8"`,
},
},
},

View file

@ -4,7 +4,7 @@ import { readFile } from "fs/promises";
import http from "http";
import os from "os";
import readline from "readline";
import url from "url";
import { URL } from "url";
import yargs from "yargs-parser";
import { buildBundle } from "./build.mjs";
@ -15,7 +15,7 @@ const args = yargs(process.argv.slice(2));
export function serve(options) {
// @ts-ignore
const server = http.createServer(async (req, res) => {
const { pathname } = url.parse(req.url || "", true);
const { pathname } = new URL(req.url || "", `http://localhost:${args.port ?? 4040}`);
if (pathname?.endsWith(".js")) {
try {
let fileToServe;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -8,7 +8,7 @@ import { messageUtil } from "@metro/common";
export default () =>
<ApplicationCommand>{
name: "debug",
name: "Goon",
description: Strings.COMMAND_DEBUG_DESC,
options: [
{
@ -18,16 +18,20 @@ export default () =>
},
],
execute([ephemeral], ctx) {
const info = getDebugInfo();
const content = [
"**ShiggyCord Debug Info**",
`> ShiggyCord: ${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");
const info = getDebugInfo();
if(info.device.codename === undefined) { info.device.codename = ""}
else{ info.device.codename = "(" + info.device.codename + ")" }
const content = [
"**💦GoonCord💦 Debug 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} ${info.device.codename}`,
].join("\n");
if (ephemeral?.value) {
messageUtil.sendBotMessage(ctx.channel.id, content);

View file

@ -4,10 +4,10 @@ import { BundleUpdaterManager } from "@lib/api/native/modules";
export default () =>
<ApplicationCommand>{
name: "reload",
name: "ReGoon",
description: Strings.COMMAND_RELOAD_DESC,
options: [],
execute() {
BundleUpdaterManager.reload();
},
};
};

View file

@ -7,9 +7,12 @@
"AUTO_DEBUGGER": "Automatically connect to Debugger",
"AUTO_DEVTOOLS": "Automatically connect to Devtools",
"ASSET_BROWSER": "Asset Browser",
"ASSET_TYPES": "Asset Types",
"IMAGE_FILES": "Image Files",
"TEXT_FILES": "Text Files",
"BRAND": "Brand",
"SHIGGYCORD": "ShiggyCold",
"SHIGGYCORD_URL": "ShiggyCold URL",
"SHIGGYCORD": "GoonCord",
"SHIGGYCORD_URL": "GoonCord URL",
"BROWSER": "Browser",
"BYTECODE": "Bytecode",
"CANCEL": "Cancel",
@ -21,7 +24,7 @@
"CLEAR_DATA_SUCCESSFUL": "Cleared data for {name}",
"CODEBERG": "Codeberg",
"CODENAME": "Codename",
"COMMAND_DEBUG_DESC": "Send ShiggyCord debug info.",
"COMMAND_DEBUG_DESC": "Send 💦GoonCord💦 debug info.",
"COMMAND_DEBUG_OPT_EPHEMERALLY": "Send debug info ephemerally.",
"COMMAND_EVAL_DESC": "Evaluate JavaScript code.",
"COMMAND_EVAL_OPT_ASYNC": "Whether to support 'await' in code. Must explicitly return for result (default: false)",
@ -67,7 +70,7 @@
"LABEL_EXTRACT_FONTS_FROM_THEME": "Extract font from theme",
"LINKS": "Links",
"LOAD_FROM_CUSTOM_URL": "Load from custom URL",
"LOAD_FROM_CUSTOM_URL_DEC": "Load ShiggyCord from a custom endpoint.",
"LOAD_FROM_CUSTOM_URL_DEC": "Load GoonCord from a custom endpoint.",
"LOAD_REACT_DEVTOOLS": "Load React DevTools",
"SHIGGY_URL": "ShiggyCord URL",
"LOADER": "Loader",
@ -91,7 +94,7 @@
"PLUGINS": "Plugins",
"REFETCH": "Refetch",
"RELOAD": "Reload",
"RELOAD_DISCORD": "Reshiggy your Discord",
"RELOAD_DISCORD": "ReGoon your Discord",
"RELOAD_IN_NORMAL_MODE": "Reload in Normal Mode",
"RELOAD_IN_NORMAL_MODE_DESC": "Safe mode currently enabled, tap to reload in normal mode",
"RELOAD_IN_SAFE_MODE": "Reload in Safe Mode",
@ -120,7 +123,7 @@
"UH_OH": "Uh Oh",
"UNINSTALL": "Uninstall",
"UNINSTALL_TITLE": "Uninstall {title}",
"URL_PLACEHOLDER": "https://github.com/kmmiio99o/ShiggyCord",
"URL_PLACEHOLDER": "https://github.com/OTKUSteyler/GoonCord",
"VERSION": "Version",
"VERSIONS": "Versions"
}

View file

@ -1,11 +1,12 @@
import { logger } from "@lib/utils/logger";
import { FluxDispatcher } from "@metro/common";
import { findByNameLazy } from "@metro/wrappers";
import { findByNameLazy, findByStoreName } from "@metro/wrappers";
import { PrimitiveType } from "intl-messageformat";
import langDefault from "./default.json";
const IntlMessageFormat = findByNameLazy(
"MessageFormat",
"MessageFormat",
) as typeof import("intl-messageformat").default;
type I18nKey = keyof typeof langDefault;
@ -17,61 +18,133 @@ const _loadedLocale = new Set<string>();
const _loadedStrings = {} as Record<string, typeof langDefault>;
export const Strings = new Proxy(
{},
{
get: (_t, prop: keyof typeof langDefault) => {
if (_currentLocale && _loadedStrings[_currentLocale]?.[prop]) {
return _loadedStrings[_currentLocale]?.[prop];
}
return langDefault[prop];
{},
{
get: (_t, prop: keyof typeof langDefault) => {
if (_currentLocale && _loadedStrings[_currentLocale]?.[prop]) {
return _loadedStrings[_currentLocale]?.[prop];
}
return langDefault[prop];
},
},
},
) as Record<I18nKey, string>;
export function initFetchI18nStrings() {
const cb = ({ locale }: { locale: string }) => {
const languageMap = {
"es-ES": "es",
"es-419": "es_419",
"zh-TW": "zh-Hant",
"zh-CN": "zh-Hans",
"pt-PT": "pt",
"pt-BR": "pt_BR",
"sv-SE": "sv",
} as Record<string, string>;
const languageMap: Record<string, string> = {
"ar-SA": "ar",
"bn-BD": "bn",
"ca-ES": "ca",
"de-DE": "de",
"es-ES": "es",
"es-419": "es",
"fa-IR": "fa",
"fi-FI": "fi",
"fr-FR": "fr",
"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);
if (resolvedLocale.startsWith("en-")) {
_currentLocale = null;
return;
}
logger.log("[i18n] fetchLocale called:", locale, "->", resolvedLocale);
if (!_loadedLocale.has(resolvedLocale)) {
_loadedLocale.add(resolvedLocale);
_loadedLocale.add(resolvedLocale);
fetch(
`https://raw.githubusercontent.com/kmmiio99o/i18n/main/resources/${resolvedLocale}/shiggycord.json`,
)
.then((r) => r.json())
.then((strings) => (_loadedStrings[resolvedLocale] = strings))
.then(
() =>
resolvedLocale === _lastSetLocale &&
(_currentLocale = resolvedLocale),
)
.catch((e) =>
console.error(
`An error occured while fetching strings for ${resolvedLocale}: ${e}`,
),
);
if (resolvedLocale.toLowerCase().startsWith("en")) {
// use local default.json for english
logger.log("[i18n] Using local default.json for English locale");
_loadedStrings[resolvedLocale] = langDefault;
_currentLocale = resolvedLocale;
} else {
fetch(`https://codeberg.org/cocobo1/kettu-i18n/raw/branch/main/base/${resolvedLocale}.json`)
.then(r => r.json())
.then(strings => {
logger.log("[i18n] Loaded strings for:", resolvedLocale);
_loadedStrings[resolvedLocale] = strings;
_currentLocale = resolvedLocale;
})
.catch(e =>
logger.error(`[i18n] Error fetching strings for ${resolvedLocale}: ${e}`),
);
}
} else {
_currentLocale = resolvedLocale;
_currentLocale = resolvedLocale;
}
};
}
FluxDispatcher.subscribe("I18N_LOAD_SUCCESS", cb);
return () => FluxDispatcher.unsubscribe("I18N_LOAD_SUCCESS", cb);
export function initFetchI18nStrings() {
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
@ -79,10 +152,20 @@ type FormatStringRet<T> = T extends PrimitiveType
: string | T | (string | T)[];
export function formatString<T = void>(
key: I18nKey,
val: Record<string, T>,
key: I18nKey,
val: Record<string, T>,
): FormatStringRet<T> {
const str = Strings[key];
// @ts-ignore
return new IntlMessageFormat(str).format(val);
const str = Strings[key];
if (!str) return str as FormatStringRet<T>;
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 { onJsxCreate } from "@lib/api/react/jsx";
import { findByName } from "@metro";
import { useEffect, useState } from "react";
import { findByNameLazy } from "@metro";
import { defineCorePlugin } from "..";
import { FluxDispatcher } from "@metro/common";
interface BunnyBadge {
label: string;
url: string;
interface Badge {
label: 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({
manifest: {
id: "bunny.badges",
version: "1.1.0",
type: "plugin",
spec: 3,
main: "",
display: {
name: "Badges",
description: "Adds badges to user's profile",
authors: [{ name: "cocobo1" }, { name: "pylixonly" }],
manifest: {
id: "bunny.badges",
version: "1.2.0",
type: "plugin",
spec: 3,
main: "",
display: {
name: "Badges",
description: "Adds badges to user's profile",
authors: [{ name: "cocobo1" }, { name: "pylixonly" }],
},
},
},
start() {
let allBadges: { [x: string]: any } | null = null;
const badgeProps = {} as Record<string, any>;
onJsxCreate("ProfileBadge", (component, ret) => {
if (ret.props.id?.startsWith("bunny-")) {
const cachedProps = badgeProps[ret.props.id];
if (cachedProps) {
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: " _",
});
start() {
onJsxCreate("ProfileBadge", (component, ret) => {
if (ret.props.id?.startsWith("rain-")) {
const cachedProps = badgeProps.get(ret.props.id);
if (cachedProps) {
ret.props.source = cachedProps.source;
ret.props.label = cachedProps.label;
ret.props.id = cachedProps.id;
}
}
});
}
});
},
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

@ -42,19 +42,19 @@ function transformLinks(content: string, config: FixEmbedSettings): string {
if (config.instagram) {
result = result.replace(
/https?:\/\/(?:www\.)?instagram\.com(\/[^\s]+)/gi,
"https://uuinstagram.com$1",
"https://kkinstagram.com$1",
);
}
// TikTok: Replace domain, keep path
if (config.tiktok) {
result = result.replace(
/https?:\/\/(?:www\.)?tiktok\.com(\/[^\s]+)/gi,
"https://tnktok.com$1",
"https://tiktokez.com$1",
);
// TikTok short links (vm.tiktok.com)
result = result.replace(
/https?:\/\/vm\.tiktok\.com\/([A-Za-z0-9]+)\/?/gi,
"https://vm.tnktok.com/$1",
"https://vm.tiktokez.com/$1",
);
}
// Reddit: Replace domain, keep path

View file

@ -3,16 +3,43 @@ import { PluginInstanceInternal } from "@lib/addons/plugins/types";
interface CorePlugin {
default: PluginInstanceInternal;
preenabled: boolean;
loader: () => PluginInstanceInternal;
}
type CorePluginRecord = Record<string, CorePlugin>;
function makeLazyPlugin(loaderFn: () => any, preenabled = true): CorePlugin {
const container: any = {
loader: () => {
// loaderFn should synchronously require the module
const mod = loaderFn();
// module may be an object with `.default` or the instance directly
const inst = mod && (mod.default ?? mod);
return inst;
},
preenabled,
get default() {
// load and cache the instance on first access
const inst = container.loader();
Object.defineProperty(container, "default", { value: inst, writable: false, enumerable: true });
return inst;
},
};
return container as CorePlugin;
}
// Called from @lib/plugins
export const getCorePlugins = (): Record<string, CorePlugin> => ({
"bunny.quickinstall": { ...require("./quickinstall"), preenabled: true },
"bunny.badges": require("./badges"),
"bunny.notrack": require("./notrack"),
"bunny.messagefix": require("./messagefix"),
"bunny.fixembed": require("./fixembed"),
"bunny.enhancements": require("./shiggyenhancements")
export const getCorePlugins = (): CorePluginRecord => ({
"bunny.quickinstall": makeLazyPlugin(() => require("./quickinstall"), true),
"bunny.badges": makeLazyPlugin(() => require("./badges")),
"bunny.notrack": makeLazyPlugin(() => require("./notrack")),
"bunny.fixembed": makeLazyPlugin(() => require("./fixembed")),
"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 || (BigInt(Date.now() - 1420070400000) << 22n).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

@ -5,13 +5,7 @@ import { installTheme, removeTheme, themes } from "@lib/addons/themes";
import { findAssetId } from "@lib/api/assets";
import { isThemeSupported } from "@lib/api/native/loader";
import { after } from "@lib/api/patcher";
import {
HTTP_REGEX_MULTI,
VD_DISCORD_SERVER_ID,
VD_PLUGINS_CHANNEL_ID,
VD_PROXY_PREFIX,
VD_THEMES_CHANNEL_ID,
} from "@lib/utils/constants";
import { HTTP_REGEX_MULTI, KETTU_DISCORD_SERVER_ID, KETTU_PLUGINS_CHANNEL_ID, KETTU_THEMES_CHANNEL_ID, VD_DISCORD_SERVER_ID, VD_PLUGINS_CHANNEL_ID, VD_PROXY_PREFIX, VD_THEMES_CHANNEL_ID } from "@lib/utils/constants";
import { lazyDestructure } from "@lib/utils/lazy";
import { Button } from "@metro/common/components";
import { findByProps, findByPropsLazy } from "@metro/wrappers";
@ -22,91 +16,74 @@ type PostType = "Plugin" | "Theme";
// const ForumPostLongPressActionSheet = findByNameProxy("ForumPostLongPressActionSheet", false);
// const { ActionSheetRow } = findByPropsProxy("ActionSheetRow");
const { useFirstForumPostMessage } = lazyDestructure(() =>
findByProps("useFirstForumPostMessage"),
);
const { useFirstForumPostMessage } = lazyDestructure(() => findByProps("useFirstForumPostMessage"));
const forumReactions = findByPropsLazy("MostCommonForumPostReaction");
const postMap = {
Plugin: {
storage: VdPluginManager.plugins,
urlsFilter: (url: string) => url.startsWith(VD_PROXY_PREFIX),
installOrRemove: (url: string) => {
const isInstalled = postMap.Plugin.storage[url];
return isInstalled
? VdPluginManager.removePlugin(url)
: VdPluginManager.installPlugin(url);
Plugin: {
storage: VdPluginManager.plugins,
urlsFilter: (url: string) => url.startsWith(VD_PROXY_PREFIX),
installOrRemove: (url: string) => {
const isInstalled = postMap.Plugin.storage[url];
return isInstalled ? VdPluginManager.removePlugin(url) : VdPluginManager.installPlugin(url);
}
},
},
Theme: {
storage: themes,
urlsFilter: (url: string) => url.endsWith(".json"),
installOrRemove: (url: string) => {
const isInstalled = postMap.Theme.storage[url];
return isInstalled ? removeTheme(url) : installTheme(url);
},
},
Theme: {
storage: themes,
urlsFilter: (url: string) => url.endsWith(".json"),
installOrRemove: (url: string) => {
const isInstalled = postMap.Theme.storage[url];
return isInstalled ? removeTheme(url) : installTheme(url);
},
}
};
function useExtractThreadContent(
thread: any,
_firstMessage = null,
actionSheet = false,
): [PostType, string] | void {
if (thread.guild_id !== VD_DISCORD_SERVER_ID) return;
function useExtractThreadContent(thread: any, _firstMessage = null, actionSheet = false): ([PostType, string]) | void {
if (thread.guild_id !== KETTU_DISCORD_SERVER_ID && thread.guild_id !== VD_DISCORD_SERVER_ID) return;
// Determine what type of addon this is.
let postType: PostType;
if (thread.parent_id === VD_PLUGINS_CHANNEL_ID) {
postType = "Plugin";
} else if (thread.parent_id === VD_THEMES_CHANNEL_ID && isThemeSupported()) {
postType = "Theme";
} else return;
// Determine what type of addon this is.
let postType: PostType;
if (thread.parent_id === KETTU_PLUGINS_CHANNEL_ID || thread.parent_id === VD_PLUGINS_CHANNEL_ID) {
postType = "Plugin";
} else if ((thread.parent_id === KETTU_THEMES_CHANNEL_ID || thread.parent_id === VD_THEMES_CHANNEL_ID) && isThemeSupported()) {
postType = "Theme";
} else return;
const { firstMessage } = actionSheet
? useFirstForumPostMessage(thread)
: { firstMessage: _firstMessage };
const { firstMessage } = actionSheet ? useFirstForumPostMessage(thread) : { firstMessage: _firstMessage };
const urls = firstMessage?.content
?.match(HTTP_REGEX_MULTI)
?.filter(postMap[postType].urlsFilter);
if (!urls || !urls[0]) return;
const urls = firstMessage?.content?.match(HTTP_REGEX_MULTI)?.filter(postMap[postType].urlsFilter);
if (!urls || !urls[0]) return;
// Sync with lib/managers/plugins
if (postType === "Plugin" && !urls[0].endsWith("/")) urls[0] += "/";
// Sync with lib/managers/plugins
if (postType === "Plugin" && !urls[0].endsWith("/")) urls[0] += "/";
return [postType, urls[0]];
return [postType, urls[0]];
}
function useInstaller(
thread: any,
firstMessage = null,
actionSheet = false,
): [true] | [false, PostType, boolean, boolean, () => Promise<void>] {
const [postType, url] =
useExtractThreadContent(thread, firstMessage, actionSheet) ?? [];
function useInstaller(thread: any, firstMessage = null, actionSheet = false): [true] | [false, PostType, boolean, boolean, () => Promise<void>] {
const [postType, url] = useExtractThreadContent(thread, firstMessage, actionSheet) ?? [];
useProxy(VdPluginManager.plugins);
useProxy(themes);
useProxy(VdPluginManager.plugins);
useProxy(themes);
const [isInstalling, setIsInstalling] = React.useState(false);
const [isInstalling, setIsInstalling] = React.useState(false);
if (!postType || !url) return [true];
if (!postType || !url) return [true];
const isInstalled = Boolean(postMap[postType].storage[url]);
const isInstalled = Boolean(postMap[postType].storage[url]);
const installOrRemove = async () => {
setIsInstalling(true);
try {
await postMap[postType].installOrRemove(url);
} catch (e: any) {
showToast(e.message, findAssetId("Small"));
} finally {
setIsInstalling(false);
}
};
const installOrRemove = async () => {
setIsInstalling(true);
try {
await postMap[postType].installOrRemove(url);
} catch (e: any) {
showToast(e.message, findAssetId("Small"));
} finally {
setIsInstalling(false);
}
};
return [false, postType, isInstalled, isInstalling, installOrRemove];
return [false, postType, isInstalled, isInstalling, installOrRemove];
}
// // apparently broken???
@ -127,43 +104,33 @@ function useInstaller(
// </ActionsSection>);
// });
const installButtonPatch = () =>
after(
"MostCommonForumPostReaction",
forumReactions,
([{ thread, firstMessage }], res) => {
const [shouldReturn, _, installed, loading, installOrRemove] =
useInstaller(thread, firstMessage, true);
if (shouldReturn) return;
const installButtonPatch = () => after("MostCommonForumPostReaction", forumReactions, ([{ thread, firstMessage }], res) => {
const [shouldReturn, _, installed, loading, installOrRemove] = useInstaller(thread, firstMessage, true);
if (shouldReturn) return;
return (
<>
{res}
<ErrorBoundary>
return <>
{res}
<ErrorBoundary>
<Button
size="sm"
loading={loading}
disabled={loading}
// variant={installed ? "destructive" : "primary"} crashes older version because "destructive" was renamed from "danger" and there's no sane way for compat check horror
variant={installed ? "secondary" : "primary"}
text={installed ? Strings.UNINSTALL : Strings.INSTALL}
onPress={installOrRemove}
icon={findAssetId(
installed ? "ic_message_delete" : "DownloadIcon",
)}
style={{ marginLeft: 8 }}
size="sm"
loading={loading}
disabled={loading}
// variant={installed ? "destructive" : "primary"} crashes older version because "destructive" was renamed from "danger" and there's no sane way for compat check horror
variant={installed ? "secondary" : "primary"}
text={installed ? Strings.UNINSTALL : Strings.INSTALL}
onPress={installOrRemove}
icon={findAssetId(installed ? "ic_message_delete" : "DownloadIcon")}
style={{ marginLeft: 8 }}
/>
</ErrorBoundary>
</>
);
},
);
</ErrorBoundary>
</>;
});
export default () => {
const patches = [
// actionSheetPatch(),
installButtonPatch(),
];
const patches = [
// actionSheetPatch(),
installButtonPatch()
];
return () => patches.map((p) => p());
return () => patches.map(p => p());
};

View file

@ -5,7 +5,7 @@ import { installTheme } from "@lib/addons/themes";
import { findAssetId } from "@lib/api/assets";
import { isThemeSupported } from "@lib/api/native/loader";
import { after, instead } from "@lib/api/patcher";
import { VD_PROXY_PREFIX, VD_THEMES_CHANNEL_ID } from "@lib/utils/constants";
import { VD_PROXY_PREFIX, VD_THEMES_CHANNEL_ID, KETTU_THEMES_CHANNEL_ID } from "@lib/utils/constants";
import { lazyDestructure } from "@lib/utils/lazy";
import { channels } from "@metro/common";
import { byMutableProp } from "@metro/filters";
@ -72,7 +72,8 @@ export default () => {
// Make clicking on theme links only work in #themes, should there be a theme proxy in the future, this can be removed.
if (
urlType === "theme" &&
getChannel(getChannelId())?.parent_id !== VD_THEMES_CHANNEL_ID
getChannel(getChannelId())?.parent_id !== VD_THEMES_CHANNEL_ID &&
getChannel(getChannelId())?.parent_id !== KETTU_THEMES_CHANNEL_ID
)
return orig.apply(this, args);

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/kmmiio99o/kmmiio99o.github.io/raw/refs/heads/main/music/shiggycord.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?
const useStyles = createStyles({
card: {
backgroundColor: semanticColors?.CARD_SECONDARY_BG,
borderRadius: 12,
overflow: "hidden",
},
header: {
padding: 0,
},
headerLeading: {
flexDirection: "column",
justifyContent: "center",
scale: 1.2,
},
headerTrailing: {
display: "flex",
flexDirection: "row",
gap: 15,
alignItems: "center",
},
headerLabel: {
...TextStyleSheet["heading-md/semibold"],
color: semanticColors.MOBILE_TEXT_HEADING_PRIMARY,
},
headerSubtitle: {
...TextStyleSheet["text-md/semibold"],
color: semanticColors.TEXT_MUTED,
},
descriptionLabel: {
...TextStyleSheet["text-md/semibold"],
color: semanticColors.TEXT_NORMAL,
},
actions: {
flexDirection: "row-reverse",
alignItems: "center",
gap: 5,
},
iconStyle: {
tintColor: semanticColors.INTERACTIVE_ICON_DEFAULT,
opacity: 0.2,
height: 64,
width: 64,
left: void 0,
right: "30%",
top: "-10%",
},
card: {
backgroundColor: semanticColors?.CARD_SECONDARY_BG,
borderRadius: 12,
overflow: "hidden"
},
header: {
padding: 0,
},
headerLeading: {
flex: 1,
flexDirection: "column",
justifyContent: "center",
scale: 1.2
},
headerTrailing: {
display: "flex",
flexDirection: "row",
gap: 15,
alignItems: "center"
},
headerLabel: {
...TextStyleSheet["heading-lg/semibold"],
},
headerLabelCompact: {
...TextStyleSheet["text-md/semibold"],
},
headerSubtitle: {
...TextStyleSheet["text-sm/semibold"],
color: semanticColors.TEXT_MUTED,
},
descriptionLabel: {
...TextStyleSheet["text-md/semibold"],
color: "text-strong",
},
actions: {
flexDirection: "row-reverse",
alignItems: "center",
gap: 5
},
iconStyle: {
tintColor: semanticColors.LOGO_PRIMARY,
opacity: 0.2,
height: 64,
width: 64,
left: void 0,
right: "30%",
top: "-10%"
}
});
interface Action {
@ -108,9 +111,9 @@ export default function AddonCard(props: CardProps) {
<Stack spacing={16}>
<View style={{ flexDirection: "row", alignItems: "center" }}>
<View style={styles.headerLeading}>
<Text style={styles.headerLabel}>{props.headerLabel}</Text>
<Text numberOfLines={1} ellipsizeMode="tail" style={styles.headerLabel}>{props.headerLabel}</Text>
{props.headerSublabel && (
<Text style={styles.headerSubtitle}>{props.headerSublabel}</Text>
<Text numberOfLines={1} ellipsizeMode="tail" style={styles.headerSubtitle}>{props.headerSublabel}</Text>
)}
</View>
<View style={[styles.headerTrailing, { marginLeft: "auto" }]}>

View file

@ -22,7 +22,6 @@ import {
} from "@metro/common/components";
import { ErrorBoundary, Search } from "@ui/components";
import { isNotNil } from "es-toolkit";
import fuzzysort from "fuzzysort";
import {
ComponentType,
ReactNode,
@ -181,6 +180,27 @@ export default function AddonPage<T extends object>({
const { bottom: bottomInset } = useSafeAreaInsets();
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(() => {
if (props.OptionsActionSheetComponent) {
navigation.setOptions({
@ -205,11 +225,13 @@ export default function AddonPage<T extends object>({
const items = values.filter((i) => isNotNil(i) && typeof i === "object");
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,
all: true,
});
}, [props.items, sortFn, search]);
}, [props.items, sortFn, search, fuzzysortReady]);
const onInstallPress = useCallback(() => {
if (!props.installAction) return () => {};

View file

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

View file

@ -11,7 +11,7 @@ export { shiggycordIcon };
export default function initSettings() {
registerSection({
name: "ShiggyCord",
name: "GoonCord",
items: [
{
key: "SHIGGYCORD",
@ -23,7 +23,7 @@ export default function initSettings() {
{
key: "BUNNY_PLUGINS",
title: () => Strings.PLUGINS,
icon: findAssetId("ActivitiesIcon"),
icon: findAssetId("PuzzlePieceIcon"),
render: () => import("@core/ui/settings/pages/Plugins"),
},
{

View file

@ -1,21 +1,111 @@
import AssetDisplay from "@core/ui/settings/pages/Developer/AssetDisplay";
import { iterateAssets } from "@lib/api/assets";
import { Text } from "@metro/common/components";
import { findAssetId } from "@lib/api/assets";
import { Strings } from "@core/i18n";
import { IconButton, TableCheckboxRow, TableRowGroup, ActionSheet, BottomSheetTitleHeader } from "@metro/common/components";
import { ErrorBoundary, Search } from "@ui/components";
import { useMemo } from "react";
import { showSheet } from "@lib/ui/sheets";
import { useProxy, createProxy } from "@core/vendetta/storage";
import { useState, useMemo, useCallback } from "react";
import { FlatList, View } from "react-native";
const IMAGE_FILES = [
{ id: "png", label: "PNG", defaultEnabled: true },
{ id: "jpg", label: "JPG", defaultEnabled: true },
{ id: "jpeg", label: "JPEG", defaultEnabled: true },
{ id: "svg", label: "SVG", defaultEnabled: true },
{ id: "gif", label: "GIF", defaultEnabled: true },
];
const TEXT_FILES = [
{ id: "jsona", label: "JSONA", defaultEnabled: false },
{ id: "json", label: "JSON", defaultEnabled: false },
{ id: "lottie", label: "Lottie", defaultEnabled: false },
];
const ALL_FILE_TYPES = [...IMAGE_FILES, ...TEXT_FILES];
// Create initial enabled filters state
const createInitialFilters = () => {
const filters: Record<string, boolean> = {};
ALL_FILE_TYPES.forEach(type => {
filters[type.id] = type.defaultEnabled;
});
return filters;
};
const { proxy: assetBrowserStorage } = createProxy({
enabledFilters: createInitialFilters()
});
export default function AssetBrowser() {
const [search, setSearch] = React.useState("");
const all = useMemo(() => Array.from(iterateAssets()), []);
const [search, setSearch] = useState("");
const [updateTick, setUpdateTick] = useState(0);
// Use useProxy to make the state reactive
useProxy(assetBrowserStorage);
const getFilteredAssets = useCallback(() => {
return Array.from(iterateAssets()).filter((asset) => {
const type = String(asset.type ?? "").toLowerCase();
return assetBrowserStorage.enabledFilters[type] === true;
});
}, [updateTick]);
const all = useMemo(() => getFilteredAssets(), [getFilteredAssets]);
const toggleFilter = (filterId: string) => {
assetBrowserStorage.enabledFilters[filterId] = !assetBrowserStorage.enabledFilters[filterId];
setUpdateTick(prev => prev + 1);
};
const handleFilterPress = () => {
showSheet("AssetBrowserFilter", () => {
useProxy(assetBrowserStorage);
return (
<ActionSheet>
<BottomSheetTitleHeader title={Strings.ASSET_TYPES} />
<TableRowGroup title={Strings.IMAGE_FILES}>
{IMAGE_FILES.map(fileType => (
<TableCheckboxRow
key={fileType.id}
label={fileType.label}
checked={assetBrowserStorage.enabledFilters[fileType.id] === true}
onPress={() => toggleFilter(fileType.id)}
/>
))}
</TableRowGroup>
<TableRowGroup title={Strings.TEXT_FILES}>
{TEXT_FILES.map(fileType => (
<TableCheckboxRow
key={fileType.id}
label={fileType.label}
checked={assetBrowserStorage.enabledFilters[fileType.id] === true}
onPress={() => toggleFilter(fileType.id)}
/>
))}
</TableRowGroup>
</ActionSheet>
);
});
};
return (
<ErrorBoundary>
<View style={{ flex: 1 }}>
<Search
style={{ margin: 10 }}
onChangeText={(v: string) => setSearch(v)}
/>
<View style={{ flexDirection: "row", margin: 10, gap: 8, alignItems: "center" }}>
<Search
style={{ flex: 1 }}
isRound
onChangeText={(v: string) => setSearch(v)}
/>
<IconButton
icon={findAssetId("FileIcon")}
variant="tertiary"
onPress={handleFilterPress}
/>
</View>
<View
style={{
flex: 1,
@ -25,18 +115,9 @@ export default function AssetBrowser() {
backgroundColor: "transparent",
}}
>
<Text
variant="text-sm/medium"
style={{
marginBottom: 16,
color: "var(--text-muted)",
}}
>
Some assets types cannot be displayed and will be marked in red.
</Text>
<FlatList
data={all.filter(
(a) => a.name.includes(search) || a.id.toString() === search,
(a) => (a.name.includes(search) || a.id.toString() === search),
)}
renderItem={({ item }: any) => <AssetDisplay asset={item} />}
contentContainerStyle={{

View file

@ -7,6 +7,9 @@ import {
connectToDebugger,
disconnectFromDebugger,
isConnectedToDebugger,
connectRdt,
disconnectRdt,
useIsRdtConnected,
} from "@lib/api/debug";
import {
getReactDevToolsProp,
@ -33,7 +36,7 @@ import { ErrorBoundary } from "@ui/components";
import ErrorBoundaryScreen from "@core/ui/reporter/components/ErrorBoundaryScreen";
import { createStyles, TextStyleSheet } from "@ui/styles";
import { NativeModules } from "react-native";
import { ScrollView, StyleSheet } from "react-native";
import { ScrollView } from "react-native";
import { showToast } from "@ui/toasts";
import { useState, useEffect } from "react";
@ -72,6 +75,7 @@ export default function Developer() {
const [isDebuggerConnected, setIsDebuggerConnected] = useState(
isConnectedToDebugger(),
);
const isRdtConnected = useIsRdtConnected();
const styles = useStyles();
const navigation = NavigationNative.useNavigation();
@ -96,6 +100,18 @@ export default function Developer() {
}
};
const handleReactDevToolsConnect = () => {
if (isRdtConnected) {
disconnectRdt();
} else {
if (!settings.devToolsUrl?.trim()) {
showToast("Invalid devTools URL!", findAssetId("Small"));
return;
}
connectRdt(settings.devToolsUrl);
}
};
return (
<ErrorBoundary>
<ScrollView
@ -106,108 +122,90 @@ export default function Developer() {
style={{ paddingVertical: 24, paddingHorizontal: 12 }}
spacing={24}
>
<TableRowGroup title={Strings.DEBUGGER_URL}>
<TextInput
placeholder="127.0.0.1:9090"
size="md"
leadingIcon={() => (
<LegacyFormText style={styles.leadingText}>
ws://
</LegacyFormText>
)}
defaultValue={settings.debuggerUrl}
onChange={(v: string) => (settings.debuggerUrl = v)}
/>
<TableSwitchRow
label={Strings.AUTO_DEBUGGER}
icon={<TableRow.Icon source={findAssetId("copy")} />}
value={settings.autoDebugger}
onValueChange={(v: boolean) => {
settings.autoDebugger = v;
}}
/>
<TableRow
label={Strings.CONNECT_TO_DEBUG_WEBSOCKET}
subLabel="Connect to Chrome DevTools for debugging"
icon={<TableRow.Icon source={findAssetId("WrenchIcon")} />}
onPress={() => connectToDebugger(settings.debuggerUrl)}
/>
</TableRowGroup>
<Stack spacing={4}>
<TableRowGroup title={Strings.DEBUGGER_URL}>
<Stack spacing={4}>
<TextInput
placeholder="127.0.0.1:9090"
size="md"
leadingIcon={() => (
<LegacyFormText style={styles.leadingText}>
ws://
</LegacyFormText>
)}
defaultValue={settings.debuggerUrl}
onChange={(v: string) => (settings.debuggerUrl = v)}
/>
</Stack>
</TableRowGroup>
{isReactDevToolsPreloaded() && (
<TableRowGroup title="React Development">
<TextInput
placeholder="127.0.0.1:8097"
size="md"
leadingIcon={() => (
<LegacyFormText style={styles.leadingText}>
ws://
</LegacyFormText>
)}
defaultValue={settings.devToolsUrl}
onChange={(v: string) => (settings.devToolsUrl = v)}
/>
<TableRowGroup>
<TableSwitchRow
label={Strings.AUTO_DEVTOOLS}
icon={<TableRow.Icon source={findAssetId("ic_badge_staff")} />}
value={settings.autoDevTools}
label={Strings.AUTO_DEBUGGER}
icon={<TableRow.Icon source={findAssetId("copy")} />}
value={settings.autoDebugger}
onValueChange={(v: boolean) => {
settings.autoDevTools = v;
settings.autoDebugger = v;
}}
/>
<TableRow
label={Strings.CONNECT_TO_REACT_DEVTOOLS}
subLabel="Connect React DevTools for component debugging"
icon={<TableRow.Icon source={findAssetId("ic_badge_staff")} />}
onPress={async () => {
if (!settings.devToolsUrl?.trim()) {
showToast("Invalid devTools URL!", findAssetId("Small"));
return;
}
try {
const devTools =
window[getReactDevToolsProp() || "__vendetta_rdc"];
if (!devTools?.connectToDevTools) {
showToast(
"React DevTools not available",
findAssetId("Small"),
);
return;
}
await devTools.connectToDevTools({
host: settings.devToolsUrl.split(":")?.[0],
resolveRNStyle: StyleSheet.flatten,
});
showToast(
"Connected to React DevTools!",
findAssetId("Check"),
);
} catch (error) {
showToast(
"Failed to connect to React DevTools",
findAssetId("Small"),
);
}
}}
label={Strings.CONNECT_TO_DEBUG_WEBSOCKET}
subLabel="Connect DevTools for debugging"
icon={<TableRow.Icon source={findAssetId("WrenchIcon")} />}
onPress={handleDebuggerConnect}
/>
</TableRowGroup>
</Stack>
{isLoaderConfigSupported() && isVendettaLoader() && (
{isReactDevToolsPreloaded() && (
<Stack spacing={4}>
<TableRowGroup title="React Development">
<Stack spacing={4}>
<TextInput
placeholder="127.0.0.1:8097"
size="md"
leadingIcon={() => (
<LegacyFormText style={styles.leadingText}>
ws://
</LegacyFormText>
)}
defaultValue={settings.devToolsUrl}
onChange={(v: string) => (settings.devToolsUrl = v)}
/>
</Stack>
</TableRowGroup>
<TableRowGroup>
<TableSwitchRow
label={Strings.LOAD_REACT_DEVTOOLS}
subLabel={`${Strings.VERSION}: ${getReactDevToolsVersion()}`}
icon={
<TableRow.Icon source={findAssetId("ic_badge_staff")} />
}
value={loaderConfig.loadReactDevTools}
label={Strings.AUTO_DEVTOOLS}
icon={<TableRow.Icon source={findAssetId("ic_badge_staff")} />}
value={settings.autoDevTools}
onValueChange={(v: boolean) => {
loaderConfig.loadReactDevTools = v;
settings.autoDevTools = v;
}}
/>
)}
</TableRowGroup>
<TableRow
label={Strings.CONNECT_TO_REACT_DEVTOOLS}
subLabel="Connect React DevTools for component debugging"
icon={<TableRow.Icon source={findAssetId("ic_badge_staff")} />}
onPress={handleReactDevToolsConnect}
/>
{isLoaderConfigSupported() && isVendettaLoader() && (
<TableSwitchRow
label={Strings.LOAD_REACT_DEVTOOLS}
subLabel={`${Strings.VERSION}: ${getReactDevToolsVersion()}`}
icon={
<TableRow.Icon source={findAssetId("ic_badge_staff")} />
}
value={loaderConfig.loadReactDevTools}
onValueChange={(v: boolean) => {
loaderConfig.loadReactDevTools = v;
}}
/>
)}
</TableRowGroup>
</Stack>
)}
{isLoaderConfigSupported() && (

View file

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

View file

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

View file

@ -11,10 +11,11 @@ import {
Text,
} from "@metro/common/components";
import { showSheet } from "@ui/sheets";
import chroma from "chroma-js";
import { createContext, useContext, useMemo } from "react";
import { Image, View } from "react-native";
const getChroma = () => require("chroma-js") as typeof import("chroma-js").default;
const CardContext = createContext<{
plugin: UnifiedPluginModel;
result: Fuzzysort.KeysResult<UnifiedPluginModel>;
@ -22,7 +23,7 @@ const CardContext = createContext<{
const useCardContext = () => useContext(CardContext);
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() {

View file

@ -1,4 +1,3 @@
import { Strings } from "@core/i18n";
import AddonCard, { CardWrapper } from "@core/ui/components/AddonCard";
import { VdThemeInfo, themes, selectTheme } from "@lib/addons/themes";
import { findAssetId } from "@lib/api/assets";
@ -36,8 +35,6 @@ export default function ThemeCard({ item: theme }: CardWrapper<VdThemeInfo>) {
{
icon: "CircleInformationIcon-primary",
onPress: () => {
// Using dynamic import directly (without wrapping in function)
// This returns a Promise that resolves to the module
const importPromise = import("./sheets/ThemeInfoActionSheet");
showSheet("ThemeInfoActionSheet", importPromise, {
theme,

View file

@ -17,7 +17,6 @@ import { settings } from "@lib/api/settings";
import { useObservable } from "@lib/api/storage";
import { lazyDestructure } from "@lib/utils/lazy";
import safeFetch from "@lib/utils/safeFetch";
import Search from "@ui/components/Search";
import { findByProps } from "@metro";
import { NavigationNative, React, clipboard } from "@metro/common";
import { showSheet } from "@lib/ui/sheets";
@ -26,16 +25,12 @@ import {
BottomSheetTitleHeader,
Button,
Card,
FlashList,
IconButton,
Stack,
TableRowGroup,
TableSwitchRow,
TableCheckboxRow,
TableRowIcon,
Text,
AlertModal,
AlertActions,
AlertActionButton,
} from "@metro/common/components";
import { View, Image } from "react-native";
@ -296,9 +291,7 @@ function BrowserThemeCard({
loading={isInstalling}
icon={
isInstalling ? (
<View
style={{ transform: [{ rotate: `${rotation}deg` }] }}
>
<View style={{ transform: [{ rotate: `${rotation}deg` }] }}>
<Image
source={findAssetId("RetryIcon")}
style={{ width: 16, height: 16 }}
@ -357,11 +350,6 @@ 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() {
useProxy(settings);
useProxy(themes);
@ -410,15 +398,15 @@ export default function Themes() {
<ActionSheet>
<BottomSheetTitleHeader title="Options" />
<View style={{ paddingVertical: 20, gap: 12 }}>
{/* Changed from TableRadioGroup to individual TableSwitchRow components
for better UX - users can now toggle options individually */}
<TableRowGroup title="Override Theme Type">
<TableSwitchRow
<TableCheckboxRow
label="Auto"
icon={<TableRowIcon source={findAssetId("RobotIcon")} />}
value={!colorsPref.type}
onValueChange={(enabled: boolean) => {
if (enabled) {
colorsPref.type = undefined;
} else {
checked={!colorsPref.type}
onPress={() => {
if (!colorsPref.type) {
colorsPref.type = "dark";
}
getCurrentTheme()?.data &&
@ -427,28 +415,24 @@ export default function Themes() {
});
}}
/>
<TableSwitchRow
<TableCheckboxRow
label="Dark"
icon={
<TableRowIcon source={findAssetId("ThemeDarkIcon")} />
}
value={colorsPref.type === "dark"}
onValueChange={(enabled: boolean) => {
colorsPref.type = enabled ? "dark" : undefined;
icon={<TableRowIcon source={findAssetId("ThemeDarkIcon")} />}
checked={colorsPref.type === "dark"}
onPress={() => {
colorsPref.type = colorsPref.type === "dark" ? undefined : "dark";
getCurrentTheme()?.data &&
updateBunnyColor(getCurrentTheme()!.data!, {
update: true,
});
}}
/>
<TableSwitchRow
<TableCheckboxRow
label="Light"
icon={
<TableRowIcon source={findAssetId("ThemeLightIcon")} />
}
value={colorsPref.type === "light"}
onValueChange={(enabled: boolean) => {
colorsPref.type = enabled ? "light" : undefined;
icon={<TableRowIcon source={findAssetId("ThemeLightIcon")} />}
checked={colorsPref.type === "light"}
onPress={() => {
colorsPref.type = colorsPref.type === "light" ? undefined : "light";
getCurrentTheme()?.data &&
updateBunnyColor(getCurrentTheme()!.data!, {
update: true,
@ -457,20 +441,13 @@ export default function Themes() {
/>
</TableRowGroup>
<TableRowGroup title="Chat Background">
<TableSwitchRow
<TableCheckboxRow
label="Show Background"
subLabel="Enable or disable themes background on chat"
icon={<TableRowIcon source={findAssetId("ImageIcon")} />}
value={!colorsPref.customBackground}
onValueChange={(enabled: boolean) => {
colorsPref.customBackground = enabled ? null : "hidden";
}}
/>
<TableSwitchRow
label="Hide Background"
icon={<TableRowIcon source={findAssetId("DenyIcon")} />}
value={colorsPref.customBackground === "hidden"}
onValueChange={(enabled: boolean) => {
colorsPref.customBackground = enabled ? "hidden" : null;
checked={!colorsPref.customBackground}
onPress={() => {
colorsPref.customBackground = !colorsPref.customBackground ? "hidden" : null;
}}
/>
</TableRowGroup>
@ -479,7 +456,7 @@ export default function Themes() {
);
})
}
/>,
/>
);
navigation.setOptions({
@ -677,6 +654,7 @@ export default function Themes() {
);
}
// Normal browse listing
return (
<BrowseThemePage
themes={themesList}

View file

@ -7,12 +7,7 @@ import React, { ComponentProps, useEffect, useState } from "react";
import { ScrollView, View, TouchableOpacity } from "react-native";
import { showToast } from "@ui/toasts";
import { showConfirmationAlert } from "@core/vendetta/alerts";
import {
fetchTheme,
removeTheme,
selectTheme,
VdThemeInfo,
} from "@lib/addons/themes";
import { fetchTheme, removeTheme, selectTheme, VdThemeInfo, } from "@lib/addons/themes";
import { formatString, Strings } from "@core/i18n";
import { semanticColors } from "@ui/color";
@ -31,7 +26,7 @@ function ThemeInfoIconButton(props: ComponentProps<typeof IconButton>) {
return <IconButton {...props} label={props.label} />;
}
// Theme title component for header
// theme title component for header
function TitleComponent({ theme }: { theme: VdThemeInfo }) {
const { authors } = theme.data;
@ -68,11 +63,11 @@ export default function ThemeInfoActionSheet({
theme,
navigation,
}: ThemeInfoActionSheetProps) {
// Use component state to track theme data
// use component state to track theme data
const [themeState, setThemeState] = useState({ ...theme });
const [loading, setLoading] = useState(false);
// Periodic refresh to detect external changes to theme state
// periodic refresh to detect external changes to theme state
useEffect(() => {
const interval = setInterval(() => {
setThemeState({ ...theme });
@ -159,31 +154,25 @@ export default function ThemeInfoActionSheet({
justifyContent: "center",
alignItems: "center",
flexWrap: "wrap",
gap: 16,
paddingHorizontal: 4,
gap: 65,
paddingHorizontal: 8,
}}
>
<ThemeInfoIconButton
label="Apply"
variant="secondary"
icon={findAssetId("CheckmarkLargeIcon")}
onPress={applyTheme}
/>
<ThemeInfoIconButton
label="Refetch"
label={Strings.REFETCH}
variant="secondary"
icon={findAssetId("RetryIcon")}
onPress={refetchTheme}
disabled={loading}
/>
<ThemeInfoIconButton
label="Copy URL"
label={Strings.COPY_URL}
variant="secondary"
icon={findAssetId("LinkIcon")}
onPress={copyThemeUrl}
/>
<ThemeInfoIconButton
label="Uninstall"
label={Strings.UNINSTALL}
variant="secondary"
icon={findAssetId("TrashIcon")}
onPress={removeThemeHandler}

View file

@ -147,7 +147,12 @@ export const initVendettaObject = (): any => {
return require("moment");
},
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() {
return require("lodash");

View file

@ -153,6 +153,8 @@ export interface StorageBackend {
}
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({
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 => {
return {
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;
await require("@metro/internals/caches").initMetroCache();
await require(".").default();
require(".").default();
} catch (e) {
const { ClientInfoManager } = require("@lib/api/native/modules");
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 { logger } from "@lib/utils/logger";
import { patchSettings } from "@ui/settings";
import { updaterSettings } from "@lib/api/settings";
import { InteractionManager } from "react-native";
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 { timings } from "@lib/utils/timings";
@ -66,52 +61,19 @@ export default async () => {
const { VdPluginManager } = await import("@core/vendetta/plugins");
const { initPlugins, updatePlugins } = await import("@lib/addons/plugins");
// Initialize Vendetta plugins (may start many plugins) — do not block UI.
VdPluginManager.initPlugins()
.then((u) => lib.unload.push(u))
.catch((e) => logger.log("Vendetta init failed:", e));
await Promise.all([
VdPluginManager.initPlugins()
.then((u) => u && lib.unload.push(u))
.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.
// Plugin repository fetching is deferred so the app can finish launching first.
// 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
}
updatePlugins()
.catch((e) => logger.log("updatePlugins failed:", e));
// Update fonts in background
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).

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 { safeFetch } from "@lib/utils";
@ -13,7 +13,8 @@ export type FontDefinition = {
}
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) {
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
* 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)
* Safe to call multiple times — skips already-registered plugins.
*/
export async function initPlugins(
options: { staggerInterval?: number; batchSize?: number } = {},
) {
const { staggerInterval = 100, batchSize = 3 } = options;
export async function registerInstalledPlugins() {
await awaitStorage(pluginRepositories, pluginSettings);
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
// settings pages and UI that read registered/core plugins show them.
// registerCorePlugins is synchronous and idempotent, but call it here as a
@ -784,6 +811,7 @@ export async function initPlugins(
}
await awaitStorage(pluginRepositories, pluginSettings);
await registerInstalledPlugins();
// Collect enabled plugin ids
const enabledIds = [...registeredPlugins.keys()].filter((id) =>
@ -792,32 +820,18 @@ export async function initPlugins(
if (enabledIds.length === 0) return;
// Helper sleep
const sleep = (ms: number) => new Promise<void>((res) => setTimeout(res, ms));
// Start plugins in batches to avoid starting all at once.
for (let i = 0; i < enabledIds.length; i += batchSize) {
const batch = enabledIds.slice(i, i + batchSize);
// Start the batch in parallel but don't let exceptions bubble to halt everything.
await Promise.allSettled(
batch.map(async (id) => {
try {
// 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);
}
}
// Start all enabled plugins in parallel
await Promise.allSettled(
enabledIds.map(async (id) => {
try {
// Only attempt to start if still enabled and not already started
if (!isPluginEnabled(id) || pluginInstances.has(id)) return;
await startPlugin(id).catch((e) => {
console.error(`Failed to start plugin ${id}:`, e);
});
} catch (error) {
console.error(`Unexpected error while starting plugin ${id}:`, error);
}
}),
);
}

View file

@ -3,6 +3,7 @@ import patchDefinitionAndResolver from "./patches/resolver";
import patchStorage from "./patches/storage";
import { ColorManifest } from "./types";
import { updateBunnyColor } from "./updater";
import fixStatusBar from "./statusbar";
/** @internal */
export default function initColors(manifest: ColorManifest | null) {
@ -11,8 +12,9 @@ export default function initColors(manifest: ColorManifest | null) {
const patches = [
patchStorage(),
patchDefinitionAndResolver(),
patchChatBackground()
patchChatBackground(),
fixStatusBar()
];
return () => patches.forEach(p => p());
}
return () => patches.forEach(p => p && (typeof p === "function" ? p() : null));
}

View file

@ -1,8 +1,9 @@
import { findByProps } from "@metro";
import chroma from "chroma-js";
import { omit } from "es-toolkit";
import { Platform, processColor } from "react-native";
const getChroma = () => require("chroma-js") as typeof import("chroma-js").default;
import { colorsPref } from "./preferences";
import { ColorManifest, InternalColorDefinition } from "./types";
@ -11,147 +12,224 @@ const tokenRef = findByProps("SemanticColor");
export function parseColorManifest(manifest: ColorManifest): InternalColorDefinition {
const resolveType = (type = "dark") => (colorsPref.type ?? type) === "dark" ? "darker" : "light";
if (manifest.spec === 3) {
const semanticColorDefinitions: InternalColorDefinition["semantic"] = {};
if (manifest.spec === 3) {
const semanticColorDefinitions: InternalColorDefinition["semantic"] = {};
for (const [semanticColorKey, semanticColorValue] of Object.entries(
manifest.main.semantic ?? {},
)) {
if (typeof semanticColorValue === "object") {
const {
type,
value,
opacity: semanticColorOpacity,
} = semanticColorValue;
for (const [semanticColorKey, semanticColorValue] of Object.entries(manifest.main.semantic ?? {})) {
if (typeof semanticColorValue === "object") {
const { type, value, opacity: semanticColorOpacity } = semanticColorValue;
if (type === "raw") {
semanticColorDefinitions[semanticColorKey] = {
value,
opacity: semanticColorOpacity ?? 1,
};
} else {
const rawColorValue = tokenRef.RawColor[value];
semanticColorDefinitions[semanticColorKey] = {
value: rawColorValue,
opacity: semanticColorOpacity ?? 1,
};
if (type === "raw") {
semanticColorDefinitions[semanticColorKey] = {
value,
opacity: semanticColorOpacity ?? 1,
};
} else {
const rawColorValue = tokenRef.RawColor[value];
semanticColorDefinitions[semanticColorKey] = {
value: rawColorValue,
opacity: semanticColorOpacity ?? 1,
};
}
} else if (typeof semanticColorValue === "string") {
if (semanticColorValue.startsWith("#")) {
semanticColorDefinitions[semanticColorKey] = {
value: getChroma()(semanticColorValue).hex(),
opacity: 1,
};
} else {
semanticColorDefinitions[semanticColorKey] = {
value: tokenRef.RawColor[semanticColorValue],
opacity: 1,
};
}
} else {
throw new Error(`Invalid semantic definitions: ${semanticColorValue}`);
}
}
} else if (typeof semanticColorValue === "string") {
if (semanticColorValue.startsWith("#")) {
semanticColorDefinitions[semanticColorKey] = {
value: chroma.hex(semanticColorValue).hex(),
opacity: 1,
};
} else {
semanticColorDefinitions[semanticColorKey] = {
value: tokenRef.RawColor[semanticColorValue],
opacity: 1,
};
}
} else {
throw new Error(`Invalid semantic definitions: ${semanticColorValue}`);
}
}
if (Platform.OS === "android") applyAndroidAlphaKeys(manifest.main.raw);
if (Platform.OS === "android") applyAndroidAlphaKeys(manifest.main.raw);
return {
spec: 3,
reference: resolveType(manifest.type),
semantic: semanticColorDefinitions,
raw: manifest.main.raw ?? {},
background: manifest.main.background,
};
}
if (manifest.spec === 2) {
// is Vendetta theme
const semanticDefinitions: InternalColorDefinition["semantic"] = {};
const background: InternalColorDefinition["background"] | undefined =
manifest.background
? {
...omit(manifest.background, ["alpha"]),
opacity: manifest.background.alpha,
}
: undefined;
if (manifest.semanticColors) {
for (const key in manifest.semanticColors) {
const values = manifest.semanticColors[key]
.map((c) => c || undefined)
.slice(0, 2);
if (!values[0]) continue;
semanticDefinitions[key] = {
value: normalizeToHex(values[resolveType() === "light" ? 1 : 0])!,
opacity: 1,
return {
spec: 3,
reference: resolveType(manifest.type),
semantic: semanticColorDefinitions,
raw: manifest.main.raw ?? {},
background: manifest.main.background,
};
}
}
if (manifest.rawColors) {
const draft: typeof manifest.rawColors = {};
if (manifest.spec === 2) { // is Vendetta theme
const semanticDefinitions: InternalColorDefinition["semantic"] = {};
const background: InternalColorDefinition["background"] | undefined = manifest.background ? {
...omit(manifest.background, ["alpha"]),
opacity: manifest.background.alpha
} : undefined;
for (const key in manifest.rawColors) {
const value = manifest.rawColors[key];
if (!value) continue;
draft[key] = normalizeToHex(value)!;
}
if (manifest.semanticColors) {
for (const key in manifest.semanticColors) {
const values = manifest.semanticColors[key].map(c => c || undefined).slice(0, 2);
if (!values[0]) continue;
if (Platform.OS === "android") applyAndroidAlphaKeys(draft);
manifest.rawColors = draft;
semanticDefinitions[key] = {
value: normalizeToHex(values[resolveType() === "light" ? 1 : 0])!,
opacity: 1
};
}
}
if (manifest.rawColors) {
const draft: typeof manifest.rawColors = {};
for (const key in manifest.rawColors) {
const value = manifest.rawColors[key];
if (!value) continue;
draft[key] = normalizeToHex(value)!;
}
if (Platform.OS === "android") applyAndroidAlphaKeys(draft);
manifest.rawColors = draft;
}
return {
spec: 2,
reference: resolveType(),
semantic: semanticDefinitions,
raw: manifest.rawColors ?? {},
background
};
}
return {
spec: 2,
reference: resolveType(),
semantic: semanticDefinitions,
raw: manifest.rawColors ?? {},
background,
};
}
if (manifest.spec === 1 || (manifest.theme_color_map && !manifest.spec)) {
throw new Error("Invalid theme spec");
const semanticDefinitions: InternalColorDefinition["semantic"] = {};
const rawDefinitions: Record<string, string> = {};
const themeIndex = resolveType() === "light" ? 1 : 0;
if (manifest.theme_color_map) {
for (const [key, colorArray] of Object.entries(manifest.theme_color_map)) {
if (!Array.isArray(colorArray) || colorArray.length < 2) {
console.warn(`[Parser] Invalid color array for ${key}:`, colorArray);
continue;
}
const colorValue = colorArray[themeIndex];
if (!colorValue) {
console.warn(`[Parser] Missing color value for ${key} at index ${themeIndex}`);
continue;
}
const normalized = normalizeToHex(colorValue);
if (normalized) {
semanticDefinitions[key] = {
value: normalized,
opacity: 1
};
}
}
}
const rawColors = manifest.colours || manifest.colors;
if (rawColors) {
for (const [key, colorValue] of Object.entries(rawColors)) {
if (typeof colorValue !== "string") continue;
const normalized = normalizeToHex(colorValue);
if (normalized) {
const discordKey = convertEnmityToDiscordRawKey(key);
rawDefinitions[discordKey] = normalized;
if (discordKey !== key) {
rawDefinitions[key] = normalized;
}
}
}
}
if (manifest.unsafe_colors) {
for (const [key, colorValue] of Object.entries(manifest.unsafe_colors)) {
if (typeof colorValue !== "string") continue;
const normalized = normalizeToHex(colorValue);
if (normalized) {
rawDefinitions[key] = normalized;
}
}
}
if (Platform.OS === "android") applyAndroidAlphaKeys(rawDefinitions);
return {
spec: 2,
reference: resolveType(),
semantic: semanticDefinitions,
raw: rawDefinitions,
background: manifest.background
};
}
throw new Error("Invalid theme spec");
}
function convertEnmityToDiscordRawKey(enmityKey: string): string {
const conversions: Record<string, string> = {
"PRIMARY_DARK": "PRIMARY_100",
"PRIMARY_DARK_100": "PRIMARY_100",
"PRIMARY_DARK_200": "PRIMARY_200",
"PRIMARY_DARK_300": "PRIMARY_300",
"PRIMARY_DARK_360": "PRIMARY_360",
"PRIMARY_DARK_400": "PRIMARY_400",
"PRIMARY_DARK_500": "PRIMARY_500",
"PRIMARY_DARK_600": "PRIMARY_600",
"PRIMARY_DARK_630": "PRIMARY_630",
"PRIMARY_DARK_700": "PRIMARY_700",
"PRIMARY_DARK_800": "PRIMARY_800",
"PRIMARY_DARK_900": "PRIMARY_900",
};
return conversions[enmityKey] || enmityKey;
}
export function applyAndroidAlphaKeys(rawColors?: Record<string, string>) {
if (!rawColors) return;
if (!rawColors) return;
// these are native Discord Android keys
const alphaMap: Record<string, [string, number]> = {
BLACK_ALPHA_60: ["BLACK", 0.6],
BRAND_NEW_360_ALPHA_20: ["BRAND_360", 0.2],
BRAND_NEW_360_ALPHA_25: ["BRAND_360", 0.25],
BRAND_NEW_500_ALPHA_20: ["BRAND_500", 0.2],
PRIMARY_DARK_500_ALPHA_20: ["PRIMARY_500", 0.2],
PRIMARY_DARK_700_ALPHA_60: ["PRIMARY_700", 0.6],
STATUS_GREEN_500_ALPHA_20: ["GREEN_500", 0.2],
STATUS_RED_500_ALPHA_20: ["RED_500", 0.2],
};
// these are native Discord Android keys
const alphaMap: Record<string, [string, number]> = {
"BLACK_ALPHA_60": ["BLACK", 0.6],
"BRAND_NEW_360_ALPHA_20": ["BRAND_360", 0.2],
"BRAND_NEW_360_ALPHA_25": ["BRAND_360", 0.25],
"BRAND_NEW_500_ALPHA_20": ["BRAND_500", 0.2],
"PRIMARY_DARK_500_ALPHA_20": ["PRIMARY_500", 0.2],
"PRIMARY_DARK_700_ALPHA_60": ["PRIMARY_700", 0.6],
"STATUS_GREEN_500_ALPHA_20": ["GREEN_500", 0.2],
"STATUS_RED_500_ALPHA_20": ["RED_500", 0.2],
};
for (const key in alphaMap) {
const [colorKey, alpha] = alphaMap[key];
if (!rawColors[colorKey]) continue;
rawColors[key] = chroma(rawColors[colorKey]).alpha(alpha).hex();
}
for (const key in alphaMap) {
const [colorKey, alpha] = alphaMap[key];
if (!rawColors[colorKey]) continue;
rawColors[key] = getChroma()(rawColors[colorKey]).alpha(alpha).hex();
}
return rawColors;
return rawColors;
}
export function normalizeToHex(
colorString: string | undefined,
): string | undefined {
if (colorString === undefined) return undefined;
if (chroma.valid(colorString)) return chroma(colorString).hex();
export function normalizeToHex(colorString: string | undefined): string | undefined {
if (colorString === undefined) return undefined;
const color = Number(processColor(colorString));
if (colorString.toLowerCase() === "transparent") {
return "#00000000";
}
return chroma
.rgb(
(color >> 16) & 0xff, // red
(color >> 8) & 0xff, // green
color & 0xff, // blue
(color >> 24) & 0xff, // alpha
)
.hex();
if (getChroma().valid(colorString)) return getChroma()(colorString).hex();
const color = Number(processColor(colorString));
return getChroma().rgb(
color >> 16 & 0xff, // red
color >> 8 & 0xff, // green
color & 0xff, // blue
color >> 24 & 0xff // alpha
).hex();
}

View file

@ -4,7 +4,7 @@ import { after } from "@lib/api/patcher";
import { useObservable } from "@lib/api/storage";
import { findInReactTree } from "@lib/utils";
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 { logger } from "@lib/utils/logger";
@ -13,53 +13,77 @@ const Messages = findByFilePathLazy("modules/messages/native/Messages.tsx", true
function ThemeBackground({ children }: { children: React.ReactNode; }) {
useObservable([colorsPref]);
if (!_colorRef.current
|| colorsPref.customBackground === "hidden"
|| !_colorRef.current.background?.url
|| _colorRef.current.background?.blur && (typeof _colorRef.current.background?.blur !== "number")
const background = _colorRef.current?.background;
const url = background?.url;
const blur = background?.blur;
if (
!_colorRef.current ||
colorsPref.customBackground === "hidden" ||
!url
) {
return children;
}
return <ImageBackground
style={{ flex: 1, height: "100%" }}
source={{ uri: _colorRef.current.background?.url }}
blurRadius={_colorRef.current.background?.blur}
>
{children}
</ImageBackground>;
if (blur !== undefined && typeof blur !== "number") {
return children;
}
return (
<ImageBackground
style={{ flex: 1, height: "100%" }}
source={{ uri: url }}
blurRadius={typeof blur === "number" ? blur : 0}
>
{children}
</ImageBackground>
);
}
export default function patchChatBackground() {
try {
if (!Messages) return;
const patches = [
after("render", Messages, (_, ret) => {
if (!_colorRef.current || !_colorRef.current.background?.url) return;
if (!ret) return ret;
const messagesComponent = findInReactTree(
ret,
x => x && "HACK_fixModalInteraction" in x.props && x?.props?.style
);
try {
if (!_colorRef.current || !_colorRef.current.background?.url) return ret;
if (messagesComponent) {
const flattened = StyleSheet.flatten(messagesComponent.props.style);
const backgroundColor = chroma(
flattened.backgroundColor || "black"
).alpha(
1 - (_colorRef.current.background?.opacity ?? 1)
).hex();
const messagesComponent = findInReactTree(
ret,
x => x && "HACK_fixModalInteraction" in (x.props ?? {}) && x?.props?.style
);
messagesComponent.props.style = StyleSheet.flatten([
messagesComponent.props.style,
{ backgroundColor }
]);
if (messagesComponent) {
try {
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 () => patches.forEach(x => x());
return () => patches.forEach(x => x?.());
} 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 { before, instead } from "@lib/api/patcher";
import { findByProps } from "@metro";
import { byMutableProp } from "@metro/filters";
import { createLazyModule } from "@metro/lazy";
import chroma from "chroma-js";
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> = {
"BG_BACKDROP": "BACKGROUND_FLOATING",
@ -22,11 +24,20 @@ const SEMANTIC_FALLBACK_MAP: Record<string, string> = {
"BG_SURFACE_RAISED": "BACKGROUND_MOBILE_PRIMARY"
};
const origRawColor = { ...tokenReference.RawColor };
export default function patchDefinitionAndResolver() {
const callback = ([theme]: any[]) => 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.defineProperty(tokenReference.RawColor, key, {
configurable: true,
@ -34,15 +45,12 @@ export default function patchDefinitionAndResolver() {
get: () => {
const ret = _colorRef.current?.raw[key];
if (ret) return ret;
return origRawColor[key];
}
});
});
const unpatches = [
before("isThemeDark", isThemeModule, callback),
before("isThemeLight", isThemeModule, callback),
before("updateTheme", NativeThemeModule, callback),
instead("resolveSemanticColor", tokenReference.default.meta ?? tokenReference.default.internal, (args: any[], orig: any) => {
if (!_colorRef.current) return orig(...args);
@ -58,8 +66,9 @@ export default function patchDefinitionAndResolver() {
}
if (semanticDef?.value) {
if (semanticDef.opacity === 1) return semanticDef.value;
return chroma(semanticDef.value).alpha(semanticDef.opacity).hex();
return semanticDef.opacity === 1
? semanticDef.value
: chroma(semanticDef.value).alpha(semanticDef.opacity).hex();
}
const rawValue = _colorRef.current.raw[colorDef.raw];
@ -72,6 +81,12 @@ export default function patchDefinitionAndResolver() {
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", {
configurable: true,
writable: true,
@ -89,4 +104,4 @@ function extractInfo(themeName: string, colorObj: any): [name: string, colorDef:
const colorDef = tokenReference.SemanticColor[propName];
return [propName, colorDef[themeName]];
}
}

View file

@ -1,4 +1,4 @@
import { StatusBar, TextInput, Platform } from "react-native";
import { StatusBar, Platform } from "react-native";
import { findByStoreNameLazy } from "@metro/wrappers";
import { _colorRef } from "./updater";
@ -7,15 +7,8 @@ const ThemeStore = findByStoreNameLazy("ThemeStore");
function getBarStyle() {
// Check for custom theme
const custom = _colorRef.current;
if (custom) {
// Bunny theme (spec 3)
if (custom.spec === 3 && custom.reference) {
return custom.reference === "light" ? "dark-content" : "light-content";
}
// Vendetta theme (spec 2)
if (custom.spec === 2 && custom.reference) {
return custom.reference === "light" ? "dark-content" : "light-content";
}
if (custom?.reference) {
return custom.reference === "light" ? "dark-content" : "light-content";
}
// Fallback to Discord theme string
@ -25,33 +18,35 @@ function getBarStyle() {
return (lowerTheme.includes("dark") || lowerTheme.includes("midnight")) ? "light-content" : "dark-content";
}
return "dark-content";
return "light-content";
}
export default function fixStatusBar() {
function applyStatusBar() {
const style = getBarStyle();
StatusBar.setBarStyle(style, true);
if (Platform.OS == "android") {
if (Platform.OS === "android") {
StatusBar.setTranslucent?.(true);
StatusBar.setBackgroundColor?.("#00000000", true);
}
}
if (Platform.OS === "android") {
const origSetBarStyle = StatusBar.setBarStyle;
StatusBar.setBarStyle = function (_style: any, ...args: any[]) {
return origSetBarStyle.call(this, getBarStyle(), ...args);
};
}
if (Platform.OS == "ios") {
function applyStatusBar() {
const style = getBarStyle();
StatusBar.setBarStyle(style, true);
}
const unsubscribe = ThemeStore?.addChangeListener?.(applyStatusBar);
const unsubscribe = ThemeStore?.addChangeListener?.(applyStatusBar);
applyStatusBar();
StatusBar.setBarStyle(getBarStyle());
let delay = 200;
for (let i = 0; i < 5; i++, delay *= 2) {
setTimeout(applyStatusBar, delay);
}
return unsubscribe;
let delay = 200;
for (let i = 0; i < 5; i++, delay *= 2) {
setTimeout(applyStatusBar, delay);
}
return unsubscribe;
}

View file

@ -37,6 +37,19 @@ export interface VendettaThemeManifest {
};
}
// enmity themes
export interface EnmityColorManifest {
spec?: 1;
name?: string;
description?: string;
authors?: Author[];
theme_color_map?: Record<string, string[]>;
colours?: Record<string, string>;
colors?: Record<string, string>;
unsafe_colors?: Record<string, string>;
background?: BackgroundDefinition;
}
/** @internal */
export interface InternalColorDefinition {
spec: 1 | 2 | 3;
@ -49,4 +62,4 @@ export interface InternalColorDefinition {
background?: BackgroundDefinition;
}
export type ColorManifest = BunnyColorManifest | VendettaThemeManifest;
export type ColorManifest = BunnyColorManifest | VendettaThemeManifest | EnmityColorManifest;

View file

@ -9,7 +9,6 @@ const origRawColor = { ...tokenRef.RawColor };
const AppearanceManager = findByPropsLazy("updateTheme");
const ThemeStore = findByStoreNameLazy("ThemeStore");
const FormDivider = findByPropsLazy("DIVIDER_COLORS");
import { colorsPref } from "./preferences";
let _inc = 1;
@ -28,45 +27,68 @@ export const _colorRef: InternalColorRef = {
lastSetDiscordTheme: "darker"
};
export function updateBunnyColor(colorManifest: ColorManifest | null, { update = true } ) {
export function updateBunnyColor(colorManifest: ColorManifest | null, { update = true }) {
if (settings.safeMode?.enabled) return;
const resolveType = (type = "dark") => (colorsPref.type ?? type) === "dark" ? "darker" : "light";
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
});
if (resolveType() == "light" || update) {
var ref = Object.assign(_colorRef, {
current: internalDef,
key: `bn-theme-${++_inc}`,
lastSetDiscordTheme: !ThemeStore.theme.startsWith("bn-theme-") || !ThemeStore.theme.startsWith("darker")
? ThemeStore.theme
: _colorRef.lastSetDiscordTheme
});
} else {
var ref = Object.assign(_colorRef, {
current: internalDef,
key: `dark`,
lastSetDiscordTheme: !ThemeStore.theme.startsWith("bn-theme-") || !ThemeStore.theme.startsWith("darker")
? ThemeStore.theme
: _colorRef.lastSetDiscordTheme
});
}
if (internalDef != null) {
if (internalDef != null) {
// Register the synthetic key BEFORE calling native, and verify it actually
// landed in every table native reads from. If any registration step fails,
// bail out to a known-good theme instead of handing native an unknown key.
try {
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];
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 => {
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.current!.reference]
};
});
} catch (e) {
console.error("Failed to register custom theme key, falling back", e);
ref.current = null; // force fallback path below
}
}
if (update) {
AppearanceManager.setShouldSyncAppearanceSettings(false);
AppearanceManager.updateTheme(internalDef != null ? ref.key : "darker");
const targetTheme = ref.current != null ? ref.key : ref.lastSetDiscordTheme;
// 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
*/
export function getThemeFromLoader(): VdThemeInfo | null {
return getStoredTheme();
return getStoredTheme() ?? getCurrentTheme();
}
/**
@ -157,6 +157,7 @@ export async function initThemes() {
}
await newAwaitStorage(colorsPref);
await awaitStorage(themes);
const currentTheme = getThemeFromLoader();
initColors(currentTheme?.data ?? null);

View file

@ -224,13 +224,14 @@ export function connectToDebugger(url: string) {
}
});
socket.addEventListener("close", () => {
showToast("Disconnected from debugger.", findAssetId("Small"));
socket.addEventListener("close", (e: any) => {
logger.error(`Debugger disconnected. (${e?.code}, ${e?.reason || "no reason specified"})`);
unpatchConsoleAndLogger();
});
socket.addEventListener("error", (err: any) => {
console.log(`Debugger error: ${err.message}`);
socket.addEventListener("error", (e: any) => {
const err = e?.message || e?.error?.message || "WebSocket Connection Errored";
logger.error('Debugger error:', err);
showToast("An error occurred with the debugger connection!", findAssetId("Small"));
unpatchConsoleAndLogger();
});
@ -281,13 +282,14 @@ export function connectRdt(url: string, quiet?: boolean) {
bump();
});
ws.addEventListener('close', () => {
ws.addEventListener('close', (e: any) => {
logger.error(`React DevTools disconnected. (${e?.code}, ${e?.reason || "no reason specified"})`);
cleanupRdt();
});
ws.addEventListener('error', (e: any) => {
cleanupRdt();
const err = e?.message ?? e?.stack ?? String(e);
const err = e?.message || e?.error?.message || "WebSocket Connection Errored";
logger.error('React DevTools error:', err);
if (!quiet) showToast(err, findAssetId("CircleXIcon-primary"));
});

View file

@ -8,6 +8,8 @@ export const dispatcher = FluxDispatcher;
type Intercept = (payload: Record<string, any> & { type: string; }) => any;
let intercepts: Intercept[] = [];
const interceptIndex = new Map<Intercept, number>();
let nextInterceptId = 0;
/**
* @internal
@ -33,7 +35,14 @@ export function injectFluxInterceptor() {
(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
*/
export function intercept(cb: Intercept) {
const id = nextInterceptId++;
interceptIndex.set(cb, id);
intercepts.push(cb);
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
const vendettaLoaderIdentity = globalThis.__vendetta_loader;
// @ts-ignore
const shiggyLoaderIdentity = globalThis.__SHIGGY_LOADER__;
export interface VendettaLoaderIdentity {
name: string;
features: {
@ -36,6 +39,10 @@ export function isRa1nLoader() {
return rainLoaderIdentity != null;
}
export function isShiggyLoader() {
return shiggyLoaderIdentity != null;
}
function polyfillVendettaLoaderIdentity() {
if (!isPyonLoader() || isVendettaLoader() || !isRa1nLoader()) return null;
@ -98,6 +105,8 @@ export function getLoaderIdentity() {
return getVendettaLoaderIdentity();
} else if (isRa1nLoader()) {
return rainLoaderIdentity();
} else if (isShiggyLoader()) {
return shiggyLoaderIdentity;
}
return null;
@ -116,6 +125,7 @@ export function getLoaderName() {
if (isPyonLoader()) return pyonLoaderIdentity.loaderName;
else if (isRa1nLoader()) return rainLoaderIdentity.loadername;
else if (isVendettaLoader()) return vendettaLoaderIdentity.name;
else if (isShiggyLoader()) return shiggyLoaderIdentity.loaderName;
return "Unknown";
}
@ -123,6 +133,7 @@ export function getLoaderName() {
export function getLoaderVersion(): string | null {
if (isPyonLoader()) return pyonLoaderIdentity.loaderVersion;
else if (isRa1nLoader()) return rainLoaderIdentity.loaderVersion;
else if (isShiggyLoader()) return shiggyLoaderIdentity.loaderVersion;
return null;
}
@ -144,7 +155,9 @@ export function isThemeSupported() {
} else if (isVendettaLoader()) {
return vendettaLoaderIdentity!!.features.themes != null;
} 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;
@ -158,6 +171,8 @@ export function getStoredTheme(): VdThemeInfo | null {
if (!themeProp) return null;
// @ts-ignore
return globalThis[themeProp] || null;
} else if (isShiggyLoader()) {
return shiggyLoaderIdentity.storedTheme || null;
}
return null;
@ -168,6 +183,8 @@ export function getThemeFilePath() {
return "pyoncord/current-theme.json";
} else if (isVendettaLoader()) {
return "vendetta_theme.json";
} else if (isShiggyLoader()) {
return "ShiggyCord/current-theme.json";
}
return null;
@ -241,6 +258,7 @@ export function getLoaderConfigPath() {
export function isFontSupported() {
if (isPyonLoader()) return pyonLoaderIdentity.fontPatch === 2;
else if (isShiggyLoader()) return shiggyLoaderIdentity.fontPatch === 2;
return false;
}
@ -248,4 +266,4 @@ export function isFontSupported() {
export async function clearBundle() {
// TODO: This should be not be hardcoded, maybe put in loader.json?
return void await removeCacheFile("bundle.js");
}
}

View file

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

View file

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

View file

@ -11,5 +11,8 @@ export const OFFICIAL_PLUGINS_REPO_URL =
export const VD_PROXY_PREFIX = "https://vd-plugins.github.io/proxy";
export const VD_DISCORD_SERVER_ID = "1427396621905432699";
export const VD_PLUGINS_CHANNEL_ID = "1091880384561684561";
export const VD_THEMES_CHANNEL_ID = "1091880434939482202";
export const VD_PLUGINS_CHANNEL_ID = "1432796541210333284";
export const VD_THEMES_CHANNEL_ID = "1408405946434195540";
export const KETTU_DISCORD_SERVER_ID = "1368145952266911755";
export const KETTU_PLUGINS_CHANNEL_ID = "1432796541210333284";
export const KETTU_THEMES_CHANNEL_ID = "1408405946434195540";

View file

@ -4,13 +4,13 @@
export type SearchTree = Record<string, any>;
export type SearchFilter = (tree: SearchTree) => boolean;
export interface FindInTreeOptions {
walkable?: string[];
ignore?: string[];
walkable?: string[] | Set<string>;
ignore?: string[] | Set<string>;
maxDepth?: number;
}
function treeSearch(tree: SearchTree, filter: SearchFilter, opts: Required<FindInTreeOptions>, depth: number): any {
if (depth > opts.maxDepth) return;
function treeSearch(tree: SearchTree, filter: SearchFilter, walkable: Set<string>, ignore: Set<string>, maxDepth: number, depth: number): any {
if (depth > maxDepth) return;
if (!tree) return;
try {
@ -22,18 +22,19 @@ function treeSearch(tree: SearchTree, filter: SearchFilter, opts: Required<FindI
if (typeof item !== "object" || item === null) continue;
try {
const found = treeSearch(item, filter, opts, depth + 1);
const found = treeSearch(item, filter, walkable, ignore, maxDepth, depth + 1);
if (found) return found;
} catch { }
}
} 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 (opts.walkable.length && !opts.walkable.includes(key)) continue;
if (opts.ignore.includes(key)) continue;
if (walkable.size && !walkable.has(key)) continue;
if (ignore.size && ignore.has(key)) continue;
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;
} catch { }
}
@ -49,5 +50,5 @@ export default function findInTree(
maxDepth = 100
}: FindInTreeOptions = {},
): 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 { TextStyles, ThemeColors } from "@ui/types";
import { Falsey } from "lodash";
import type { Falsey } from "lodash";
import { FC, MutableRefObject, PropsWithoutRef, ReactNode, RefObject } from "react";
import type * as RN from "react-native";
import { ImageSourcePropType, PressableProps } from "react-native";
@ -211,7 +211,7 @@ interface TableRowIconProps {
export type TableRowIcon = React.FC<TableRowIconProps>;
interface TableRowGroupProps {
title: string;
title?: string;
children: ReactNode;
}

View file

@ -16,23 +16,17 @@ let _metroCache = null as unknown as ReturnType<typeof buildInitCache>;
export const getMetroCache = () => _metroCache;
function buildInitCache() {
const modulesCount = Object.keys(window.modules).length;
const cache = {
_v: CACHE_VERSION,
_buildNumber: NativeClientInfoModule.getConstants().Build,
_modulesCount: Object.keys(window.modules).length,
_modulesCount: modulesCount,
flagsIndex: {} as Record<string, number>,
findIndex: {} as Record<string, ModulesMap | undefined>,
polyfillIndex: {} as Record<string, ModulesMap | undefined>
} 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;
return cache;
}
@ -52,7 +46,8 @@ export async function initMetroCache() {
_metroCache = null!;
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!;
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;
if (_source[prop] && exportsKeysLength >= _source[prop]) {
if (_source[prop] && exportsKeysLength <= _source[prop]) {
continue;
}
_module[prop] = actualExports[prop];
_source[prop] = Reflect.ownKeys(actualExports).length;
cacher.cacheId(id);
if (exportsKeysLength === 1) {
redesignProps.delete(prop);
}
}
}