Compare commits

...

146 commits

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

Replace eager chroma and fuzzysort imports with lazy require/cached
loads to reduce startup bundle and runtime cost. Add createMemoryBackend
(an in-memory storage with debounced persistence) and use it for fonts
to accelerate startup. Add per-store write debouncers and change lodash
import to a type-only import
2026-02-23 17:28:55 +01:00
kmmiio99o
8857eaf144
chore: bump dependencies 2026-02-23 16:31:11 +01:00
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
kmmiio99o
0a8058cd00
Merge remote-tracking branch 'upstream/main' 2026-01-02 00:44:23 +01:00
kmmiio99o
28e024c611
fix versioning 2026-01-02 00:33:53 +01:00
Kernel Builder
ade7693b9d
Merge remote-tracking branch 'upstream/dev' into dev 2026-01-01 19:34:19 +01:00
cocobo1
9fc8a87ded Merge branch 'dev' 2026-01-01 20:00:11 +02:00
cocobo1
0df137977d fix(themes): fix theme backgrounds for SOME people 2026-01-01 19:59:39 +02: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
Kernel Builder
81b32a491e
Merge remote-tracking branch 'upstream/main' into dev 2026-01-01 18:18:27 +01:00
Kernel Builder
179679f215
fix background in themes and change version to normal 1.4
fix by purple_eyez from revenge, shiggies for him (may likely still not
work on some phones)
2026-01-01 18:12:08 +01:00
cocobo1
95a5b9e80c Merge pull request 'fix: use snowflake for message nonce' (#22) from plus024/Kettu:main into main
Reviewed-on: https://codeberg.org/cocobo1/Kettu/pulls/22
2025-12-31 14:01:34 +01:00
plus024
cc2545ebb2 fix: use snowflake for message nonce 2025-12-31 21:36:24 +11:00
kmmiio99o
3f0c22342a
Merge two plugin pages for easier management 2025-12-27 15:12:21 +01:00
kmmiio99o
199ed9f8b2
fix: Property 'WeakRef' doesn't exist 2025-12-27 14:30:44 +01:00
kmmiio99o
2ee516b5fd
hopefully fix iOS crash??
I guess no, but eh
2025-12-26 18:55:53 +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
67 changed files with 2691 additions and 3096 deletions

View file

@ -5,15 +5,6 @@ on:
branches: branches:
- main - main
workflow_dispatch: workflow_dispatch:
inputs:
release_name:
description: 'Custom Release Name (e.g., "Jolly", "Christmas")'
required: true
type: string
release_tag:
description: 'Release Tag (e.g., "v1.0.0")'
required: true
type: string
jobs: jobs:
release: release:
@ -38,38 +29,33 @@ jobs:
run: bun install run: bun install
shell: bash shell: bash
- name: Determine Release Info - name: Extract version
id: release_info id: extract
run: | run: |
# For workflow_dispatch, use the provided inputs # Extract major.minor.patch version from scripts/build.mjs
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then # This locates the first quoted three-segment version string in the build script (e.g. "1.1.7")
release_name="${{ github.event.inputs.release_name }}" if [ ! -f scripts/build.mjs ]; then
release_tag="${{ github.event.inputs.release_tag }}" echo "scripts/build.mjs not found" >&2
else exit 1
# For push events, extract version and generate a name fi
if [ ! -f scripts/build.mjs ]; then # Print a snippet of the file for debugging in workflow logs
echo "scripts/build.mjs not found" >&2 echo '--- scripts/build.mjs (snippet) ---' >&2
exit 1 sed -n '1,200p' scripts/build.mjs >&2 || true
fi
# Extract version # 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-9A-Za-z.-]+)?\"' scripts/build.mjs | tr -d '\"' | head -n1 || true) raw=$(grep -m1 -oE '\"[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?(-[0-9A-Za-z.-]+)?\"' scripts/build.mjs | tr -d '\"' | head -n1 || true)
version=$(printf '%s' "$raw" | sed -E 's/^([0-9]+\.[0-9]+\.[0-9]+).*/\1/')
if ! printf '%s' "$version" | grep -xE '^[0-9]+\.[0-9]+\.[0-9]+$' >/dev/null; then # Strip any prerelease suffix, keep only major.minor.patch or major.minor.patch.patch
echo "Failed to extract a valid base version from scripts/build.mjs" >&2 version=$(printf '%s' "$raw" | sed -E 's/^([0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?).*/\1/')
exit 1
fi
# Use version-based naming for push events # Validate final result is either major.minor.patch or major.minor.patch.patch
release_name="Release v${version}" if ! printf '%s' "$version" | grep -xE '^[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?$' >/dev/null; then
release_tag="v${version}" echo "Failed to extract a valid base version from scripts/build.mjs" >&2
sed -n '1,200p' scripts/build.mjs >&2 || true
exit 1
fi fi
echo "release_name=${release_name}" >> $GITHUB_OUTPUT echo "version=$version" >> $GITHUB_OUTPUT
echo "release_tag=${release_tag}" >> $GITHUB_OUTPUT
echo "Using release name: ${release_name}" >&2
echo "Using release tag: ${release_tag}" >&2
shell: bash shell: bash
- name: Prepare tag - name: Prepare tag
@ -79,7 +65,9 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
tag="${{ steps.release_info.outputs.release_tag }}" # Use the extracted version directly as the tag for main branch releases
version="${{ steps.extract.outputs.version }}"
tag="${version}"
echo "[debug] computed tag=${tag}" >&2 echo "[debug] computed tag=${tag}" >&2
@ -107,10 +95,10 @@ jobs:
- name: Create Release - name: Create Release
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v1
with: with:
tag_name: ${{ steps.release_info.outputs.release_tag }} tag_name: ${{ steps.prepare.outputs.tag }}
name: ${{ steps.release_info.outputs.release_name }} name: Release ${{ steps.prepare.outputs.tag }}
files: | files: |
dist/shiggycord.js dist/gooncord.js
dist/shiggycord.min.js dist/gooncord.min.js
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -1,7 +1,3 @@
{ {
"languages": { "format_on_save": "off",
"TypeScript": {
"format_on_save": "off"
}
}
} }

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 ## Installing
### Android ### 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 ### Android/iOS
- **Injecting bundle:** - **Injecting bundle:**
```url ```url
https://github.com/kmmiio99o/ShiggyCord/releases/latest/download/shiggycord.js https://github.com/OTKUSteyler/GoonCord/releases/latest/download/gooncord.js
``` ```
## Building ## 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. Go to Settings > General and enable Developer Settings.
1. Clone the repo: 1. Clone the repo:
``` ```
git clone https://github.com/kmmiio99o/Shiggycord.git git clone https://github.com/OTKUSteyler/gooncord.git
``` ```
1. Install dependencies: 1. Install dependencies:
``` ```
bun i bun i
``` ```
1. Build ShiggyCord's code: 1. Build GoonCord's code:
``` ```
bun run build 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. 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. 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 ShiggyCord's bundled code from your server, rather than GitHub. 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! 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)

1962
bun.lock

File diff suppressed because it is too large Load diff

View file

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

View file

@ -30,7 +30,7 @@ let context = null;
const config = { const config = {
entryPoints: ["src/entry.ts"], entryPoints: ["src/entry.ts"],
bundle: true, bundle: true,
outfile: "dist/shiggycord.js", outfile: "dist/gooncord.js",
format: "iife", format: "iife",
splitting: false, splitting: false,
// Enable minification by default for release builds (when a release-branch is provided). // Enable minification by default for release builds (when a release-branch is provided).
@ -50,12 +50,14 @@ const config = {
window: "globalThis", window: "globalThis",
__DEV__: JSON.stringify(releaseBranch !== "main"), __DEV__: JSON.stringify(releaseBranch !== "main"),
}, },
inject: ["./shims/asyncIteratorSymbol.js", "./shims/promiseAllSettled.js"], inject: ["./shims/asyncIteratorSymbol.js", "./shims/promiseAllSettled.js", "./shims/weakref.js"],
legalComments: "none", legalComments: "none",
alias: { alias: {
"!ShiggyCord-deps-shim!": "./shims/depsModule.ts", "!ShiggyCord-deps-shim!": "./shims/depsModule.ts",
spitroast: "./node_modules/spitroast", spitroast: "./node_modules/spitroast",
"react/jsx-runtime": "./shims/jsxRuntime", "react/jsx-runtime": "./shims/jsxRuntime",
"@gullerya/object-observer":
path.resolve("./node_modules/@gullerya/object-observer/dist/object-observer.min.js"),
}, },
plugins: [ plugins: [
globalPlugin({ globalPlugin({
@ -75,7 +77,7 @@ const config = {
constModules: { constModules: {
globals: { globals: {
"bunny-build-info": { "bunny-build-info": {
version: `"Jolly 1.4"`, version: `"1.4.1.8"`,
}, },
}, },
}, },

View file

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

128
shims/weakref.js Normal file
View file

@ -0,0 +1,128 @@
// @ts-nocheck
/**
* WeakRef / FinalizationRegistry shim
*
* Purpose:
* - Provide a safe fallback when the runtime doesn't implement WeakRef or FinalizationRegistry.
* - This fallback avoids a ReferenceError and keeps code paths functional, but it does NOT
* emulate true weak-reference GC behavior. Use it only as a compatibility fallback.
*
*/
let _shimWarned = false;
function _warnOnce(msg) {
if (!_shimWarned) {
// keep this quiet by default; print only when fallback is actually used
try {
// Prefer console.warn when available
if (typeof console !== "undefined" && typeof console.warn === "function") {
console.warn(`[weakref-shim] ${msg}`);
}
} catch (_) {}
_shimWarned = true;
}
}
/**
* WeakRef fallback
* - Keeps a strong reference to the target (so it will not be GC'd).
* - Implements the minimal `deref()` API.
*/
class WeakRefFallback {
constructor(value) {
// Keep a strong reference: this is *not* weak.
this.__value = value;
_warnOnce(
"WeakRef is not available in this runtime. Using a strong-reference fallback — memory usage/semantics may differ."
);
}
deref() {
return this.__value;
}
}
/**
* FinalizationRegistry fallback
* - No-op registry: register/unregister do nothing.
* - Implement `cleanupSome` as a best-effort sync call when a callback is provided,
* but it cannot be tied to GC.
*/
class FinalizationRegistryFallback {
constructor(cleanupCallback) {
// store callback to allow optional manual invocation via cleanupSome
this.__cleanupCallback = typeof cleanupCallback === "function" ? cleanupCallback : null;
_warnOnce(
"FinalizationRegistry is not available in this runtime. Using a no-op fallback — finalizers will not run based on GC."
);
}
/**
* register(target, heldValue, unregisterToken?)
* - No-op in fallback. We keep no strong references here on purpose (can't emulate GC).
*/
register(_target /*, _heldValue, _unregisterToken */) {
// intentionally no-op
}
/**
* unregister(unregisterToken)
* - No-op in fallback.
*/
unregister(_unregisterToken) {
// intentionally no-op
}
/**
* cleanupSome(callback?)
* - If provided a callback or a stored cleanup callback exists, call it immediately.
* - This is NOT equivalent to real FinalizationRegistry cleanup; it's only a manual helper.
*/
cleanupSome(callback) {
const cb = typeof callback === "function" ? callback : this.__cleanupCallback;
if (typeof cb === "function") {
try {
cb();
} catch (e) {
try {
if (typeof console !== "undefined" && typeof console.error === "function") {
console.error("[weakref-shim] error running cleanupSome callback:", e);
}
} catch (_) {}
}
}
}
}
// If native implementations exist, prefer them; otherwise install fallbacks.
// We assign to globalThis so code using `WeakRef`/`FinalizationRegistry` globals doesn't crash.
const NativeWeakRef =
typeof globalThis !== "undefined" && typeof globalThis.WeakRef === "function" ? globalThis.WeakRef : undefined;
const NativeFinalizationRegistry =
typeof globalThis !== "undefined" && typeof globalThis.FinalizationRegistry === "function"
? globalThis.FinalizationRegistry
: undefined;
const WeakRefImpl = NativeWeakRef ?? WeakRefFallback;
const FinalizationRegistryImpl = NativeFinalizationRegistry ?? FinalizationRegistryFallback;
try {
if (typeof globalThis !== "undefined") {
if (typeof globalThis.WeakRef === "undefined") {
// Install fallback
globalThis.WeakRef = WeakRefImpl;
}
if (typeof globalThis.FinalizationRegistry === "undefined") {
globalThis.FinalizationRegistry = FinalizationRegistryImpl;
}
}
} catch (e) {
// Some embedder environments may forbid writing globals; ignore in that case.
_warnOnce("Unable to assign WeakRef/FinalizationRegistry to globalThis: " + (e && e.message));
}
// Export the implementations so the inject mechanism (or explicit imports) can reference them.
// Export names are sorted to satisfy lint rules that require sorted exports.
export { FinalizationRegistryImpl as FinalizationRegistry, WeakRefImpl as WeakRef };

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,16 +3,43 @@ import { PluginInstanceInternal } from "@lib/addons/plugins/types";
interface CorePlugin { interface CorePlugin {
default: PluginInstanceInternal; default: PluginInstanceInternal;
preenabled: boolean; 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 // Called from @lib/plugins
export const getCorePlugins = (): Record<string, CorePlugin> => ({ export const getCorePlugins = (): CorePluginRecord => ({
"bunny.quickinstall": { ...require("./quickinstall"), preenabled: true }, "bunny.quickinstall": makeLazyPlugin(() => require("./quickinstall"), true),
"bunny.badges": require("./badges"), "bunny.badges": makeLazyPlugin(() => require("./badges")),
"bunny.notrack": require("./notrack"), "bunny.notrack": makeLazyPlugin(() => require("./notrack")),
"bunny.messagefix": require("./messagefix"), "bunny.fixembed": makeLazyPlugin(() => require("./fixembed")),
"bunny.fixembed": require("./fixembed"), "bunny.favouriteanything": makeLazyPlugin(() => require("./favouriteanything")),
"bunny.enhancements": require("./shiggyenhancements") "bunny.nodelete": makeLazyPlugin(() => require("./nodelete")),
"bunny.maxverstappen": makeLazyPlugin(() => require("./maxverstappen")),
}); });
/** /**

Binary file not shown.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,13 +5,7 @@ import { installTheme, removeTheme, themes } from "@lib/addons/themes";
import { findAssetId } from "@lib/api/assets"; import { findAssetId } from "@lib/api/assets";
import { isThemeSupported } from "@lib/api/native/loader"; import { isThemeSupported } from "@lib/api/native/loader";
import { after } from "@lib/api/patcher"; import { after } from "@lib/api/patcher";
import { 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";
HTTP_REGEX_MULTI,
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 { lazyDestructure } from "@lib/utils/lazy";
import { Button } from "@metro/common/components"; import { Button } from "@metro/common/components";
import { findByProps, findByPropsLazy } from "@metro/wrappers"; import { findByProps, findByPropsLazy } from "@metro/wrappers";
@ -22,91 +16,74 @@ type PostType = "Plugin" | "Theme";
// const ForumPostLongPressActionSheet = findByNameProxy("ForumPostLongPressActionSheet", false); // const ForumPostLongPressActionSheet = findByNameProxy("ForumPostLongPressActionSheet", false);
// const { ActionSheetRow } = findByPropsProxy("ActionSheetRow"); // const { ActionSheetRow } = findByPropsProxy("ActionSheetRow");
const { useFirstForumPostMessage } = lazyDestructure(() => const { useFirstForumPostMessage } = lazyDestructure(() => findByProps("useFirstForumPostMessage"));
findByProps("useFirstForumPostMessage"),
);
const forumReactions = findByPropsLazy("MostCommonForumPostReaction"); const forumReactions = findByPropsLazy("MostCommonForumPostReaction");
const postMap = { const postMap = {
Plugin: { Plugin: {
storage: VdPluginManager.plugins, storage: VdPluginManager.plugins,
urlsFilter: (url: string) => url.startsWith(VD_PROXY_PREFIX), urlsFilter: (url: string) => url.startsWith(VD_PROXY_PREFIX),
installOrRemove: (url: string) => { installOrRemove: (url: string) => {
const isInstalled = postMap.Plugin.storage[url]; const isInstalled = postMap.Plugin.storage[url];
return isInstalled return isInstalled ? VdPluginManager.removePlugin(url) : VdPluginManager.installPlugin(url);
? VdPluginManager.removePlugin(url) }
: VdPluginManager.installPlugin(url);
}, },
}, Theme: {
Theme: { storage: themes,
storage: themes, urlsFilter: (url: string) => url.endsWith(".json"),
urlsFilter: (url: string) => url.endsWith(".json"), installOrRemove: (url: string) => {
installOrRemove: (url: string) => { const isInstalled = postMap.Theme.storage[url];
const isInstalled = postMap.Theme.storage[url]; return isInstalled ? removeTheme(url) : installTheme(url);
return isInstalled ? removeTheme(url) : installTheme(url); },
}, }
},
}; };
function useExtractThreadContent( function useExtractThreadContent(thread: any, _firstMessage = null, actionSheet = false): ([PostType, string]) | void {
thread: any, if (thread.guild_id !== KETTU_DISCORD_SERVER_ID && thread.guild_id !== VD_DISCORD_SERVER_ID) return;
_firstMessage = null,
actionSheet = false,
): [PostType, string] | void {
if (thread.guild_id !== VD_DISCORD_SERVER_ID) return;
// Determine what type of addon this is. // Determine what type of addon this is.
let postType: PostType; let postType: PostType;
if (thread.parent_id === VD_PLUGINS_CHANNEL_ID) { if (thread.parent_id === KETTU_PLUGINS_CHANNEL_ID || thread.parent_id === VD_PLUGINS_CHANNEL_ID) {
postType = "Plugin"; postType = "Plugin";
} else if (thread.parent_id === VD_THEMES_CHANNEL_ID && isThemeSupported()) { } else if ((thread.parent_id === KETTU_THEMES_CHANNEL_ID || thread.parent_id === VD_THEMES_CHANNEL_ID) && isThemeSupported()) {
postType = "Theme"; postType = "Theme";
} else return; } else return;
const { firstMessage } = actionSheet const { firstMessage } = actionSheet ? useFirstForumPostMessage(thread) : { firstMessage: _firstMessage };
? useFirstForumPostMessage(thread)
: { firstMessage: _firstMessage };
const urls = firstMessage?.content const urls = firstMessage?.content?.match(HTTP_REGEX_MULTI)?.filter(postMap[postType].urlsFilter);
?.match(HTTP_REGEX_MULTI) if (!urls || !urls[0]) return;
?.filter(postMap[postType].urlsFilter);
if (!urls || !urls[0]) return;
// Sync with lib/managers/plugins // Sync with lib/managers/plugins
if (postType === "Plugin" && !urls[0].endsWith("/")) urls[0] += "/"; if (postType === "Plugin" && !urls[0].endsWith("/")) urls[0] += "/";
return [postType, urls[0]]; return [postType, urls[0]];
} }
function useInstaller( function useInstaller(thread: any, firstMessage = null, actionSheet = false): [true] | [false, PostType, boolean, boolean, () => Promise<void>] {
thread: any, const [postType, url] = useExtractThreadContent(thread, firstMessage, actionSheet) ?? [];
firstMessage = null,
actionSheet = false,
): [true] | [false, PostType, boolean, boolean, () => Promise<void>] {
const [postType, url] =
useExtractThreadContent(thread, firstMessage, actionSheet) ?? [];
useProxy(VdPluginManager.plugins); useProxy(VdPluginManager.plugins);
useProxy(themes); 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 () => { const installOrRemove = async () => {
setIsInstalling(true); setIsInstalling(true);
try { try {
await postMap[postType].installOrRemove(url); await postMap[postType].installOrRemove(url);
} catch (e: any) { } catch (e: any) {
showToast(e.message, findAssetId("Small")); showToast(e.message, findAssetId("Small"));
} finally { } finally {
setIsInstalling(false); setIsInstalling(false);
} }
}; };
return [false, postType, isInstalled, isInstalling, installOrRemove]; return [false, postType, isInstalled, isInstalling, installOrRemove];
} }
// // apparently broken??? // // apparently broken???
@ -127,43 +104,33 @@ function useInstaller(
// </ActionsSection>); // </ActionsSection>);
// }); // });
const installButtonPatch = () => const installButtonPatch = () => after("MostCommonForumPostReaction", forumReactions, ([{ thread, firstMessage }], res) => {
after( const [shouldReturn, _, installed, loading, installOrRemove] = useInstaller(thread, firstMessage, true);
"MostCommonForumPostReaction", if (shouldReturn) return;
forumReactions,
([{ thread, firstMessage }], res) => {
const [shouldReturn, _, installed, loading, installOrRemove] =
useInstaller(thread, firstMessage, true);
if (shouldReturn) return;
return ( return <>
<> {res}
{res} <ErrorBoundary>
<ErrorBoundary>
<Button <Button
size="sm" size="sm"
loading={loading} loading={loading}
disabled={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 ? "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"} variant={installed ? "secondary" : "primary"}
text={installed ? Strings.UNINSTALL : Strings.INSTALL} text={installed ? Strings.UNINSTALL : Strings.INSTALL}
onPress={installOrRemove} onPress={installOrRemove}
icon={findAssetId( icon={findAssetId(installed ? "ic_message_delete" : "DownloadIcon")}
installed ? "ic_message_delete" : "DownloadIcon", style={{ marginLeft: 8 }}
)}
style={{ marginLeft: 8 }}
/> />
</ErrorBoundary> </ErrorBoundary>
</> </>;
); });
},
);
export default () => { export default () => {
const patches = [ const patches = [
// actionSheetPatch(), // actionSheetPatch(),
installButtonPatch(), 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 { findAssetId } from "@lib/api/assets";
import { isThemeSupported } from "@lib/api/native/loader"; import { isThemeSupported } from "@lib/api/native/loader";
import { after, instead } from "@lib/api/patcher"; 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 { lazyDestructure } from "@lib/utils/lazy";
import { channels } from "@metro/common"; import { channels } from "@metro/common";
import { byMutableProp } from "@metro/filters"; 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. // Make clicking on theme links only work in #themes, should there be a theme proxy in the future, this can be removed.
if ( if (
urlType === "theme" && 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); 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? // TODO: These styles work weirdly. Low DPI Has cramped text. Fix?
const useStyles = createStyles({ const useStyles = createStyles({
card: { card: {
backgroundColor: semanticColors?.CARD_SECONDARY_BG, backgroundColor: semanticColors?.CARD_SECONDARY_BG,
borderRadius: 12, borderRadius: 12,
overflow: "hidden", overflow: "hidden"
}, },
header: { header: {
padding: 0, padding: 0,
}, },
headerLeading: { headerLeading: {
flexDirection: "column", flex: 1,
justifyContent: "center", flexDirection: "column",
scale: 1.2, justifyContent: "center",
}, scale: 1.2
headerTrailing: { },
display: "flex", headerTrailing: {
flexDirection: "row", display: "flex",
gap: 15, flexDirection: "row",
alignItems: "center", gap: 15,
}, alignItems: "center"
headerLabel: { },
...TextStyleSheet["heading-md/semibold"], headerLabel: {
color: semanticColors.MOBILE_TEXT_HEADING_PRIMARY, ...TextStyleSheet["heading-lg/semibold"],
}, },
headerSubtitle: { headerLabelCompact: {
...TextStyleSheet["text-md/semibold"], ...TextStyleSheet["text-md/semibold"],
color: semanticColors.TEXT_MUTED, },
}, headerSubtitle: {
descriptionLabel: { ...TextStyleSheet["text-sm/semibold"],
...TextStyleSheet["text-md/semibold"], color: semanticColors.TEXT_MUTED,
color: semanticColors.TEXT_NORMAL, },
}, descriptionLabel: {
actions: { ...TextStyleSheet["text-md/semibold"],
flexDirection: "row-reverse", color: "text-strong",
alignItems: "center", },
gap: 5, actions: {
}, flexDirection: "row-reverse",
iconStyle: { alignItems: "center",
tintColor: semanticColors.INTERACTIVE_ICON_DEFAULT, gap: 5
opacity: 0.2, },
height: 64, iconStyle: {
width: 64, tintColor: semanticColors.LOGO_PRIMARY,
left: void 0, opacity: 0.2,
right: "30%", height: 64,
top: "-10%", width: 64,
}, left: void 0,
right: "30%",
top: "-10%"
}
}); });
interface Action { interface Action {
@ -108,9 +111,9 @@ export default function AddonCard(props: CardProps) {
<Stack spacing={16}> <Stack spacing={16}>
<View style={{ flexDirection: "row", alignItems: "center" }}> <View style={{ flexDirection: "row", alignItems: "center" }}>
<View style={styles.headerLeading}> <View style={styles.headerLeading}>
<Text style={styles.headerLabel}>{props.headerLabel}</Text> <Text numberOfLines={1} ellipsizeMode="tail" style={styles.headerLabel}>{props.headerLabel}</Text>
{props.headerSublabel && ( {props.headerSublabel && (
<Text style={styles.headerSubtitle}>{props.headerSublabel}</Text> <Text numberOfLines={1} ellipsizeMode="tail" style={styles.headerSubtitle}>{props.headerSublabel}</Text>
)} )}
</View> </View>
<View style={[styles.headerTrailing, { marginLeft: "auto" }]}> <View style={[styles.headerTrailing, { marginLeft: "auto" }]}>

View file

@ -28,8 +28,6 @@ import {
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
useRef,
useState,
} from "react"; } from "react";
import { Image, ScrollView, View } from "react-native"; import { Image, ScrollView, View } from "react-native";
@ -182,6 +180,27 @@ export default function AddonPage<T extends object>({
const { bottom: bottomInset } = useSafeAreaInsets(); const { bottom: bottomInset } = useSafeAreaInsets();
const navigation = NavigationNative.useNavigation(); const navigation = NavigationNative.useNavigation();
// Lazy-load fuzzysort to reduce initial bundle size
const fuzzysortRef = React.useRef<any>(null);
const [fuzzysortReady, setFuzzysortReady] = React.useState(false);
React.useEffect(() => {
let mounted = true;
import("fuzzysort")
.then((mod) => {
const f = (mod as any).default ?? mod;
if (mounted) {
fuzzysortRef.current = f;
setFuzzysortReady(true);
}
})
.catch(() => {
// ignore load errors
});
return () => {
mounted = false;
};
}, []);
useEffect(() => { useEffect(() => {
if (props.OptionsActionSheetComponent) { if (props.OptionsActionSheetComponent) {
navigation.setOptions({ navigation.setOptions({
@ -199,27 +218,6 @@ export default function AddonPage<T extends object>({
} }
}, [navigation]); }, [navigation]);
const fuzzysortRef = useRef<any>(null);
const [fuzzLoaded, setFuzzLoaded] = useState(false);
useEffect(() => {
let cancelled = false;
if (!fuzzysortRef.current) {
import("fuzzysort")
.then((m) => {
if (cancelled) return;
fuzzysortRef.current = m.default || m;
setFuzzLoaded(true);
})
.catch(() => {
// ignore dynamic import failures; we'll fallback to simple filtering
});
}
return () => {
cancelled = true;
};
}, []);
const results = useMemo(() => { const results = useMemo(() => {
let values = props.items; let values = props.items;
if (props.resolveItem) if (props.resolveItem)
@ -227,84 +225,13 @@ export default function AddonPage<T extends object>({
const items = values.filter((i) => isNotNil(i) && typeof i === "object"); const items = values.filter((i) => isNotNil(i) && typeof i === "object");
if (!search && sortFn) items.sort(sortFn); if (!search && sortFn) items.sort(sortFn);
// Prefer fuzzysort when loaded and a search query exists if (!fuzzysortReady || !fuzzysortRef.current) return [] as any[];
if (fuzzysortRef.current && search) {
try {
return fuzzysortRef.current.go(search, items, {
keys: props.searchKeywords,
all: true,
});
} catch {
// If fuzzysort throws for some reason, fall back to the simple filter below
}
}
// If there is no search query, return items shaped similarly to fuzzysort results return fuzzysortRef.current.go(search, items, {
const makeFallbackResult = (obj: any, keys: any[]) => { keys: props.searchKeywords,
const r: any = { obj }; all: true,
// current search query in the outer scope; only highlight when there's an active query
const q = String(search ?? "")
.toLowerCase()
.trim();
for (let k = 0; k < keys.length; k++) {
const key = keys[k];
let text = "";
try {
text =
typeof key === "string" ? String(obj[key] ?? "") : String(key(obj));
} catch {
text = "";
}
r[k] = {
target: text,
highlight: (cb: any) => {
// Do not highlight anything when there is no active search.
if (!q) return [];
const lower = text.toLowerCase();
const idx = lower.indexOf(q);
if (idx === -1) return [];
// Split into before/match/after and invoke the callback for each segment so
// consumers can render matched substring(s) with highlight styling while
// preserving surrounding context.
const parts: any[] = [];
let i = 0;
const before = text.slice(0, idx);
const match = text.slice(idx, idx + q.length);
const after = text.slice(idx + q.length);
if (before) parts.push(cb(before, i++));
parts.push(cb(match, i++));
if (after) parts.push(cb(after, i++));
return parts;
},
};
}
return r;
};
if (!search) {
return items.map((obj: any) =>
makeFallbackResult(obj, props.searchKeywords),
);
}
// Basic fallback: case-insensitive substring match across configured keys
const q = String(search).toLowerCase();
const filtered = items.filter((it: any) => {
for (const key of props.searchKeywords) {
try {
const text =
typeof key === "string" ? String(it[key] ?? "") : String(key(it));
if (text.toLowerCase().includes(q)) return true;
} catch {
// ignore any accessor errors and continue
}
}
return false;
}); });
}, [props.items, sortFn, search, fuzzysortReady]);
return filtered.map((obj: any) =>
makeFallbackResult(obj, props.searchKeywords),
);
}, [props.items, sortFn, search, fuzzLoaded]);
const onInstallPress = useCallback(() => { const onInstallPress = useCallback(() => {
if (!props.installAction) return () => {}; if (!props.installAction) return () => {};

View file

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

View file

@ -11,7 +11,7 @@ export { shiggycordIcon };
export default function initSettings() { export default function initSettings() {
registerSection({ registerSection({
name: "ShiggyCord", name: "GoonCord",
items: [ items: [
{ {
key: "SHIGGYCORD", key: "SHIGGYCORD",
@ -20,19 +20,10 @@ export default function initSettings() {
render: () => import("@core/ui/settings/pages/General"), render: () => import("@core/ui/settings/pages/General"),
useTrailing: () => `(${version})`, useTrailing: () => `(${version})`,
}, },
{
key: "CORE_PLUGINS",
title: () => Strings.PLUGINS,
icon: findAssetId("ActivitiesIcon"),
render: () => import("@core/ui/settings/pages/Plugins/CorePlugins"),
},
{ {
key: "BUNNY_PLUGINS", key: "BUNNY_PLUGINS",
title: () => "External Plugins", title: () => Strings.PLUGINS,
icon: icon: findAssetId("PuzzlePieceIcon"),
findAssetId("DownloadIcon") ??
findAssetId("LinkIcon") ??
findAssetId("ActivitiesIcon"),
render: () => import("@core/ui/settings/pages/Plugins"), render: () => import("@core/ui/settings/pages/Plugins"),
}, },
{ {

View file

@ -1,21 +1,111 @@
import AssetDisplay from "@core/ui/settings/pages/Developer/AssetDisplay"; import AssetDisplay from "@core/ui/settings/pages/Developer/AssetDisplay";
import { iterateAssets } from "@lib/api/assets"; 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 { 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"; 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() { export default function AssetBrowser() {
const [search, setSearch] = React.useState(""); const [search, setSearch] = useState("");
const all = useMemo(() => Array.from(iterateAssets()), []); 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 ( return (
<ErrorBoundary> <ErrorBoundary>
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<Search <View style={{ flexDirection: "row", margin: 10, gap: 8, alignItems: "center" }}>
style={{ margin: 10 }} <Search
onChangeText={(v: string) => setSearch(v)} style={{ flex: 1 }}
/> isRound
onChangeText={(v: string) => setSearch(v)}
/>
<IconButton
icon={findAssetId("FileIcon")}
variant="tertiary"
onPress={handleFilterPress}
/>
</View>
<View <View
style={{ style={{
flex: 1, flex: 1,
@ -25,18 +115,9 @@ export default function AssetBrowser() {
backgroundColor: "transparent", 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 <FlatList
data={all.filter( 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} />} renderItem={({ item }: any) => <AssetDisplay asset={item} />}
contentContainerStyle={{ contentContainerStyle={{

View file

@ -7,6 +7,9 @@ import {
connectToDebugger, connectToDebugger,
disconnectFromDebugger, disconnectFromDebugger,
isConnectedToDebugger, isConnectedToDebugger,
connectRdt,
disconnectRdt,
useIsRdtConnected,
} from "@lib/api/debug"; } from "@lib/api/debug";
import { import {
getReactDevToolsProp, getReactDevToolsProp,
@ -33,7 +36,7 @@ import { ErrorBoundary } from "@ui/components";
import ErrorBoundaryScreen from "@core/ui/reporter/components/ErrorBoundaryScreen"; import ErrorBoundaryScreen from "@core/ui/reporter/components/ErrorBoundaryScreen";
import { createStyles, TextStyleSheet } from "@ui/styles"; import { createStyles, TextStyleSheet } from "@ui/styles";
import { NativeModules } from "react-native"; import { NativeModules } from "react-native";
import { ScrollView, StyleSheet } from "react-native"; import { ScrollView } from "react-native";
import { showToast } from "@ui/toasts"; import { showToast } from "@ui/toasts";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
@ -72,6 +75,7 @@ export default function Developer() {
const [isDebuggerConnected, setIsDebuggerConnected] = useState( const [isDebuggerConnected, setIsDebuggerConnected] = useState(
isConnectedToDebugger(), isConnectedToDebugger(),
); );
const isRdtConnected = useIsRdtConnected();
const styles = useStyles(); const styles = useStyles();
const navigation = NavigationNative.useNavigation(); 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 ( return (
<ErrorBoundary> <ErrorBoundary>
<ScrollView <ScrollView
@ -106,108 +122,90 @@ export default function Developer() {
style={{ paddingVertical: 24, paddingHorizontal: 12 }} style={{ paddingVertical: 24, paddingHorizontal: 12 }}
spacing={24} spacing={24}
> >
<TableRowGroup title={Strings.DEBUGGER_URL}> <Stack spacing={4}>
<TextInput <TableRowGroup title={Strings.DEBUGGER_URL}>
placeholder="127.0.0.1:9090" <Stack spacing={4}>
size="md" <TextInput
leadingIcon={() => ( placeholder="127.0.0.1:9090"
<LegacyFormText style={styles.leadingText}> size="md"
ws:// leadingIcon={() => (
</LegacyFormText> <LegacyFormText style={styles.leadingText}>
)} ws://
defaultValue={settings.debuggerUrl} </LegacyFormText>
onChange={(v: string) => (settings.debuggerUrl = v)} )}
/> defaultValue={settings.debuggerUrl}
<TableSwitchRow onChange={(v: string) => (settings.debuggerUrl = v)}
label={Strings.AUTO_DEBUGGER} />
icon={<TableRow.Icon source={findAssetId("copy")} />} </Stack>
value={settings.autoDebugger} </TableRowGroup>
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>
{isReactDevToolsPreloaded() && ( <TableRowGroup>
<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)}
/>
<TableSwitchRow <TableSwitchRow
label={Strings.AUTO_DEVTOOLS} label={Strings.AUTO_DEBUGGER}
icon={<TableRow.Icon source={findAssetId("ic_badge_staff")} />} icon={<TableRow.Icon source={findAssetId("copy")} />}
value={settings.autoDevTools} value={settings.autoDebugger}
onValueChange={(v: boolean) => { onValueChange={(v: boolean) => {
settings.autoDevTools = v; settings.autoDebugger = v;
}} }}
/> />
<TableRow <TableRow
label={Strings.CONNECT_TO_REACT_DEVTOOLS} label={Strings.CONNECT_TO_DEBUG_WEBSOCKET}
subLabel="Connect React DevTools for component debugging" subLabel="Connect DevTools for debugging"
icon={<TableRow.Icon source={findAssetId("ic_badge_staff")} />} icon={<TableRow.Icon source={findAssetId("WrenchIcon")} />}
onPress={async () => { onPress={handleDebuggerConnect}
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"),
);
}
}}
/> />
</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 <TableSwitchRow
label={Strings.LOAD_REACT_DEVTOOLS} label={Strings.AUTO_DEVTOOLS}
subLabel={`${Strings.VERSION}: ${getReactDevToolsVersion()}`} icon={<TableRow.Icon source={findAssetId("ic_badge_staff")} />}
icon={ value={settings.autoDevTools}
<TableRow.Icon source={findAssetId("ic_badge_staff")} />
}
value={loaderConfig.loadReactDevTools}
onValueChange={(v: boolean) => { onValueChange={(v: boolean) => {
loaderConfig.loadReactDevTools = v; settings.autoDevTools = v;
}} }}
/> />
)} <TableRow
</TableRowGroup> 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() && ( {isLoaderConfigSupported() && (

View file

@ -16,8 +16,9 @@ import {
Text, Text,
} from "@metro/common/components"; } from "@metro/common/components";
import { findByProps } from "@metro"; import { findByProps } from "@metro";
import { useMemo, useState, useEffect } from "react"; import { useMemo, useState } from "react";
import { PixelRatio, View } from "react-native"; import { PixelRatio, View } from "react-native";
import { WebView } from "react-native-webview";
import previewHtml from "./preview.html"; import previewHtml from "./preview.html";
import FontEditor from "./FontEditor"; import FontEditor from "./FontEditor";
@ -35,7 +36,6 @@ const useStyles = createStyles({
function FontPreview({ font }: { font: FontDefinition }) { function FontPreview({ font }: { font: FontDefinition }) {
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [WebViewComponent, setWebViewComponent] = useState<any>(null);
const styles = useStyles(); const styles = useStyles();
const TEXT_DEFAULT = useToken(tokens.colors.TEXT_DEFAULT); const TEXT_DEFAULT = useToken(tokens.colors.TEXT_DEFAULT);
@ -53,61 +53,25 @@ function FontPreview({ font }: { font: FontDefinition }) {
[font.main, fontFamily, fontSize, TEXT_DEFAULT], [font.main, fontFamily, fontSize, TEXT_DEFAULT],
); );
// Memoize generated HTML to avoid string replacement on every render
const html = useMemo(
() => previewHtml.replace("$$props", JSON.stringify(props)),
[props],
);
// Lazy-load the WebView implementation so it's not included in the main bundle
useEffect(() => {
let cancelled = false;
import("react-native-webview")
.then((m) => {
if (cancelled) return;
// support both default export and named WebView export
setWebViewComponent(m.WebView || m.default || m);
})
.catch(() => {
// ignore dynamic import failures; component will show placeholder
});
return () => {
cancelled = true;
};
}, []);
return ( return (
<View style={{ width: "100%", height: 64 }}> <View style={{ width: "100%", height: 64 }}>
{WebViewComponent ? ( <WebView
<> onMessage={() => setLoaded(true)}
<WebViewComponent source={{
onMessage={() => setLoaded(true)} html: previewHtml.replace("$$props", JSON.stringify(props)),
source={{ html }} }}
javaScriptEnabled javaScriptEnabled
scrollEnabled={false} scrollEnabled={false}
overScrollMode="never" overScrollMode="never"
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
pointerEvents="none" pointerEvents="none"
style={[ style={[
styles.full, styles.full,
{ backgroundColor: "transparent", opacity: Number(loaded) }, { backgroundColor: "transparent", opacity: Number(loaded) },
]} ]}
/> />
{!loaded && ( {!loaded && (
<View
style={[
styles.full,
{ justifyContent: "center", alignItems: "center" },
]}
>
<Text color="text-muted" variant="heading-lg/semibold">
Loading...
</Text>
</View>
)}
</>
) : (
<View <View
style={[ style={[
styles.full, styles.full,

View file

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

View file

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

View file

@ -1,45 +0,0 @@
import { React } from "@metro/common";
import AddonPage from "@core/ui/components/AddonPage";
import PluginCard from "@core/ui/settings/pages/Plugins/components/PluginCard";
import { corePluginInstances, registeredPlugins } from "@lib/addons/plugins";
import { UnifiedPluginModel } from "@core/ui/settings/pages/Plugins/models";
import unifyBunnyPlugin from "@core/ui/settings/pages/Plugins/models/bunny";
import { Strings } from "@core/i18n";
/**
* Core plugins settings page
*
* This page shows only the built-in core plugins. Core plugins are always
* installed (can't be uninstalled) and are registered in `corePluginInstances`.
*
* We reuse the existing `AddonPage` + `PluginCard` components and feed them
* the normalized `UnifiedPluginModel` objects produced by `unifyBunnyPlugin`.
*/
export default function CorePlugins() {
return (
<AddonPage<UnifiedPluginModel>
CardComponent={PluginCard}
title={Strings.PLUGINS}
searchKeywords={[
"name",
"description",
(p) =>
p.authors
?.map((a: any) => (typeof a === "string" ? a : a.name))
.join() || "",
]}
// Provide simple sort options so the Search input renders round and a sort menu appears
sortOptions={{
"Name (A-Z)": (a, b) => a.name.localeCompare(b.name),
"Name (Z-A)": (a, b) => b.name.localeCompare(a.name),
}}
// Only core plugins: map corePluginInstances -> registeredPlugins -> unify
items={[...corePluginInstances.keys()]
.map((id) => registeredPlugins.get(id))
.filter((m): m is any => m !== undefined)
.map(unifyBunnyPlugin)}
ListHeaderComponent={() => null}
/>
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,6 +3,7 @@ import patchDefinitionAndResolver from "./patches/resolver";
import patchStorage from "./patches/storage"; import patchStorage from "./patches/storage";
import { ColorManifest } from "./types"; import { ColorManifest } from "./types";
import { updateBunnyColor } from "./updater"; import { updateBunnyColor } from "./updater";
import fixStatusBar from "./statusbar";
/** @internal */ /** @internal */
export default function initColors(manifest: ColorManifest | null) { export default function initColors(manifest: ColorManifest | null) {
@ -11,8 +12,9 @@ export default function initColors(manifest: ColorManifest | null) {
const patches = [ const patches = [
patchStorage(), patchStorage(),
patchDefinitionAndResolver(), 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 { findByProps } from "@metro";
import chroma from "chroma-js";
import { omit } from "es-toolkit"; import { omit } from "es-toolkit";
import { Platform, processColor } from "react-native"; import { Platform, processColor } from "react-native";
const getChroma = () => require("chroma-js") as typeof import("chroma-js").default;
import { colorsPref } from "./preferences"; import { colorsPref } from "./preferences";
import { ColorManifest, InternalColorDefinition } from "./types"; import { ColorManifest, InternalColorDefinition } from "./types";
@ -11,147 +12,224 @@ const tokenRef = findByProps("SemanticColor");
export function parseColorManifest(manifest: ColorManifest): InternalColorDefinition { export function parseColorManifest(manifest: ColorManifest): InternalColorDefinition {
const resolveType = (type = "dark") => (colorsPref.type ?? type) === "dark" ? "darker" : "light"; const resolveType = (type = "dark") => (colorsPref.type ?? type) === "dark" ? "darker" : "light";
if (manifest.spec === 3) { if (manifest.spec === 3) {
const semanticColorDefinitions: InternalColorDefinition["semantic"] = {}; const semanticColorDefinitions: InternalColorDefinition["semantic"] = {};
for (const [semanticColorKey, semanticColorValue] of Object.entries( for (const [semanticColorKey, semanticColorValue] of Object.entries(manifest.main.semantic ?? {})) {
manifest.main.semantic ?? {}, if (typeof semanticColorValue === "object") {
)) { const { type, value, opacity: semanticColorOpacity } = semanticColorValue;
if (typeof semanticColorValue === "object") {
const {
type,
value,
opacity: semanticColorOpacity,
} = semanticColorValue;
if (type === "raw") { if (type === "raw") {
semanticColorDefinitions[semanticColorKey] = { semanticColorDefinitions[semanticColorKey] = {
value, value,
opacity: semanticColorOpacity ?? 1, opacity: semanticColorOpacity ?? 1,
}; };
} else { } else {
const rawColorValue = tokenRef.RawColor[value]; const rawColorValue = tokenRef.RawColor[value];
semanticColorDefinitions[semanticColorKey] = { semanticColorDefinitions[semanticColorKey] = {
value: rawColorValue, value: rawColorValue,
opacity: semanticColorOpacity ?? 1, 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 { return {
spec: 3, spec: 3,
reference: resolveType(manifest.type), reference: resolveType(manifest.type),
semantic: semanticColorDefinitions, semantic: semanticColorDefinitions,
raw: manifest.main.raw ?? {}, raw: manifest.main.raw ?? {},
background: manifest.main.background, 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,
}; };
}
} }
if (manifest.rawColors) { if (manifest.spec === 2) { // is Vendetta theme
const draft: typeof manifest.rawColors = {}; 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) { if (manifest.semanticColors) {
const value = manifest.rawColors[key]; for (const key in manifest.semanticColors) {
if (!value) continue; const values = manifest.semanticColors[key].map(c => c || undefined).slice(0, 2);
draft[key] = normalizeToHex(value)!; if (!values[0]) continue;
}
if (Platform.OS === "android") applyAndroidAlphaKeys(draft); semanticDefinitions[key] = {
manifest.rawColors = draft; 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 { if (manifest.spec === 1 || (manifest.theme_color_map && !manifest.spec)) {
spec: 2,
reference: resolveType(),
semantic: semanticDefinitions,
raw: manifest.rawColors ?? {},
background,
};
}
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>) { export function applyAndroidAlphaKeys(rawColors?: Record<string, string>) {
if (!rawColors) return; if (!rawColors) return;
// these are native Discord Android keys // these are native Discord Android keys
const alphaMap: Record<string, [string, number]> = { const alphaMap: Record<string, [string, number]> = {
BLACK_ALPHA_60: ["BLACK", 0.6], "BLACK_ALPHA_60": ["BLACK", 0.6],
BRAND_NEW_360_ALPHA_20: ["BRAND_360", 0.2], "BRAND_NEW_360_ALPHA_20": ["BRAND_360", 0.2],
BRAND_NEW_360_ALPHA_25: ["BRAND_360", 0.25], "BRAND_NEW_360_ALPHA_25": ["BRAND_360", 0.25],
BRAND_NEW_500_ALPHA_20: ["BRAND_500", 0.2], "BRAND_NEW_500_ALPHA_20": ["BRAND_500", 0.2],
PRIMARY_DARK_500_ALPHA_20: ["PRIMARY_500", 0.2], "PRIMARY_DARK_500_ALPHA_20": ["PRIMARY_500", 0.2],
PRIMARY_DARK_700_ALPHA_60: ["PRIMARY_700", 0.6], "PRIMARY_DARK_700_ALPHA_60": ["PRIMARY_700", 0.6],
STATUS_GREEN_500_ALPHA_20: ["GREEN_500", 0.2], "STATUS_GREEN_500_ALPHA_20": ["GREEN_500", 0.2],
STATUS_RED_500_ALPHA_20: ["RED_500", 0.2], "STATUS_RED_500_ALPHA_20": ["RED_500", 0.2],
}; };
for (const key in alphaMap) { for (const key in alphaMap) {
const [colorKey, alpha] = alphaMap[key]; const [colorKey, alpha] = alphaMap[key];
if (!rawColors[colorKey]) continue; if (!rawColors[colorKey]) continue;
rawColors[key] = chroma(rawColors[colorKey]).alpha(alpha).hex(); rawColors[key] = getChroma()(rawColors[colorKey]).alpha(alpha).hex();
} }
return rawColors; return rawColors;
} }
export function normalizeToHex( export function normalizeToHex(colorString: string | undefined): string | undefined {
colorString: string | undefined, if (colorString === undefined) return undefined;
): string | undefined {
if (colorString === undefined) return undefined;
if (chroma.valid(colorString)) return chroma(colorString).hex();
const color = Number(processColor(colorString)); if (colorString.toLowerCase() === "transparent") {
return "#00000000";
}
return chroma if (getChroma().valid(colorString)) return getChroma()(colorString).hex();
.rgb(
(color >> 16) & 0xff, // red const color = Number(processColor(colorString));
(color >> 8) & 0xff, // green
color & 0xff, // blue return getChroma().rgb(
(color >> 24) & 0xff, // alpha color >> 16 & 0xff, // red
) color >> 8 & 0xff, // green
.hex(); color & 0xff, // blue
color >> 24 & 0xff // alpha
).hex();
} }

View file

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

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 { findByStoreNameLazy } from "@metro/wrappers";
import { _colorRef } from "./updater"; import { _colorRef } from "./updater";
@ -7,15 +7,8 @@ const ThemeStore = findByStoreNameLazy("ThemeStore");
function getBarStyle() { function getBarStyle() {
// Check for custom theme // Check for custom theme
const custom = _colorRef.current; const custom = _colorRef.current;
if (custom) { if (custom?.reference) {
// Bunny theme (spec 3) return custom.reference === "light" ? "dark-content" : "light-content";
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";
}
} }
// Fallback to Discord theme string // Fallback to Discord theme string
@ -25,33 +18,35 @@ function getBarStyle() {
return (lowerTheme.includes("dark") || lowerTheme.includes("midnight")) ? "light-content" : "dark-content"; return (lowerTheme.includes("dark") || lowerTheme.includes("midnight")) ? "light-content" : "dark-content";
} }
return "dark-content"; return "light-content";
} }
export default function fixStatusBar() { 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; const origSetBarStyle = StatusBar.setBarStyle;
StatusBar.setBarStyle = function (_style: any, ...args: any[]) { StatusBar.setBarStyle = function (_style: any, ...args: any[]) {
return origSetBarStyle.call(this, getBarStyle(), ...args); return origSetBarStyle.call(this, getBarStyle(), ...args);
}; };
} }
if (Platform.OS == "ios") { const unsubscribe = ThemeStore?.addChangeListener?.(applyStatusBar);
function applyStatusBar() {
const style = getBarStyle();
StatusBar.setBarStyle(style, true);
}
const unsubscribe = ThemeStore?.addChangeListener?.(applyStatusBar); applyStatusBar();
StatusBar.setBarStyle(getBarStyle()); let delay = 200;
for (let i = 0; i < 5; i++, delay *= 2) {
let delay = 200; setTimeout(applyStatusBar, delay);
for (let i = 0; i < 5; i++, delay *= 2) {
setTimeout(applyStatusBar, delay);
}
return unsubscribe;
} }
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 */ /** @internal */
export interface InternalColorDefinition { export interface InternalColorDefinition {
spec: 1 | 2 | 3; spec: 1 | 2 | 3;
@ -49,4 +62,4 @@ export interface InternalColorDefinition {
background?: BackgroundDefinition; 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 AppearanceManager = findByPropsLazy("updateTheme");
const ThemeStore = findByStoreNameLazy("ThemeStore"); const ThemeStore = findByStoreNameLazy("ThemeStore");
const FormDivider = findByPropsLazy("DIVIDER_COLORS"); const FormDivider = findByPropsLazy("DIVIDER_COLORS");
import { colorsPref } from "./preferences";
let _inc = 1; let _inc = 1;
@ -28,45 +27,68 @@ export const _colorRef: InternalColorRef = {
lastSetDiscordTheme: "darker" lastSetDiscordTheme: "darker"
}; };
export function updateBunnyColor(colorManifest: ColorManifest | null, { update = true } ) { export function updateBunnyColor(colorManifest: ColorManifest | null, { update = true }) {
if (settings.safeMode?.enabled) return; if (settings.safeMode?.enabled) return;
const resolveType = (type = "dark") => (colorsPref.type ?? type) === "dark" ? "darker" : "light";
const internalDef = colorManifest ? parseColorManifest(colorManifest) : null; const internalDef = colorManifest ? parseColorManifest(colorManifest) : null;
const ref = Object.assign(_colorRef, {
current: internalDef,
key: `bn-theme-${++_inc}`,
lastSetDiscordTheme: !ThemeStore.theme.startsWith("bn-theme-")
? ThemeStore.theme
: _colorRef.lastSetDiscordTheme
});
if (resolveType() == "light" || update) { if (internalDef != null) {
var ref = Object.assign(_colorRef, { // Register the synthetic key BEFORE calling native, and verify it actually
current: internalDef, // landed in every table native reads from. If any registration step fails,
key: `bn-theme-${++_inc}`, // bail out to a known-good theme instead of handing native an unknown key.
lastSetDiscordTheme: !ThemeStore.theme.startsWith("bn-theme-") || !ThemeStore.theme.startsWith("darker") try {
? 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) {
tokenRef.Theme[ref.key.toUpperCase()] = ref.key; tokenRef.Theme[ref.key.toUpperCase()] = ref.key;
if (!FormDivider.DIVIDER_COLORS[ref.current!.reference]) {
throw new Error(`Missing divider color reference: ${ref.current!.reference}`);
}
FormDivider.DIVIDER_COLORS[ref.key] = FormDivider.DIVIDER_COLORS[ref.current!.reference]; FormDivider.DIVIDER_COLORS[ref.key] = FormDivider.DIVIDER_COLORS[ref.current!.reference];
Object.keys(tokenRef.Shadow).forEach(k => tokenRef.Shadow[k][ref.key] = tokenRef.Shadow[k][ref.current!.reference]); Object.keys(tokenRef.Shadow).forEach(k => {
if (!(ref.current!.reference in tokenRef.Shadow[k])) {
throw new Error(`Missing shadow reference: ${ref.current!.reference}`);
}
tokenRef.Shadow[k][ref.key] = tokenRef.Shadow[k][ref.current!.reference];
});
Object.keys(tokenRef.SemanticColor).forEach(k => { Object.keys(tokenRef.SemanticColor).forEach(k => {
if (!(ref.current!.reference in tokenRef.SemanticColor[k])) {
throw new Error(`Missing semantic color reference: ${ref.current!.reference}`);
}
tokenRef.SemanticColor[k][ref.key] = { tokenRef.SemanticColor[k][ref.key] = {
...tokenRef.SemanticColor[k][ref.current!.reference] ...tokenRef.SemanticColor[k][ref.current!.reference]
}; };
}); });
} catch (e) {
console.error("Failed to register custom theme key, falling back", e);
ref.current = null; // force fallback path below
} }
}
if (update) { if (update) {
AppearanceManager.setShouldSyncAppearanceSettings(false); AppearanceManager.setShouldSyncAppearanceSettings(false);
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 * @internal
*/ */
export function getThemeFromLoader(): VdThemeInfo | null { export function getThemeFromLoader(): VdThemeInfo | null {
return getStoredTheme(); return getStoredTheme() ?? getCurrentTheme();
} }
/** /**
@ -157,6 +157,7 @@ export async function initThemes() {
} }
await newAwaitStorage(colorsPref); await newAwaitStorage(colorsPref);
await awaitStorage(themes);
const currentTheme = getThemeFromLoader(); const currentTheme = getThemeFromLoader();
initColors(currentTheme?.data ?? null); initColors(currentTheme?.data ?? null);

View file

@ -43,7 +43,6 @@ export async function toggleSafeMode() {
setTimeout(BundleUpdaterManager.reload, 400); setTimeout(BundleUpdaterManager.reload, 400);
} }
// --- Debugger State & Logic ---
let socket: WebSocket | undefined; let socket: WebSocket | undefined;
let originalConsoleLog: any; let originalConsoleLog: any;
let originalConsoleError: any; let originalConsoleError: any;
@ -225,13 +224,14 @@ export function connectToDebugger(url: string) {
} }
}); });
socket.addEventListener("close", () => { socket.addEventListener("close", (e: any) => {
showToast("Disconnected from debugger.", findAssetId("Small")); logger.error(`Debugger disconnected. (${e?.code}, ${e?.reason || "no reason specified"})`);
unpatchConsoleAndLogger(); unpatchConsoleAndLogger();
}); });
socket.addEventListener("error", (err: any) => { socket.addEventListener("error", (e: any) => {
console.log(`Debugger error: ${err.message}`); 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")); showToast("An error occurred with the debugger connection!", findAssetId("Small"));
unpatchConsoleAndLogger(); unpatchConsoleAndLogger();
}); });
@ -254,7 +254,6 @@ export function isConnectedToDebugger(): boolean {
return socket?.readyState === WebSocket.OPEN; return socket?.readyState === WebSocket.OPEN;
} }
// --- React DevTools (RDT) Logic ---
const rdtPort = 8097; const rdtPort = 8097;
export let rdtClient: WebSocket | null = null; export let rdtClient: WebSocket | null = null;
export let rdtConnected = false; export let rdtConnected = false;
@ -283,13 +282,14 @@ export function connectRdt(url: string, quiet?: boolean) {
bump(); bump();
}); });
ws.addEventListener('close', () => { ws.addEventListener('close', (e: any) => {
logger.error(`React DevTools disconnected. (${e?.code}, ${e?.reason || "no reason specified"})`);
cleanupRdt(); cleanupRdt();
}); });
ws.addEventListener('error', (e: any) => { ws.addEventListener('error', (e: any) => {
cleanupRdt(); 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); logger.error('React DevTools error:', err);
if (!quiet) showToast(err, findAssetId("CircleXIcon-primary")); if (!quiet) showToast(err, findAssetId("CircleXIcon-primary"));
}); });
@ -318,8 +318,6 @@ export function useIsRdtConnected() {
return connected; return connected;
} }
// --- Core Utils ---
/** /**
* @internal * @internal
*/ */

View file

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

View file

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

View file

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

View file

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

View file

@ -11,5 +11,8 @@ export const OFFICIAL_PLUGINS_REPO_URL =
export const VD_PROXY_PREFIX = "https://vd-plugins.github.io/proxy"; export const VD_PROXY_PREFIX = "https://vd-plugins.github.io/proxy";
export const VD_DISCORD_SERVER_ID = "1427396621905432699"; export const VD_DISCORD_SERVER_ID = "1427396621905432699";
export const VD_PLUGINS_CHANNEL_ID = "1091880384561684561"; export const VD_PLUGINS_CHANNEL_ID = "1432796541210333284";
export const VD_THEMES_CHANNEL_ID = "1091880434939482202"; 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 SearchTree = Record<string, any>;
export type SearchFilter = (tree: SearchTree) => boolean; export type SearchFilter = (tree: SearchTree) => boolean;
export interface FindInTreeOptions { export interface FindInTreeOptions {
walkable?: string[]; walkable?: string[] | Set<string>;
ignore?: string[]; ignore?: string[] | Set<string>;
maxDepth?: number; maxDepth?: number;
} }
function treeSearch(tree: SearchTree, filter: SearchFilter, opts: Required<FindInTreeOptions>, depth: number): any { function treeSearch(tree: SearchTree, filter: SearchFilter, walkable: Set<string>, ignore: Set<string>, maxDepth: number, depth: number): any {
if (depth > opts.maxDepth) return; if (depth > maxDepth) return;
if (!tree) return; if (!tree) return;
try { try {
@ -22,18 +22,19 @@ function treeSearch(tree: SearchTree, filter: SearchFilter, opts: Required<FindI
if (typeof item !== "object" || item === null) continue; if (typeof item !== "object" || item === null) continue;
try { try {
const found = treeSearch(item, filter, opts, depth + 1); const found = treeSearch(item, filter, walkable, ignore, maxDepth, depth + 1);
if (found) return found; if (found) return found;
} catch { } } catch { }
} }
} else if (typeof tree === "object") { } else if (typeof tree === "object") {
for (const key of Object.keys(tree)) { for (const key in tree) {
if (Object.prototype.hasOwnProperty.call(tree, key) === false) continue;
if (typeof tree[key] !== "object" || tree[key] === null) continue; if (typeof tree[key] !== "object" || tree[key] === null) continue;
if (opts.walkable.length && !opts.walkable.includes(key)) continue; if (walkable.size && !walkable.has(key)) continue;
if (opts.ignore.includes(key)) continue; if (ignore.size && ignore.has(key)) continue;
try { try {
const found = treeSearch(tree[key], filter, opts, depth + 1); const found = treeSearch(tree[key], filter, walkable, ignore, maxDepth, depth + 1);
if (found) return found; if (found) return found;
} catch { } } catch { }
} }
@ -49,5 +50,5 @@ export default function findInTree(
maxDepth = 100 maxDepth = 100
}: FindInTreeOptions = {}, }: FindInTreeOptions = {},
): any | undefined { ): any | undefined {
return treeSearch(tree, filter, { walkable, ignore, maxDepth }, 0); return treeSearch(tree, filter, new Set(walkable), new Set(ignore), maxDepth, 0);
} }

View file

@ -1,6 +1,6 @@
import { Nullish } from "@lib/utils/types"; import { Nullish } from "@lib/utils/types";
import { TextStyles, ThemeColors } from "@ui/types"; import { TextStyles, ThemeColors } from "@ui/types";
import { Falsey } from "lodash"; import type { Falsey } from "lodash";
import { FC, MutableRefObject, PropsWithoutRef, ReactNode, RefObject } from "react"; import { FC, MutableRefObject, PropsWithoutRef, ReactNode, RefObject } from "react";
import type * as RN from "react-native"; import type * as RN from "react-native";
import { ImageSourcePropType, PressableProps } from "react-native"; import { ImageSourcePropType, PressableProps } from "react-native";
@ -211,7 +211,7 @@ interface TableRowIconProps {
export type TableRowIcon = React.FC<TableRowIconProps>; export type TableRowIcon = React.FC<TableRowIconProps>;
interface TableRowGroupProps { interface TableRowGroupProps {
title: string; title?: string;
children: ReactNode; children: ReactNode;
} }

View file

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

View file

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