Rename Kettu to ShiggyCord and update branding
- Replace all references to "Kettu" with "ShiggyCord" in code, UI, and docs - Update icons and assets to use shiggy.png - Change repo URLs to GitHub ShiggyCord repository - Update build output to shiggycord.js - Remove Codeberg and legacy workflow/config files - Add new GitHub release workflow and semantic-release config - Update i18n strings and debug info to reflect ShiggyCord branding - Refactor settings, plugin, and theme pages for new branding and improved UX - Add FixEmbed and MessageFix core plugins - Refactor NoTrack plugin for improved telemetry blocking - Remove unused PluginBrowser page - Miscellaneous code style and formatting improvements
This commit is contained in:
parent
a9c340541d
commit
bc3abf5a6d
56 changed files with 4776 additions and 3393 deletions
|
|
@ -1,18 +0,0 @@
|
||||||
|
|
||||||
# These are supported funding model platforms
|
|
||||||
|
|
||||||
|
|
||||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
|
||||||
patreon: # Replace with a single Patreon username
|
|
||||||
open_collective: # Replace with a single Open Collective username
|
|
||||||
ko_fi: cocobo1
|
|
||||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
|
||||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
|
||||||
liberapay: # Replace with a single Liberapay username
|
|
||||||
issuehunt: # Replace with a single IssueHunt username
|
|
||||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
|
||||||
polar: # Replace with a single Polar username
|
|
||||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
|
||||||
thanks_dev: # Replace with a single thanks.dev username
|
|
||||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
|
||||||
|
|
||||||
|
|
@ -1,161 +0,0 @@
|
||||||
name: 🐞 Bug report
|
|
||||||
description: Report a bug or an issue.
|
|
||||||
title: 'bug: '
|
|
||||||
labels: ['Bug report']
|
|
||||||
body:
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
> [!IMPORTANT]
|
|
||||||
> ## Before you submit this issue...
|
|
||||||
> We might reject any issues that can't be solved within the repository, or reports that are too general. For example:
|
|
||||||
>
|
|
||||||
> <details>
|
|
||||||
> <summary><b>"Kettu crashes upon starting..."</b></summary>
|
|
||||||
> > There are lots of different things that could be causing this, but it's probably not related to this repository.
|
|
||||||
> </details>
|
|
||||||
> <details>
|
|
||||||
> <summary><b>"Kettu freezes on splash screen..."</b></summary>
|
|
||||||
> > The issue is too general and should be reported only when it occurs after an update to a specific Discord version or after a specific action or configuration has been made within Kettu.
|
|
||||||
> </details>
|
|
||||||
> <details>
|
|
||||||
> <summary><b>"Kettu refuses to install/stuck on downloading..."</b></summary>
|
|
||||||
> > This is an external issue (sideloading) that does not have a place in this repository. If it's a problem with Kettu Manager, report it to the <a href="https://github.com/C0C0B01/KettuManager">appropriate repository</a>.
|
|
||||||
> </details>
|
|
||||||
>
|
|
||||||
> If you think that your problem belongs to one of these, you should avoid submitting this report unless you are told to do so.
|
|
||||||
>
|
|
||||||
> Please report the problem or ask for help in the appropriate place. For example, if the problem occurs in Kettu Manager, report it in the Kettu Manager repository.
|
|
||||||
|
|
||||||
- type: checkboxes
|
|
||||||
id: checklist
|
|
||||||
attributes:
|
|
||||||
label: Checklist
|
|
||||||
description: You should ensure the completion of the task before proceeding to check it off the checklist. Neglecting to do so may impede the efficiency of the issue resolution process. The developer has the right to delete the issue directly if you check the list blindly.
|
|
||||||
options:
|
|
||||||
- label: I am able to reproduce the bug with the latest version of Kettu and Discord.
|
|
||||||
required: true
|
|
||||||
- label: I've checked that there is no open or closed issue about this bug.
|
|
||||||
required: true
|
|
||||||
- label: The title of this issue accurately describes the bug.
|
|
||||||
required: true
|
|
||||||
- label: This is not a feature request or a general question.
|
|
||||||
required: true
|
|
||||||
- label: This bug can be reproduced consistently and is not caused by a plugin.
|
|
||||||
required: true
|
|
||||||
- label: I have read the notes above and ensured that my issue is clear and within this repository.
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: Bug description
|
|
||||||
description: |
|
|
||||||
- Describe your bug in detail
|
|
||||||
- Add steps to reproduce the bug if possible (Step 1. ... Step 2. ...)
|
|
||||||
- Add images and videos if possible
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: Stack trace
|
|
||||||
description: If this bug causes a JS crash, please paste the stack trace here.
|
|
||||||
render: shell
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: Component stack trace
|
|
||||||
description: If this bug causes a JS crash, please paste the component stack trace here.
|
|
||||||
render: shell
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: Native crash trace
|
|
||||||
description: If this bug causes a native crash, please paste the crash trace here. On Android, this can be accessed by doing `logcat | grep AndroidRuntime`.
|
|
||||||
render: shell
|
|
||||||
|
|
||||||
- type: input
|
|
||||||
id: discord-version
|
|
||||||
attributes:
|
|
||||||
label: Discord version
|
|
||||||
description: |
|
|
||||||
The version of Discord you are using. You can find this in the settings. You may leave both the version number and the build number.
|
|
||||||
placeholder: |
|
|
||||||
Example:
|
|
||||||
"253.2 (66074)"
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: input
|
|
||||||
id: version
|
|
||||||
attributes:
|
|
||||||
label: Version
|
|
||||||
description: |
|
|
||||||
The version of Kettu the issue is occuring on. You can find this in the settings. Kettu version is in formatted like the following: `v0.0.5` and NOT like this: `beaf689-main`.
|
|
||||||
placeholder: |
|
|
||||||
Example:
|
|
||||||
"v0.0.4"
|
|
||||||
|
|
||||||
- type: input
|
|
||||||
id: loader-version
|
|
||||||
attributes:
|
|
||||||
label: Loader version
|
|
||||||
description: |
|
|
||||||
The version of the loader you are using. You can find this in the settings.
|
|
||||||
placeholder: |
|
|
||||||
Example:
|
|
||||||
"KettuXposed (v0.0.2)"
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: input
|
|
||||||
id: device
|
|
||||||
attributes:
|
|
||||||
label: Device
|
|
||||||
description: |
|
|
||||||
The device model you are using.
|
|
||||||
placeholder: |
|
|
||||||
Example:
|
|
||||||
"Samsung A35 5G"
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: input
|
|
||||||
id: os-version
|
|
||||||
attributes:
|
|
||||||
label: OS version
|
|
||||||
description: |
|
|
||||||
The version of the operating system you are using.
|
|
||||||
placeholder: |
|
|
||||||
Example:
|
|
||||||
"Android 12/Oneui 4"
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: Debug information
|
|
||||||
description: |
|
|
||||||
Debug information is important for the developer to understand the environment in which the bug occurred. You can get this information by sending `/debug` in the chat.
|
|
||||||
placeholder: |
|
|
||||||
Example:
|
|
||||||
Kettu: v0.6.7 (KettuXposed v0.2.4)
|
|
||||||
Discord: 255.2 (255202)
|
|
||||||
React: 18.3.1 (RN 0.74.5-discord-11)
|
|
||||||
Hermes: for RN 0.74.5-discord-11 (bcv96)
|
|
||||||
System: Android 13 (SDK 33)
|
|
||||||
Device: M2101K7BG (secret)
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: Additional context
|
|
||||||
description: Add additional context here.
|
|
||||||
- type: checkboxes
|
|
||||||
id: acknowledgements
|
|
||||||
attributes:
|
|
||||||
label: Acknowledgements
|
|
||||||
description: Your bug report will be closed if you don't follow the checklist below.
|
|
||||||
options:
|
|
||||||
- label: I have checked all open and closed bug reports and this is not a duplicate.
|
|
||||||
required: true
|
|
||||||
- label: I have chosen an appropriate title.
|
|
||||||
required: true
|
|
||||||
- label: All requested information has been provided properly.
|
|
||||||
required: true
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
name: Node.js CI / CD (Bun + Forgejo)
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ "main" ]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
buildAndRelease:
|
|
||||||
runs-on: codeberg-tiny
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Install Bun
|
|
||||||
run: |
|
|
||||||
curl -fsSL https://bun.sh/install | bash
|
|
||||||
export BUN_INSTALL="$HOME/.bun"
|
|
||||||
export PATH="$BUN_INSTALL/bin:$PATH"
|
|
||||||
echo "$BUN_INSTALL/bin" >> $GITHUB_PATH
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
export BUN_INSTALL="$HOME/.bun"
|
|
||||||
export PATH="$BUN_INSTALL/bin:$PATH"
|
|
||||||
bun install
|
|
||||||
|
|
||||||
- name: Build project
|
|
||||||
run: |
|
|
||||||
export BUN_INSTALL="$HOME/.bun"
|
|
||||||
export PATH="$BUN_INSTALL/bin:$PATH"
|
|
||||||
bun run build --release-branch=${GITHUB_REF_NAME:-${CI_COMMIT_REF_NAME}} --build-minify
|
|
||||||
|
|
||||||
- name: Push JS files to dist branch
|
|
||||||
run: |
|
|
||||||
git config --local user.email "actions@forgejo.local"
|
|
||||||
git config --local user.name "Forgejo Actions"
|
|
||||||
|
|
||||||
mkdir -p temp_dist
|
|
||||||
find dist -name "*.js" -exec cp {} temp_dist/ \;
|
|
||||||
|
|
||||||
git checkout --orphan dist-temp
|
|
||||||
git rm -rf --cached .
|
|
||||||
|
|
||||||
cp -r temp_dist/* .
|
|
||||||
rm -rf temp_dist
|
|
||||||
|
|
||||||
git add *.js
|
|
||||||
git commit -m "Update build $GITHUB_SHA" || exit 0
|
|
||||||
git push -f origin HEAD:dist
|
|
||||||
|
|
||||||
git checkout -f ${GITHUB_REF_NAME:-${CI_COMMIT_REF_NAME}}
|
|
||||||
|
|
||||||
- name: Merge main into github
|
|
||||||
if: github.ref == 'refs/heads/main'
|
|
||||||
run: |
|
|
||||||
git config --local user.email "actions@forgejo.local"
|
|
||||||
git config --local user.name "Forgejo Actions"
|
|
||||||
git fetch origin
|
|
||||||
git checkout github || git checkout -b github
|
|
||||||
git merge origin/main --no-edit --allow-unrelated-histories
|
|
||||||
git push --force origin github
|
|
||||||
51
.github/workflows/auto-release.yml
vendored
Normal file
51
.github/workflows/auto-release.yml
vendored
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
name: Automatic Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- "dist/shiggycord.js"
|
||||||
|
- "dist/shiggycord.min.js"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
name: Create Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@v1
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install
|
||||||
|
|
||||||
|
- name: Extract bunny version
|
||||||
|
id: bunny_version
|
||||||
|
run: |
|
||||||
|
version=$(grep 'const version:' src/modules.d.ts | head -n1 | sed 's/[^0-9.]//g')
|
||||||
|
echo "version=$version" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Build (if needed)
|
||||||
|
run: bun run build
|
||||||
|
|
||||||
|
- name: Create Release
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
with:
|
||||||
|
tag_name: v${{ steps.bunny_version.outputs.version }}
|
||||||
|
name: Release v${{ steps.bunny_version.outputs.version }}
|
||||||
|
files: |
|
||||||
|
dist/shiggycord.js
|
||||||
|
dist/shiggycord.min.js
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
49
.releaserc.json
Normal file
49
.releaserc.json
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
{
|
||||||
|
"branches": ["main"],
|
||||||
|
"plugins": [
|
||||||
|
"@semantic-release/commit-analyzer",
|
||||||
|
"@semantic-release/release-notes-generator",
|
||||||
|
[
|
||||||
|
"@semantic-release/changelog",
|
||||||
|
{
|
||||||
|
"changelogFile": "CHANGELOG.md"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@semantic-release/npm",
|
||||||
|
{
|
||||||
|
"npmPublish": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@semantic-release/github",
|
||||||
|
{
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"path": "dist/shiggycord.js",
|
||||||
|
"label": "ShiggyCord Bundle"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "dist/shiggycord.min.js",
|
||||||
|
"label": "ShiggyCord Bundle (minified)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@semantic-release/git",
|
||||||
|
{
|
||||||
|
"assets": ["package.json", "CHANGELOG.md"],
|
||||||
|
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@saithodev/semantic-release-backmerge",
|
||||||
|
{
|
||||||
|
"branches": ["dev"],
|
||||||
|
"backmergeStrategy": "merge",
|
||||||
|
"backmergeMessage": "chore(release): merge release ${nextRelease.version} into dev [skip ci]"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
18
.vscode/settings.json
vendored
18
.vscode/settings.json
vendored
|
|
@ -1,18 +0,0 @@
|
||||||
{
|
|
||||||
"editor.codeActionsOnSave": {
|
|
||||||
"source.fixAll.eslint": "explicit"
|
|
||||||
},
|
|
||||||
"[typescript]": {
|
|
||||||
"editor.defaultFormatter": "vscode.typescript-language-features"
|
|
||||||
},
|
|
||||||
"[typescriptreact]": {
|
|
||||||
"editor.defaultFormatter": "vscode.typescript-language-features"
|
|
||||||
},
|
|
||||||
"[json]": {
|
|
||||||
"editor.formatOnSave": false
|
|
||||||
},
|
|
||||||
"javascript.format.semicolons": "insert",
|
|
||||||
"typescript.format.semicolons": "insert",
|
|
||||||
"typescript.preferences.quoteStyle": "double",
|
|
||||||
"javascript.preferences.quoteStyle": "double"
|
|
||||||
}
|
|
||||||
16
README.md
16
README.md
|
|
@ -1,31 +1,31 @@
|
||||||
# ShiggyCord
|
# ShiggyCord
|
||||||
|
|
||||||
An unofficial fork of Kettu, made just for fun
|
An unofficial fork of ShiggyCord, made just for fun
|
||||||
Idea taken from Rosiecord
|
Idea taken from Rosiecord
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
|
|
||||||
### Android/iOS
|
### Android/iOS
|
||||||
- **Injecting bundle:** ``https://github.com/kmmiio99o/ShiggyCord/releases/<VERSION>/kettu.js``
|
- **Injecting bundle:** ``https://github.com/kmmiio99o/ShiggyCord/releases/<VERSION>/shiggycord.js``
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
1. Install a Kettu loader with loader config support (any mentioned in the [Installing](#installing) section).
|
1. Install a ShiggyCord 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://codeberg.org/kmmiio99o/Shiggycord.git
|
git clone https://github.com/kmmiio99o/Shiggycord.git
|
||||||
```
|
```
|
||||||
1. Install dependencies:
|
1. Install dependencies:
|
||||||
```
|
```
|
||||||
bun i
|
bun i
|
||||||
```
|
```
|
||||||
1. Build Kettu's code:
|
1. Build ShiggyCord'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/kettu.js`) in the new input box labelled `Kettu 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/shiggycord.js`) in the new input box labelled `ShiggyCord URL`.
|
||||||
1. Restart Discord. Upon reload, you should notice that your device will download Kettu's bundled code from your server, rather than GitHub.
|
1. Restart Discord. Upon reload, you should notice that your device will download ShiggyCord'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`. `kettu.js` will be served on your local address under the port 4040. You will then insert `http://<local ip address>:4040/kettu.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`. `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.
|
||||||
|
|
|
||||||
108
package.json
108
package.json
|
|
@ -1,58 +1,58 @@
|
||||||
{
|
{
|
||||||
"name": "Kettu",
|
"name": "ShiggyCord",
|
||||||
"private": true,
|
"private": true,
|
||||||
"author": {
|
"author": {
|
||||||
"name": "cocobo1",
|
"name": "kmmiio99o",
|
||||||
"url": "https://github.com/C0C0B01"
|
"url": "https://github.com/kmmiio99o"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node scripts/build.mjs",
|
"build": "node scripts/build.mjs",
|
||||||
"serve": "node scripts/serve.mjs",
|
"serve": "node scripts/serve.mjs",
|
||||||
"serve:adb": "bun run serve --adb",
|
"serve:adb": "bun run serve --adb",
|
||||||
"lint": "eslint ./src --ext .js,.jsx,.ts,.tsx"
|
"lint": "eslint ./src --ext .js,.jsx,.ts,.tsx"
|
||||||
},
|
},
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@saithodev/semantic-release-backmerge": "^4.0.1",
|
"@saithodev/semantic-release-backmerge": "^4.0.1",
|
||||||
"@semantic-release/changelog": "^6.0.3",
|
"@semantic-release/changelog": "^6.0.3",
|
||||||
"@semantic-release/exec": "^6.0.3",
|
"@semantic-release/exec": "^6.0.3",
|
||||||
"@semantic-release/git": "^10.0.1",
|
"@semantic-release/git": "^10.0.1",
|
||||||
"semantic-release": "^24.2.7",
|
"semantic-release": "^24.2.7",
|
||||||
"@swc/core": "^1.13.1",
|
"@swc/core": "^1.13.1",
|
||||||
"@swc/helpers": "^0.5.17",
|
"@swc/helpers": "^0.5.17",
|
||||||
"@types/node": "^20.19.9",
|
"@types/node": "^20.19.9",
|
||||||
"@types/yargs-parser": "^21.0.3",
|
"@types/yargs-parser": "^21.0.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||||
"@typescript-eslint/parser": "^7.18.0",
|
"@typescript-eslint/parser": "^7.18.0",
|
||||||
"@typescript-eslint/typescript-estree": "^7.18.0",
|
"@typescript-eslint/typescript-estree": "^7.18.0",
|
||||||
"chalk": "^5.4.1",
|
"chalk": "^5.4.1",
|
||||||
"esbuild": "^0.20.2",
|
"esbuild": "^0.20.2",
|
||||||
"esbuild-plugin-globals": "^0.2.0",
|
"esbuild-plugin-globals": "^0.2.0",
|
||||||
"eslint": "^8.57.1",
|
"eslint": "^8.57.1",
|
||||||
"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": "^12.1.1",
|
||||||
"eslint-plugin-unused-imports": "^3.2.0",
|
"eslint-plugin-unused-imports": "^3.2.0",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"yargs-parser": "^21.1.1"
|
"yargs-parser": "^21.1.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gullerya/object-observer": "^6.1.4",
|
"@gullerya/object-observer": "^6.1.4",
|
||||||
"@shopify/react-native-skia": "^1.12.4",
|
"@shopify/react-native-skia": "^1.12.4",
|
||||||
"@tanstack/react-query": "^5.83.0",
|
"@tanstack/react-query": "^5.83.0",
|
||||||
"@types/chroma-js": "~2.4.5",
|
"@types/chroma-js": "~2.4.5",
|
||||||
"@types/lodash": "~4.17.20",
|
"@types/lodash": "~4.17.20",
|
||||||
"@types/react": "19.1.8",
|
"@types/react": "19.1.8",
|
||||||
"react-native": "0.78.0",
|
"react-native": "0.78.0",
|
||||||
"es-toolkit": "^1.39.7",
|
"es-toolkit": "^1.39.7",
|
||||||
"fuzzysort": "^3.1.0",
|
"fuzzysort": "^3.1.0",
|
||||||
"intl-messageformat": "^10.7.16",
|
"intl-messageformat": "^10.7.16",
|
||||||
"moment": "2.22.2",
|
"moment": "2.22.2",
|
||||||
"react-native-reanimated": "^3.18.0",
|
"react-native-reanimated": "^3.18.0",
|
||||||
"spitroast": "^1.4.4",
|
"spitroast": "^1.4.4",
|
||||||
"type-fest": "^4.41.0"
|
"type-fest": "^4.41.0"
|
||||||
},
|
},
|
||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
"@swc/core"
|
"@swc/core"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,119 +13,121 @@ import { printBuildSuccess } from "./util.mjs";
|
||||||
|
|
||||||
/** @type string[] */
|
/** @type string[] */
|
||||||
const metroDeps = await (async () => {
|
const metroDeps = await (async () => {
|
||||||
const ast = await swc.parseFile(path.resolve("./shims/depsModule.ts"));
|
const ast = await swc.parseFile(path.resolve("./shims/depsModule.ts"));
|
||||||
return ast.body.at(-1).expression.right.properties.map(p => p.key.value);
|
return ast.body.at(-1).expression.right.properties.map((p) => p.key.value);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const args = yargs(process.argv.slice(2));
|
const args = yargs(process.argv.slice(2));
|
||||||
const {
|
const {
|
||||||
"release-branch": releaseBranch,
|
"release-branch": releaseBranch,
|
||||||
"build-minify": buildMinify,
|
"build-minify": buildMinify,
|
||||||
"dev": dev
|
dev: dev,
|
||||||
} = args;
|
} = args;
|
||||||
|
|
||||||
let context = null;
|
let context = null;
|
||||||
|
|
||||||
/** @type {import("esbuild").BuildOptions} */
|
/** @type {import("esbuild").BuildOptions} */
|
||||||
const config = {
|
const config = {
|
||||||
entryPoints: ["src/entry.ts"],
|
entryPoints: ["src/entry.ts"],
|
||||||
bundle: true,
|
bundle: true,
|
||||||
outfile: "dist/kettu.js",
|
outfile: "dist/shiggycord.js",
|
||||||
format: "iife",
|
format: "iife",
|
||||||
splitting: false,
|
splitting: false,
|
||||||
external: [],
|
external: [],
|
||||||
supported: {
|
supported: {
|
||||||
// Hermes does not actually support const and let, even though it syntactically
|
// Hermes does not actually support const and let, even though it syntactically
|
||||||
// accepts it, but it's treated just like 'var' and causes issues
|
// accepts it, but it's treated just like 'var' and causes issues
|
||||||
"const-and-let": false
|
"const-and-let": false,
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
js: "//# sourceURL=kettu"
|
js: "//# sourceURL=shiggycord",
|
||||||
},
|
},
|
||||||
loader: {
|
loader: {
|
||||||
".png": "dataurl"
|
".png": "dataurl",
|
||||||
},
|
},
|
||||||
define: {
|
define: {
|
||||||
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"],
|
||||||
legalComments: "none",
|
legalComments: "none",
|
||||||
alias: {
|
alias: {
|
||||||
"!kettu-deps-shim!": "./shims/depsModule.ts",
|
"!kettu-deps-shim!": "./shims/depsModule.ts",
|
||||||
"spitroast": "./node_modules/spitroast",
|
spitroast: "./node_modules/spitroast",
|
||||||
"react/jsx-runtime": "./shims/jsxRuntime"
|
"react/jsx-runtime": "./shims/jsxRuntime",
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
globalPlugin({
|
globalPlugin({
|
||||||
...metroDeps.reduce((obj, key) => {
|
...metroDeps.reduce((obj, key) => {
|
||||||
obj[key] = `require("!kettu-deps-shim!")[${JSON.stringify(key)}]`;
|
obj[key] = `require("!kettu-deps-shim!")[${JSON.stringify(key)}]`;
|
||||||
return obj;
|
return obj;
|
||||||
}, {})
|
}, {}),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "swc",
|
name: "swc",
|
||||||
setup(build) {
|
setup(build) {
|
||||||
build.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, async args => {
|
build.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, async (args) => {
|
||||||
const result = await swc.transformFile(args.path, {
|
const result = await swc.transformFile(args.path, {
|
||||||
jsc: {
|
jsc: {
|
||||||
externalHelpers: true,
|
externalHelpers: true,
|
||||||
transform: {
|
transform: {
|
||||||
constModules: {
|
constModules: {
|
||||||
globals: {
|
globals: {
|
||||||
"bunny-build-info": {
|
"bunny-build-info": {
|
||||||
version: `"1.3.0"`
|
version: `"1.0.0"`,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
react: {
|
react: {
|
||||||
runtime: "automatic"
|
runtime: "automatic",
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// https://github.com/facebook/hermes/blob/3815fec63d1a6667ca3195160d6e12fee6a0d8d5/doc/Features.md
|
// https://github.com/facebook/hermes/blob/3815fec63d1a6667ca3195160d6e12fee6a0d8d5/doc/Features.md
|
||||||
// https://github.com/facebook/hermes/issues/696#issuecomment-1396235791
|
// https://github.com/facebook/hermes/issues/696#issuecomment-1396235791
|
||||||
env: {
|
env: {
|
||||||
targets: "fully supports es6",
|
targets: "fully supports es6",
|
||||||
include: [
|
include: [
|
||||||
"transform-block-scoping",
|
"transform-block-scoping",
|
||||||
"transform-classes",
|
"transform-classes",
|
||||||
"transform-async-to-generator",
|
"transform-async-to-generator",
|
||||||
"transform-async-generator-functions"
|
"transform-async-generator-functions",
|
||||||
],
|
],
|
||||||
exclude: [
|
exclude: [
|
||||||
"transform-parameters",
|
"transform-parameters",
|
||||||
"transform-template-literals",
|
"transform-template-literals",
|
||||||
"transform-exponentiation-operator",
|
"transform-exponentiation-operator",
|
||||||
"transform-named-capturing-groups-regex",
|
"transform-named-capturing-groups-regex",
|
||||||
"transform-nullish-coalescing-operator",
|
"transform-nullish-coalescing-operator",
|
||||||
"transform-object-rest-spread",
|
"transform-object-rest-spread",
|
||||||
"transform-optional-chaining",
|
"transform-optional-chaining",
|
||||||
"transform-logical-assignment-operators"
|
"transform-logical-assignment-operators",
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return { contents: result.code };
|
return { contents: result.code };
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function buildBundle(overrideConfig = {}) {
|
export async function buildBundle(overrideConfig = {}) {
|
||||||
context = {
|
context = {
|
||||||
hash: releaseBranch ? execSync("git rev-parse --short HEAD").toString().trim() : crypto.randomBytes(8).toString("hex").slice(0, 7)
|
hash: releaseBranch
|
||||||
};
|
? execSync("git rev-parse --short HEAD").toString().trim()
|
||||||
|
: crypto.randomBytes(8).toString("hex").slice(0, 7),
|
||||||
|
};
|
||||||
|
|
||||||
const initialStartTime = performance.now();
|
const initialStartTime = performance.now();
|
||||||
await build({ ...config, ...overrideConfig });
|
await build({ ...config, ...overrideConfig });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
config,
|
config,
|
||||||
context,
|
context,
|
||||||
timeTook: performance.now() - initialStartTime
|
timeTook: performance.now() - initialStartTime,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const pathToThisFile = path.resolve(fileURLToPath(import.meta.url));
|
const pathToThisFile = path.resolve(fileURLToPath(import.meta.url));
|
||||||
|
|
@ -133,25 +135,16 @@ const pathPassedToNode = path.resolve(process.argv[1]);
|
||||||
const isThisFileBeingRunViaCLI = pathToThisFile.includes(pathPassedToNode);
|
const isThisFileBeingRunViaCLI = pathToThisFile.includes(pathPassedToNode);
|
||||||
|
|
||||||
if (isThisFileBeingRunViaCLI) {
|
if (isThisFileBeingRunViaCLI) {
|
||||||
const { timeTook } = await buildBundle();
|
const { timeTook } = await buildBundle();
|
||||||
|
|
||||||
printBuildSuccess(
|
printBuildSuccess(context.hash, releaseBranch, timeTook);
|
||||||
context.hash,
|
|
||||||
releaseBranch,
|
|
||||||
timeTook
|
|
||||||
);
|
|
||||||
|
|
||||||
if (buildMinify) {
|
if (buildMinify) {
|
||||||
const { timeTook } = await buildBundle({
|
const { timeTook } = await buildBundle({
|
||||||
minify: true,
|
minify: true,
|
||||||
outfile: config.outfile.replace(/\.js$/, ".min.js")
|
outfile: config.outfile.replace(/\.js$/, ".min.js"),
|
||||||
});
|
});
|
||||||
|
|
||||||
printBuildSuccess(
|
printBuildSuccess(context.hash, releaseBranch, timeTook, true);
|
||||||
context.hash,
|
}
|
||||||
releaseBranch,
|
|
||||||
timeTook,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,47 +13,43 @@ import { printBuildSuccess } from "./util.mjs";
|
||||||
const args = yargs(process.argv.slice(2));
|
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 } = url.parse(req.url || "", true);
|
||||||
if (pathname?.endsWith(".js")) {
|
if (pathname?.endsWith(".js")) {
|
||||||
try {
|
try {
|
||||||
const { config, context, timeTook } = await buildBundle();
|
const { config, context, timeTook } = await buildBundle();
|
||||||
|
|
||||||
printBuildSuccess(
|
printBuildSuccess(context.hash, args.production, timeTook);
|
||||||
context.hash,
|
|
||||||
args.production,
|
|
||||||
timeTook
|
|
||||||
);
|
|
||||||
|
|
||||||
res.writeHead(200, { "Content-Type": "application/javascript" });
|
res.writeHead(200, { "Content-Type": "application/javascript" });
|
||||||
res.end(await readFile(config.outfile, "utf-8"));
|
res.end(await readFile(config.outfile, "utf-8"));
|
||||||
} catch {
|
} catch {
|
||||||
res.writeHead(500);
|
res.writeHead(500);
|
||||||
res.end();
|
res.end();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
res.writeHead(404);
|
res.writeHead(404);
|
||||||
res.end();
|
res.end();
|
||||||
}
|
|
||||||
}, options);
|
|
||||||
|
|
||||||
server.listen(args.port ?? 4040);
|
|
||||||
|
|
||||||
console.info(chalk.bold.yellowBright("Serving Kettu on:"));
|
|
||||||
|
|
||||||
const netInterfaces = os.networkInterfaces();
|
|
||||||
for (const netinterfaces of Object.values(netInterfaces)) {
|
|
||||||
for (const details of netinterfaces || []) {
|
|
||||||
if (details.family !== "IPv4") continue;
|
|
||||||
const port = chalk.green(server.address()?.port.toString());
|
|
||||||
console.info(` http://${details.address}:${port}/bundle.js`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}, options);
|
||||||
|
|
||||||
return server;
|
server.listen(args.port ?? 4040);
|
||||||
|
|
||||||
|
console.info(chalk.bold.yellowBright("Serving ShiggyCord on:"));
|
||||||
|
|
||||||
|
const netInterfaces = os.networkInterfaces();
|
||||||
|
for (const netinterfaces of Object.values(netInterfaces)) {
|
||||||
|
for (const details of netinterfaces || []) {
|
||||||
|
if (details.family !== "IPv4") continue;
|
||||||
|
const port = chalk.green(server.address()?.port.toString());
|
||||||
|
console.info(` http://${details.address}:${port}/shiggycord.js`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return server;
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = serve();
|
const server = serve();
|
||||||
|
|
||||||
console.log("\nPress Q key or Ctrl+C to exit.");
|
console.log("\nPress Q key or Ctrl+C to exit.");
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 31 KiB |
BIN
src/assets/icons/shiggy.png
Normal file
BIN
src/assets/icons/shiggy.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
|
|
@ -1,35 +1,41 @@
|
||||||
import { Strings } from "@core/i18n";
|
import { Strings } from "@core/i18n";
|
||||||
import { ApplicationCommand, ApplicationCommandOptionType } from "@lib/api/commands/types";
|
import {
|
||||||
|
ApplicationCommand,
|
||||||
|
ApplicationCommandOptionType,
|
||||||
|
} from "@lib/api/commands/types";
|
||||||
import { getDebugInfo } from "@lib/api/debug";
|
import { getDebugInfo } from "@lib/api/debug";
|
||||||
import { messageUtil } from "@metro/common";
|
import { messageUtil } from "@metro/common";
|
||||||
|
|
||||||
export default () => <ApplicationCommand>{
|
export default () =>
|
||||||
|
<ApplicationCommand>{
|
||||||
name: "debug",
|
name: "debug",
|
||||||
description: Strings.COMMAND_DEBUG_DESC,
|
description: Strings.COMMAND_DEBUG_DESC,
|
||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
name: "ephemeral",
|
name: "ephemeral",
|
||||||
type: ApplicationCommandOptionType.BOOLEAN,
|
type: ApplicationCommandOptionType.BOOLEAN,
|
||||||
description: Strings.COMMAND_DEBUG_OPT_EPHEMERALLY,
|
description: Strings.COMMAND_DEBUG_OPT_EPHEMERALLY,
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
execute([ephemeral], ctx) {
|
execute([ephemeral], ctx) {
|
||||||
const info = getDebugInfo();
|
const info = getDebugInfo();
|
||||||
const content = [
|
const content = [
|
||||||
"**Kettu Debug Info**",
|
"**ShiggyCord Debug Info**",
|
||||||
`> Kettu: ${info.bunny.version} (${info.bunny.loader.name} ${info.bunny.loader.version})`,
|
`> ShiggyCord: ${info.bunny.version} (${info.bunny.loader.name} ${info.bunny.loader.version})`,
|
||||||
`> Discord: ${info.discord.version} (${info.discord.build})`,
|
`> Discord: ${info.discord.version} (${info.discord.build})`,
|
||||||
`> React: ${info.react.version} (RN ${info.react.nativeVersion})`,
|
`> React: ${info.react.version} (RN ${info.react.nativeVersion})`,
|
||||||
`> Hermes: ${info.hermes.version} (bcv${info.hermes.bytecodeVersion})`,
|
`> Hermes: ${info.hermes.version} (bcv${info.hermes.bytecodeVersion})`,
|
||||||
`> System: ${info.os.name} ${info.os.version} ${info.os.sdk ? `(SDK ${info.os.sdk})` : ""}`.trimEnd(),
|
`> System: ${info.os.name} ${info.os.version} ${info.os.sdk ? `(SDK ${info.os.sdk})` : ""}`.trimEnd(),
|
||||||
`> Device: ${info.device.model} (${info.device.codename})`,
|
`> Device: ${info.device.model}`,
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
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 = Date.now().toString();
|
||||||
messageUtil.sendMessage(ctx.channel.id, { content }, void 0, {nonce:fixNonce});
|
messageUtil.sendMessage(ctx.channel.id, { content }, void 0, {
|
||||||
}
|
nonce: fixNonce,
|
||||||
}
|
});
|
||||||
};
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,121 +1,121 @@
|
||||||
{
|
{
|
||||||
"ABOUT": "About",
|
"ABOUT": "About",
|
||||||
"ACTIONS": "Actions",
|
"ACTIONS": "Actions",
|
||||||
"ARE_YOU_SURE_TO_CLEAR_DATA": "Are you sure you wish to clear the data of {name}?",
|
"ARE_YOU_SURE_TO_CLEAR_DATA": "Are you sure you wish to clear the data of {name}?",
|
||||||
"ARE_YOU_SURE_TO_DELETE_PLUGIN": "Are you sure you wish to delete {name}? This will clear all of the plugin's data.",
|
"ARE_YOU_SURE_TO_DELETE_PLUGIN": "Are you sure you wish to delete {name}? This will clear all of the plugin's data.",
|
||||||
"ARE_YOU_SURE_TO_DELETE_THEME": "Are you sure you wish to delete {name}?",
|
"ARE_YOU_SURE_TO_DELETE_THEME": "Are you sure you wish to delete {name}?",
|
||||||
"ASSET_BROWSER": "Asset Browser",
|
"ASSET_BROWSER": "Asset Browser",
|
||||||
"BRAND": "Brand",
|
"BRAND": "Brand",
|
||||||
"PUPU": "Kettu",
|
"PUPU": "ShiggyCord",
|
||||||
"PUPU_URL": "Kettu URL",
|
"PUPU_URL": "ShiggyCord URL",
|
||||||
"BROWSER": "Browser",
|
"BROWSER": "Browser",
|
||||||
"BYTECODE": "Bytecode",
|
"BYTECODE": "Bytecode",
|
||||||
"CANCEL": "Cancel",
|
"CANCEL": "Cancel",
|
||||||
"CLEAR": "Clear",
|
"CLEAR": "Clear",
|
||||||
"CLEAR_BUNDLE": "Clear JS Bundle",
|
"CLEAR_BUNDLE": "Clear JS Bundle",
|
||||||
"CLEAR_BUNDLE_DESC": "Clear the cached bundle. This will force a re-download of the bundle next app launch.",
|
"CLEAR_BUNDLE_DESC": "Clear the cached bundle. This will force a re-download of the bundle next app launch.",
|
||||||
"CLEAR_DATA": "Clear data",
|
"CLEAR_DATA": "Clear data",
|
||||||
"CLEAR_DATA_FAILED": "Failed to clear data for {name}",
|
"CLEAR_DATA_FAILED": "Failed to clear data for {name}",
|
||||||
"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 Kettu debug info.",
|
"COMMAND_DEBUG_DESC": "Send ShiggyCord debug info.",
|
||||||
"COMMAND_DEBUG_OPT_EPHEMERALLY": "Send debug info ephemerally.",
|
"COMMAND_DEBUG_OPT_EPHEMERALLY": "Send debug info ephemerally.",
|
||||||
"COMMAND_EVAL_DESC": "Evaluate JavaScript code.",
|
"COMMAND_EVAL_DESC": "Evaluate JavaScript code.",
|
||||||
"COMMAND_EVAL_OPT_ASYNC": "Whether to support 'await' in code. Must explicitly return for result (default: false)",
|
"COMMAND_EVAL_OPT_ASYNC": "Whether to support 'await' in code. Must explicitly return for result (default: false)",
|
||||||
"COMMAND_EVAL_OPT_CODE": "The code to evaluate.",
|
"COMMAND_EVAL_OPT_CODE": "The code to evaluate.",
|
||||||
"COMMAND_PLUGINS_DESC": "Send list of installed plugins.",
|
"COMMAND_PLUGINS_DESC": "Send list of installed plugins.",
|
||||||
"COMMAND_PLUGINS_OPT_EPHEMERALLY": "Send plugins list ephemerally.",
|
"COMMAND_PLUGINS_OPT_EPHEMERALLY": "Send plugins list ephemerally.",
|
||||||
"COMPONENT": "Component",
|
"COMPONENT": "Component",
|
||||||
"CONFIRMATION_LINK_IS_A_TYPE": "This link is a **{urlType, select, plugin {Plugin} theme {Theme} other {Add-on}}**, would you like to install it?",
|
"CONFIRMATION_LINK_IS_A_TYPE": "This link is a **{urlType, select, plugin {Plugin} theme {Theme} other {Add-on}}**, would you like to install it?",
|
||||||
"CONNECT_TO_DEBUG_WEBSOCKET": "Connect to debug WebSocket",
|
"CONNECT_TO_DEBUG_WEBSOCKET": "Connect to debug WebSocket",
|
||||||
"CONNECT_TO_REACT_DEVTOOLS": "Connect to React DevTools",
|
"CONNECT_TO_REACT_DEVTOOLS": "Connect to React DevTools",
|
||||||
"CONTINUE": "Continue",
|
"CONTINUE": "Continue",
|
||||||
"COPIED_TO_CLIPBOARD": "Copied to clipboard",
|
"COPIED_TO_CLIPBOARD": "Copied to clipboard",
|
||||||
"COPY_URL": "Copy URL",
|
"COPY_URL": "Copy URL",
|
||||||
"DEBUG": "Debug",
|
"DEBUG": "Debug",
|
||||||
"DEBUGGER_URL": "Debugger URL",
|
"DEBUGGER_URL": "Debugger URL",
|
||||||
"DELETE": "Delete",
|
"DELETE": "Delete",
|
||||||
"DESC_EXTRACT_FONTS_FROM_THEME": "Looks out for \"fonts\" field in your currently applied theme and install it.",
|
"DESC_EXTRACT_FONTS_FROM_THEME": "Looks out for \"fonts\" field in your currently applied theme and install it.",
|
||||||
"DEVELOPER": "Developer",
|
"DEVELOPER": "Developer",
|
||||||
"DEVELOPER_SETTINGS": "Developer Settings",
|
"DEVELOPER_SETTINGS": "Developer Settings",
|
||||||
"DISABLE_THEME": "Disable Theme",
|
"DISABLE_THEME": "Disable Theme",
|
||||||
"DISABLE_UPDATES": "Disable Updates",
|
"DISABLE_UPDATES": "Disable Updates",
|
||||||
"DISCORD_SERVER": "Discord Server",
|
"DISCORD_SERVER": "Discord Server",
|
||||||
"DONE": "Done",
|
"DONE": "Done",
|
||||||
"ENABLE_EVAL_COMMAND": "Enable /eval command",
|
"ENABLE_EVAL_COMMAND": "Enable /eval command",
|
||||||
"ENABLE_EVAL_COMMAND_DESC": "Evaluate JavaScript directly from a command. Be cautious when using this command as it may pose a security risk. Make sure to know what you are doing.",
|
"ENABLE_EVAL_COMMAND_DESC": "Evaluate JavaScript directly from a command. Be cautious when using this command as it may pose a security risk. Make sure to know what you are doing.",
|
||||||
"ENABLE_UPDATES": "Enable Updates",
|
"ENABLE_UPDATES": "Enable Updates",
|
||||||
"ERROR_BOUNDARY_TOOLS_LABEL": "ErrorBoundary Tools",
|
"ERROR_BOUNDARY_TOOLS_LABEL": "ErrorBoundary Tools",
|
||||||
"EXTRACT": "Extract",
|
"EXTRACT": "Extract",
|
||||||
"FONT_NAME": "Font Name",
|
"FONT_NAME": "Font Name",
|
||||||
"FONTS": "Fonts",
|
"FONTS": "Fonts",
|
||||||
"GENERAL": "General",
|
"GENERAL": "General",
|
||||||
"GITHUB": "GitHub",
|
"GITHUB": "GitHub",
|
||||||
"HOLD_UP": "Hold Up",
|
"HOLD_UP": "Hold Up",
|
||||||
"INFO": "Info",
|
"INFO": "Info",
|
||||||
"INSTALL": "Install",
|
"INSTALL": "Install",
|
||||||
"INSTALL_ADDON": "Install an add-on",
|
"INSTALL_ADDON": "Install an add-on",
|
||||||
"INSTALL_FONT": "Install a font",
|
"INSTALL_FONT": "Install a font",
|
||||||
"INSTALL_PLUGIN": "Install a plugin",
|
"INSTALL_PLUGIN": "Install a plugin",
|
||||||
"INSTALL_REACT_DEVTOOLS": "Install React DevTools",
|
"INSTALL_REACT_DEVTOOLS": "Install React DevTools",
|
||||||
"INSTALL_THEME": "Install a theme",
|
"INSTALL_THEME": "Install a theme",
|
||||||
"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 Kettu from a custom endpoint.",
|
"LOAD_FROM_CUSTOM_URL_DEC": "Load ShiggyCord from a custom endpoint.",
|
||||||
"LOAD_REACT_DEVTOOLS": "Load React DevTools",
|
"LOAD_REACT_DEVTOOLS": "Load React DevTools",
|
||||||
"LOADER": "Loader",
|
"LOADER": "Loader",
|
||||||
"MACHINE_ID": "Machine ID",
|
"MACHINE_ID": "Machine ID",
|
||||||
"MANUFACTURER": "Manufacturer",
|
"MANUFACTURER": "Manufacturer",
|
||||||
"MESSAGE": "Message",
|
"MESSAGE": "Message",
|
||||||
"MISCELLANEOUS": "Miscellaneous",
|
"MISCELLANEOUS": "Miscellaneous",
|
||||||
"MODAL_RELOAD_REQUIRED": "Reload app?",
|
"MODAL_RELOAD_REQUIRED": "Reload app?",
|
||||||
"MODAL_RELOAD_REQUIRED_DESC": "A reload is required to see the changes. Do you want to reload now?",
|
"MODAL_RELOAD_REQUIRED_DESC": "A reload is required to see the changes. Do you want to reload now?",
|
||||||
"MODAL_THEME_REFETCHED": "Theme refetched",
|
"MODAL_THEME_REFETCHED": "Theme refetched",
|
||||||
"MODAL_THEME_REFETCHED_DESC": "A reload is required to see the changes. Do you want to reload now?",
|
"MODAL_THEME_REFETCHED_DESC": "A reload is required to see the changes. Do you want to reload now?",
|
||||||
"MODAL_UNPROXIED_PLUGIN_DESC": "The plugin you are trying to install has not been proxied/verified by staff. Are you sure you want to continue?",
|
"MODAL_UNPROXIED_PLUGIN_DESC": "The plugin you are trying to install has not been proxied/verified by staff. Are you sure you want to continue?",
|
||||||
"MODAL_UNPROXIED_PLUGIN_HEADER": "Unproxied Plugin",
|
"MODAL_UNPROXIED_PLUGIN_HEADER": "Unproxied Plugin",
|
||||||
"MODEL": "Model",
|
"MODEL": "Model",
|
||||||
"OPEN_IN_BROWSER": "Open in Browser",
|
"OPEN_IN_BROWSER": "Open in Browser",
|
||||||
"OPERATING_SYSTEM": "Operating System",
|
"OPERATING_SYSTEM": "Operating System",
|
||||||
"OVERFLOW_PLUGIN_SETTINGS": "Plugin Settings",
|
"OVERFLOW_PLUGIN_SETTINGS": "Plugin Settings",
|
||||||
"PLATFORM": "Platform",
|
"PLATFORM": "Platform",
|
||||||
"PLUGIN_REFETCH_FAILED": "Failed to refetch plugin",
|
"PLUGIN_REFETCH_FAILED": "Failed to refetch plugin",
|
||||||
"PLUGIN_REFETCH_SUCCESSFUL": "Successfully refetched plugin",
|
"PLUGIN_REFETCH_SUCCESSFUL": "Successfully refetched plugin",
|
||||||
"PLUGINS": "Plugins",
|
"PLUGINS": "Plugins",
|
||||||
"REFETCH": "Refetch",
|
"REFETCH": "Refetch",
|
||||||
"RELOAD": "Reload",
|
"RELOAD": "Reload",
|
||||||
"RELOAD_DISCORD": "Reload Discord",
|
"RELOAD_DISCORD": "Reshiggy 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",
|
||||||
"RELOAD_IN_SAFE_MODE_DESC": "Tap to reload Discord without loading addons",
|
"RELOAD_IN_SAFE_MODE_DESC": "Tap to reload Discord without loading addons",
|
||||||
"REMOVE": "Remove",
|
"REMOVE": "Remove",
|
||||||
"RESTART_REQUIRED_TO_TAKE_EFFECT": "Restart is required to take effect",
|
"RESTART_REQUIRED_TO_TAKE_EFFECT": "Restart is required to take effect",
|
||||||
"RETRY": "Retry",
|
"RETRY": "Retry",
|
||||||
"RETRY_RENDER": "Retry Render",
|
"RETRY_RENDER": "Retry Render",
|
||||||
"SAFE_MODE": "Safe Mode",
|
"SAFE_MODE": "Safe Mode",
|
||||||
"SAFE_MODE_NOTICE_FONTS": "You are in safe mode, meaning fonts have been temporarily disabled. {enabled, select, true {If a font appears to be causing the issue, you can press below to disable it persistently.} other {}}",
|
"SAFE_MODE_NOTICE_FONTS": "You are in safe mode, meaning fonts have been temporarily disabled. {enabled, select, true {If a font appears to be causing the issue, you can press below to disable it persistently.} other {}}",
|
||||||
"SAFE_MODE_NOTICE_PLUGINS": "You are in safe mode, so plugins cannot be loaded. Disable any misbehaving plugins, then return to Normal Mode from the General settings page.",
|
"SAFE_MODE_NOTICE_PLUGINS": "You are in safe mode, so plugins cannot be loaded. Disable any misbehaving plugins, then return to Normal Mode from the General settings page.",
|
||||||
"SAFE_MODE_NOTICE_THEMES": "You are in safe mode, meaning themes have been temporarily disabled. {enabled, select, true {If a theme appears to be causing the issue, you can press below to disable it persistently.} other {}}",
|
"SAFE_MODE_NOTICE_THEMES": "You are in safe mode, meaning themes have been temporarily disabled. {enabled, select, true {If a theme appears to be causing the issue, you can press below to disable it persistently.} other {}}",
|
||||||
"SEARCH": "Search",
|
"SEARCH": "Search",
|
||||||
"SEPARATOR": ", ",
|
"SEPARATOR": ", ",
|
||||||
"SETTINGS_ACTIVATE_DISCORD_EXPERIMENTS": "Activate Discord Experiments",
|
"SETTINGS_ACTIVATE_DISCORD_EXPERIMENTS": "Activate Discord Experiments",
|
||||||
"SETTINGS_ACTIVATE_DISCORD_EXPERIMENTS_DESC": "Warning: Messing with this feature may lead to account termination. I heavily discourage using this and am not responsible for anything that happens if you use it",
|
"SETTINGS_ACTIVATE_DISCORD_EXPERIMENTS_DESC": "Warning: Messing with this feature may lead to account termination. I heavily discourage using this and am not responsible for anything that happens if you use it",
|
||||||
"STACK_TRACE": "Stack Trace",
|
"STACK_TRACE": "Stack Trace",
|
||||||
"SUCCESSFULLY_INSTALLED": "Successfully installed",
|
"SUCCESSFULLY_INSTALLED": "Successfully installed",
|
||||||
"THEME_EXTRACTOR_DESC": "This pack overrides the following: {fonts}",
|
"THEME_EXTRACTOR_DESC": "This pack overrides the following: {fonts}",
|
||||||
"THEME_REFETCH_FAILED": "Failed to refetch theme",
|
"THEME_REFETCH_FAILED": "Failed to refetch theme",
|
||||||
"THEME_REFETCH_SUCCESSFUL": "Successfully refetched theme",
|
"THEME_REFETCH_SUCCESSFUL": "Successfully refetched theme",
|
||||||
"THEMES": "Themes",
|
"THEMES": "Themes",
|
||||||
"THEMES_RELOAD_FOR_CHANGES": "Reload the app to fully apply changes",
|
"THEMES_RELOAD_FOR_CHANGES": "Reload the app to fully apply changes",
|
||||||
"TOASTS_INSTALLED_PLUGIN": "Installed plugin",
|
"TOASTS_INSTALLED_PLUGIN": "Installed plugin",
|
||||||
"TOASTS_PLUGIN_UPDATE": "{update, select, true {Enabled} other {Disabled}} updates for {name}",
|
"TOASTS_PLUGIN_UPDATE": "{update, select, true {Enabled} other {Disabled}} updates for {name}",
|
||||||
"UH_OH": "Uh Oh",
|
"UH_OH": "Uh Oh",
|
||||||
"UNINSTALL": "Uninstall",
|
"UNINSTALL": "Uninstall",
|
||||||
"UNINSTALL_TITLE": "Uninstall {title}",
|
"UNINSTALL_TITLE": "Uninstall {title}",
|
||||||
"URL_PLACEHOLDER": "https://codeberg.org/cocobo1/Kettu",
|
"URL_PLACEHOLDER": "https://github.com/kmmiio99o/ShiggyCord",
|
||||||
"VERSION": "Version",
|
"VERSION": "Version",
|
||||||
"VERSIONS": "Versions"
|
"VERSIONS": "Versions"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@ import { PrimitiveType } from "intl-messageformat";
|
||||||
|
|
||||||
import langDefault from "./default.json";
|
import langDefault from "./default.json";
|
||||||
|
|
||||||
const IntlMessageFormat = findByNameLazy("MessageFormat") as typeof import("intl-messageformat").default;
|
const IntlMessageFormat = findByNameLazy(
|
||||||
|
"MessageFormat",
|
||||||
|
) as typeof import("intl-messageformat").default;
|
||||||
|
|
||||||
type I18nKey = keyof typeof langDefault;
|
type I18nKey = keyof typeof langDefault;
|
||||||
|
|
||||||
|
|
@ -14,55 +16,73 @@ let _lastSetLocale: string | null = null;
|
||||||
const _loadedLocale = new Set<string>();
|
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() {
|
export function initFetchI18nStrings() {
|
||||||
const cb = ({ locale }: { locale: string; }) => {
|
const cb = ({ locale }: { locale: string }) => {
|
||||||
const languageMap = {
|
const languageMap = {
|
||||||
"es-ES": "es",
|
"es-ES": "es",
|
||||||
"es-419": "es_419",
|
"es-419": "es_419",
|
||||||
"zh-TW": "zh-Hant",
|
"zh-TW": "zh-Hant",
|
||||||
"zh-CN": "zh-Hans",
|
"zh-CN": "zh-Hans",
|
||||||
"pt-PT": "pt",
|
"pt-PT": "pt",
|
||||||
"pt-BR": "pt_BR",
|
"pt-BR": "pt_BR",
|
||||||
"sv-SE": "sv"
|
"sv-SE": "sv",
|
||||||
} as Record<string, string>;
|
} as Record<string, string>;
|
||||||
|
|
||||||
const resolvedLocale = _lastSetLocale = languageMap[locale] ?? locale;
|
const resolvedLocale = (_lastSetLocale = languageMap[locale] ?? locale);
|
||||||
|
|
||||||
if (resolvedLocale.startsWith("en-")) {
|
if (resolvedLocale.startsWith("en-")) {
|
||||||
_currentLocale = null;
|
_currentLocale = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_loadedLocale.has(resolvedLocale)) {
|
if (!_loadedLocale.has(resolvedLocale)) {
|
||||||
_loadedLocale.add(resolvedLocale);
|
_loadedLocale.add(resolvedLocale);
|
||||||
|
|
||||||
fetch(`https://raw.githubusercontent.com/C0C0B01/i18n/main/resources/${resolvedLocale}/kettu.json`)
|
fetch(
|
||||||
.then(r => r.json())
|
`https://raw.githubusercontent.com/C0C0B01/i18n/main/resources/${resolvedLocale}/shiggycord.json`,
|
||||||
.then(strings => _loadedStrings[resolvedLocale] = strings)
|
)
|
||||||
.then(() => resolvedLocale === _lastSetLocale && (_currentLocale = resolvedLocale))
|
.then((r) => r.json())
|
||||||
.catch(e => console.error(`An error occured while fetching strings for ${resolvedLocale}: ${e}`));
|
.then((strings) => (_loadedStrings[resolvedLocale] = strings))
|
||||||
} else {
|
.then(
|
||||||
_currentLocale = resolvedLocale;
|
() =>
|
||||||
}
|
resolvedLocale === _lastSetLocale &&
|
||||||
};
|
(_currentLocale = resolvedLocale),
|
||||||
|
)
|
||||||
|
.catch((e) =>
|
||||||
|
console.error(
|
||||||
|
`An error occured while fetching strings for ${resolvedLocale}: ${e}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
_currentLocale = resolvedLocale;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
FluxDispatcher.subscribe("I18N_LOAD_SUCCESS", cb);
|
FluxDispatcher.subscribe("I18N_LOAD_SUCCESS", cb);
|
||||||
return () => FluxDispatcher.unsubscribe("I18N_LOAD_SUCCESS", cb);
|
return () => FluxDispatcher.unsubscribe("I18N_LOAD_SUCCESS", cb);
|
||||||
}
|
}
|
||||||
|
|
||||||
type FormatStringRet<T> = T extends PrimitiveType ? string : string | T | (string | T)[];
|
type FormatStringRet<T> = T extends PrimitiveType
|
||||||
|
? string
|
||||||
|
: string | T | (string | T)[];
|
||||||
|
|
||||||
export function formatString<T = void>(key: I18nKey, val: Record<string, T>): FormatStringRet<T> {
|
export function formatString<T = void>(
|
||||||
const str = Strings[key];
|
key: I18nKey,
|
||||||
// @ts-ignore
|
val: Record<string, T>,
|
||||||
return new IntlMessageFormat(str).format(val);
|
): FormatStringRet<T> {
|
||||||
|
const str = Strings[key];
|
||||||
|
// @ts-ignore
|
||||||
|
return new IntlMessageFormat(str).format(val);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
194
src/core/plugins/fixembed/index.ts
Normal file
194
src/core/plugins/fixembed/index.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
import { defineCorePlugin } from "..";
|
||||||
|
import { findByProps } from "@metro";
|
||||||
|
import { after } from "@lib/api/patcher";
|
||||||
|
import { logger } from "@lib/utils/logger";
|
||||||
|
import { settings } from "@lib/api/settings";
|
||||||
|
import { React } from "@metro/common";
|
||||||
|
|
||||||
|
const { FormSection, FormRow, FormSwitch, FormText } = findByProps(
|
||||||
|
"FormSection",
|
||||||
|
"FormRow",
|
||||||
|
"FormSwitch",
|
||||||
|
"FormText",
|
||||||
|
);
|
||||||
|
const { ScrollView } = require("react-native");
|
||||||
|
|
||||||
|
type FixEmbedSettings = {
|
||||||
|
enabled: boolean;
|
||||||
|
twitter: boolean;
|
||||||
|
instagram: boolean;
|
||||||
|
tiktok: boolean;
|
||||||
|
reddit: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare module "@lib/api/settings" {
|
||||||
|
interface Settings {
|
||||||
|
fixembed?: FixEmbedSettings;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MessageActions = findByProps("sendMessage");
|
||||||
|
let unpatch: (() => void) | null = null;
|
||||||
|
|
||||||
|
function transformLinks(content: string, config: FixEmbedSettings): string {
|
||||||
|
let result = content;
|
||||||
|
// Twitter/X: Replace domain, keep path
|
||||||
|
if (config.twitter) {
|
||||||
|
result = result.replace(
|
||||||
|
/https?:\/\/(?:www\.)?(twitter\.com|x\.com)(\/[^\s]+)/gi,
|
||||||
|
"https://fxtwitter.com$2",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Instagram: Replace domain, keep path
|
||||||
|
if (config.instagram) {
|
||||||
|
result = result.replace(
|
||||||
|
/https?:\/\/(?:www\.)?instagram\.com(\/[^\s]+)/gi,
|
||||||
|
"https://ddinstagram.com$1",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// TikTok: Replace domain, keep path
|
||||||
|
if (config.tiktok) {
|
||||||
|
result = result.replace(
|
||||||
|
/https?:\/\/(?:www\.)?tiktok\.com(\/[^\s]+)/gi,
|
||||||
|
"https://tnktok.com$1",
|
||||||
|
);
|
||||||
|
// TikTok short links (vm.tiktok.com)
|
||||||
|
result = result.replace(
|
||||||
|
/https?:\/\/vm\.tiktok\.com\/([A-Za-z0-9]+)\/?/gi,
|
||||||
|
"https://vm.tnktok.com/$1",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Reddit: Replace domain, keep path
|
||||||
|
if (config.reddit) {
|
||||||
|
result = result.replace(
|
||||||
|
/https?:\/\/(?:www\.)?reddit\.com(\/[^\s]+)/gi,
|
||||||
|
"https://rxddit.com$1",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineCorePlugin({
|
||||||
|
manifest: {
|
||||||
|
id: "bunny.fixembed",
|
||||||
|
version: "1.0.0",
|
||||||
|
type: "plugin",
|
||||||
|
spec: 3,
|
||||||
|
main: "",
|
||||||
|
display: {
|
||||||
|
name: "FixEmbed",
|
||||||
|
description:
|
||||||
|
"Improves social media embeds by using privacy-friendly alternative frontends.",
|
||||||
|
authors: [{ name: "ShiggyCord Team" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
SettingsComponent() {
|
||||||
|
const { useState, useEffect } = React;
|
||||||
|
const [config, setConfig] = useState<FixEmbedSettings>({
|
||||||
|
enabled: settings.fixembed?.enabled ?? true,
|
||||||
|
twitter: settings.fixembed?.twitter ?? true,
|
||||||
|
instagram: settings.fixembed?.instagram ?? true,
|
||||||
|
tiktok: settings.fixembed?.tiktok ?? true,
|
||||||
|
reddit: settings.fixembed?.reddit ?? true,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
settings.fixembed = config;
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
const updateConfig = (key: keyof FixEmbedSettings, value: boolean) => {
|
||||||
|
setConfig((prev) => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return React.createElement(ScrollView, { style: { flex: 1 } }, [
|
||||||
|
React.createElement(
|
||||||
|
FormSection,
|
||||||
|
{ title: "General Settings" },
|
||||||
|
React.createElement(FormRow, {
|
||||||
|
label: "Enable FixEmbed",
|
||||||
|
subLabel: "Master switch for all link conversions",
|
||||||
|
trailing: React.createElement(FormSwitch, {
|
||||||
|
value: config.enabled,
|
||||||
|
onValueChange: (v: boolean) => updateConfig("enabled", v),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
React.createElement(FormSection, { title: "Platforms" }, [
|
||||||
|
React.createElement(FormRow, {
|
||||||
|
label: "Twitter/X",
|
||||||
|
trailing: React.createElement(FormSwitch, {
|
||||||
|
value: config.twitter,
|
||||||
|
disabled: !config.enabled,
|
||||||
|
onValueChange: (v: boolean) => updateConfig("twitter", v),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
React.createElement(FormRow, {
|
||||||
|
label: "Instagram",
|
||||||
|
trailing: React.createElement(FormSwitch, {
|
||||||
|
value: config.instagram,
|
||||||
|
disabled: !config.enabled,
|
||||||
|
onValueChange: (v: boolean) => updateConfig("instagram", v),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
React.createElement(FormRow, {
|
||||||
|
label: "TikTok",
|
||||||
|
trailing: React.createElement(FormSwitch, {
|
||||||
|
value: config.tiktok,
|
||||||
|
disabled: !config.enabled,
|
||||||
|
onValueChange: (v: boolean) => updateConfig("tiktok", v),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
React.createElement(FormRow, {
|
||||||
|
label: "Reddit",
|
||||||
|
trailing: React.createElement(FormSwitch, {
|
||||||
|
value: config.reddit,
|
||||||
|
disabled: !config.enabled,
|
||||||
|
onValueChange: (v: boolean) => updateConfig("reddit", v),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
React.createElement(
|
||||||
|
FormSection,
|
||||||
|
{ title: "About" },
|
||||||
|
React.createElement(
|
||||||
|
FormText,
|
||||||
|
{ style: { padding: 12 } },
|
||||||
|
"This plugin automatically converts social media links to privacy-friendly alternative frontends for better embeds.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
|
||||||
|
start() {
|
||||||
|
logger.log("FixEmbed: Starting plugin");
|
||||||
|
settings.fixembed = settings.fixembed || {
|
||||||
|
enabled: true,
|
||||||
|
twitter: true,
|
||||||
|
instagram: true,
|
||||||
|
tiktok: true,
|
||||||
|
reddit: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!unpatch) {
|
||||||
|
unpatch = after("sendMessage", MessageActions, (args) => {
|
||||||
|
const config = settings.fixembed!;
|
||||||
|
if (!config.enabled) return;
|
||||||
|
if (args[1]?.content) {
|
||||||
|
args[1].content = transformLinks(args[1].content, config);
|
||||||
|
args[1].nonce = args[1].nonce || Math.random().toString(36).slice(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
logger.log("FixEmbed: Patched outgoing messages");
|
||||||
|
},
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
logger.log("FixEmbed: Stopping plugin");
|
||||||
|
if (unpatch) {
|
||||||
|
unpatch();
|
||||||
|
unpatch = null;
|
||||||
|
}
|
||||||
|
logger.log("FixEmbed: Unpatched outgoing messages");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -1,22 +1,26 @@
|
||||||
import { PluginInstanceInternal } from "@lib/addons/plugins/types";
|
import { PluginInstanceInternal } from "@lib/addons/plugins/types";
|
||||||
|
|
||||||
interface CorePlugin {
|
interface CorePlugin {
|
||||||
default: PluginInstanceInternal;
|
default: PluginInstanceInternal;
|
||||||
preenabled: boolean;
|
preenabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called from @lib/plugins
|
// Called from @lib/plugins
|
||||||
export const getCorePlugins = (): Record<string, CorePlugin> => ({
|
export const getCorePlugins = (): Record<string, CorePlugin> => ({
|
||||||
"bunny.quickinstall": require("./quickinstall"),
|
"bunny.quickinstall": { ...require("./quickinstall"), preenabled: true },
|
||||||
"bunny.badges": require("./badges"),
|
"bunny.badges": require("./badges"),
|
||||||
"vendetta.notrack": require("./notrack")
|
"bunny.notrack": { ...require("./notrack"), preenabled: true },
|
||||||
|
"bunny.messagefix": { ...require("./messagefix") },
|
||||||
|
"bunny.fixembed": { ...require("./fixembed"), preenabled: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
export function defineCorePlugin(instance: PluginInstanceInternal): PluginInstanceInternal {
|
export function defineCorePlugin(
|
||||||
// @ts-expect-error
|
instance: PluginInstanceInternal,
|
||||||
instance[Symbol.for("bunny.core.plugin")] = true;
|
): PluginInstanceInternal {
|
||||||
return instance;
|
// @ts-expect-error
|
||||||
|
instance[Symbol.for("bunny.core.plugin")] = true;
|
||||||
|
return instance;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
56
src/core/plugins/messagefix/index.ts
Normal file
56
src/core/plugins/messagefix/index.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
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: "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");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -1,36 +1,230 @@
|
||||||
import network from "./lib/network";
|
|
||||||
import console from "./lib/console";
|
|
||||||
import miscellanous from "./lib/miscellanous";
|
|
||||||
import sentry from "./lib/sentry";
|
|
||||||
import { defineCorePlugin } from "..";
|
import { defineCorePlugin } from "..";
|
||||||
|
import { findByProps } from "@metro";
|
||||||
|
import { instead } from "@lib/api/patcher";
|
||||||
import { logger } from "@lib/utils/logger";
|
import { logger } from "@lib/utils/logger";
|
||||||
|
|
||||||
const patches = [
|
// Type for functions that need to be cleaned up when plugin is disabled
|
||||||
network(),
|
type PatchCleanupFn = () => void;
|
||||||
console(),
|
|
||||||
miscellanous(),
|
// Store all active patches for cleanup
|
||||||
// Sentry causes issues
|
let patches: PatchCleanupFn[] = [];
|
||||||
// sentry(),
|
|
||||||
];
|
// AnalyticsUtils and other tracking utilities
|
||||||
|
const AnalyticsUtils = findByProps("AnalyticsActionHandlers");
|
||||||
|
const SuperPropUtils = findByProps("encodeProperties", "track");
|
||||||
|
const VoiceStateUtils = findByProps("getVoiceStateMetadata");
|
||||||
|
const CrashReportUtils = findByProps("submitLiveCrashReport");
|
||||||
|
const MetricsUtils = findByProps("_metrics");
|
||||||
|
|
||||||
|
// Sentry interface definition to avoid TypeScript errors
|
||||||
|
interface SentryClient {
|
||||||
|
getOptions: () => { enabled: boolean };
|
||||||
|
close: () => void;
|
||||||
|
open: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SentryScope {
|
||||||
|
clear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SentryHub {
|
||||||
|
getClient: () => SentryClient | undefined;
|
||||||
|
getStackTop: () => { scope: SentryScope };
|
||||||
|
getScope: () => SentryScope;
|
||||||
|
addBreadcrumb?: (breadcrumb: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GlobalSentry {
|
||||||
|
hub?: SentryHub;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Sentry objects with proper type checking
|
||||||
|
const sentryGlobal = (globalThis as unknown as { __SENTRY__?: GlobalSentry })
|
||||||
|
.__SENTRY__;
|
||||||
|
const sentryHub = sentryGlobal?.hub;
|
||||||
|
const sentryClient = sentryHub?.getClient();
|
||||||
|
|
||||||
|
// Sentry related objects with type safety
|
||||||
|
const Sentry = {
|
||||||
|
initializer: findByProps("initSentry"),
|
||||||
|
main: sentryHub,
|
||||||
|
client: sentryClient,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Utility function to create no-operation patches
|
||||||
|
const noop = (prop: string, parent: Record<string, any>): PatchCleanupFn => {
|
||||||
|
try {
|
||||||
|
return instead(prop, parent, () => undefined);
|
||||||
|
} catch (e) {
|
||||||
|
return () => false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Network tracking prevention
|
||||||
|
function patchNetwork(): PatchCleanupFn {
|
||||||
|
// Analytics and tracking endpoints to block
|
||||||
|
const analyticsTest =
|
||||||
|
/client-analytics\.braintreegateway\.com|discord\.com\/api\/v9\/(science|track)|app\.adjust\..*|.*\.ingest\.sentry\.io/;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const unpatch = instead(
|
||||||
|
"send",
|
||||||
|
XMLHttpRequest.prototype,
|
||||||
|
function (
|
||||||
|
this: XMLHttpRequest & { __sentry_xhr__?: { url: string } },
|
||||||
|
args: unknown[],
|
||||||
|
orig: Function,
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
this.__sentry_xhr__?.url &&
|
||||||
|
analyticsTest.test(this.__sentry_xhr__.url)
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return orig.apply(this, args);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return unpatch;
|
||||||
|
} catch (e) {
|
||||||
|
return () => false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Console patching to remove Sentry wrappers
|
||||||
|
function patchConsole(): PatchCleanupFn {
|
||||||
|
type ConsoleFunction = Function & { __sentry_original__?: Function };
|
||||||
|
type ConsoleFunctions = Record<string, ConsoleFunction>;
|
||||||
|
|
||||||
|
const sentrified: ConsoleFunctions = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// We need to use Object.keys because TypeScript doesn't allow indexing Console with strings
|
||||||
|
(Object.keys(console) as Array<keyof Console>).forEach((key) => {
|
||||||
|
const consoleFunc = (console as unknown as ConsoleFunctions)[key];
|
||||||
|
if (consoleFunc) {
|
||||||
|
sentrified[key] = consoleFunc;
|
||||||
|
const originalFunc = consoleFunc.__sentry_original__;
|
||||||
|
(console as unknown as ConsoleFunctions)[key] =
|
||||||
|
originalFunc ?? consoleFunc;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.log("Failed to de-sentrify console functions!", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
Object.keys(sentrified).forEach((key) => {
|
||||||
|
if (sentrified[key]) {
|
||||||
|
(console as unknown as ConsoleFunctions)[key] = sentrified[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Miscellaneous tracking functions
|
||||||
|
function patchMiscellaneous(): PatchCleanupFn {
|
||||||
|
const miscPatches = [
|
||||||
|
// Global analytics utilities
|
||||||
|
AnalyticsUtils?.AnalyticsActionHandlers &&
|
||||||
|
noop("handleTrack", AnalyticsUtils.AnalyticsActionHandlers),
|
||||||
|
AnalyticsUtils?.AnalyticsActionHandlers &&
|
||||||
|
noop("handleFingerprint", AnalyticsUtils.AnalyticsActionHandlers),
|
||||||
|
|
||||||
|
// Super properties tracking
|
||||||
|
SuperPropUtils && noop("track", SuperPropUtils),
|
||||||
|
|
||||||
|
// Voice state metadata tracking
|
||||||
|
VoiceStateUtils && noop("trackWithMetadata", VoiceStateUtils),
|
||||||
|
|
||||||
|
// Crash reporter
|
||||||
|
CrashReportUtils && noop("submitLiveCrashReport", CrashReportUtils),
|
||||||
|
|
||||||
|
// Metrics
|
||||||
|
MetricsUtils?._metrics && noop("push", MetricsUtils._metrics),
|
||||||
|
].filter(Boolean) as PatchCleanupFn[];
|
||||||
|
|
||||||
|
return () => miscPatches.forEach((p) => p());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sentry error reporting prevention
|
||||||
|
function patchSentry(): PatchCleanupFn {
|
||||||
|
const sentryPatches: PatchCleanupFn[] = [];
|
||||||
|
|
||||||
|
// Add patches if Sentry components exist
|
||||||
|
if (Sentry.initializer) {
|
||||||
|
sentryPatches.push(noop("initSentry", Sentry.initializer));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Sentry.main && Sentry.main.addBreadcrumb) {
|
||||||
|
sentryPatches.push(noop("addBreadcrumb", Sentry.main));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable Sentry client
|
||||||
|
if (Sentry.client) {
|
||||||
|
try {
|
||||||
|
Sentry.client.getOptions().enabled = false;
|
||||||
|
Sentry.client.close();
|
||||||
|
|
||||||
|
if (Sentry.main) {
|
||||||
|
if (Sentry.main.getStackTop) {
|
||||||
|
Sentry.main.getStackTop().scope.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Sentry.main.getScope) {
|
||||||
|
Sentry.main.getScope().clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Silent fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
try {
|
||||||
|
sentryPatches.forEach((p) => p());
|
||||||
|
|
||||||
|
// Re-enable Sentry client on cleanup
|
||||||
|
if (Sentry.client) {
|
||||||
|
Sentry.client.getOptions().enabled = true;
|
||||||
|
Sentry.client.open();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Silent fail
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default defineCorePlugin({
|
export default defineCorePlugin({
|
||||||
manifest: {
|
manifest: {
|
||||||
id: "vendetta.notrack",
|
id: "bunny.notrack",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
type: "plugin",
|
type: "plugin",
|
||||||
spec: 3,
|
spec: 3,
|
||||||
main: "",
|
main: "",
|
||||||
display: {
|
display: {
|
||||||
name: "NoTrack",
|
name: "NoTrack",
|
||||||
description: "Disables Discord's telemetry",
|
description: "Disables Discord's telemetry",
|
||||||
authors: [{ name: "maisymoe" }]
|
authors: [{ name: "maisymoe" }],
|
||||||
}
|
|
||||||
},
|
},
|
||||||
start() {
|
},
|
||||||
network(),
|
|
||||||
console(),
|
|
||||||
miscellanous(),
|
|
||||||
sentry()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
start() {
|
||||||
|
// Apply all patches
|
||||||
|
patches = [
|
||||||
|
patchNetwork(),
|
||||||
|
patchConsole(),
|
||||||
|
patchMiscellaneous(),
|
||||||
|
patchSentry(),
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
logger.log("NoTrack: Enabled - all telemetry tracking disabled");
|
||||||
|
},
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
// Call all cleanup functions
|
||||||
|
patches.forEach((p) => p?.());
|
||||||
|
patches = [];
|
||||||
|
|
||||||
|
logger.log("NoTrack: Disabled - telemetry tracking restored");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
import { logger } from "@lib/utils/logger";
|
|
||||||
|
|
||||||
export default () => {
|
|
||||||
const sentrified = {};
|
|
||||||
|
|
||||||
try {
|
|
||||||
Object.keys(console).forEach((x) => {
|
|
||||||
sentrified[x] = console[x];
|
|
||||||
console[x] = console[x].__sentry_original__ ?? console[x];
|
|
||||||
});
|
|
||||||
} catch(e) {
|
|
||||||
logger.log("Failed to de-sentrify console functions!", e);
|
|
||||||
};
|
|
||||||
|
|
||||||
return () => Object.keys(console).forEach((x) => console[x] = sentrified[x]);
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
import { findByProps } from "@metro";
|
|
||||||
import { noop } from "./utils";
|
|
||||||
|
|
||||||
// Stuff in here is non-specific tracking scattered around the app
|
|
||||||
|
|
||||||
const AnalyticsUtils = findByProps("AnalyticsActionHandlers");
|
|
||||||
const SuperPropUtils = findByProps("encodeProperties", "track");
|
|
||||||
const VoiceStateUtils = findByProps("getVoiceStateMetadata");
|
|
||||||
const CrashReportUtils = findByProps("submitLiveCrashReport");
|
|
||||||
const MetricsUtils = findByProps("_metrics");
|
|
||||||
|
|
||||||
export default () => {
|
|
||||||
const patches = [
|
|
||||||
// Global analytics utilities
|
|
||||||
...(["handleTrack", "handleFingerprint"].map((i) => noop(i, AnalyticsUtils.AnalyticsActionHandlers))),
|
|
||||||
// TODO: This is a getter - look into patching it
|
|
||||||
// noop("analyticsTrackingStoreMaker", AnalyticsUtils),
|
|
||||||
|
|
||||||
// Super properties tracking
|
|
||||||
noop("track", SuperPropUtils),
|
|
||||||
|
|
||||||
// Voice state metadata tracking
|
|
||||||
noop("trackWithMetadata", VoiceStateUtils),
|
|
||||||
|
|
||||||
// Crash reporter
|
|
||||||
noop("submitLiveCrashReport", CrashReportUtils),
|
|
||||||
|
|
||||||
// Whatever "metrics" implies
|
|
||||||
noop("push", MetricsUtils._metrics),
|
|
||||||
];
|
|
||||||
|
|
||||||
return () => patches.forEach((p) => p());
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
import { instead } from "@lib/api/patcher";
|
|
||||||
|
|
||||||
export default () => {
|
|
||||||
// https://github.com/yellowsink/shelter-plugins/blob/3a62d1557b704f4617f72882edba826218eb8c6e/plugins/antitrack/index.js#L10-L25
|
|
||||||
const analyticsTest = /client-analytics\.braintreegateway\.com|discord\.com\/api\/v9\/(science|track)|app\.adjust\..*|.*\.ingest\.sentry\.io/;
|
|
||||||
|
|
||||||
return instead(
|
|
||||||
"send",
|
|
||||||
XMLHttpRequest.prototype,
|
|
||||||
function (args, orig) { if (!analyticsTest.test(this.__sentry_xhr__?.url)) return orig.apply(this, args) },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
import { findByProps } from "@metro";
|
|
||||||
import { noop } from "./utils";
|
|
||||||
|
|
||||||
const Sentry = {
|
|
||||||
initializer: findByProps("initSentry"),
|
|
||||||
main: globalThis.__SENTRY__?.hub,
|
|
||||||
client: globalThis.__SENTRY__?.hub?.getClient(),
|
|
||||||
};
|
|
||||||
|
|
||||||
export default () => {
|
|
||||||
const patches = [
|
|
||||||
noop("initSentry", Sentry.initializer),
|
|
||||||
noop("addBreadcrumb", Sentry.main),
|
|
||||||
];
|
|
||||||
|
|
||||||
Sentry.client.getOptions().enabled = false;
|
|
||||||
Sentry.client.close();
|
|
||||||
Sentry.main.getStackTop().scope.clear();
|
|
||||||
Sentry.main.getScope().clear();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
patches.forEach((p) => p());
|
|
||||||
Sentry.client.getOptions().enabled = true;
|
|
||||||
Sentry.client.open();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
import { instead } from "@lib/api/patcher";
|
|
||||||
|
|
||||||
export const noop = (prop: string, parent: any) => instead(prop, parent, () => {});
|
|
||||||
|
|
@ -5,7 +5,13 @@ 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 { HTTP_REGEX_MULTI, VD_DISCORD_SERVER_ID, VD_PLUGINS_CHANNEL_ID, VD_PROXY_PREFIX, VD_THEMES_CHANNEL_ID } from "@lib/utils/constants";
|
import {
|
||||||
|
HTTP_REGEX_MULTI,
|
||||||
|
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";
|
||||||
|
|
@ -16,74 +22,91 @@ 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(() => findByProps("useFirstForumPostMessage"));
|
const { useFirstForumPostMessage } = lazyDestructure(() =>
|
||||||
|
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 ? VdPluginManager.removePlugin(url) : VdPluginManager.installPlugin(url);
|
return isInstalled
|
||||||
}
|
? VdPluginManager.removePlugin(url)
|
||||||
|
: VdPluginManager.installPlugin(url);
|
||||||
},
|
},
|
||||||
Theme: {
|
},
|
||||||
storage: themes,
|
Theme: {
|
||||||
urlsFilter: (url: string) => url.endsWith(".json"),
|
storage: themes,
|
||||||
installOrRemove: (url: string) => {
|
urlsFilter: (url: string) => url.endsWith(".json"),
|
||||||
const isInstalled = postMap.Theme.storage[url];
|
installOrRemove: (url: string) => {
|
||||||
return isInstalled ? removeTheme(url) : installTheme(url);
|
const isInstalled = postMap.Theme.storage[url];
|
||||||
},
|
return isInstalled ? removeTheme(url) : installTheme(url);
|
||||||
}
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function useExtractThreadContent(thread: any, _firstMessage = null, actionSheet = false): ([PostType, string]) | void {
|
function useExtractThreadContent(
|
||||||
if (thread.guild_id !== VD_DISCORD_SERVER_ID) return;
|
thread: any,
|
||||||
|
_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 === VD_PLUGINS_CHANNEL_ID) {
|
||||||
postType = "Plugin";
|
postType = "Plugin";
|
||||||
} else if (thread.parent_id === VD_THEMES_CHANNEL_ID && isThemeSupported()) {
|
} else if (thread.parent_id === VD_THEMES_CHANNEL_ID && isThemeSupported()) {
|
||||||
postType = "Theme";
|
postType = "Theme";
|
||||||
} else return;
|
} else return;
|
||||||
|
|
||||||
const { firstMessage } = actionSheet ? useFirstForumPostMessage(thread) : { firstMessage: _firstMessage };
|
const { firstMessage } = actionSheet
|
||||||
|
? useFirstForumPostMessage(thread)
|
||||||
|
: { firstMessage: _firstMessage };
|
||||||
|
|
||||||
const urls = firstMessage?.content?.match(HTTP_REGEX_MULTI)?.filter(postMap[postType].urlsFilter);
|
const urls = firstMessage?.content
|
||||||
if (!urls || !urls[0]) return;
|
?.match(HTTP_REGEX_MULTI)
|
||||||
|
?.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(thread: any, firstMessage = null, actionSheet = false): [true] | [false, PostType, boolean, boolean, () => Promise<void>] {
|
function useInstaller(
|
||||||
const [postType, url] = useExtractThreadContent(thread, firstMessage, actionSheet) ?? [];
|
thread: any,
|
||||||
|
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???
|
||||||
|
|
@ -104,33 +127,43 @@ function useInstaller(thread: any, firstMessage = null, actionSheet = false): [t
|
||||||
// </ActionsSection>);
|
// </ActionsSection>);
|
||||||
// });
|
// });
|
||||||
|
|
||||||
const installButtonPatch = () => after("MostCommonForumPostReaction", forumReactions, ([{ thread, firstMessage }], res) => {
|
const installButtonPatch = () =>
|
||||||
const [shouldReturn, _, installed, loading, installOrRemove] = useInstaller(thread, firstMessage, true);
|
after(
|
||||||
if (shouldReturn) return;
|
"MostCommonForumPostReaction",
|
||||||
|
forumReactions,
|
||||||
|
([{ thread, firstMessage }], res) => {
|
||||||
|
const [shouldReturn, _, installed, loading, installOrRemove] =
|
||||||
|
useInstaller(thread, firstMessage, true);
|
||||||
|
if (shouldReturn) return;
|
||||||
|
|
||||||
return <>
|
return (
|
||||||
{res}
|
<>
|
||||||
<ErrorBoundary>
|
{res}
|
||||||
|
<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(installed ? "ic_message_delete" : "DownloadIcon")}
|
icon={findAssetId(
|
||||||
style={{ marginLeft: 8 }}
|
installed ? "ic_message_delete" : "DownloadIcon",
|
||||||
|
)}
|
||||||
|
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());
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,22 +5,22 @@ import patchUrl from "./url";
|
||||||
let patches = [] as (() => unknown)[];
|
let patches = [] as (() => unknown)[];
|
||||||
|
|
||||||
export default defineCorePlugin({
|
export default defineCorePlugin({
|
||||||
manifest: {
|
manifest: {
|
||||||
id: "bunny.quickinstall",
|
id: "bunny.quickinstall",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
type: "plugin",
|
type: "plugin",
|
||||||
spec: 3,
|
spec: 3,
|
||||||
main: "",
|
main: "",
|
||||||
display: {
|
display: {
|
||||||
name: "QuickInstall",
|
name: "QuickInstall",
|
||||||
description: "Quickly install Vendetta plugins and themes",
|
description: "Quickly install Vendetta plugins and themes",
|
||||||
authors: [{ name: "Vendetta Team" }]
|
authors: [{ name: "Vendetta Team" }],
|
||||||
}
|
|
||||||
},
|
},
|
||||||
start() {
|
},
|
||||||
patches = [patchForumPost(), patchUrl()];
|
start() {
|
||||||
},
|
patches = [patchForumPost(), patchUrl()];
|
||||||
stop() {
|
},
|
||||||
patches.forEach(p => p());
|
stop() {
|
||||||
}
|
patches.forEach((p) => p());
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -14,71 +14,79 @@ import { findByProps, findByPropsLazy } from "@metro/wrappers";
|
||||||
import { showToast } from "@ui/toasts";
|
import { showToast } from "@ui/toasts";
|
||||||
import { Linking } from "react-native";
|
import { Linking } from "react-native";
|
||||||
|
|
||||||
const showSimpleActionSheet = findExports(byMutableProp("showSimpleActionSheet"));
|
const showSimpleActionSheet = findExports(
|
||||||
|
byMutableProp("showSimpleActionSheet"),
|
||||||
|
);
|
||||||
const handleClick = findByPropsLazy("handleClick");
|
const handleClick = findByPropsLazy("handleClick");
|
||||||
const { getChannelId } = lazyDestructure(() => channels);
|
const { getChannelId } = lazyDestructure(() => channels);
|
||||||
const { getChannel } = lazyDestructure(() => findByProps("getChannel"));
|
const { getChannel } = lazyDestructure(() => findByProps("getChannel"));
|
||||||
|
|
||||||
function typeFromUrl(url: string) {
|
function typeFromUrl(url: string) {
|
||||||
if (url.startsWith(VD_PROXY_PREFIX)) {
|
if (url.startsWith(VD_PROXY_PREFIX)) {
|
||||||
return "plugin";
|
return "plugin";
|
||||||
} else if (url.endsWith(".json") && isThemeSupported()) {
|
} else if (url.endsWith(".json") && isThemeSupported()) {
|
||||||
return "theme";
|
return "theme";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function installWithToast(type: "plugin" | "theme", url: string) {
|
function installWithToast(type: "plugin" | "theme", url: string) {
|
||||||
(type === "plugin" ? VdPluginManager.installPlugin.bind(VdPluginManager) : installTheme)(url)
|
(type === "plugin"
|
||||||
.then(() => {
|
? VdPluginManager.installPlugin.bind(VdPluginManager)
|
||||||
showToast(Strings.SUCCESSFULLY_INSTALLED, findAssetId("Check"));
|
: installTheme)(url)
|
||||||
})
|
.then(() => {
|
||||||
.catch((e: Error) => {
|
showToast(Strings.SUCCESSFULLY_INSTALLED, findAssetId("Check"));
|
||||||
showToast(e.message, findAssetId("Small"));
|
})
|
||||||
});
|
.catch((e: Error) => {
|
||||||
|
showToast(e.message, findAssetId("Small"));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default () => {
|
export default () => {
|
||||||
const patches = new Array<Function>();
|
const patches = new Array<Function>();
|
||||||
|
|
||||||
patches.push(
|
patches.push(
|
||||||
after("showSimpleActionSheet", showSimpleActionSheet, args => {
|
after("showSimpleActionSheet", showSimpleActionSheet, (args) => {
|
||||||
if (args[0].key !== "LongPressUrl") return;
|
if (args[0].key !== "LongPressUrl") return;
|
||||||
const {
|
const {
|
||||||
header: { title: url },
|
header: { title: url },
|
||||||
options,
|
options,
|
||||||
} = args[0];
|
} = args[0];
|
||||||
|
|
||||||
const urlType = typeFromUrl(url);
|
const urlType = typeFromUrl(url);
|
||||||
if (!urlType) return;
|
if (!urlType) return;
|
||||||
|
|
||||||
options.push({
|
options.push({
|
||||||
label: Strings.INSTALL_ADDON,
|
label: Strings.INSTALL_ADDON,
|
||||||
onPress: () => installWithToast(urlType, url),
|
onPress: () => installWithToast(urlType, url),
|
||||||
});
|
});
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
patches.push(
|
patches.push(
|
||||||
instead("handleClick", handleClick, async function (this: any, args, orig) {
|
instead("handleClick", handleClick, async function (this: any, args, orig) {
|
||||||
const { href: url } = args[0];
|
const { href: url } = args[0];
|
||||||
|
|
||||||
const urlType = typeFromUrl(url);
|
const urlType = typeFromUrl(url);
|
||||||
if (!urlType) return orig.apply(this, args);
|
if (!urlType) return orig.apply(this, args);
|
||||||
|
|
||||||
// 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 (urlType === "theme" && getChannel(getChannelId())?.parent_id !== VD_THEMES_CHANNEL_ID) return orig.apply(this, args);
|
if (
|
||||||
|
urlType === "theme" &&
|
||||||
|
getChannel(getChannelId())?.parent_id !== VD_THEMES_CHANNEL_ID
|
||||||
|
)
|
||||||
|
return orig.apply(this, args);
|
||||||
|
|
||||||
showConfirmationAlert({
|
showConfirmationAlert({
|
||||||
title: Strings.HOLD_UP,
|
title: Strings.HOLD_UP,
|
||||||
content: formatString("CONFIRMATION_LINK_IS_A_TYPE", { urlType }),
|
content: formatString("CONFIRMATION_LINK_IS_A_TYPE", { urlType }),
|
||||||
onConfirm: () => installWithToast(urlType, url),
|
onConfirm: () => installWithToast(urlType, url),
|
||||||
confirmText: Strings.INSTALL,
|
confirmText: Strings.INSTALL,
|
||||||
cancelText: Strings.CANCEL,
|
cancelText: Strings.CANCEL,
|
||||||
secondaryConfirmText: Strings.OPEN_IN_BROWSER,
|
secondaryConfirmText: Strings.OPEN_IN_BROWSER,
|
||||||
onConfirmSecondary: () => Linking.openURL(url),
|
onConfirmSecondary: () => Linking.openURL(url),
|
||||||
});
|
});
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return () => patches.forEach(p => p());
|
return () => patches.forEach((p) => p());
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,154 +1,176 @@
|
||||||
import { findAssetId } from "@lib/api/assets";
|
import { findAssetId } from "@lib/api/assets";
|
||||||
import { lazyDestructure } from "@lib/utils/lazy";
|
import { lazyDestructure } from "@lib/utils/lazy";
|
||||||
import { Card, FormRadio, FormSwitch, IconButton, LegacyFormRow, Stack, Text } from "@metro/common/components";
|
import {
|
||||||
|
Card,
|
||||||
|
FormRadio,
|
||||||
|
FormSwitch,
|
||||||
|
IconButton,
|
||||||
|
LegacyFormRow,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
} from "@metro/common/components";
|
||||||
import { findByProps } from "@metro/wrappers";
|
import { findByProps } from "@metro/wrappers";
|
||||||
import { semanticColors } from "@ui/color";
|
import { semanticColors } from "@ui/color";
|
||||||
import { createStyles, TextStyleSheet } from "@ui/styles";
|
import { createStyles, TextStyleSheet } from "@ui/styles";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
const { hideActionSheet } = lazyDestructure(() => findByProps("openLazy", "hideActionSheet"));
|
const { hideActionSheet } = lazyDestructure(() =>
|
||||||
const { showSimpleActionSheet } = lazyDestructure(() => findByProps("showSimpleActionSheet"));
|
findByProps("openLazy", "hideActionSheet"),
|
||||||
|
);
|
||||||
|
const { showSimpleActionSheet } = lazyDestructure(() =>
|
||||||
|
findByProps("showSimpleActionSheet"),
|
||||||
|
);
|
||||||
|
|
||||||
// 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",
|
flexDirection: "column",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
scale: 1.2
|
scale: 1.2,
|
||||||
},
|
},
|
||||||
headerTrailing: {
|
headerTrailing: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
gap: 15,
|
gap: 15,
|
||||||
alignItems: "center"
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
headerLabel: {
|
headerLabel: {
|
||||||
...TextStyleSheet["heading-md/semibold"],
|
...TextStyleSheet["heading-md/semibold"],
|
||||||
color: semanticColors.TEXT_NORMAL,
|
color: semanticColors.TEXT_NORMAL,
|
||||||
},
|
},
|
||||||
headerSubtitle: {
|
headerSubtitle: {
|
||||||
...TextStyleSheet["text-md/semibold"],
|
...TextStyleSheet["text-md/semibold"],
|
||||||
color: semanticColors.TEXT_MUTED,
|
color: semanticColors.TEXT_MUTED,
|
||||||
},
|
},
|
||||||
descriptionLabel: {
|
descriptionLabel: {
|
||||||
...TextStyleSheet["text-md/semibold"],
|
...TextStyleSheet["text-md/semibold"],
|
||||||
color: semanticColors.TEXT_NORMAL,
|
color: semanticColors.TEXT_NORMAL,
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
flexDirection: "row-reverse",
|
flexDirection: "row-reverse",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 5
|
gap: 5,
|
||||||
},
|
},
|
||||||
iconStyle: {
|
iconStyle: {
|
||||||
tintColor: semanticColors.LOGO_PRIMARY,
|
tintColor: semanticColors.LOGO_PRIMARY,
|
||||||
opacity: 0.2,
|
opacity: 0.2,
|
||||||
height: 64,
|
height: 64,
|
||||||
width: 64,
|
width: 64,
|
||||||
left: void 0,
|
left: void 0,
|
||||||
right: "30%",
|
right: "30%",
|
||||||
top: "-10%"
|
top: "-10%",
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
interface Action {
|
interface Action {
|
||||||
icon: string;
|
icon: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OverflowAction extends Action {
|
interface OverflowAction extends Action {
|
||||||
label: string;
|
label: string;
|
||||||
isDestructive?: boolean;
|
isDestructive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CardWrapper<T> {
|
export interface CardWrapper<T> {
|
||||||
item: T;
|
item: T;
|
||||||
result: Fuzzysort.KeysResult<T>;
|
result: Fuzzysort.KeysResult<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CardProps {
|
interface CardProps {
|
||||||
index?: number;
|
index?: number;
|
||||||
headerLabel: string;
|
headerLabel: string;
|
||||||
headerSublabel?: string;
|
headerSublabel?: string;
|
||||||
headerIcon?: string;
|
headerIcon?: string;
|
||||||
toggleType?: "switch" | "radio";
|
toggleType?: "switch" | "radio";
|
||||||
toggleValue: () => boolean;
|
toggleValue: () => boolean;
|
||||||
onToggleChange?: (v: boolean) => void;
|
onToggleChange?: (v: boolean) => void;
|
||||||
descriptionLabel?: string;
|
descriptionLabel?: string;
|
||||||
actions?: Action[];
|
actions?: Action[];
|
||||||
overflowTitle?: string;
|
overflowTitle?: string;
|
||||||
overflowActions?: OverflowAction[];
|
overflowActions?: OverflowAction[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AddonCard(props: CardProps) {
|
export default function AddonCard(props: CardProps) {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<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 style={styles.headerLabel}>{props.headerLabel}</Text>
|
||||||
{props.headerSublabel && (
|
{props.headerSublabel && (
|
||||||
<Text style={styles.headerSubtitle}>{props.headerSublabel}</Text>
|
<Text style={styles.headerSubtitle}>{props.headerSublabel}</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<View style={[styles.headerTrailing, { marginLeft: "auto" }]}>
|
<View style={[styles.headerTrailing, { marginLeft: "auto" }]}>
|
||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
{props.overflowActions &&
|
{props.overflowActions && (
|
||||||
<IconButton
|
<IconButton
|
||||||
onPress={() => showSimpleActionSheet({
|
onPress={() =>
|
||||||
key: "CardOverflow",
|
showSimpleActionSheet({
|
||||||
header: {
|
key: "CardOverflow",
|
||||||
title: props.overflowTitle,
|
header: {
|
||||||
icon: props.headerIcon && <LegacyFormRow.Icon style={{ marginRight: 8 }} source={findAssetId(props.headerIcon)} />,
|
title: props.overflowTitle,
|
||||||
onClose: () => hideActionSheet(),
|
icon: props.headerIcon && (
|
||||||
},
|
<LegacyFormRow.Icon
|
||||||
options: props.overflowActions?.map(i => ({
|
style={{ marginRight: 8 }}
|
||||||
...i,
|
source={findAssetId(props.headerIcon)}
|
||||||
icon: findAssetId(i.icon)
|
/>
|
||||||
})),
|
),
|
||||||
})}
|
},
|
||||||
size="sm"
|
options: props.overflowActions?.map((i) => ({
|
||||||
variant="secondary"
|
...i,
|
||||||
icon={findAssetId("CircleInformationIcon-primary")}
|
icon: findAssetId(i.icon),
|
||||||
/>}
|
})),
|
||||||
{props.actions?.map(({ icon, onPress, disabled }) => (
|
})
|
||||||
<IconButton
|
}
|
||||||
onPress={onPress}
|
size="sm"
|
||||||
disabled={disabled}
|
variant="secondary"
|
||||||
size="sm"
|
icon={findAssetId("CircleInformationIcon-primary")}
|
||||||
variant="secondary"
|
/>
|
||||||
icon={findAssetId(icon)}
|
)}
|
||||||
/>
|
{props.actions?.map(({ icon, onPress, disabled }) => (
|
||||||
))}
|
<IconButton
|
||||||
</View>
|
onPress={onPress}
|
||||||
{props.toggleType && (props.toggleType === "switch" ?
|
disabled={disabled}
|
||||||
<FormSwitch
|
size="sm"
|
||||||
value={props.toggleValue()}
|
variant="secondary"
|
||||||
onValueChange={props.onToggleChange}
|
icon={findAssetId(icon)}
|
||||||
/>
|
/>
|
||||||
:
|
))}
|
||||||
<TouchableOpacity onPress={() => {
|
</View>
|
||||||
props.onToggleChange?.(!props.toggleValue());
|
{props.toggleType &&
|
||||||
}}>
|
(props.toggleType === "switch" ? (
|
||||||
<FormRadio selected={props.toggleValue()} />
|
<FormSwitch
|
||||||
</TouchableOpacity>
|
value={props.toggleValue()}
|
||||||
)}
|
onValueChange={props.onToggleChange}
|
||||||
</View>
|
/>
|
||||||
</View>
|
) : (
|
||||||
{props.descriptionLabel && <Text variant="text-md/medium">
|
<TouchableOpacity
|
||||||
{props.descriptionLabel}
|
onPress={() => {
|
||||||
</Text>}
|
props.onToggleChange?.(!props.toggleValue());
|
||||||
</Stack>
|
}}
|
||||||
</Card >
|
>
|
||||||
);
|
<FormRadio selected={props.toggleValue()} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{props.descriptionLabel && (
|
||||||
|
<Text variant="text-md/medium">{props.descriptionLabel}</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,217 +7,294 @@ import isValidHttpUrl from "@lib/utils/isValidHttpUrl";
|
||||||
import { lazyDestructure, proxyLazy } from "@lib/utils/lazy";
|
import { lazyDestructure, proxyLazy } from "@lib/utils/lazy";
|
||||||
import { findByProps } from "@metro";
|
import { findByProps } from "@metro";
|
||||||
import { clipboard, NavigationNative } from "@metro/common";
|
import { clipboard, NavigationNative } from "@metro/common";
|
||||||
import { AlertActionButton, AlertModal, Button, FlashList, FloatingActionButton, HelpMessage, IconButton, Stack, Text, TextInput, useSafeAreaInsets } from "@metro/common/components";
|
import {
|
||||||
|
AlertActionButton,
|
||||||
|
AlertModal,
|
||||||
|
Button,
|
||||||
|
FlashList,
|
||||||
|
FloatingActionButton,
|
||||||
|
HelpMessage,
|
||||||
|
IconButton,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
useSafeAreaInsets,
|
||||||
|
} from "@metro/common/components";
|
||||||
import { ErrorBoundary, Search } from "@ui/components";
|
import { ErrorBoundary, Search } from "@ui/components";
|
||||||
import { isNotNil } from "es-toolkit";
|
import { isNotNil } from "es-toolkit";
|
||||||
import fuzzysort from "fuzzysort";
|
import fuzzysort from "fuzzysort";
|
||||||
import { ComponentType, ReactNode, useCallback, useEffect, useMemo } from "react";
|
import {
|
||||||
|
ComponentType,
|
||||||
|
ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
} from "react";
|
||||||
import { Image, ScrollView, View } from "react-native";
|
import { Image, ScrollView, View } from "react-native";
|
||||||
|
|
||||||
const { showSimpleActionSheet, hideActionSheet } = lazyDestructure(() => findByProps("showSimpleActionSheet"));
|
const { showSimpleActionSheet, hideActionSheet } = lazyDestructure(() =>
|
||||||
|
findByProps("showSimpleActionSheet"),
|
||||||
|
);
|
||||||
|
|
||||||
type SearchKeywords<T> = Array<string | ((obj: T & {}) => string)>;
|
type SearchKeywords<T> = Array<string | ((obj: T & {}) => string)>;
|
||||||
|
|
||||||
interface AddonPageProps<T extends object, I = any> {
|
interface AddonPageProps<T extends object, I = any> {
|
||||||
title: string;
|
title: string;
|
||||||
items: I[];
|
items: I[];
|
||||||
searchKeywords: SearchKeywords<T>;
|
searchKeywords: SearchKeywords<T>;
|
||||||
sortOptions?: Record<string, (a: T, b: T) => number>;
|
sortOptions?: Record<string, (a: T, b: T) => number>;
|
||||||
resolveItem?: (value: I) => T | undefined;
|
resolveItem?: (value: I) => T | undefined;
|
||||||
safeModeHint?: {
|
safeModeHint?: {
|
||||||
message?: string;
|
message?: string;
|
||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
};
|
};
|
||||||
installAction?: {
|
installAction?: {
|
||||||
label?: string;
|
label?: string;
|
||||||
// Ignored when onPress is defined!
|
// Ignored when onPress is defined!
|
||||||
fetchFn?: (url: string) => Promise<void>;
|
fetchFn?: (url: string) => Promise<void>;
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
OptionsActionSheetComponent?: ComponentType<any>;
|
OptionsActionSheetComponent?: ComponentType<any>;
|
||||||
|
|
||||||
CardComponent: ComponentType<CardWrapper<T>>;
|
CardComponent: ComponentType<CardWrapper<T>>;
|
||||||
ListHeaderComponent?: ComponentType<any>;
|
ListHeaderComponent?: ComponentType<any>;
|
||||||
ListFooterComponent?: ComponentType<any>;
|
ListFooterComponent?: ComponentType<any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function InputAlert(props: { label: string, fetchFn: (url: string) => Promise<void>; }) {
|
function InputAlert(props: {
|
||||||
const [value, setValue] = React.useState("");
|
label: string;
|
||||||
const [error, setError] = React.useState("");
|
fetchFn: (url: string) => Promise<void>;
|
||||||
const [isFetching, setIsFetching] = React.useState(false);
|
}) {
|
||||||
|
const [value, setValue] = React.useState("");
|
||||||
|
const [error, setError] = React.useState("");
|
||||||
|
const [isFetching, setIsFetching] = React.useState(false);
|
||||||
|
|
||||||
function onConfirmWrapper() {
|
function onConfirmWrapper() {
|
||||||
setIsFetching(true);
|
setIsFetching(true);
|
||||||
|
|
||||||
props.fetchFn(value)
|
props
|
||||||
.then(() => dismissAlert("AddonInputAlert"))
|
.fetchFn(value)
|
||||||
.catch((e: unknown) => e instanceof Error ? setError(e.message) : String(e))
|
.then(() => dismissAlert("AddonInputAlert"))
|
||||||
.finally(() => setIsFetching(false));
|
.catch((e: unknown) =>
|
||||||
}
|
e instanceof Error ? setError(e.message) : String(e),
|
||||||
|
)
|
||||||
|
.finally(() => setIsFetching(false));
|
||||||
|
}
|
||||||
|
|
||||||
return <AlertModal
|
return (
|
||||||
title={props.label}
|
<AlertModal
|
||||||
content="Type in the source URL you want to install from:"
|
title={props.label}
|
||||||
extraContent={
|
content="Type in the source URL you want to install from:"
|
||||||
<Stack style={{ marginTop: -12 }}>
|
extraContent={
|
||||||
<TextInput
|
<Stack style={{ marginTop: -12 }}>
|
||||||
autoFocus={true}
|
<TextInput
|
||||||
isClearable={true}
|
autoFocus={true}
|
||||||
value={value}
|
isClearable={true}
|
||||||
onChange={(v: string) => {
|
value={value}
|
||||||
setValue(v);
|
onChange={(v: string) => {
|
||||||
if (error) setError("");
|
setValue(v);
|
||||||
}}
|
if (error) setError("");
|
||||||
returnKeyType="done"
|
}}
|
||||||
onSubmitEditing={onConfirmWrapper}
|
returnKeyType="done"
|
||||||
state={error ? "error" : undefined}
|
onSubmitEditing={onConfirmWrapper}
|
||||||
errorMessage={error || undefined}
|
state={error ? "error" : undefined}
|
||||||
/>
|
errorMessage={error || undefined}
|
||||||
<ScrollView
|
/>
|
||||||
horizontal={true}
|
<ScrollView
|
||||||
showsHorizontalScrollIndicator={false}
|
horizontal={true}
|
||||||
style={{ gap: 8 }}
|
showsHorizontalScrollIndicator={false}
|
||||||
>
|
style={{ gap: 8 }}
|
||||||
<Button
|
>
|
||||||
size="sm"
|
|
||||||
variant="tertiary"
|
|
||||||
text="Import from clipboard"
|
|
||||||
icon={findAssetId("ClipboardListIcon")}
|
|
||||||
onPress={() => clipboard.getString().then((str: string) => setValue(str))}
|
|
||||||
/>
|
|
||||||
</ScrollView>
|
|
||||||
</Stack>
|
|
||||||
}
|
|
||||||
actions={
|
|
||||||
<Stack>
|
|
||||||
{/* Manual button as we don't want alert to immediately dismiss when we tap on it */}
|
|
||||||
<Button
|
|
||||||
loading={isFetching}
|
|
||||||
text="Install"
|
|
||||||
variant="primary"
|
|
||||||
disabled={!value || !isValidHttpUrl(value)}
|
|
||||||
onPress={onConfirmWrapper}
|
|
||||||
/>
|
|
||||||
<AlertActionButton
|
|
||||||
disabled={isFetching}
|
|
||||||
text="Cancel"
|
|
||||||
variant="secondary"
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
}
|
|
||||||
/>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AddonPage<T extends object>({ CardComponent, ...props }: AddonPageProps<T>) {
|
|
||||||
const [search, setSearch] = React.useState("");
|
|
||||||
const [sortFn, setSortFn] = React.useState<((a: T, b: T) => number) | null>(() => null);
|
|
||||||
const { bottom: bottomInset } = useSafeAreaInsets();
|
|
||||||
const navigation = NavigationNative.useNavigation();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (props.OptionsActionSheetComponent) {
|
|
||||||
navigation.setOptions({
|
|
||||||
headerRight: () => <IconButton
|
|
||||||
size="sm"
|
|
||||||
variant="secondary"
|
|
||||||
icon={findAssetId("MoreHorizontalIcon")}
|
|
||||||
onPress={() => showSheet("AddonMoreSheet", props.OptionsActionSheetComponent!)}
|
|
||||||
/>
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [navigation]);
|
|
||||||
|
|
||||||
const results = useMemo(() => {
|
|
||||||
let values = props.items;
|
|
||||||
if (props.resolveItem) values = values.map(props.resolveItem).filter(isNotNil);
|
|
||||||
const items = values.filter(i => isNotNil(i) && typeof i === "object");
|
|
||||||
if (!search && sortFn) items.sort(sortFn);
|
|
||||||
|
|
||||||
return fuzzysort.go(search, items, { keys: props.searchKeywords, all: true });
|
|
||||||
}, [props.items, sortFn, search]);
|
|
||||||
|
|
||||||
const onInstallPress = useCallback(() => {
|
|
||||||
if (!props.installAction) return () => { };
|
|
||||||
const { label, onPress, fetchFn } = props.installAction;
|
|
||||||
if (fetchFn) {
|
|
||||||
openAlert("AddonInputAlert", <InputAlert label={label ?? "Install"} fetchFn={fetchFn} />);
|
|
||||||
} else {
|
|
||||||
onPress?.();
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (results.length === 0 && !search) {
|
|
||||||
return <View style={{ gap: 32, flexGrow: 1, justifyContent: "center", alignItems: "center" }}>
|
|
||||||
<View style={{ gap: 8, alignItems: "center" }}>
|
|
||||||
<Image source={findAssetId("empty_quick_switcher")!} />
|
|
||||||
<Text variant="text-lg/semibold" color="text-normal">
|
|
||||||
Oops! Nothing to see here… yet!
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="sm"
|
||||||
icon={findAssetId("DownloadIcon")}
|
variant="tertiary"
|
||||||
text={props.installAction?.label ?? "Install"}
|
text="Import from clipboard"
|
||||||
onPress={onInstallPress}
|
icon={findAssetId("ClipboardListIcon")}
|
||||||
|
onPress={() =>
|
||||||
|
clipboard.getString().then((str: string) => setValue(str))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</View>;
|
</ScrollView>
|
||||||
}
|
</Stack>
|
||||||
|
}
|
||||||
const headerElement = (
|
actions={
|
||||||
<View style={{ paddingBottom: 8 }}>
|
<Stack>
|
||||||
{settings.safeMode?.enabled && <View style={{ marginBottom: 10 }}>
|
{/* Manual button as we don't want alert to immediately dismiss when we tap on it */}
|
||||||
<HelpMessage messageType={0}>
|
<Button
|
||||||
{props.safeModeHint?.message}
|
loading={isFetching}
|
||||||
</HelpMessage>
|
text="Install"
|
||||||
{props.safeModeHint?.footer}
|
variant="primary"
|
||||||
</View>}
|
disabled={!value || !isValidHttpUrl(value)}
|
||||||
<View style={{ flexDirection: "row", gap: 8 }}>
|
onPress={onConfirmWrapper}
|
||||||
<Search style={{ flexGrow: 1 }} isRound={!!props.sortOptions} onChangeText={v => setSearch(v)} />
|
/>
|
||||||
{props.sortOptions && <IconButton
|
<AlertActionButton
|
||||||
icon={findAssetId("MoreVerticalIcon")}
|
disabled={isFetching}
|
||||||
variant="tertiary"
|
text="Cancel"
|
||||||
disabled={!!search}
|
variant="secondary"
|
||||||
onPress={() => showSimpleActionSheet({
|
/>
|
||||||
key: "AddonListSortOptions",
|
</Stack>
|
||||||
header: {
|
}
|
||||||
title: "Sort Options",
|
/>
|
||||||
onClose: () => hideActionSheet("AddonListSortOptions"),
|
);
|
||||||
},
|
}
|
||||||
options: Object.entries(props.sortOptions!).map(([name, fn]) => ({
|
|
||||||
label: name,
|
export default function AddonPage<T extends object>({
|
||||||
onPress: () => setSortFn(() => fn)
|
CardComponent,
|
||||||
}))
|
...props
|
||||||
})}
|
}: AddonPageProps<T>) {
|
||||||
/>}
|
const [search, setSearch] = React.useState("");
|
||||||
</View>
|
const [sortFn, setSortFn] = React.useState<((a: T, b: T) => number) | null>(
|
||||||
{props.ListHeaderComponent && <props.ListHeaderComponent />}
|
() => null,
|
||||||
</View>
|
);
|
||||||
);
|
const { bottom: bottomInset } = useSafeAreaInsets();
|
||||||
|
const navigation = NavigationNative.useNavigation();
|
||||||
return (
|
|
||||||
<ErrorBoundary>
|
useEffect(() => {
|
||||||
<FlashList
|
if (props.OptionsActionSheetComponent) {
|
||||||
data={results}
|
navigation.setOptions({
|
||||||
extraData={search}
|
headerRight: () => (
|
||||||
estimatedItemSize={136}
|
<IconButton
|
||||||
ListHeaderComponent={headerElement}
|
size="sm"
|
||||||
ListEmptyComponent={() => <View style={{ gap: 12, padding: 12, alignItems: "center" }}>
|
variant="secondary"
|
||||||
<Image source={findAssetId("devices_not_found")!} />
|
icon={findAssetId("MoreHorizontalIcon")}
|
||||||
<Text variant="text-lg/semibold" color="text-normal">
|
onPress={() =>
|
||||||
Hmmm... could not find that!
|
showSheet("AddonMoreSheet", props.OptionsActionSheetComponent!)
|
||||||
</Text>
|
}
|
||||||
</View>}
|
/>
|
||||||
contentContainerStyle={{ padding: 8, paddingHorizontal: 12, paddingBottom: 90 }}
|
),
|
||||||
ItemSeparatorComponent={() => <View style={{ height: 8 }} />}
|
});
|
||||||
ListFooterComponent={props.ListFooterComponent}
|
}
|
||||||
renderItem={({ item }: any) => <CardComponent item={item.obj} result={item} />}
|
}, [navigation]);
|
||||||
/>
|
|
||||||
{props.installAction && <FloatingActionButton
|
const results = useMemo(() => {
|
||||||
positionBottom={bottomInset + 8}
|
let values = props.items;
|
||||||
icon={findAssetId("PlusLargeIcon")}
|
if (props.resolveItem)
|
||||||
onPress={onInstallPress}
|
values = values.map(props.resolveItem).filter(isNotNil);
|
||||||
/>}
|
const items = values.filter((i) => isNotNil(i) && typeof i === "object");
|
||||||
</ErrorBoundary>
|
if (!search && sortFn) items.sort(sortFn);
|
||||||
);
|
|
||||||
|
return fuzzysort.go(search, items, {
|
||||||
|
keys: props.searchKeywords,
|
||||||
|
all: true,
|
||||||
|
});
|
||||||
|
}, [props.items, sortFn, search]);
|
||||||
|
|
||||||
|
const onInstallPress = useCallback(() => {
|
||||||
|
if (!props.installAction) return () => {};
|
||||||
|
const { label, onPress, fetchFn } = props.installAction;
|
||||||
|
if (fetchFn) {
|
||||||
|
openAlert(
|
||||||
|
"AddonInputAlert",
|
||||||
|
<InputAlert label={label ?? "Install"} fetchFn={fetchFn} />,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
onPress?.();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (results.length === 0 && !search) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
gap: 32,
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ gap: 8, alignItems: "center" }}>
|
||||||
|
<Image source={findAssetId("empty_quick_switcher")!} />
|
||||||
|
<Text variant="text-lg/semibold" color="text-normal">
|
||||||
|
Oops! Nothing to see here… yet!
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
icon={findAssetId("DownloadIcon")}
|
||||||
|
text={props.installAction?.label ?? "Install"}
|
||||||
|
onPress={onInstallPress}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerElement = (
|
||||||
|
<View style={{ paddingBottom: 8 }}>
|
||||||
|
{settings.safeMode?.enabled && (
|
||||||
|
<View style={{ marginBottom: 10 }}>
|
||||||
|
<HelpMessage messageType={0}>
|
||||||
|
{props.safeModeHint?.message}
|
||||||
|
</HelpMessage>
|
||||||
|
{props.safeModeHint?.footer}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={{ flexDirection: "row", gap: 8 }}>
|
||||||
|
<Search
|
||||||
|
style={{ flexGrow: 1 }}
|
||||||
|
isRound={!!props.sortOptions}
|
||||||
|
onChangeText={(v) => setSearch(v)}
|
||||||
|
/>
|
||||||
|
{props.sortOptions && (
|
||||||
|
<IconButton
|
||||||
|
icon={findAssetId("MoreVerticalIcon")}
|
||||||
|
variant="tertiary"
|
||||||
|
disabled={!!search}
|
||||||
|
onPress={() =>
|
||||||
|
showSimpleActionSheet({
|
||||||
|
key: "AddonListSortOptions",
|
||||||
|
header: {
|
||||||
|
title: "Sort Options",
|
||||||
|
},
|
||||||
|
options: Object.entries(props.sortOptions!).map(
|
||||||
|
([name, fn]) => ({
|
||||||
|
label: name,
|
||||||
|
onPress: () => setSortFn(() => fn),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{props.ListHeaderComponent && <props.ListHeaderComponent />}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<FlashList
|
||||||
|
data={results}
|
||||||
|
extraData={search}
|
||||||
|
estimatedItemSize={136}
|
||||||
|
ListHeaderComponent={headerElement}
|
||||||
|
ListEmptyComponent={() => (
|
||||||
|
<View style={{ gap: 12, padding: 12, alignItems: "center" }}>
|
||||||
|
<Image source={findAssetId("devices_not_found")!} />
|
||||||
|
<Text variant="text-lg/semibold" color="text-normal">
|
||||||
|
Hmmm... could not find that!
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
contentContainerStyle={{
|
||||||
|
padding: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingBottom: 90,
|
||||||
|
}}
|
||||||
|
ItemSeparatorComponent={() => <View style={{ height: 8 }} />}
|
||||||
|
ListFooterComponent={props.ListFooterComponent}
|
||||||
|
renderItem={({ item }: any) => (
|
||||||
|
<CardComponent item={item.obj} result={item} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{props.installAction && (
|
||||||
|
<FloatingActionButton
|
||||||
|
positionBottom={bottomInset + 8}
|
||||||
|
icon={findAssetId("PlusLargeIcon")}
|
||||||
|
onPress={onInstallPress}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,48 +5,78 @@ import { settings } from "@lib/api/settings";
|
||||||
import { Codeblock, ErrorBoundary } from "@lib/ui/components";
|
import { Codeblock, ErrorBoundary } from "@lib/ui/components";
|
||||||
import { createStyles } from "@lib/ui/styles";
|
import { createStyles } from "@lib/ui/styles";
|
||||||
import { tokens } from "@metro/common";
|
import { tokens } from "@metro/common";
|
||||||
import { Button, Card, SafeAreaProvider, SafeAreaView, Text } from "@metro/common/components";
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
SafeAreaProvider,
|
||||||
|
SafeAreaView,
|
||||||
|
Text,
|
||||||
|
} from "@metro/common/components";
|
||||||
import { ScrollView, View } from "react-native";
|
import { ScrollView, View } from "react-native";
|
||||||
|
|
||||||
import ErrorComponentStackCard from "./ErrorComponentStackCard";
|
import ErrorComponentStackCard from "./ErrorComponentStackCard";
|
||||||
import ErrorStackCard from "./ErrorStackCard";
|
import ErrorStackCard from "./ErrorStackCard";
|
||||||
|
|
||||||
const useStyles = createStyles({
|
const useStyles = createStyles({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: tokens.colors.BG_BASE_SECONDARY,
|
backgroundColor: tokens.colors.BG_BASE_SECONDARY,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
height: "100%",
|
height: "100%",
|
||||||
gap: 12
|
gap: 12,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function ErrorBoundaryScreen(props: {
|
export default function ErrorBoundaryScreen(props: {
|
||||||
error: Error;
|
error: Error;
|
||||||
rerender: () => void;
|
rerender: () => void;
|
||||||
}) {
|
}) {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const debugInfo = getDebugInfo();
|
const debugInfo = getDebugInfo();
|
||||||
|
|
||||||
return <ErrorBoundary>
|
return (
|
||||||
<SafeAreaProvider>
|
<ErrorBoundary>
|
||||||
<SafeAreaView style={styles.container}>
|
<SafeAreaProvider>
|
||||||
<View style={{ gap: 4 }}>
|
<SafeAreaView style={styles.container}>
|
||||||
<Text variant="display-lg">Uh oh.</Text>
|
<View style={{ gap: 4 }}>
|
||||||
<Text variant="text-md/normal">A crash occurred while rendering a component. This could be caused by a plugin, Kettu, or Discord itself.</Text>
|
<Text variant="display-lg">Uh oh.</Text>
|
||||||
<Text variant="text-sm/normal" color="text-muted">{debugInfo.os.name}; {debugInfo.discord.build} ({debugInfo.discord.version}); {debugInfo.bunny.version}</Text>
|
<Text variant="text-md/normal">
|
||||||
</View>
|
A crash occurred while rendering a component. This could be caused
|
||||||
<ScrollView fadingEdgeLength={64} contentContainerStyle={{ gap: 12 }}>
|
by a plugin, ShiggyCord, or Discord itself.
|
||||||
<Codeblock selectable={true}>{props.error.message}</Codeblock>
|
</Text>
|
||||||
{hasStack(props.error) && <ErrorStackCard error={props.error} />}
|
<Text variant="text-sm/normal" color="text-muted">
|
||||||
{isComponentStack(props.error) ? <ErrorComponentStackCard componentStack={props.error.componentStack} /> : null}
|
{debugInfo.os.name}; {debugInfo.discord.build} (
|
||||||
</ScrollView>
|
{debugInfo.discord.version}); {debugInfo.bunny.version}
|
||||||
<Card style={{ gap: 6 }}>
|
</Text>
|
||||||
<Button text="Reload Discord" onPress={() => BundleUpdaterManager.reload()} />
|
</View>
|
||||||
{!settings.safeMode?.enabled && <Button text="Reload in Safe Mode" onPress={() => toggleSafeMode()} />}
|
<ScrollView fadingEdgeLength={64} contentContainerStyle={{ gap: 12 }}>
|
||||||
<Button variant="destructive" text="Retry Render" onPress={() => props.rerender()} />
|
<Codeblock selectable={true}>{props.error.message}</Codeblock>
|
||||||
</Card>
|
{hasStack(props.error) && <ErrorStackCard error={props.error} />}
|
||||||
</SafeAreaView>
|
{isComponentStack(props.error) ? (
|
||||||
</SafeAreaProvider>
|
<ErrorComponentStackCard
|
||||||
</ErrorBoundary>;
|
componentStack={props.error.componentStack}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</ScrollView>
|
||||||
|
<Card style={{ gap: 6 }}>
|
||||||
|
<Button
|
||||||
|
text="Reload Discord"
|
||||||
|
onPress={() => BundleUpdaterManager.reload()}
|
||||||
|
/>
|
||||||
|
{!settings.safeMode?.enabled && (
|
||||||
|
<Button
|
||||||
|
text="Reload in Safe Mode"
|
||||||
|
onPress={() => toggleSafeMode()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
text="Retry Render"
|
||||||
|
onPress={() => props.rerender()}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</SafeAreaView>
|
||||||
|
</SafeAreaProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import PupuIcon from "@assets/icons/kettu.png";
|
import PupuIcon from "@assets/icons/shiggy.png";
|
||||||
import { Strings } from "@core/i18n";
|
import { Strings } from "@core/i18n";
|
||||||
import { useProxy } from "@core/vendetta/storage";
|
import { useProxy } from "@core/vendetta/storage";
|
||||||
import { findAssetId } from "@lib/api/assets";
|
import { findAssetId } from "@lib/api/assets";
|
||||||
|
|
@ -10,68 +10,62 @@ import { version } from "bunny-build-info";
|
||||||
export { PupuIcon };
|
export { PupuIcon };
|
||||||
|
|
||||||
export default function initSettings() {
|
export default function initSettings() {
|
||||||
|
registerSection({
|
||||||
registerSection({
|
name: "ShiggyCord",
|
||||||
name: "Kettu",
|
items: [
|
||||||
items: [
|
{
|
||||||
{
|
key: "SHIGGYCORD",
|
||||||
key: "KETTU",
|
title: () => Strings.PUPU,
|
||||||
title: () => Strings.PUPU,
|
icon: { uri: PupuIcon },
|
||||||
icon: { uri: PupuIcon },
|
render: () => import("@core/ui/settings/pages/General"),
|
||||||
render: () => import("@core/ui/settings/pages/General"),
|
useTrailing: () => `(${version})`,
|
||||||
useTrailing: () => `(${version})`
|
},
|
||||||
},
|
{
|
||||||
{
|
key: "BUNNY_PLUGINS",
|
||||||
key: "BUNNY_PLUGINS",
|
title: () => Strings.PLUGINS,
|
||||||
title: () => Strings.PLUGINS,
|
icon: findAssetId("ActivitiesIcon"),
|
||||||
icon: findAssetId("ActivitiesIcon"),
|
render: () => import("@core/ui/settings/pages/Plugins"),
|
||||||
render: () => import("@core/ui/settings/pages/Plugins")
|
},
|
||||||
},
|
{
|
||||||
{
|
key: "BUNNY_THEMES",
|
||||||
key: "BUNNY_THEMES",
|
title: () => Strings.THEMES,
|
||||||
title: () => Strings.THEMES,
|
icon: findAssetId("PaintPaletteIcon"),
|
||||||
icon: findAssetId("PaintPaletteIcon"),
|
render: () => import("@core/ui/settings/pages/Themes"),
|
||||||
render: () => import("@core/ui/settings/pages/Themes"),
|
usePredicate: () => isThemeSupported(),
|
||||||
usePredicate: () => isThemeSupported()
|
},
|
||||||
},
|
{
|
||||||
{
|
key: "BUNNY_FONTS",
|
||||||
key: "BUNNY_FONTS",
|
title: () => Strings.FONTS,
|
||||||
title: () => Strings.FONTS,
|
icon: findAssetId("ic_add_text"),
|
||||||
icon: findAssetId("ic_add_text"),
|
render: () => import("@core/ui/settings/pages/Fonts"),
|
||||||
render: () => import("@core/ui/settings/pages/Fonts"),
|
usePredicate: () => isFontSupported(),
|
||||||
usePredicate: () => isFontSupported()
|
},
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "KETTU_BROWSER",
|
|
||||||
title: () => Strings.BROWSER,
|
|
||||||
icon: findAssetId("UploadIcon"),
|
|
||||||
render: () => import("@core/ui/settings/pages/PluginBrowser"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "BUNNY_DEVELOPER",
|
|
||||||
title: () => Strings.DEVELOPER,
|
|
||||||
icon: findAssetId("WrenchIcon"),
|
|
||||||
render: () => import("@core/ui/settings/pages/Developer"),
|
|
||||||
usePredicate: () => useProxy(settings).developerSettings ?? false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
// Compat with Bunny Plugins that use configs in settings
|
{
|
||||||
registerSection({
|
key: "BUNNY_DEVELOPER",
|
||||||
name: "Bunny",
|
title: () => Strings.DEVELOPER,
|
||||||
items: []
|
icon: findAssetId("WrenchIcon"),
|
||||||
});
|
render: () => import("@core/ui/settings/pages/Developer"),
|
||||||
|
usePredicate: () => useProxy(settings).developerSettings ?? false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
// Compat with Revenge Plugins that use configs in settings
|
// Compat with Bunny Plugins that use configs in settings
|
||||||
registerSection({
|
registerSection({
|
||||||
name: "Revenge",
|
name: "Bunny",
|
||||||
items: []
|
items: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Compat with Vendetta Plugins that use configs in settings
|
// Compat with Revenge Plugins that use configs in settings
|
||||||
registerSection({
|
registerSection({
|
||||||
name: "Vendetta",
|
name: "Revenge",
|
||||||
items: []
|
items: [],
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
// Compat with Vendetta Plugins that use configs in settings
|
||||||
|
registerSection({
|
||||||
|
name: "Vendetta",
|
||||||
|
items: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,25 @@ import AssetBrowser from "@core/ui/settings/pages/Developer/AssetBrowser";
|
||||||
import { useProxy } from "@core/vendetta/storage";
|
import { useProxy } from "@core/vendetta/storage";
|
||||||
import { findAssetId } from "@lib/api/assets";
|
import { findAssetId } from "@lib/api/assets";
|
||||||
import { connectToDebugger } from "@lib/api/debug";
|
import { connectToDebugger } from "@lib/api/debug";
|
||||||
import { getReactDevToolsProp, getReactDevToolsVersion, isLoaderConfigSupported, isReactDevToolsPreloaded, isVendettaLoader } from "@lib/api/native/loader";
|
import {
|
||||||
|
getReactDevToolsProp,
|
||||||
|
getReactDevToolsVersion,
|
||||||
|
isLoaderConfigSupported,
|
||||||
|
isReactDevToolsPreloaded,
|
||||||
|
isVendettaLoader,
|
||||||
|
} from "@lib/api/native/loader";
|
||||||
import { loaderConfig, settings } from "@lib/api/settings";
|
import { loaderConfig, settings } from "@lib/api/settings";
|
||||||
import { lazyDestructure } from "@lib/utils/lazy";
|
import { lazyDestructure } from "@lib/utils/lazy";
|
||||||
import { NavigationNative } from "@metro/common";
|
import { NavigationNative } from "@metro/common";
|
||||||
import { Button, LegacyFormText, Stack, TableRow, TableRowGroup, TableSwitchRow, TextInput } from "@metro/common/components";
|
import {
|
||||||
|
Button,
|
||||||
|
LegacyFormText,
|
||||||
|
Stack,
|
||||||
|
TableRow,
|
||||||
|
TableRowGroup,
|
||||||
|
TableSwitchRow,
|
||||||
|
TextInput,
|
||||||
|
} from "@metro/common/components";
|
||||||
import { findByProps } from "@metro/wrappers";
|
import { findByProps } from "@metro/wrappers";
|
||||||
import { semanticColors } from "@ui/color";
|
import { semanticColors } from "@ui/color";
|
||||||
import { ErrorBoundary } from "@ui/components";
|
import { ErrorBoundary } from "@ui/components";
|
||||||
|
|
@ -16,171 +30,261 @@ import { createStyles, TextStyleSheet } from "@ui/styles";
|
||||||
import { NativeModules } from "react-native";
|
import { NativeModules } from "react-native";
|
||||||
import { ScrollView, StyleSheet } from "react-native";
|
import { ScrollView, StyleSheet } from "react-native";
|
||||||
|
|
||||||
const { hideActionSheet } = lazyDestructure(() => findByProps("openLazy", "hideActionSheet"));
|
const { hideActionSheet } = lazyDestructure(() =>
|
||||||
const { showSimpleActionSheet } = lazyDestructure(() => findByProps("showSimpleActionSheet"));
|
findByProps("openLazy", "hideActionSheet"),
|
||||||
const { openAlert } = lazyDestructure(() => findByProps("openAlert", "dismissAlert"));
|
);
|
||||||
const { AlertModal, AlertActionButton } = lazyDestructure(() => findByProps("AlertModal", "AlertActions"));
|
const { showSimpleActionSheet } = lazyDestructure(() =>
|
||||||
|
findByProps("showSimpleActionSheet"),
|
||||||
|
);
|
||||||
|
const { openAlert } = lazyDestructure(() =>
|
||||||
|
findByProps("openAlert", "dismissAlert"),
|
||||||
|
);
|
||||||
|
const { AlertModal, AlertActionButton } = lazyDestructure(() =>
|
||||||
|
findByProps("AlertModal", "AlertActions"),
|
||||||
|
);
|
||||||
|
|
||||||
const RDT_EMBED_LINK = "";
|
const RDT_EMBED_LINK = "";
|
||||||
|
|
||||||
const useStyles = createStyles({
|
const useStyles = createStyles({
|
||||||
leadingText: {
|
leadingText: {
|
||||||
...TextStyleSheet["heading-md/semibold"],
|
...TextStyleSheet["heading-md/semibold"],
|
||||||
color: semanticColors.TEXT_MUTED,
|
color: semanticColors.TEXT_MUTED,
|
||||||
marginRight: -4
|
marginRight: -4,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function Developer() {
|
export default function Developer() {
|
||||||
const [rdtFileExists, fs] = useFileExists("preloads/reactDevtools.js");
|
const [rdtFileExists, fs] = useFileExists("preloads/reactDevtools.js");
|
||||||
|
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const navigation = NavigationNative.useNavigation();
|
const navigation = NavigationNative.useNavigation();
|
||||||
|
|
||||||
useProxy(settings);
|
useProxy(settings);
|
||||||
useProxy(loaderConfig);
|
useProxy(loaderConfig);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ paddingBottom: 38 }}>
|
<ScrollView
|
||||||
<Stack style={{ paddingVertical: 24, paddingHorizontal: 12 }} spacing={24}>
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={{ paddingBottom: 38 }}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
style={{ paddingVertical: 24, paddingHorizontal: 12 }}
|
||||||
|
spacing={24}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
label={Strings.DEBUGGER_URL}
|
||||||
|
placeholder="127.0.0.1:9090"
|
||||||
|
size="md"
|
||||||
|
leadingIcon={() => (
|
||||||
|
<LegacyFormText style={styles.leadingText}>ws://</LegacyFormText>
|
||||||
|
)}
|
||||||
|
defaultValue={settings.debuggerUrl}
|
||||||
|
onChange={(v: string) => (settings.debuggerUrl = v)}
|
||||||
|
/>
|
||||||
|
<TableRowGroup title="Debugging & Connection">
|
||||||
|
<TableRow
|
||||||
|
label={Strings.CONNECT_TO_DEBUG_WEBSOCKET}
|
||||||
|
subLabel="Connect to Chrome DevTools for debugging"
|
||||||
|
icon={<TableRow.Icon source={findAssetId("WrenchIcon")} />}
|
||||||
|
onPress={() => connectToDebugger(settings.debuggerUrl)}
|
||||||
|
/>
|
||||||
|
<TableSwitchRow
|
||||||
|
label={Strings.ENABLE_EVAL_COMMAND}
|
||||||
|
subLabel={Strings.ENABLE_EVAL_COMMAND_DESC}
|
||||||
|
icon={<TableRow.Icon source={findAssetId("PencilIcon")} />}
|
||||||
|
value={!!settings.enableEvalCommand}
|
||||||
|
onValueChange={(v: boolean) => {
|
||||||
|
settings.enableEvalCommand = v;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TableRowGroup>
|
||||||
|
|
||||||
|
{isReactDevToolsPreloaded() && (
|
||||||
|
<TableRowGroup title="React Development">
|
||||||
|
<TableRow
|
||||||
|
label={Strings.CONNECT_TO_REACT_DEVTOOLS}
|
||||||
|
subLabel="Connect React DevTools for component debugging"
|
||||||
|
icon={<TableRow.Icon source={findAssetId("ic_badge_staff")} />}
|
||||||
|
onPress={() =>
|
||||||
|
window[
|
||||||
|
getReactDevToolsProp() || "__vendetta_rdc"
|
||||||
|
]?.connectToDevTools({
|
||||||
|
host: settings.debuggerUrl.split(":")?.[0],
|
||||||
|
resolveRNStyle: StyleSheet.flatten,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TableRow
|
||||||
|
label={Strings.INSTALL_REACT_DEVTOOLS}
|
||||||
|
subLabel={Strings.RESTART_REQUIRED_TO_TAKE_EFFECT}
|
||||||
|
icon={<TableRow.Icon source={findAssetId("DownloadIcon")} />}
|
||||||
|
trailing={
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
loading={rdtFileExists === CheckState.LOADING}
|
||||||
|
disabled={rdtFileExists === CheckState.LOADING}
|
||||||
|
variant={
|
||||||
|
rdtFileExists === CheckState.TRUE
|
||||||
|
? "secondary"
|
||||||
|
: "primary"
|
||||||
|
}
|
||||||
|
text={
|
||||||
|
rdtFileExists === CheckState.TRUE
|
||||||
|
? Strings.UNINSTALL
|
||||||
|
: Strings.INSTALL
|
||||||
|
}
|
||||||
|
onPress={async () => {
|
||||||
|
if (rdtFileExists === CheckState.FALSE) {
|
||||||
|
fs.downloadFile(
|
||||||
|
RDT_EMBED_LINK,
|
||||||
|
"preloads/reactDevtools.js",
|
||||||
|
);
|
||||||
|
} else if (rdtFileExists === CheckState.TRUE) {
|
||||||
|
fs.removeFile("preloads/reactDevtools.js");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
icon={findAssetId(
|
||||||
|
rdtFileExists === CheckState.TRUE
|
||||||
|
? "ic_message_delete"
|
||||||
|
: "DownloadIcon",
|
||||||
|
)}
|
||||||
|
style={{ marginLeft: 8 }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
{isLoaderConfigSupported() && (
|
||||||
|
<TableRowGroup title="Loader Configuration">
|
||||||
|
<TableSwitchRow
|
||||||
|
label={Strings.LOAD_FROM_CUSTOM_URL}
|
||||||
|
subLabel={Strings.LOAD_FROM_CUSTOM_URL_DEC}
|
||||||
|
icon={<TableRow.Icon source={findAssetId("LinkIcon")} />}
|
||||||
|
value={loaderConfig.customLoadUrl.enabled}
|
||||||
|
onValueChange={(v: boolean) => {
|
||||||
|
loaderConfig.customLoadUrl.enabled = v;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{loaderConfig.customLoadUrl.enabled && (
|
||||||
|
<TableRow
|
||||||
|
label={
|
||||||
<TextInput
|
<TextInput
|
||||||
label={Strings.DEBUGGER_URL}
|
defaultValue={loaderConfig.customLoadUrl.url}
|
||||||
placeholder="127.0.0.1:9090"
|
size="md"
|
||||||
size="md"
|
onChange={(v: string) =>
|
||||||
leadingIcon={() => <LegacyFormText style={styles.leadingText}>ws://</LegacyFormText>}
|
(loaderConfig.customLoadUrl.url = v)
|
||||||
defaultValue={settings.debuggerUrl}
|
}
|
||||||
onChange={(v: string) => settings.debuggerUrl = v}
|
placeholder="http://localhost:4040/shiggycord.js"
|
||||||
|
label={Strings.PUPU_URL}
|
||||||
/>
|
/>
|
||||||
<TableRowGroup title={Strings.DEBUG}>
|
}
|
||||||
<TableRow
|
/>
|
||||||
label={Strings.CONNECT_TO_DEBUG_WEBSOCKET}
|
)}
|
||||||
icon={<TableRow.Icon source={findAssetId("copy")} />}
|
</TableRowGroup>
|
||||||
onPress={() => connectToDebugger(settings.debuggerUrl)}
|
)}
|
||||||
/>
|
<TableRowGroup title="Inspection & Testing">
|
||||||
{isReactDevToolsPreloaded() && <>
|
<TableRow
|
||||||
<TableRow
|
arrow
|
||||||
label={Strings.CONNECT_TO_REACT_DEVTOOLS}
|
label={Strings.ASSET_BROWSER}
|
||||||
icon={<TableRow.Icon source={findAssetId("ic_badge_staff")} />}
|
subLabel="Browse and inspect Discord's assets"
|
||||||
onPress={() => window[getReactDevToolsProp() || "__vendetta_rdc"]?.connectToDevTools({
|
icon={<TableRow.Icon source={findAssetId("ic_image")} />}
|
||||||
host: settings.debuggerUrl.split(":")?.[0],
|
trailing={TableRow.Arrow}
|
||||||
resolveRNStyle: StyleSheet.flatten,
|
onPress={() =>
|
||||||
})}
|
navigation.push("PUPU_CUSTOM_PAGE", {
|
||||||
/>
|
title: Strings.ASSET_BROWSER,
|
||||||
</>}
|
render: AssetBrowser,
|
||||||
</TableRowGroup>
|
})
|
||||||
{isLoaderConfigSupported() && <>
|
}
|
||||||
<TableRowGroup title="Loader config">
|
/>
|
||||||
<TableSwitchRow
|
<TableRow
|
||||||
label={Strings.LOAD_FROM_CUSTOM_URL}
|
arrow
|
||||||
subLabel={Strings.LOAD_FROM_CUSTOM_URL_DEC}
|
label={Strings.ERROR_BOUNDARY_TOOLS_LABEL}
|
||||||
icon={<TableRow.Icon source={findAssetId("copy")} />}
|
subLabel="Test error boundaries and crash handling"
|
||||||
value={loaderConfig.customLoadUrl.enabled}
|
icon={<TableRow.Icon source={findAssetId("ic_warning_24px")} />}
|
||||||
onValueChange={(v: boolean) => {
|
onPress={() =>
|
||||||
loaderConfig.customLoadUrl.enabled = v;
|
showSimpleActionSheet({
|
||||||
}}
|
key: "ErrorBoundaryTools",
|
||||||
/>
|
header: {
|
||||||
{loaderConfig.customLoadUrl.enabled && <TableRow label={<TextInput
|
title: "Which ErrorBoundary do you want to trip?",
|
||||||
defaultValue={loaderConfig.customLoadUrl.url}
|
icon: (
|
||||||
size="md"
|
<TableRow.Icon
|
||||||
onChange={(v: string) => loaderConfig.customLoadUrl.url = v}
|
style={{ marginRight: 8 }}
|
||||||
placeholder="http://localhost:4040/kettu.js"
|
source={findAssetId("ic_warning_24px")}
|
||||||
label={Strings.PUPU_URL}
|
/>
|
||||||
/>} />}
|
),
|
||||||
{isReactDevToolsPreloaded() && isVendettaLoader() && <TableSwitchRow
|
},
|
||||||
label={Strings.LOAD_REACT_DEVTOOLS}
|
options: [
|
||||||
subLabel={`${Strings.VERSION}: ${getReactDevToolsVersion()}`}
|
// @ts-expect-error
|
||||||
icon={<TableRow.Icon source={findAssetId("ic_badge_staff")} />}
|
// Of course, to trigger an error, we need to do something incorrectly. The below will do!
|
||||||
value={loaderConfig.loadReactDevTools}
|
{
|
||||||
onValueChange={(v: boolean) => {
|
label: Strings.PUPU,
|
||||||
loaderConfig.loadReactDevTools = v;
|
onPress: () =>
|
||||||
}}
|
navigation.push("SHIGGYCORD_CUSTOM_PAGE", {
|
||||||
/>}
|
render: () => <undefined />,
|
||||||
</TableRowGroup>
|
}),
|
||||||
</>}
|
},
|
||||||
<TableRowGroup title="Other">
|
{
|
||||||
<TableRow
|
label: "Discord",
|
||||||
// Thanks for this Revenge team!
|
isDestructive: true,
|
||||||
label={Strings.CLEAR_BUNDLE}
|
onPress: () =>
|
||||||
|
navigation.push("PUPU_CUSTOM_PAGE", {
|
||||||
|
noErrorBoundary: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TableRowGroup>
|
||||||
|
|
||||||
icon={<TableRow.Icon source={findAssetId("TrashIcon")} />}
|
<TableRowGroup title="Dangerous Actions">
|
||||||
onPress={() => {
|
<TableRow
|
||||||
openAlert("pupu-clear-bundle-reload-confirmation", <AlertModal
|
// Thanks for this Revenge team!
|
||||||
title={Strings.MODAL_RELOAD_REQUIRED}
|
label={Strings.CLEAR_BUNDLE}
|
||||||
content={Strings.MODAL_RELOAD_REQUIRED_DESC}
|
subLabel="Clear cached bundle and force reload"
|
||||||
actions={
|
icon={<TableRow.Icon source={findAssetId("TrashIcon")} />}
|
||||||
<Stack>
|
onPress={() => {
|
||||||
<AlertActionButton text={Strings.RELOAD} variant="destructive" onPress={() => NativeModules.BundleUpdaterManager.reload()} />
|
openAlert(
|
||||||
<AlertActionButton text={Strings.CANCEL} variant="secondary" />
|
"pupu-clear-bundle-reload-confirmation",
|
||||||
</Stack>
|
<AlertModal
|
||||||
}
|
title={Strings.MODAL_RELOAD_REQUIRED}
|
||||||
/>);
|
content={Strings.MODAL_RELOAD_REQUIRED_DESC}
|
||||||
}}
|
actions={
|
||||||
|
<Stack>
|
||||||
|
<AlertActionButton
|
||||||
|
text={Strings.RELOAD}
|
||||||
|
variant="destructive"
|
||||||
|
onPress={() =>
|
||||||
|
NativeModules.BundleUpdaterManager.reload()
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<TableRow
|
<AlertActionButton
|
||||||
arrow
|
text={Strings.CANCEL}
|
||||||
label={Strings.ASSET_BROWSER}
|
variant="secondary"
|
||||||
icon={<TableRow.Icon source={findAssetId("ic_image")} />}
|
|
||||||
trailing={TableRow.Arrow}
|
|
||||||
onPress={() => navigation.push("PUPU_CUSTOM_PAGE", {
|
|
||||||
title: Strings.ASSET_BROWSER,
|
|
||||||
render: AssetBrowser,
|
|
||||||
})}
|
|
||||||
/>
|
/>
|
||||||
<TableRow
|
</Stack>
|
||||||
arrow
|
}
|
||||||
label={Strings.ERROR_BOUNDARY_TOOLS_LABEL}
|
/>,
|
||||||
icon={<TableRow.Icon source={findAssetId("ic_warning_24px")} />}
|
);
|
||||||
onPress={() => showSimpleActionSheet({
|
}}
|
||||||
key: "ErrorBoundaryTools",
|
/>
|
||||||
header: {
|
</TableRowGroup>
|
||||||
title: "Which ErrorBoundary do you want to trip?",
|
</Stack>
|
||||||
icon: <TableRow.Icon style={{ marginRight: 8 }} source={findAssetId("ic_warning_24px")} />,
|
</ScrollView>
|
||||||
onClose: () => hideActionSheet(),
|
</ErrorBoundary>
|
||||||
},
|
);
|
||||||
options: [
|
}
|
||||||
// @ts-expect-error
|
|
||||||
// Of course, to trigger an error, we need to do something incorrectly. The below will do!
|
|
||||||
{ label: Strings.PUPU, onPress: () => navigation.push("PUPU_CUSTOM_PAGE", { render: () => <undefined /> }) },
|
|
||||||
{ label: "Discord", isDestructive: true, onPress: () => navigation.push("PUPU_CUSTOM_PAGE", { noErrorBoundary: true }) },
|
|
||||||
],
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<TableRow
|
|
||||||
label={Strings.INSTALL_REACT_DEVTOOLS}
|
|
||||||
subLabel={Strings.RESTART_REQUIRED_TO_TAKE_EFFECT}
|
|
||||||
icon={<TableRow.Icon source={findAssetId("DownloadIcon")} />}
|
|
||||||
trailing={
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
loading={rdtFileExists === CheckState.LOADING}
|
|
||||||
disabled={rdtFileExists === CheckState.LOADING}
|
|
||||||
variant={rdtFileExists === CheckState.TRUE ? "secondary" : "primary"}
|
|
||||||
text={rdtFileExists === CheckState.TRUE ? Strings.UNINSTALL : Strings.INSTALL}
|
|
||||||
onPress={async () => {
|
|
||||||
if (rdtFileExists === CheckState.FALSE) {
|
|
||||||
fs.downloadFile(RDT_EMBED_LINK, "preloads/reactDevtools.js");
|
|
||||||
} else if (rdtFileExists === CheckState.TRUE) {
|
|
||||||
fs.removeFile("preloads/reactDevtools.js");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
icon={findAssetId(rdtFileExists === CheckState.TRUE ? "ic_message_delete" : "DownloadIcon")}
|
|
||||||
style={{ marginLeft: 8 }}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<TableSwitchRow
|
|
||||||
label={Strings.ENABLE_EVAL_COMMAND}
|
|
||||||
subLabel={Strings.ENABLE_EVAL_COMMAND_DESC}
|
|
||||||
icon={<TableRow.Icon source={findAssetId("PencilIcon")} />}
|
|
||||||
value={!!settings.enableEvalCommand}
|
|
||||||
onValueChange={(v: boolean) => {
|
|
||||||
settings.enableEvalCommand = v;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</TableRowGroup>
|
|
||||||
</Stack>
|
|
||||||
</ScrollView>
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -4,89 +4,159 @@ import Version from "@core/ui/settings/pages/General/Version";
|
||||||
import { useProxy } from "@core/vendetta/storage";
|
import { useProxy } from "@core/vendetta/storage";
|
||||||
import { getDebugInfo } from "@lib/api/debug";
|
import { getDebugInfo } from "@lib/api/debug";
|
||||||
import { settings } from "@lib/api/settings";
|
import { settings } from "@lib/api/settings";
|
||||||
import { Stack, TableRowGroup } from "@metro/common/components";
|
import { Stack, TableRowGroup, TableRow, Text } from "@metro/common/components";
|
||||||
import { Platform, ScrollView } from "react-native";
|
import { Platform, ScrollView, View } from "react-native";
|
||||||
|
import { findAssetId } from "@lib/api/assets";
|
||||||
|
|
||||||
export default function About() {
|
export default function About() {
|
||||||
const debugInfo = getDebugInfo();
|
const debugInfo = getDebugInfo();
|
||||||
useProxy(settings);
|
useProxy(settings);
|
||||||
|
|
||||||
const versions = [
|
const coreVersions = [
|
||||||
{
|
{
|
||||||
label: Strings.PUPU,
|
label: Strings.PUPU,
|
||||||
version: debugInfo.bunny.version,
|
version: debugInfo.bunny.version,
|
||||||
icon: { uri: PupuIcon },
|
icon: { uri: PupuIcon },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Discord",
|
label: "Discord",
|
||||||
version: `${debugInfo.discord.version} (${debugInfo.discord.build})`,
|
version: `${debugInfo.discord.version} (${debugInfo.discord.build})`,
|
||||||
icon: "Discord",
|
icon: "Discord",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "React",
|
label: Strings.LOADER,
|
||||||
version: debugInfo.react.version,
|
version: `${debugInfo.bunny.loader.name} ${debugInfo.bunny.loader.version}`,
|
||||||
icon: "ScienceIcon",
|
icon: "DownloadIcon",
|
||||||
},
|
},
|
||||||
{
|
];
|
||||||
label: "React Native",
|
|
||||||
version: debugInfo.react.nativeVersion,
|
|
||||||
icon: "MobilePhoneIcon",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: Strings.BYTECODE,
|
|
||||||
version: debugInfo.hermes.bytecodeVersion,
|
|
||||||
icon: "TopicsIcon",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const platformInfo = [
|
const runtimeVersions = [
|
||||||
{
|
{
|
||||||
label: Strings.LOADER,
|
label: "React",
|
||||||
version: `${debugInfo.bunny.loader.name} (${debugInfo.bunny.loader.version})`,
|
version: debugInfo.react.version,
|
||||||
icon: "DownloadIcon",
|
icon: "ScienceIcon",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: Strings.OPERATING_SYSTEM,
|
label: "React Native",
|
||||||
version: `${debugInfo.os.name} ${debugInfo.os.version}`,
|
version: debugInfo.react.nativeVersion,
|
||||||
icon: "ScreenIcon"
|
icon: "MobilePhoneIcon",
|
||||||
},
|
},
|
||||||
...(debugInfo.os.sdk ? [{
|
{
|
||||||
label: "SDK",
|
label: Strings.BYTECODE,
|
||||||
|
version: debugInfo.hermes.bytecodeVersion,
|
||||||
|
icon: "TopicsIcon",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const deviceInfo = [
|
||||||
|
{
|
||||||
|
label: Strings.OPERATING_SYSTEM,
|
||||||
|
version: `${debugInfo.os.name} ${debugInfo.os.version}`,
|
||||||
|
icon: "ScreenIcon",
|
||||||
|
},
|
||||||
|
...(debugInfo.os.sdk
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: "SDK Version",
|
||||||
version: debugInfo.os.sdk,
|
version: debugInfo.os.sdk,
|
||||||
icon: "StaffBadgeIcon"
|
icon: "StaffBadgeIcon",
|
||||||
}] : []),
|
},
|
||||||
{
|
]
|
||||||
label: Strings.MANUFACTURER,
|
: []),
|
||||||
version: debugInfo.device.manufacturer,
|
{
|
||||||
icon: "WrenchIcon"
|
label: "Device",
|
||||||
},
|
version: `${debugInfo.device.brand} ${debugInfo.device.model}`,
|
||||||
{
|
icon: "MobilePhoneIcon",
|
||||||
label: Strings.BRAND,
|
},
|
||||||
version: debugInfo.device.brand,
|
{
|
||||||
icon: "MagicWandIcon"
|
label: Strings.MANUFACTURER,
|
||||||
},
|
version: debugInfo.device.manufacturer,
|
||||||
{
|
icon: "WrenchIcon",
|
||||||
label: Strings.MODEL,
|
},
|
||||||
version: debugInfo.device.model,
|
];
|
||||||
icon: "MobilePhoneIcon"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: Platform.select({ android: Strings.CODENAME })!,
|
|
||||||
version: debugInfo.device.codename,
|
|
||||||
icon: "TagIcon"
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ paddingBottom: 38 }}>
|
<ScrollView
|
||||||
<Stack style={{ paddingVertical: 24, paddingHorizontal: 12 }} spacing={24}>
|
style={{ flex: 1 }}
|
||||||
<TableRowGroup title={Strings.VERSIONS}>
|
contentContainerStyle={{ paddingBottom: 38 }}
|
||||||
{versions.map(v => <Version label={v.label} version={v.version} icon={v.icon} />)}
|
>
|
||||||
</TableRowGroup>
|
<Stack
|
||||||
<TableRowGroup title={Strings.PLATFORM}>
|
style={{ paddingVertical: 24, paddingHorizontal: 12 }}
|
||||||
{platformInfo.map(p => <Version label={p.label} version={p.version} icon={p.icon} />)}
|
spacing={24}
|
||||||
</TableRowGroup>
|
>
|
||||||
</Stack>
|
<View style={{ alignItems: "center", paddingVertical: 16 }}>
|
||||||
</ScrollView>
|
<Text variant="heading-xl/bold" style={{ textAlign: "center" }}>
|
||||||
);
|
{Strings.PUPU}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
variant="text-md/medium"
|
||||||
|
style={{ textAlign: "center", marginTop: 4 }}
|
||||||
|
>
|
||||||
|
Shiggy your discord client!
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TableRowGroup title="Core Components">
|
||||||
|
{coreVersions.map((v, i) => (
|
||||||
|
<Version
|
||||||
|
key={i}
|
||||||
|
label={v.label}
|
||||||
|
version={v.version}
|
||||||
|
icon={v.icon}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TableRowGroup>
|
||||||
|
|
||||||
|
<TableRowGroup title="Runtime Environment">
|
||||||
|
{runtimeVersions.map((v, i) => (
|
||||||
|
<Version
|
||||||
|
key={i}
|
||||||
|
label={v.label}
|
||||||
|
version={v.version}
|
||||||
|
icon={v.icon}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TableRowGroup>
|
||||||
|
|
||||||
|
<TableRowGroup title="Device Information">
|
||||||
|
{deviceInfo.map((p, i) => (
|
||||||
|
<Version
|
||||||
|
key={i}
|
||||||
|
label={p.label}
|
||||||
|
version={p.version}
|
||||||
|
icon={p.icon}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TableRowGroup>
|
||||||
|
|
||||||
|
<TableRowGroup title="Additional Details">
|
||||||
|
<TableRow
|
||||||
|
label="Architecture"
|
||||||
|
trailing={
|
||||||
|
<TableRow.TrailingText
|
||||||
|
text={Platform.OS === "android" ? "Android ARM64" : "iOS ARM64"}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
icon={<TableRow.Icon source={findAssetId("ScreenIcon")} />}
|
||||||
|
/>
|
||||||
|
<TableRow
|
||||||
|
label="Debug Mode"
|
||||||
|
trailing={
|
||||||
|
<TableRow.TrailingText text={__DEV__ ? "Enabled" : "Disabled"} />
|
||||||
|
}
|
||||||
|
icon={<TableRow.Icon source={findAssetId("WrenchIcon")} />}
|
||||||
|
/>
|
||||||
|
<TableRow
|
||||||
|
label="Build Type"
|
||||||
|
trailing={
|
||||||
|
<TableRow.TrailingText
|
||||||
|
text={settings.developerSettings ? "Development" : "Production"}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
icon={<TableRow.Icon source={findAssetId("HammerIcon")} />}
|
||||||
|
/>
|
||||||
|
</TableRowGroup>
|
||||||
|
</Stack>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,113 +8,174 @@ import { getDebugInfo } from "@lib/api/debug";
|
||||||
import { BundleUpdaterManager } from "@lib/api/native/modules";
|
import { BundleUpdaterManager } from "@lib/api/native/modules";
|
||||||
import { settings } from "@lib/api/settings";
|
import { settings } from "@lib/api/settings";
|
||||||
import { openAlert } from "@lib/ui/alerts";
|
import { openAlert } from "@lib/ui/alerts";
|
||||||
import { DISCORD_SERVER, GITHUB, CODEBERG } from "@lib/utils/constants";
|
import { DISCORD_SERVER, GITHUB } from "@lib/utils/constants";
|
||||||
import { NavigationNative } from "@metro/common";
|
import { NavigationNative } from "@metro/common";
|
||||||
import { AlertActionButton, AlertActions, AlertModal, Stack, TableRow, TableRowGroup, TableSwitchRow } from "@metro/common/components";
|
import {
|
||||||
|
AlertActionButton,
|
||||||
|
AlertActions,
|
||||||
|
AlertModal,
|
||||||
|
Stack,
|
||||||
|
TableRow,
|
||||||
|
TableRowGroup,
|
||||||
|
TableSwitchRow,
|
||||||
|
} from "@metro/common/components";
|
||||||
import { Linking, ScrollView } from "react-native";
|
import { Linking, ScrollView } from "react-native";
|
||||||
import CodebergIcon from "@assets/icons/codeberg-logo_icon_white.png";
|
import CodebergIcon from "@assets/icons/codeberg-logo_icon_white.png";
|
||||||
|
|
||||||
export default function General() {
|
export default function General() {
|
||||||
useProxy(settings);
|
useProxy(settings);
|
||||||
|
|
||||||
const debugInfo = getDebugInfo();
|
const debugInfo = getDebugInfo();
|
||||||
const navigation = NavigationNative.useNavigation();
|
const navigation = NavigationNative.useNavigation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ paddingBottom: 38 }}>
|
<ScrollView
|
||||||
<Stack style={{ paddingVertical: 24, paddingHorizontal: 12 }} spacing={24}>
|
style={{ flex: 1 }}
|
||||||
<TableRowGroup title={Strings.INFO}>
|
contentContainerStyle={{ paddingBottom: 38 }}
|
||||||
<TableRow
|
>
|
||||||
label={Strings.PUPU}
|
<Stack
|
||||||
icon={<TableRow.Icon source={{ uri: PupuIcon }} />}
|
style={{ paddingVertical: 24, paddingHorizontal: 12 }}
|
||||||
trailing={<TableRow.TrailingText text={debugInfo.bunny.version} />}
|
spacing={24}
|
||||||
/>
|
>
|
||||||
<TableRow
|
<TableRowGroup title="App Information">
|
||||||
label={"Discord"}
|
<TableRow
|
||||||
icon={<TableRow.Icon source={findAssetId("Discord")!} />}
|
label={Strings.PUPU}
|
||||||
trailing={<TableRow.TrailingText text={`${debugInfo.discord.version} (${debugInfo.discord.build})`} />}
|
subLabel={`Build ${debugInfo.bunny.version.split("-")[0] || debugInfo.bunny.version}`}
|
||||||
/>
|
icon={<TableRow.Icon source={{ uri: PupuIcon }} />}
|
||||||
<TableRow
|
trailing={
|
||||||
arrow
|
<TableRow.TrailingText
|
||||||
label={Strings.ABOUT}
|
text={
|
||||||
icon={<TableRow.Icon source={findAssetId("CircleInformationIcon-primary")!} />}
|
debugInfo.bunny.version.includes("-")
|
||||||
onPress={() => navigation.push("PUPU_CUSTOM_PAGE", {
|
? `#${debugInfo.bunny.version.split("-").slice(1).join("-")}`
|
||||||
title: Strings.ABOUT,
|
: "ShiggyCord"
|
||||||
render: () => <About />,
|
}
|
||||||
})}
|
/>
|
||||||
/>
|
}
|
||||||
</TableRowGroup>
|
/>
|
||||||
<TableRowGroup title={Strings.LINKS}>
|
<TableRow
|
||||||
<TableRow
|
label="Discord"
|
||||||
arrow={true}
|
subLabel={`Version ${debugInfo.discord.version}`}
|
||||||
label={Strings.DISCORD_SERVER}
|
icon={<TableRow.Icon source={findAssetId("Discord")!} />}
|
||||||
icon={<TableRow.Icon source={findAssetId("Discord")!} />}
|
trailing={
|
||||||
onPress={() => Linking.openURL(DISCORD_SERVER)}
|
<TableRow.TrailingText
|
||||||
/>
|
text={`Build ${debugInfo.discord.build}`}
|
||||||
<TableRow
|
/>
|
||||||
arrow={true}
|
}
|
||||||
label={Strings.CODEBERG}
|
/>
|
||||||
icon={<TableRow.Icon source={{ uri: CodebergIcon}} />}
|
<TableRow
|
||||||
onPress={() => Linking.openURL(CODEBERG)}
|
label="Loader"
|
||||||
/>
|
subLabel="ShiggyCord loader information"
|
||||||
<TableRow
|
icon={<TableRow.Icon source={findAssetId("DownloadIcon")!} />}
|
||||||
arrow={true}
|
trailing={
|
||||||
label={Strings.GITHUB}
|
<TableRow.TrailingText
|
||||||
icon={<TableRow.Icon source={findAssetId("img_account_sync_github_white")!} />}
|
text={`${debugInfo.bunny.loader.name} ${debugInfo.bunny.loader.version}`}
|
||||||
onPress={() => Linking.openURL(GITHUB)}
|
/>
|
||||||
/>
|
}
|
||||||
</TableRowGroup>
|
/>
|
||||||
<TableRowGroup title={Strings.ACTIONS}>
|
<TableRow
|
||||||
<TableRow
|
label="Platform"
|
||||||
label={Strings.RELOAD_DISCORD}
|
subLabel={`${debugInfo.device.brand} ${debugInfo.device.model}`}
|
||||||
icon={<TableRow.Icon source={findAssetId("RetryIcon")!} />}
|
icon={<TableRow.Icon source={findAssetId("ScreenIcon")!} />}
|
||||||
|
trailing={<TableRow.TrailingText text={debugInfo.os.name} />}
|
||||||
|
/>
|
||||||
|
</TableRowGroup>
|
||||||
|
<TableRowGroup title="Quick Actions">
|
||||||
|
<TableRow
|
||||||
|
label={Strings.RELOAD_DISCORD}
|
||||||
|
subLabel="Restart the application"
|
||||||
|
icon={<TableRow.Icon source={findAssetId("RetryIcon")!} />}
|
||||||
|
onPress={() => BundleUpdaterManager.reload()}
|
||||||
|
/>
|
||||||
|
<TableSwitchRow
|
||||||
|
label="Safe Mode"
|
||||||
|
subLabel="Temporarily disable all add-ons"
|
||||||
|
icon={<TableRow.Icon source={findAssetId("ShieldIcon")!} />}
|
||||||
|
value={isSafeMode()}
|
||||||
|
onValueChange={(to: boolean) => {
|
||||||
|
toggleSafeMode({ to, reload: false });
|
||||||
|
openAlert(
|
||||||
|
"bunny-reload-safe-mode",
|
||||||
|
<AlertModal
|
||||||
|
title="Reload now?"
|
||||||
|
content={
|
||||||
|
!to
|
||||||
|
? "All add-ons will load normally."
|
||||||
|
: "All add-ons will be temporarily disabled upon reload."
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
<AlertActions>
|
||||||
|
<AlertActionButton
|
||||||
|
text="Reload Now"
|
||||||
|
variant="destructive"
|
||||||
onPress={() => BundleUpdaterManager.reload()}
|
onPress={() => BundleUpdaterManager.reload()}
|
||||||
/>
|
/>
|
||||||
<TableSwitchRow
|
<AlertActionButton text="Later" variant="secondary" />
|
||||||
label={"Safe Mode"}
|
</AlertActions>
|
||||||
subLabel={"Load Kettu without loading add-ons"}
|
}
|
||||||
icon={<TableRow.Icon source={findAssetId("ShieldIcon")!} />}
|
/>,
|
||||||
value={isSafeMode()}
|
);
|
||||||
onValueChange={(to: boolean) => {
|
}}
|
||||||
toggleSafeMode({ to, reload: false });
|
/>
|
||||||
openAlert(
|
</TableRowGroup>
|
||||||
"bunny-reload-safe-mode",
|
<TableRowGroup title="Developer">
|
||||||
<AlertModal
|
<TableSwitchRow
|
||||||
title="Reload now?"
|
label={Strings.DEVELOPER_SETTINGS}
|
||||||
content={!to ? "All add-ons will load normally." : "All add-ons will be temporarily disabled upon reload."}
|
subLabel="Enable developer tools and settings"
|
||||||
actions={<AlertActions>
|
icon={<TableRow.Icon source={findAssetId("WrenchIcon")!} />}
|
||||||
<AlertActionButton
|
value={settings.developerSettings}
|
||||||
text="Reload Now"
|
onValueChange={(v: boolean) => {
|
||||||
variant="destructive"
|
settings.developerSettings = v;
|
||||||
onPress={() => BundleUpdaterManager.reload()}
|
}}
|
||||||
/>
|
/>
|
||||||
<AlertActionButton text="Later" variant="secondary" />
|
<TableSwitchRow
|
||||||
</AlertActions>}
|
label={Strings.SETTINGS_ACTIVATE_DISCORD_EXPERIMENTS}
|
||||||
/>
|
subLabel={Strings.SETTINGS_ACTIVATE_DISCORD_EXPERIMENTS_DESC}
|
||||||
);
|
icon={<TableRow.Icon source={findAssetId("FlaskIcon")!} />}
|
||||||
}}
|
value={settings.enableDiscordDeveloperSettings}
|
||||||
/>
|
onValueChange={(v: boolean) => {
|
||||||
<TableSwitchRow
|
settings.enableDiscordDeveloperSettings = v;
|
||||||
label={Strings.DEVELOPER_SETTINGS}
|
}}
|
||||||
icon={<TableRow.Icon source={findAssetId("WrenchIcon")!} />}
|
/>
|
||||||
value={settings.developerSettings}
|
</TableRowGroup>
|
||||||
onValueChange={(v: boolean) => {
|
<TableRowGroup title="Community & Support">
|
||||||
settings.developerSettings = v;
|
<TableRow
|
||||||
}}
|
arrow={true}
|
||||||
/>
|
label={Strings.DISCORD_SERVER}
|
||||||
</TableRowGroup>
|
subLabel="Join our community server"
|
||||||
<TableRowGroup title={Strings.MISCELLANEOUS}>
|
icon={<TableRow.Icon source={findAssetId("Discord")!} />}
|
||||||
<TableSwitchRow
|
onPress={() => Linking.openURL(DISCORD_SERVER)}
|
||||||
label={Strings.SETTINGS_ACTIVATE_DISCORD_EXPERIMENTS}
|
/>
|
||||||
subLabel={Strings.SETTINGS_ACTIVATE_DISCORD_EXPERIMENTS_DESC}
|
<TableRow
|
||||||
icon={<TableRow.Icon source={findAssetId("WrenchIcon")!} />}
|
arrow={true}
|
||||||
value={settings.enableDiscordDeveloperSettings}
|
label={Strings.GITHUB}
|
||||||
onValueChange={(v: boolean) => {
|
subLabel="Source code and issues"
|
||||||
settings.enableDiscordDeveloperSettings = v;
|
icon={
|
||||||
}}
|
<TableRow.Icon
|
||||||
/>
|
source={findAssetId("img_account_sync_github_white")!}
|
||||||
</TableRowGroup>
|
/>
|
||||||
</Stack>
|
}
|
||||||
</ScrollView>
|
onPress={() => Linking.openURL(GITHUB)}
|
||||||
);
|
/>
|
||||||
|
</TableRowGroup>
|
||||||
|
<TableRowGroup title="System Information">
|
||||||
|
<TableRow
|
||||||
|
arrow
|
||||||
|
label={Strings.ABOUT}
|
||||||
|
subLabel="Detailed technical information"
|
||||||
|
icon={
|
||||||
|
<TableRow.Icon
|
||||||
|
source={findAssetId("CircleInformationIcon-primary")!}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onPress={() =>
|
||||||
|
navigation.push("PUPU_CUSTOM_PAGE", {
|
||||||
|
title: Strings.ABOUT,
|
||||||
|
render: () => <About />,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TableRowGroup>
|
||||||
|
</Stack>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,499 +0,0 @@
|
||||||
import { React, NavigationNative } from "@metro/common";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { Stack, Button, IconButton, Text, Card, FlashList } from "@metro/common/components";
|
|
||||||
import { findAssetId } from "@lib/api/assets";
|
|
||||||
import safeFetch from "@lib/utils/safeFetch";
|
|
||||||
import { showToast } from "@ui/toasts";
|
|
||||||
import Search from "@ui/components/Search";
|
|
||||||
import { VdPluginManager } from "@core/vendetta/plugins";
|
|
||||||
import { installTheme, themes, removeTheme } from "@lib/addons/themes";
|
|
||||||
import { clipboard } from "@metro/common";
|
|
||||||
import { hideSheet, showSheet } from "@lib/ui/sheets";
|
|
||||||
import { AlertActionButton } from "@lib/ui/components/wrappers";
|
|
||||||
import { dismissAlert, openAlert } from "@lib/ui/alerts";
|
|
||||||
import { ActionSheet, AlertModal, AlertActions, TableRow, TableRowGroup } from "@metro/common/components";
|
|
||||||
import { lazyDestructure } from "@lib/utils/lazy";
|
|
||||||
import { findByProps } from "@metro";
|
|
||||||
|
|
||||||
const { showSimpleActionSheet, hideActionSheet } = lazyDestructure(() => findByProps("showSimpleActionSheet"));
|
|
||||||
|
|
||||||
interface BaseAddonData {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
authors: string[];
|
|
||||||
installUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PluginData extends BaseAddonData {
|
|
||||||
status: "working" | "broken" | "warning" | string;
|
|
||||||
sourceUrl: string;
|
|
||||||
warningMessage?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ThemeData extends BaseAddonData {}
|
|
||||||
|
|
||||||
type AddonData = PluginData | ThemeData;
|
|
||||||
|
|
||||||
const PLUGIN_URL = "https://raw.githubusercontent.com/Purple-EyeZ/Plugins-List/refs/heads/main/src/plugins-data.json";
|
|
||||||
const THEME_URL = "https://raw.githubusercontent.com/kmmiio99o/theme-marketplace/refs/heads/main/themes.json";
|
|
||||||
|
|
||||||
function normalizeIdFromInstallUrl(url: string) {
|
|
||||||
return url.endsWith("/") ? url : url + "/";
|
|
||||||
}
|
|
||||||
|
|
||||||
// @ts-ignore (i cant be bothered to type these)
|
|
||||||
function InstallButton({ addon, isPluginMode, installing, setInstalling, setRefreshTick }) {
|
|
||||||
const normId = normalizeIdFromInstallUrl(addon.installUrl);
|
|
||||||
const [installed, setInstalled] = React.useState(() =>
|
|
||||||
isPluginMode ? Boolean(VdPluginManager.plugins[normId]) : Boolean(themes[addon.installUrl])
|
|
||||||
);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
setInstalled(isPluginMode ? Boolean(VdPluginManager.plugins[normId]) : Boolean(themes[addon.installUrl]));
|
|
||||||
}, [addon.installUrl, setRefreshTick, isPluginMode]);
|
|
||||||
|
|
||||||
const installAddon = async () => {
|
|
||||||
if (installing.has(normId)) return;
|
|
||||||
setInstalling((prev: Iterable<unknown> | null | undefined) => new Set(prev).add(normId));
|
|
||||||
try {
|
|
||||||
if (isPluginMode) {
|
|
||||||
await VdPluginManager.installPlugin(normId, true);
|
|
||||||
} else {
|
|
||||||
await installTheme(addon.installUrl);
|
|
||||||
}
|
|
||||||
showToast(`Installed ${addon.name}`, findAssetId("CheckIcon"));
|
|
||||||
setInstalled(true);
|
|
||||||
} catch (e) {
|
|
||||||
showToast(e instanceof Error ? e.message : String(e), findAssetId("CircleXIcon-primary"));
|
|
||||||
} finally {
|
|
||||||
setInstalling((prev: Iterable<unknown> | null | undefined) => { const s = new Set(prev); s.delete(normId); return s; });
|
|
||||||
setRefreshTick((t: number) => t + 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const uninstallAddon = async () => {
|
|
||||||
try {
|
|
||||||
if (isPluginMode) {
|
|
||||||
await VdPluginManager.removePlugin(normId);
|
|
||||||
} else {
|
|
||||||
await removeTheme(addon.installUrl);
|
|
||||||
}
|
|
||||||
showToast(`Uninstalled ${addon.name}`, findAssetId("TrashIcon"));
|
|
||||||
setInstalled(false);
|
|
||||||
} catch (e) {
|
|
||||||
showToast(e instanceof Error ? e.message : String(e), findAssetId("CircleXIcon-primary"));
|
|
||||||
} finally {
|
|
||||||
setRefreshTick((t: number) => t + 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const promptInstall = () => {
|
|
||||||
if (!isPluginMode) return installAddon();
|
|
||||||
|
|
||||||
const plugin = addon as PluginData;
|
|
||||||
const needsWarn = (plugin.status && plugin.status !== "working") || (plugin.warningMessage && plugin.warningMessage.trim().length > 0);
|
|
||||||
if (!needsWarn) return installAddon();
|
|
||||||
|
|
||||||
const lines: string[] = [];
|
|
||||||
if (plugin.status && plugin.status !== "working") {
|
|
||||||
if (plugin.status === "broken") lines.push("This plugin is marked as broken, please be aware you may encounter issues");
|
|
||||||
else if (plugin.status === "warning") lines.push("This plugin may have issues");
|
|
||||||
else lines.push(`Status: ${plugin.status}`);
|
|
||||||
}
|
|
||||||
if (plugin.warningMessage) lines.push(plugin.warningMessage);
|
|
||||||
|
|
||||||
openAlert("plugins-list-install-warning", (
|
|
||||||
<AlertModal
|
|
||||||
title="Warning!"
|
|
||||||
content="This plugin may not work as expected."
|
|
||||||
extraContent={<Text variant="text-sm/normal" color="text-muted">{lines.join("\n\n")}</Text>}
|
|
||||||
actions={<AlertActions>
|
|
||||||
<AlertActionButton
|
|
||||||
text="Install Anyway"
|
|
||||||
variant="primary"
|
|
||||||
onPress={() => { dismissAlert("plugins-list-install-warning"); installAddon(); }}
|
|
||||||
/>
|
|
||||||
<AlertActionButton
|
|
||||||
text="Cancel"
|
|
||||||
variant="secondary"
|
|
||||||
onPress={() => dismissAlert("plugins-list-install-warning")}
|
|
||||||
/>
|
|
||||||
</AlertActions>}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
loading={installing.has(normId)}
|
|
||||||
text={!installed ? (installing.has(normId) ? "Installing..." : "Install") : "Uninstall"}
|
|
||||||
disabled={installing.has(normId)}
|
|
||||||
onPress={!installed ? promptInstall : uninstallAddon}
|
|
||||||
variant={!installed ? "primary" : "destructive"}
|
|
||||||
icon={findAssetId(!installed ? "DownloadIcon" : "TrashIcon")}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// @ts-ignore (i cant be bothered to type these)
|
|
||||||
function TrailingButtons({ addon, isPluginMode, installing, setInstalling, setRefreshTick }) {
|
|
||||||
const copyAddonLink = () => {
|
|
||||||
clipboard.setString(addon.installUrl);
|
|
||||||
// @ts-ignore
|
|
||||||
showToast.showCopyToClipboard?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
const copySourceUrl = () => {
|
|
||||||
const plugin = addon as PluginData;
|
|
||||||
clipboard.setString(plugin.sourceUrl);
|
|
||||||
// @ts-ignore
|
|
||||||
showToast.showCopyToClipboard?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
const openAddonMenu = () => {
|
|
||||||
const actions = [
|
|
||||||
{
|
|
||||||
label: `Copy ${isPluginMode ? 'Plugin' : 'Theme'} Link`,
|
|
||||||
icon: findAssetId("CopyIcon"),
|
|
||||||
onPress: copyAddonLink
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
if (isPluginMode && (addon as PluginData).sourceUrl) {
|
|
||||||
actions.push({
|
|
||||||
label: "Copy Source URL",
|
|
||||||
icon: findAssetId("CopyIcon"),
|
|
||||||
onPress: copySourceUrl
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const sheetKey = `${isPluginMode ? 'plugin' : 'theme'}-menu`;
|
|
||||||
showSheet(sheetKey, () => (
|
|
||||||
<ActionSheet>
|
|
||||||
<TableRowGroup title={`${isPluginMode ? 'Plugin' : 'Theme'} Info`}>
|
|
||||||
{actions.map((action, index) => (
|
|
||||||
<TableRow
|
|
||||||
key={index}
|
|
||||||
label={action.label}
|
|
||||||
icon={<TableRow.Icon source={action.icon} />}
|
|
||||||
onPress={() => {
|
|
||||||
action.onPress();
|
|
||||||
hideSheet(sheetKey);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</TableRowGroup>
|
|
||||||
</ActionSheet>
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack spacing={8} direction="horizontal">
|
|
||||||
<IconButton
|
|
||||||
size="sm"
|
|
||||||
onPress={openAddonMenu}
|
|
||||||
variant="secondary"
|
|
||||||
icon={findAssetId("MoreHorizontalIcon")}
|
|
||||||
/>
|
|
||||||
<InstallButton
|
|
||||||
addon={addon}
|
|
||||||
isPluginMode={isPluginMode}
|
|
||||||
installing={installing}
|
|
||||||
setInstalling={setInstalling}
|
|
||||||
setRefreshTick={setRefreshTick}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// @ts-ignore (i cant be bothered to type these)
|
|
||||||
function AddonCard({ addon, isPluginMode, installing, setInstalling, setRefreshTick }) {
|
|
||||||
const { name, description, authors } = addon;
|
|
||||||
const plugin = addon as PluginData;
|
|
||||||
|
|
||||||
let statusColor = "text-normal";
|
|
||||||
if (isPluginMode) {
|
|
||||||
if (plugin.status === "working") statusColor = "#4ADE80";
|
|
||||||
if (plugin.status === "broken") statusColor = "#EF4444";
|
|
||||||
if (plugin.status === "warning") statusColor = "#F59E0B";
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<Stack spacing={16}>
|
|
||||||
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
|
|
||||||
<View style={{ flexShrink: 1 }}>
|
|
||||||
<Text numberOfLines={1} variant="heading-lg/semibold">
|
|
||||||
{name}
|
|
||||||
</Text>
|
|
||||||
<Text variant="text-md/semibold" color="text-muted">
|
|
||||||
by {authors?.join(", ") || "Unknown"}
|
|
||||||
</Text>
|
|
||||||
{isPluginMode && (
|
|
||||||
<Text variant="text-md/semibold" style={{ color: statusColor }}>
|
|
||||||
Status: {plugin.status}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<View>
|
|
||||||
<TrailingButtons
|
|
||||||
addon={addon}
|
|
||||||
isPluginMode={isPluginMode}
|
|
||||||
installing={installing}
|
|
||||||
setInstalling={setInstalling}
|
|
||||||
setRefreshTick={setRefreshTick}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<Text variant="text-md/medium">
|
|
||||||
{description}
|
|
||||||
</Text>
|
|
||||||
{isPluginMode && plugin.warningMessage && (
|
|
||||||
<Text variant="text-sm/medium" color="text-muted">
|
|
||||||
Warning: {plugin.warningMessage}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Sort {
|
|
||||||
DateNewest = "Newest",
|
|
||||||
DateOldest = "Oldest",
|
|
||||||
NameAZ = "Name (A–Z)",
|
|
||||||
NameZA = "Name (Z–A)",
|
|
||||||
WorkingFirst = "Working First",
|
|
||||||
BrokenFirst = "Broken First",
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BrowserPage() {
|
|
||||||
const navigation = NavigationNative.useNavigation();
|
|
||||||
|
|
||||||
const [mode, setMode] = React.useState<"plugins" | "themes">("plugins");
|
|
||||||
const [plugins, setPlugins] = React.useState<PluginData[]>([]);
|
|
||||||
const [themesList, setThemesList] = React.useState<ThemeData[]>([]);
|
|
||||||
const [loading, setLoading] = React.useState(true);
|
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
|
||||||
const [searchQuery, setSearchQuery] = React.useState("");
|
|
||||||
const [installing, setInstalling] = React.useState<Set<string>>(new Set());
|
|
||||||
const [refreshTick, setRefreshTick] = React.useState(0);
|
|
||||||
const [sort, setSort] = React.useState<Sort>(Sort.DateNewest);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
navigation.setOptions({
|
|
||||||
title: "Browser"
|
|
||||||
});
|
|
||||||
}, [navigation]);
|
|
||||||
|
|
||||||
const fetchData = React.useCallback(async (isPluginMode: boolean) => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const url = isPluginMode ? PLUGIN_URL : THEME_URL;
|
|
||||||
const response = await safeFetch(url);
|
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
let addonList: AddonData[] = [];
|
|
||||||
if (Array.isArray(data)) {
|
|
||||||
addonList = data;
|
|
||||||
} else if (isPluginMode && data.OFFICIAL_PLUGINS) {
|
|
||||||
addonList = data.OFFICIAL_PLUGINS;
|
|
||||||
} else if (!isPluginMode) {
|
|
||||||
// Handle any other structure for themes - try common property names
|
|
||||||
addonList = data.OFFICIAL_THEMES || data.themes || data.THEMES || data.items || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPluginMode) {
|
|
||||||
setPlugins(addonList as PluginData[]);
|
|
||||||
} else {
|
|
||||||
setThemesList(addonList as ThemeData[]);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : String(e));
|
|
||||||
if (isPluginMode) {
|
|
||||||
setPlugins([]);
|
|
||||||
} else {
|
|
||||||
setThemesList([]);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchPlugins = React.useCallback(() => fetchData(true), [fetchData]);
|
|
||||||
const fetchThemes = React.useCallback(() => fetchData(false), [fetchData]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
fetchPlugins();
|
|
||||||
fetchThemes();
|
|
||||||
}, [fetchPlugins, fetchThemes]);
|
|
||||||
|
|
||||||
const filterList = (list: AddonData[]) => {
|
|
||||||
if (!list) return [] as AddonData[];
|
|
||||||
const q = searchQuery.toLowerCase();
|
|
||||||
if (!q) return list;
|
|
||||||
return list.filter(p =>
|
|
||||||
p.name.toLowerCase().includes(q)
|
|
||||||
|| p.description.toLowerCase().includes(q)
|
|
||||||
|| (p.authors || []).some(a => a.toLowerCase().includes(q))
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const sortedAndFiltered = React.useMemo(() => {
|
|
||||||
const list = filterList(mode === "plugins" ? plugins : themesList);
|
|
||||||
|
|
||||||
const getStatusPriority = (status: PluginData["status"], sortBy: Sort): number => {
|
|
||||||
if (sortBy === Sort.WorkingFirst) {
|
|
||||||
return status === "working" || status === "warning" ? 0 : 1;
|
|
||||||
}
|
|
||||||
if (sortBy === Sort.BrokenFirst) {
|
|
||||||
return status === "broken" ? 0 : 1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
switch (sort) {
|
|
||||||
case Sort.DateNewest:
|
|
||||||
return [...list].reverse();
|
|
||||||
case Sort.DateOldest:
|
|
||||||
return [...list];
|
|
||||||
case Sort.NameAZ:
|
|
||||||
return [...list].sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
case Sort.NameZA:
|
|
||||||
return [...list].sort((a, b) => b.name.localeCompare(a.name));
|
|
||||||
case Sort.WorkingFirst:
|
|
||||||
if (mode === "plugins") {
|
|
||||||
return [...list].sort((a, b) => {
|
|
||||||
const pa = getStatusPriority((a as PluginData).status, Sort.WorkingFirst);
|
|
||||||
const pb = getStatusPriority((b as PluginData).status, Sort.WorkingFirst);
|
|
||||||
return pa !== pb ? pa - pb : a.name.localeCompare(b.name);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return list;
|
|
||||||
case Sort.BrokenFirst:
|
|
||||||
if (mode === "plugins") {
|
|
||||||
return [...list].sort((a, b) => {
|
|
||||||
const pa = getStatusPriority((a as PluginData).status, Sort.BrokenFirst);
|
|
||||||
const pb = getStatusPriority((b as PluginData).status, Sort.BrokenFirst);
|
|
||||||
return pa !== pb ? pa - pb : a.name.localeCompare(b.name);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return list;
|
|
||||||
default:
|
|
||||||
return list;
|
|
||||||
}
|
|
||||||
}, [plugins, themesList, mode, searchQuery, sort]);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<View style={{ flex: 1, paddingHorizontal: 8, justifyContent: "center", alignItems: "center" }}>
|
|
||||||
<Card style={{ gap: 8 }}>
|
|
||||||
<Text style={{ textAlign: "center" }} variant="heading-lg/bold">
|
|
||||||
An error occurred while fetching the repository
|
|
||||||
</Text>
|
|
||||||
<Text style={{ textAlign: "center" }} variant="text-sm/medium" color="text-muted">
|
|
||||||
{error}
|
|
||||||
</Text>
|
|
||||||
<Button
|
|
||||||
size="lg"
|
|
||||||
text="Refetch"
|
|
||||||
onPress={() => fetchData(mode === "plugins")}
|
|
||||||
icon={findAssetId("RetryIcon")}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<View style={{ justifyContent: "center", alignItems: "center", paddingHorizontal: 10 }}>
|
|
||||||
<Stack spacing={12}>
|
|
||||||
<View style={{ flexDirection: "row", justifyContent: "center", alignItems: 'center', paddingTop: 10 }}>
|
|
||||||
<Stack spacing={10} style={{ flexDirection: "row", justifyContent: "center", alignItems: 'center' }}>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
text="Plugins"
|
|
||||||
variant={mode === "plugins" ? "primary" : "secondary"}
|
|
||||||
onPress={() => setMode("plugins")}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
text="Themes"
|
|
||||||
variant={mode === "themes" ? "primary" : "secondary"}
|
|
||||||
onPress={() => setMode("themes")}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, paddingBottom: 6 }}>
|
|
||||||
<Search
|
|
||||||
placeholder={`Search ${mode}...`}
|
|
||||||
onChangeText={setSearchQuery}
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
/>
|
|
||||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
||||||
<IconButton
|
|
||||||
size="sm"
|
|
||||||
variant="tertiary"
|
|
||||||
icon={findAssetId("MoreVerticalIcon")}
|
|
||||||
disabled={!!searchQuery}
|
|
||||||
onPress={() => showSimpleActionSheet({
|
|
||||||
key: "AddonListSortOptions",
|
|
||||||
header: {
|
|
||||||
title: "Sort Options",
|
|
||||||
onClose: () => hideActionSheet("AddonListSortOptions"),
|
|
||||||
},
|
|
||||||
options: Object.entries(Sort).map(([key, value]) => ({
|
|
||||||
label: value,
|
|
||||||
onPress: () => {
|
|
||||||
setSort(value as Sort);
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
</View>
|
|
||||||
</Stack>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<FlashList
|
|
||||||
data={sortedAndFiltered}
|
|
||||||
refreshing={loading}
|
|
||||||
onRefresh={mode === "plugins" ? fetchPlugins : fetchThemes}
|
|
||||||
estimatedItemSize={200}
|
|
||||||
contentContainerStyle={{ paddingBottom: 90, paddingHorizontal: 5 }}
|
|
||||||
ListHeaderComponent={mode === "plugins" ? (
|
|
||||||
<View style={{ paddingVertical: 6, paddingHorizontal: 8 }}>
|
|
||||||
<Card border="strong">
|
|
||||||
<View style={{ flex: 1, justifyContent: "center", alignItems: "center", flexDirection: "row" }}>
|
|
||||||
<View style={{ gap: 6, flexShrink: 1 }}>
|
|
||||||
<Text variant="heading-md/bold">Unproxied Plugins</Text>
|
|
||||||
<Text variant="text-sm/medium" color="text-muted">
|
|
||||||
Plugins installed from this source have not been checked for safety, install at your own risk
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</Card>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
//@ts-ignore
|
|
||||||
renderItem={({ item: addon }) => (
|
|
||||||
<View style={{ paddingVertical: 6, paddingHorizontal: 8 }}>
|
|
||||||
<AddonCard
|
|
||||||
addon={addon}
|
|
||||||
isPluginMode={mode === "plugins"}
|
|
||||||
installing={installing}
|
|
||||||
setInstalling={setInstalling}
|
|
||||||
setRefreshTick={setRefreshTick}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -3,154 +3,182 @@ import { UnifiedPluginModel } from "@core/ui/settings/pages/Plugins/models";
|
||||||
import { usePluginCardStyles } from "@core/ui/settings/pages/Plugins/usePluginCardStyles";
|
import { usePluginCardStyles } from "@core/ui/settings/pages/Plugins/usePluginCardStyles";
|
||||||
import { findAssetId } from "@lib/api/assets";
|
import { findAssetId } from "@lib/api/assets";
|
||||||
import { NavigationNative, tokens } from "@metro/common";
|
import { NavigationNative, tokens } from "@metro/common";
|
||||||
import { Card, IconButton, Stack, TableSwitch, Text } from "@metro/common/components";
|
import {
|
||||||
|
Card,
|
||||||
|
IconButton,
|
||||||
|
Stack,
|
||||||
|
TableSwitch,
|
||||||
|
Text,
|
||||||
|
} from "@metro/common/components";
|
||||||
import { showSheet } from "@ui/sheets";
|
import { showSheet } from "@ui/sheets";
|
||||||
import chroma from "chroma-js";
|
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 CardContext = createContext<{ plugin: UnifiedPluginModel, result: Fuzzysort.KeysResult<UnifiedPluginModel>; }>(null!);
|
const CardContext = createContext<{
|
||||||
|
plugin: UnifiedPluginModel;
|
||||||
|
result: Fuzzysort.KeysResult<UnifiedPluginModel>;
|
||||||
|
}>(null!);
|
||||||
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 chroma(tokens.unsafe_rawColors.YELLOW_300).alpha(0.3).hex();
|
||||||
}
|
}
|
||||||
|
|
||||||
function Title() {
|
function Title() {
|
||||||
const styles = usePluginCardStyles();
|
const styles = usePluginCardStyles();
|
||||||
const { plugin, result } = useCardContext();
|
const { plugin, result } = useCardContext();
|
||||||
|
|
||||||
// could be empty if the plugin name is irrelevant!
|
// could be empty if the plugin name is irrelevant!
|
||||||
const highlightedNode = result[0].highlight((m, i) =>
|
const highlightedNode = result[0].highlight((m, i) => (
|
||||||
<Text key={i} style={{ backgroundColor: getHighlightColor() }}>
|
<Text key={i} style={{ backgroundColor: getHighlightColor() }}>
|
||||||
{m}
|
{m}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
));
|
||||||
|
|
||||||
const icon = plugin.icon && findAssetId(plugin.icon);
|
const icon = plugin.icon && findAssetId(plugin.icon);
|
||||||
|
|
||||||
const textNode = (
|
const textNode = (
|
||||||
<Text
|
<Text numberOfLines={1} variant="heading-lg/semibold">
|
||||||
numberOfLines={1}
|
{highlightedNode.length ? highlightedNode : plugin.name}
|
||||||
variant="heading-lg/semibold"
|
</Text>
|
||||||
>
|
);
|
||||||
{highlightedNode.length ? highlightedNode : plugin.name}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
|
|
||||||
return <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
return (
|
||||||
{icon && <Image
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
||||||
style={styles.smallIcon}
|
{icon && <Image style={styles.smallIcon} source={icon} />}
|
||||||
source={icon}
|
{textNode}
|
||||||
/>}
|
</View>
|
||||||
{textNode}
|
);
|
||||||
</View>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function Authors() {
|
function Authors() {
|
||||||
const { plugin, result } = useCardContext();
|
const { plugin, result } = useCardContext();
|
||||||
const styles = usePluginCardStyles();
|
const styles = usePluginCardStyles();
|
||||||
|
|
||||||
if (!plugin.authors) return null;
|
if (!plugin.authors) return null;
|
||||||
|
|
||||||
// could be empty if the author(s) are irrelevant with the search!
|
// could be empty if the author(s) are irrelevant with the search!
|
||||||
const highlightedNode = result[2].highlight((m, i) =>
|
const highlightedNode = result[2].highlight((m, i) => (
|
||||||
<Text key={i} style={{ backgroundColor: getHighlightColor() }}>
|
<Text key={i} style={{ backgroundColor: getHighlightColor() }}>
|
||||||
{m}
|
{m}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
));
|
||||||
|
|
||||||
const badges = plugin.getBadges();
|
const badges = plugin.getBadges();
|
||||||
const authorText = highlightedNode.length > 0 ? highlightedNode : plugin.authors.map(a => a.name).join(", ");
|
const authorText =
|
||||||
|
highlightedNode.length > 0
|
||||||
|
? highlightedNode
|
||||||
|
: plugin.authors.map((a) => a.name).join(", ");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flexDirection: "row", flexWrap: "wrap", flexShrink: 1, gap: 4 }}>
|
<View
|
||||||
<Text variant="text-sm/semibold" color="text-muted">
|
style={{ flexDirection: "row", flexWrap: "wrap", flexShrink: 1, gap: 4 }}
|
||||||
by {authorText}
|
>
|
||||||
</Text>
|
<Text variant="text-sm/semibold" color="text-muted">
|
||||||
{badges.length > 0 && <View style={styles.badgesContainer}>
|
by {authorText}
|
||||||
{badges.map((b, i) => <Image
|
</Text>
|
||||||
key={i}
|
{badges.length > 0 && (
|
||||||
source={b.source}
|
<View style={styles.badgesContainer}>
|
||||||
style={styles.badgeIcon}
|
{badges.map((b, i) => (
|
||||||
/>)}
|
<Image key={i} source={b.source} style={styles.badgeIcon} />
|
||||||
</View>}
|
))}
|
||||||
</View>
|
</View>
|
||||||
);
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Description() {
|
function Description() {
|
||||||
const { plugin, result } = useCardContext();
|
const { plugin, result } = useCardContext();
|
||||||
|
|
||||||
// could be empty if the description is irrelevant with the search!
|
// could be empty if the description is irrelevant with the search!
|
||||||
const highlightedNode = result[1].highlight((m, i) =>
|
const highlightedNode = result[1].highlight((m, i) => (
|
||||||
<Text key={i} style={{ backgroundColor: getHighlightColor() }}>{m}</Text>
|
<Text key={i} style={{ backgroundColor: getHighlightColor() }}>
|
||||||
);
|
{m}
|
||||||
|
</Text>
|
||||||
|
));
|
||||||
|
|
||||||
return <Text variant="text-md/medium">
|
return (
|
||||||
{highlightedNode.length ? highlightedNode : plugin.description}
|
<Text variant="text-md/medium">
|
||||||
</Text>;
|
{highlightedNode.length ? highlightedNode : plugin.description}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const Actions = () => {
|
const Actions = () => {
|
||||||
const { plugin } = useCardContext();
|
const { plugin } = useCardContext();
|
||||||
const navigation = NavigationNative.useNavigation();
|
const navigation = NavigationNative.useNavigation();
|
||||||
|
|
||||||
return <View style={{ flexDirection: "row", gap: 6 }}>
|
return (
|
||||||
<IconButton
|
<View style={{ flexDirection: "row", gap: 6 }}>
|
||||||
size="sm"
|
<IconButton
|
||||||
variant="secondary"
|
size="sm"
|
||||||
icon={findAssetId("WrenchIcon")}
|
variant="secondary"
|
||||||
disabled={!plugin.getPluginSettingsComponent()}
|
icon={findAssetId("WrenchIcon")}
|
||||||
onPress={() => navigation.push("PUPU_CUSTOM_PAGE", {
|
disabled={!plugin.getPluginSettingsComponent()}
|
||||||
title: plugin.name,
|
onPress={() =>
|
||||||
render: plugin.getPluginSettingsComponent(),
|
navigation.push("PUPU_CUSTOM_PAGE", {
|
||||||
})}
|
title: plugin.name,
|
||||||
/>
|
render: plugin.getPluginSettingsComponent(),
|
||||||
<IconButton
|
})
|
||||||
size="sm"
|
}
|
||||||
variant="secondary"
|
/>
|
||||||
icon={findAssetId("CircleInformationIcon-primary")}
|
<IconButton
|
||||||
onPress={() => void showSheet(
|
size="sm"
|
||||||
"PluginInfoActionSheet",
|
variant="secondary"
|
||||||
plugin.resolveSheetComponent(),
|
icon={findAssetId("CircleInformationIcon-primary")}
|
||||||
{ plugin, navigation }
|
onPress={() =>
|
||||||
)}
|
void showSheet(
|
||||||
/>
|
"PluginInfoActionSheet",
|
||||||
</View>;
|
plugin.resolveSheetComponent(),
|
||||||
|
{ plugin, navigation },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PluginCard({ result, item: plugin }: CardWrapper<UnifiedPluginModel>) {
|
export default function PluginCard({
|
||||||
plugin.usePluginState();
|
result,
|
||||||
|
item: plugin,
|
||||||
|
}: CardWrapper<UnifiedPluginModel>) {
|
||||||
|
plugin.usePluginState();
|
||||||
|
|
||||||
const [, forceUpdate] = React.useReducer(() => ({}), 0);
|
const [, forceUpdate] = React.useReducer(() => ({}), 0);
|
||||||
const cardContextValue = useMemo(() => ({ plugin, result }), [plugin, result]);
|
const cardContextValue = useMemo(
|
||||||
|
() => ({ plugin, result }),
|
||||||
|
[plugin, result],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardContext.Provider value={cardContextValue}>
|
<CardContext.Provider value={cardContextValue}>
|
||||||
<Card>
|
<Card>
|
||||||
<Stack spacing={16}>
|
<Stack spacing={16}>
|
||||||
<View style={{ flexDirection: "row", justifyContent: "space-between" }}>
|
<View
|
||||||
<View style={{ flexShrink: 1 }}>
|
style={{ flexDirection: "row", justifyContent: "space-between" }}
|
||||||
<Title />
|
>
|
||||||
<Authors />
|
<View style={{ flexShrink: 1 }}>
|
||||||
</View>
|
<Title />
|
||||||
<View>
|
<Authors />
|
||||||
<Stack spacing={12} direction="horizontal">
|
</View>
|
||||||
<Actions />
|
<View>
|
||||||
<TableSwitch
|
<Stack spacing={12} direction="horizontal">
|
||||||
value={plugin.isEnabled()}
|
<Actions />
|
||||||
onValueChange={(v: boolean) => {
|
<TableSwitch
|
||||||
plugin.toggle(v);
|
value={plugin.isEnabled()}
|
||||||
forceUpdate();
|
onValueChange={(v: boolean) => {
|
||||||
}}
|
plugin.toggle(v);
|
||||||
/>
|
forceUpdate();
|
||||||
</Stack>
|
}}
|
||||||
</View>
|
/>
|
||||||
</View>
|
</Stack>
|
||||||
<Description />
|
</View>
|
||||||
</Stack>
|
</View>
|
||||||
</Card>
|
<Description />
|
||||||
</CardContext.Provider>
|
</Stack>
|
||||||
);
|
</Card>
|
||||||
|
</CardContext.Provider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,26 @@
|
||||||
|
import { React, NavigationNative } from "@metro/common";
|
||||||
|
const { useReducer } = React;
|
||||||
|
import { View, Image } from "react-native";
|
||||||
|
import {
|
||||||
|
Stack,
|
||||||
|
Button,
|
||||||
|
IconButton,
|
||||||
|
Text,
|
||||||
|
Card,
|
||||||
|
FlashList,
|
||||||
|
} from "@metro/common/components";
|
||||||
import { Strings } from "@core/i18n";
|
import { Strings } from "@core/i18n";
|
||||||
import AddonPage from "@core/ui/components/AddonPage";
|
import AddonPage from "@core/ui/components/AddonPage";
|
||||||
import PluginCard from "@core/ui/settings/pages/Plugins/components/PluginCard";
|
import PluginCard from "@core/ui/settings/pages/Plugins/components/PluginCard";
|
||||||
import { VdPluginManager } from "@core/vendetta/plugins";
|
import { VdPluginManager } from "@core/vendetta/plugins";
|
||||||
import { useProxy } from "@core/vendetta/storage";
|
import { useProxy } from "@core/vendetta/storage";
|
||||||
import { isCorePlugin, isPluginInstalled, pluginSettings, registeredPlugins } from "@lib/addons/plugins";
|
import {
|
||||||
|
corePluginInstances,
|
||||||
|
isCorePlugin,
|
||||||
|
isPluginInstalled,
|
||||||
|
pluginSettings,
|
||||||
|
registeredPlugins,
|
||||||
|
} from "@lib/addons/plugins";
|
||||||
import { Author } from "@lib/addons/types";
|
import { Author } from "@lib/addons/types";
|
||||||
import { findAssetId } from "@lib/api/assets";
|
import { findAssetId } from "@lib/api/assets";
|
||||||
import { settings } from "@lib/api/settings";
|
import { settings } from "@lib/api/settings";
|
||||||
|
|
@ -12,125 +29,539 @@ import { showToast } from "@lib/ui/toasts";
|
||||||
import { BUNNY_PROXY_PREFIX, VD_PROXY_PREFIX } from "@lib/utils/constants";
|
import { BUNNY_PROXY_PREFIX, VD_PROXY_PREFIX } from "@lib/utils/constants";
|
||||||
import { lazyDestructure } from "@lib/utils/lazy";
|
import { lazyDestructure } from "@lib/utils/lazy";
|
||||||
import { findByProps } from "@metro";
|
import { findByProps } from "@metro";
|
||||||
import { NavigationNative } from "@metro/common";
|
|
||||||
import { Button, Card, FlashList, IconButton, Text } from "@metro/common/components";
|
|
||||||
import { ComponentProps } from "react";
|
import { ComponentProps } from "react";
|
||||||
import { View } from "react-native";
|
import safeFetch from "@lib/utils/safeFetch";
|
||||||
|
import Search from "@ui/components/Search";
|
||||||
|
import { clipboard } from "@metro/common";
|
||||||
|
import {
|
||||||
|
ActionSheet,
|
||||||
|
AlertModal,
|
||||||
|
AlertActions,
|
||||||
|
TableRow,
|
||||||
|
TableRowGroup,
|
||||||
|
} from "@metro/common/components";
|
||||||
|
|
||||||
import { UnifiedPluginModel } from "./models";
|
import { UnifiedPluginModel } from "./models";
|
||||||
import unifyBunnyPlugin from "./models/bunny";
|
import unifyBunnyPlugin from "./models/bunny";
|
||||||
import unifyVdPlugin from "./models/vendetta";
|
import unifyVdPlugin from "./models/vendetta";
|
||||||
|
|
||||||
const { openAlert } = lazyDestructure(() => findByProps("openAlert", "dismissAlert"));
|
const { openAlert } = lazyDestructure(() =>
|
||||||
const { AlertModal, AlertActions, AlertActionButton } = lazyDestructure(() => findByProps("AlertModal", "AlertActions"));
|
findByProps("openAlert", "dismissAlert"),
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
AlertModal: AlertModalComponent,
|
||||||
|
AlertActions: AlertActionsComponent,
|
||||||
|
AlertActionButton,
|
||||||
|
} = lazyDestructure(() => findByProps("AlertModal", "AlertActions"));
|
||||||
|
const { showSimpleActionSheet, hideActionSheet } = lazyDestructure(() =>
|
||||||
|
findByProps("showSimpleActionSheet"),
|
||||||
|
);
|
||||||
|
|
||||||
interface PluginPageProps extends Partial<ComponentProps<typeof AddonPage<UnifiedPluginModel>>> {
|
interface BaseAddonData {
|
||||||
useItems: () => unknown[];
|
name: string;
|
||||||
|
description: string;
|
||||||
|
authors: string[];
|
||||||
|
installUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PluginPage(props: PluginPageProps) {
|
interface PluginData extends BaseAddonData {
|
||||||
const items = props.useItems();
|
status: "working" | "broken" | "warning" | string;
|
||||||
|
sourceUrl: string;
|
||||||
|
warningMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
return <AddonPage<UnifiedPluginModel>
|
type AddonData = PluginData;
|
||||||
CardComponent={PluginCard}
|
|
||||||
title={Strings.PLUGINS}
|
const PLUGIN_URL =
|
||||||
searchKeywords={[
|
"https://raw.githubusercontent.com/Purple-EyeZ/Plugins-List/refs/heads/main/src/plugins-data.json";
|
||||||
"name",
|
|
||||||
"description",
|
enum Sort {
|
||||||
p => p.authors?.map(
|
DateNewest = "Date Added (Newest First)",
|
||||||
(a: Author | string) => typeof a === "string" ? a : a.name
|
DateOldest = "Date Added (Oldest First)",
|
||||||
).join() || ""
|
NameAZ = "Name (A-Z)",
|
||||||
]}
|
NameZA = "Name (Z-A)",
|
||||||
sortOptions={{
|
WorkingFirst = "Working First",
|
||||||
"Name (A-Z)": (a, b) => a.name.localeCompare(b.name),
|
BrokenFirst = "Broken First",
|
||||||
"Name (Z-A)": (a, b) => b.name.localeCompare(a.name)
|
}
|
||||||
|
|
||||||
|
function normalizeIdFromInstallUrl(url: string) {
|
||||||
|
return url.endsWith("/") ? url : url + "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
function InstallButton({
|
||||||
|
addon,
|
||||||
|
installing,
|
||||||
|
setInstalling,
|
||||||
|
setRefreshTick,
|
||||||
|
}: any) {
|
||||||
|
const pluginId = normalizeIdFromInstallUrl(addon.installUrl);
|
||||||
|
const plugin = VdPluginManager.plugins[pluginId];
|
||||||
|
const installed = !!plugin;
|
||||||
|
const isInstalling = installing.has(pluginId);
|
||||||
|
|
||||||
|
// Determine if this is the QuickInstall plugin which can't be disabled
|
||||||
|
const isQuickInstall = pluginId.toLowerCase().includes("quickinstall");
|
||||||
|
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
||||||
|
const [rotation, setRotation] = React.useState(0);
|
||||||
|
const [pulse, setPulse] = React.useState(1);
|
||||||
|
|
||||||
|
const handleInstall = async () => {
|
||||||
|
if (isInstalling) return;
|
||||||
|
|
||||||
|
// Check for broken or warning status
|
||||||
|
if (addon.status === "broken" || addon.status === "warning") {
|
||||||
|
const isBroken = addon.status === "broken";
|
||||||
|
openAlert(
|
||||||
|
"bunny-plugin-status-warning",
|
||||||
|
<AlertModalComponent
|
||||||
|
title={isBroken ? "Plugin is Broken" : "Plugin Warning"}
|
||||||
|
content={
|
||||||
|
isBroken
|
||||||
|
? "This plugin is marked as broken and may not work properly or could cause issues. Do you still want to install it?"
|
||||||
|
: "This plugin has warnings and might have issues. Do you want to continue with the installation?"
|
||||||
|
}
|
||||||
|
extraContent={
|
||||||
|
addon.warningMessage ? (
|
||||||
|
<Card>
|
||||||
|
<Text variant="text-md/normal">{addon.warningMessage}</Text>
|
||||||
|
</Card>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
<AlertActionsComponent>
|
||||||
|
<AlertActionButton
|
||||||
|
text="Install Anyway"
|
||||||
|
variant={isBroken ? "destructive" : "primary"}
|
||||||
|
onPress={continueInstallation}
|
||||||
|
/>
|
||||||
|
<AlertActionButton text="Cancel" variant="secondary" />
|
||||||
|
</AlertActionsComponent>
|
||||||
|
}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
continueInstallation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const performInstall = async () => {
|
||||||
|
setInstalling((prev: Set<string>) => new Set([...prev, pluginId]));
|
||||||
|
|
||||||
|
// Start animation
|
||||||
|
const animationInterval = setInterval(() => {
|
||||||
|
setRotation((prev) => (prev + 15) % 360);
|
||||||
|
setPulse((prev) => 0.8 + 0.2 * Math.sin(Date.now() / 200));
|
||||||
|
forceUpdate();
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await VdPluginManager.installPlugin(addon.installUrl);
|
||||||
|
setRefreshTick((prev: number) => prev + 1);
|
||||||
|
} catch (e) {
|
||||||
|
openAlert(
|
||||||
|
"bunny-plugin-install-failed",
|
||||||
|
<AlertModalComponent
|
||||||
|
title="Install Failed"
|
||||||
|
content={`Unable to install plugin from '${addon.installUrl}':`}
|
||||||
|
extraContent={
|
||||||
|
<Card>
|
||||||
|
<Text variant="text-md/normal">
|
||||||
|
{e instanceof Error ? e.message : String(e)}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
}
|
||||||
|
actions={<AlertActionButton text="Okay" variant="primary" />}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
clearInterval(animationInterval);
|
||||||
|
setRotation(0);
|
||||||
|
setPulse(1);
|
||||||
|
setInstalling((prev: Set<string>) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(pluginId);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const continueInstallation = async () => {
|
||||||
|
if (
|
||||||
|
!addon.installUrl.startsWith(VD_PROXY_PREFIX) &&
|
||||||
|
!addon.installUrl.startsWith(BUNNY_PROXY_PREFIX) &&
|
||||||
|
!settings.developerSettings
|
||||||
|
) {
|
||||||
|
openAlert(
|
||||||
|
"bunny-plugin-unproxied-confirmation",
|
||||||
|
<AlertModalComponent
|
||||||
|
title="Hold On!"
|
||||||
|
content="You're trying to install a plugin from an unproxied external source. This means you're trusting the creator to run their code in this app without your knowledge. Are you sure you want to continue?"
|
||||||
|
extraContent={
|
||||||
|
<Card>
|
||||||
|
<Text variant="text-md/bold">{addon.installUrl}</Text>
|
||||||
|
</Card>
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
<AlertActionsComponent>
|
||||||
|
<AlertActionButton
|
||||||
|
text="Continue"
|
||||||
|
variant="primary"
|
||||||
|
onPress={performInstall}
|
||||||
|
/>
|
||||||
|
<AlertActionButton text="Cancel" variant="secondary" />
|
||||||
|
</AlertActionsComponent>
|
||||||
|
}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
performInstall();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUninstall = () => {
|
||||||
|
VdPluginManager.removePlugin(pluginId);
|
||||||
|
setRefreshTick((prev: number) => prev + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (installed) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
text="Uninstall"
|
||||||
|
variant="destructive"
|
||||||
|
icon={findAssetId("TrashIcon")}
|
||||||
|
onPress={handleUninstall}
|
||||||
|
disabled={isQuickInstall}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ opacity: pulse }}>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
text={isInstalling ? "Installing..." : "Install"}
|
||||||
|
variant="primary"
|
||||||
|
disabled={isInstalling}
|
||||||
|
loading={isInstalling}
|
||||||
|
icon={
|
||||||
|
isInstalling ? (
|
||||||
|
<View style={{ transform: [{ rotate: `${rotation}deg` }] }}>
|
||||||
|
<Image
|
||||||
|
source={findAssetId("RetryIcon")}
|
||||||
|
style={{ width: 16, height: 16 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
findAssetId("DownloadIcon")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onPress={handleInstall}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrailingButtons({
|
||||||
|
addon,
|
||||||
|
installing,
|
||||||
|
setInstalling,
|
||||||
|
setRefreshTick,
|
||||||
|
}: any) {
|
||||||
|
return (
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
||||||
|
<IconButton
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
icon={findAssetId("MoreHorizontalIcon")}
|
||||||
|
onPress={() => {
|
||||||
|
showSimpleActionSheet({
|
||||||
|
key: "AddonShareSheet",
|
||||||
|
header: {
|
||||||
|
title: "Share Plugin",
|
||||||
|
},
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: "Copy Install URL",
|
||||||
|
onPress: () => {
|
||||||
|
clipboard.setString(addon.installUrl);
|
||||||
|
// URL copied
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Copy Source URL",
|
||||||
|
onPress: () => {
|
||||||
|
if (addon.sourceUrl) {
|
||||||
|
clipboard.setString(addon.sourceUrl);
|
||||||
|
// Source URL copied
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
safeModeHint={{ message: Strings.SAFE_MODE_NOTICE_PLUGINS }}
|
/>
|
||||||
items={items}
|
<InstallButton
|
||||||
{...props}
|
addon={addon}
|
||||||
/>;
|
installing={installing}
|
||||||
|
setInstalling={setInstalling}
|
||||||
|
setRefreshTick={setRefreshTick}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BrowseAddonCard({
|
||||||
|
addon,
|
||||||
|
installing,
|
||||||
|
setInstalling,
|
||||||
|
setRefreshTick,
|
||||||
|
}: any) {
|
||||||
|
const { name, description, authors, status, warningMessage } = addon;
|
||||||
|
|
||||||
|
let statusColor = "text-normal";
|
||||||
|
if (status === "working") statusColor = "#4ADE80";
|
||||||
|
if (status === "broken") statusColor = "#EF4444";
|
||||||
|
if (status === "warning") statusColor = "#F59E0B";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Stack spacing={16}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flexShrink: 1 }}>
|
||||||
|
<Text numberOfLines={1} variant="heading-lg/semibold">
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
<Text variant="text-md/semibold" color="text-muted">
|
||||||
|
by {authors?.join(", ") || "Unknown"}
|
||||||
|
</Text>
|
||||||
|
<Text variant="text-md/semibold" style={{ color: statusColor }}>
|
||||||
|
Status: {status}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<TrailingButtons
|
||||||
|
addon={addon}
|
||||||
|
installing={installing}
|
||||||
|
setInstalling={setInstalling}
|
||||||
|
setRefreshTick={setRefreshTick}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text variant="text-md/medium">{description}</Text>
|
||||||
|
{warningMessage && (
|
||||||
|
<Text variant="text-sm/medium" color="text-muted">
|
||||||
|
Warning: {warningMessage}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginPageProps
|
||||||
|
extends Partial<ComponentProps<typeof AddonPage<UnifiedPluginModel>>> {
|
||||||
|
useItems: () => unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function InstalledPluginPage(props: PluginPageProps) {
|
||||||
|
const items = props.useItems();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AddonPage<UnifiedPluginModel>
|
||||||
|
CardComponent={PluginCard}
|
||||||
|
title={Strings.PLUGINS}
|
||||||
|
searchKeywords={[
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
(p) =>
|
||||||
|
p.authors
|
||||||
|
?.map((a: Author | string) => (typeof a === "string" ? a : a.name))
|
||||||
|
.join() || "",
|
||||||
|
]}
|
||||||
|
sortOptions={{
|
||||||
|
"Name (A-Z)": (a, b) => a.name.localeCompare(b.name),
|
||||||
|
"Name (Z-A)": (a, b) => b.name.localeCompare(a.name),
|
||||||
|
}}
|
||||||
|
safeModeHint={{ message: Strings.SAFE_MODE_NOTICE_PLUGINS }}
|
||||||
|
items={items}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BrowsePluginPageProps {
|
||||||
|
plugins: PluginData[];
|
||||||
|
installing: Set<string>;
|
||||||
|
setInstalling: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||||
|
setRefreshTick: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BrowsePluginPage(props: BrowsePluginPageProps) {
|
||||||
|
return (
|
||||||
|
<AddonPage<PluginData>
|
||||||
|
CardComponent={({ item }) => (
|
||||||
|
<BrowseAddonCard
|
||||||
|
addon={item}
|
||||||
|
installing={props.installing}
|
||||||
|
setInstalling={props.setInstalling}
|
||||||
|
setRefreshTick={props.setRefreshTick}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
title={Strings.PLUGINS}
|
||||||
|
searchKeywords={[
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
(p) => (p.authors || []).join(", "),
|
||||||
|
]}
|
||||||
|
sortOptions={{
|
||||||
|
"Date (Newest)": (a, b) => 0,
|
||||||
|
"Date (Oldest)": (a, b) => 0,
|
||||||
|
"Name (A-Z)": (a, b) => a.name.localeCompare(b.name),
|
||||||
|
"Name (Z-A)": (a, b) => b.name.localeCompare(a.name),
|
||||||
|
}}
|
||||||
|
items={props.plugins}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Plugins() {
|
export default function Plugins() {
|
||||||
useProxy(settings);
|
useProxy(settings);
|
||||||
const navigation = NavigationNative.useNavigation();
|
const navigation = NavigationNative.useNavigation();
|
||||||
|
|
||||||
return <PluginPage
|
const [mode, setMode] = React.useState<"installed" | "browse">("installed");
|
||||||
|
const [plugins, setPlugins] = React.useState<PluginData[]>([]);
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
const [installing, setInstalling] = React.useState<Set<string>>(new Set());
|
||||||
|
const [refreshTick, setRefreshTick] = React.useState(0);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () => (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
text={mode === "installed" ? "Browse" : "Installed"}
|
||||||
|
icon={findAssetId(mode === "installed" ? "LinkIcon" : "DownloadIcon")}
|
||||||
|
onPress={() => setMode(mode === "installed" ? "browse" : "installed")}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [navigation, mode]);
|
||||||
|
|
||||||
|
const fetchPlugins = React.useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await safeFetch(PLUGIN_URL);
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
let pluginList: PluginData[] = [];
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
pluginList = data;
|
||||||
|
} else if (data.OFFICIAL_PLUGINS) {
|
||||||
|
pluginList = data.OFFICIAL_PLUGINS;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPlugins(pluginList);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
setPlugins([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (mode === "browse") {
|
||||||
|
fetchPlugins();
|
||||||
|
}
|
||||||
|
}, [mode, fetchPlugins, refreshTick]);
|
||||||
|
|
||||||
|
if (mode === "installed") {
|
||||||
|
return (
|
||||||
|
<InstalledPluginPage
|
||||||
useItems={() => {
|
useItems={() => {
|
||||||
useProxy(VdPluginManager.plugins);
|
useProxy(VdPluginManager.plugins);
|
||||||
useObservable([pluginSettings]);
|
useObservable([pluginSettings]);
|
||||||
|
|
||||||
const vdPlugins = Object.values(VdPluginManager.plugins).map(unifyVdPlugin);
|
const vdPlugins = Object.values(VdPluginManager.plugins).map(
|
||||||
const bnPlugins = [...registeredPlugins.values()].filter(p => isPluginInstalled(p.id) && !isCorePlugin(p.id)).map(unifyBunnyPlugin);
|
unifyVdPlugin,
|
||||||
|
);
|
||||||
|
|
||||||
return [...vdPlugins, ...bnPlugins];
|
// Core plugins (always shown first)
|
||||||
}}
|
const corePlugins = [...corePluginInstances.keys()]
|
||||||
ListHeaderComponent={() => {
|
.map((id) => registeredPlugins.get(id))
|
||||||
const unproxiedPlugins = Object.values(VdPluginManager.plugins).filter(p => !p.id.startsWith(VD_PROXY_PREFIX) && !p.id.startsWith(BUNNY_PROXY_PREFIX));
|
.filter(Boolean)
|
||||||
if (!unproxiedPlugins.length) return null;
|
.map(unifyBunnyPlugin);
|
||||||
|
|
||||||
return <View style={{ marginVertical: 12, marginHorizontal: 10 }}>
|
// Regular installed plugins (non-core)
|
||||||
<Card border="strong">
|
const bnPlugins = [...registeredPlugins.values()]
|
||||||
<View style={{ flex: 1, justifyContent: "center", alignItems: "center", flexDirection: "row" }}>
|
.filter((p) => isPluginInstalled(p.id) && !isCorePlugin(p.id))
|
||||||
<View style={{ gap: 6, flexShrink: 1 }}>
|
.map(unifyBunnyPlugin);
|
||||||
<Text variant="heading-md/bold">Unproxied Plugins Found</Text>
|
|
||||||
<Text variant="text-sm/medium" color="text-muted">
|
return [...corePlugins, ...vdPlugins, ...bnPlugins];
|
||||||
Plugins installed from unproxied sources may run unverified code in this app without your awareness.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={{ marginLeft: "auto" }}>
|
|
||||||
<IconButton
|
|
||||||
size="sm"
|
|
||||||
variant="secondary"
|
|
||||||
icon={findAssetId("CircleInformationIcon-primary")}
|
|
||||||
style={{ marginLeft: 8 }}
|
|
||||||
onPress={() => {
|
|
||||||
navigation.push("PUPU_CUSTOM_PAGE", {
|
|
||||||
title: "Unproxied Plugins",
|
|
||||||
render: () => {
|
|
||||||
return <FlashList
|
|
||||||
data={unproxiedPlugins}
|
|
||||||
contentContainerStyle={{ padding: 8 }}
|
|
||||||
ItemSeparatorComponent={() => <View style={{ height: 8 }} />}
|
|
||||||
renderItem={({ item: p }: any) => <Card>
|
|
||||||
<Text variant="heading-md/semibold">{p.id}</Text>
|
|
||||||
</Card>}
|
|
||||||
/>;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</Card>
|
|
||||||
</View>;
|
|
||||||
}}
|
}}
|
||||||
|
ListHeaderComponent={() => null}
|
||||||
installAction={{
|
installAction={{
|
||||||
label: "Install a plugin",
|
label: "Install a plugin",
|
||||||
fetchFn: async (url: string) => {
|
fetchFn: async (url: string) => {
|
||||||
if (!url.startsWith(VD_PROXY_PREFIX) && !url.startsWith(BUNNY_PROXY_PREFIX) && !settings.developerSettings) {
|
return await VdPluginManager.installPlugin(url);
|
||||||
openAlert("bunny-plugin-unproxied-confirmation", <AlertModal
|
},
|
||||||
title="Hold On!"
|
|
||||||
content="You're trying to install a plugin from an unproxied external source. This means you're trusting the creator to run their code in this app without your knowledge. Are you sure you want to continue?"
|
|
||||||
extraContent={<Card><Text variant="text-md/bold">{url}</Text></Card>}
|
|
||||||
actions={<AlertActions>
|
|
||||||
<AlertActionButton text="Continue" variant="primary" onPress={() => {
|
|
||||||
VdPluginManager.installPlugin(url)
|
|
||||||
.then(() => showToast(Strings.TOASTS_INSTALLED_PLUGIN, findAssetId("Check")))
|
|
||||||
.catch(e => openAlert("bunny-plugin-install-failed", <AlertModal
|
|
||||||
title="Install Failed"
|
|
||||||
content={`Unable to install plugin from '${url}':`}
|
|
||||||
extraContent={<Card><Text variant="text-md/normal">{e instanceof Error ? e.message : String(e)}</Text></Card>}
|
|
||||||
actions={<AlertActionButton text="Okay" variant="primary" />}
|
|
||||||
/>));
|
|
||||||
}} />
|
|
||||||
<AlertActionButton text="Cancel" variant="secondary" />
|
|
||||||
</AlertActions>}
|
|
||||||
/>);
|
|
||||||
} else {
|
|
||||||
return await VdPluginManager.installPlugin(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>;
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Browse mode
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card style={{ gap: 8 }}>
|
||||||
|
<Text style={{ textAlign: "center" }} variant="heading-lg/bold">
|
||||||
|
An error occurred while fetching the repository
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{ textAlign: "center" }}
|
||||||
|
variant="text-sm/medium"
|
||||||
|
color="text-muted"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
text="Refetch"
|
||||||
|
onPress={fetchPlugins}
|
||||||
|
icon={findAssetId("RetryIcon")}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BrowsePluginPage
|
||||||
|
plugins={plugins}
|
||||||
|
installing={installing}
|
||||||
|
setInstalling={setInstalling}
|
||||||
|
setRefreshTick={setRefreshTick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,14 @@ import { useObservable } from "@lib/api/storage";
|
||||||
|
|
||||||
import { UnifiedPluginModel } from ".";
|
import { UnifiedPluginModel } from ".";
|
||||||
|
|
||||||
export default function unifyBunnyPlugin(manifest: BunnyPluginManifest): UnifiedPluginModel {
|
export default function unifyBunnyPlugin(
|
||||||
return {
|
manifest: BunnyPluginManifest,
|
||||||
id: manifest.id,
|
): UnifiedPluginModel {
|
||||||
name: manifest.display.name,
|
return {
|
||||||
description: manifest.display.description,
|
id: manifest.id,
|
||||||
authors: manifest.display.authors,
|
name: manifest.display.name,
|
||||||
|
description: manifest.display.description,
|
||||||
|
authors: manifest.display.authors,
|
||||||
|
|
||||||
getBadges() {
|
getBadges() {
|
||||||
return [
|
return [
|
||||||
|
|
|
||||||
|
|
@ -3,32 +3,35 @@ import { useProxy } from "@core/vendetta/storage";
|
||||||
|
|
||||||
import { UnifiedPluginModel } from ".";
|
import { UnifiedPluginModel } from ".";
|
||||||
|
|
||||||
export default function unifyVdPlugin(vdPlugin: VendettaPlugin): UnifiedPluginModel {
|
export default function unifyVdPlugin(
|
||||||
return {
|
vdPlugin: VendettaPlugin,
|
||||||
id: vdPlugin.id,
|
): UnifiedPluginModel {
|
||||||
name: vdPlugin.manifest.name,
|
return {
|
||||||
description: vdPlugin.manifest.description,
|
id: vdPlugin.id,
|
||||||
authors: vdPlugin.manifest.authors,
|
name: vdPlugin.manifest.name,
|
||||||
icon: vdPlugin.manifest.vendetta?.icon,
|
description: vdPlugin.manifest.description,
|
||||||
|
authors: vdPlugin.manifest.authors,
|
||||||
|
icon: vdPlugin.manifest.vendetta?.icon,
|
||||||
|
|
||||||
getBadges() {
|
getBadges() {
|
||||||
return [];
|
return [];
|
||||||
},
|
},
|
||||||
isEnabled: () => vdPlugin.enabled,
|
isEnabled: () => vdPlugin.enabled,
|
||||||
isInstalled: () => Boolean(vdPlugin && VdPluginManager.plugins[vdPlugin.id]),
|
isInstalled: () =>
|
||||||
usePluginState() {
|
Boolean(vdPlugin && VdPluginManager.plugins[vdPlugin.id]),
|
||||||
useProxy(VdPluginManager.plugins[vdPlugin.id]);
|
usePluginState() {
|
||||||
},
|
useProxy(VdPluginManager.plugins[vdPlugin.id]);
|
||||||
toggle(start: boolean) {
|
},
|
||||||
start
|
toggle(start: boolean) {
|
||||||
? VdPluginManager.startPlugin(vdPlugin.id)
|
start
|
||||||
: VdPluginManager.stopPlugin(vdPlugin.id);
|
? VdPluginManager.startPlugin(vdPlugin.id)
|
||||||
},
|
: VdPluginManager.stopPlugin(vdPlugin.id);
|
||||||
resolveSheetComponent() {
|
},
|
||||||
return import("../sheets/VdPluginInfoActionSheet");
|
resolveSheetComponent() {
|
||||||
},
|
return import("../sheets/PluginInfoActionSheet");
|
||||||
getPluginSettingsComponent() {
|
},
|
||||||
return VdPluginManager.getSettings(vdPlugin.id);
|
getPluginSettingsComponent() {
|
||||||
},
|
return VdPluginManager.getSettings(vdPlugin.id);
|
||||||
};
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,106 +1,246 @@
|
||||||
import { startPlugin } from "@lib/addons/plugins";
|
import { refreshPlugin, uninstallPlugin } from "@lib/addons/plugins";
|
||||||
import { findAssetId } from "@lib/api/assets";
|
import { findAssetId } from "@lib/api/assets";
|
||||||
|
import { purgeStorage } from "@lib/api/storage";
|
||||||
import { hideSheet } from "@lib/ui/sheets";
|
import { hideSheet } from "@lib/ui/sheets";
|
||||||
import { ActionSheet, Card, ContextMenu, IconButton, Text } from "@metro/common/components";
|
import {
|
||||||
import { ComponentProps } from "react";
|
ActionSheet,
|
||||||
|
Card,
|
||||||
|
ContextMenu,
|
||||||
|
IconButton,
|
||||||
|
Text,
|
||||||
|
} from "@metro/common/components";
|
||||||
|
import { clipboard } from "@metro/common";
|
||||||
|
import { ComponentProps, useState } from "react";
|
||||||
import { ScrollView, View } from "react-native";
|
import { ScrollView, View } from "react-native";
|
||||||
|
import { showToast } from "@ui/toasts";
|
||||||
|
import { showConfirmationAlert } from "@core/vendetta/alerts";
|
||||||
|
import { VdPluginManager } from "@core/vendetta/plugins";
|
||||||
|
import { purgeStorage as purgeVdStorage } from "@core/vendetta/storage";
|
||||||
|
|
||||||
import { PluginInfoActionSheetProps } from "./common";
|
import { PluginInfoActionSheetProps } from "./common";
|
||||||
import TitleComponent from "./TitleComponent";
|
import TitleComponent from "./TitleComponent";
|
||||||
|
|
||||||
function PluginInfoIconButton(props: ComponentProps<typeof IconButton>) {
|
function PluginInfoIconButton(props: ComponentProps<typeof IconButton>) {
|
||||||
const { onPress } = props;
|
const { onPress } = props;
|
||||||
props.onPress &&= () => {
|
props.onPress &&= () => {
|
||||||
|
hideSheet("PluginInfoActionSheet");
|
||||||
|
onPress?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
return <IconButton {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PluginInfoActionSheet({
|
||||||
|
plugin,
|
||||||
|
navigation,
|
||||||
|
}: PluginInfoActionSheetProps) {
|
||||||
|
plugin.usePluginState();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Determine plugin type
|
||||||
|
const isVendettaPlugin = plugin.id.includes("/");
|
||||||
|
const isCorePlugin =
|
||||||
|
plugin.id.startsWith("bunny.") || plugin.id.startsWith("vendetta.");
|
||||||
|
|
||||||
|
const copyPluginUrl = () => {
|
||||||
|
// Get appropriate URL for plugin type
|
||||||
|
let url = plugin.id;
|
||||||
|
|
||||||
|
if (isVendettaPlugin) {
|
||||||
|
// Vendetta plugins use the full URL as ID
|
||||||
|
url = plugin.id;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
// Try to get repository URL for Bunny plugins
|
||||||
|
const pluginAny = plugin as any;
|
||||||
|
const repoUrl =
|
||||||
|
pluginAny._manifest?.parentRepository ||
|
||||||
|
pluginAny.manifest?.parentRepository;
|
||||||
|
|
||||||
|
url = repoUrl ? `${repoUrl}/builds/${plugin.id}` : plugin.id;
|
||||||
|
} catch (e) {
|
||||||
|
url = plugin.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clipboard.setString(url);
|
||||||
|
if (typeof showToast?.showCopyToClipboard === "function") {
|
||||||
|
showToast.showCopyToClipboard();
|
||||||
|
} else {
|
||||||
|
showToast("Copied to clipboard");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refetchPlugin = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
if (isVendettaPlugin) {
|
||||||
|
// For Vendetta plugins
|
||||||
|
const vdPlugin = VdPluginManager.plugins[plugin.id];
|
||||||
|
if (vdPlugin.enabled) VdPluginManager.stopPlugin(plugin.id, false);
|
||||||
|
|
||||||
|
await VdPluginManager.fetchPlugin(plugin.id);
|
||||||
|
if (vdPlugin.enabled) await VdPluginManager.startPlugin(plugin.id);
|
||||||
|
|
||||||
|
showToast("Plugin refreshed successfully");
|
||||||
|
} else {
|
||||||
|
// For Bunny plugins
|
||||||
|
refreshPlugin(plugin.id);
|
||||||
|
showToast("Plugin refreshed successfully");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to refresh plugin:", e);
|
||||||
|
showToast("Failed to refresh plugin");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearPluginData = () => {
|
||||||
|
showConfirmationAlert({
|
||||||
|
title: "Clear Data",
|
||||||
|
content:
|
||||||
|
"Are you sure you want to clear all data for this plugin? This action cannot be undone.",
|
||||||
|
confirmText: "Clear",
|
||||||
|
confirmColor: "red",
|
||||||
|
cancelText: "Cancel",
|
||||||
|
onConfirm: async () => {
|
||||||
hideSheet("PluginInfoActionSheet");
|
hideSheet("PluginInfoActionSheet");
|
||||||
onPress?.();
|
try {
|
||||||
};
|
if (isVendettaPlugin) {
|
||||||
|
// For Vendetta plugins
|
||||||
|
const vdPlugin = VdPluginManager.plugins[plugin.id];
|
||||||
|
if (vdPlugin.enabled) VdPluginManager.stopPlugin(plugin.id, false);
|
||||||
|
|
||||||
return <IconButton {...props} />;
|
await purgeVdStorage(plugin.id);
|
||||||
}
|
|
||||||
|
if (vdPlugin.enabled) await VdPluginManager.startPlugin(plugin.id);
|
||||||
export default function PluginInfoActionSheet({ plugin, navigation }: PluginInfoActionSheetProps) {
|
} else {
|
||||||
plugin.usePluginState();
|
// For Bunny plugins
|
||||||
|
await purgeStorage(`plugins/storage/${plugin.id}.json`);
|
||||||
return <ActionSheet>
|
}
|
||||||
<ScrollView contentContainerStyle={{ gap: 12, marginBottom: 12 }}>
|
showToast("Plugin data cleared successfully");
|
||||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, paddingVertical: 24, justifyContent: "space-between", width: "100%" }}>
|
} catch (e) {
|
||||||
<TitleComponent plugin={plugin} />
|
console.error("Failed to clear plugin data:", e);
|
||||||
<ContextMenu
|
showToast("Failed to clear plugin data");
|
||||||
items={[
|
}
|
||||||
{
|
},
|
||||||
label: "Details",
|
});
|
||||||
iconSource: findAssetId("CircleInformationIcon-primary"),
|
};
|
||||||
action: () => {
|
|
||||||
}
|
const uninstallPluginHandler = () => {
|
||||||
},
|
if (isCorePlugin) {
|
||||||
// {
|
showToast("Core plugins cannot be uninstalled");
|
||||||
// label: true ? "Disable Updates" : "Enable Updates",
|
return;
|
||||||
// iconSource: true ? findAssetId("ClockXIcon") : findAssetId("ClockIcon"),
|
}
|
||||||
// action: () => {
|
|
||||||
|
showConfirmationAlert({
|
||||||
// }
|
title: "Uninstall Plugin",
|
||||||
// },
|
content:
|
||||||
{
|
"Are you sure you want to uninstall this plugin? This action cannot be undone.",
|
||||||
label: "Clear Data",
|
confirmText: "Uninstall",
|
||||||
iconSource: findAssetId("FileIcon"),
|
confirmColor: "red",
|
||||||
variant: "destructive",
|
cancelText: "Cancel",
|
||||||
action: () => {
|
onConfirm: async () => {
|
||||||
}
|
hideSheet("PluginInfoActionSheet");
|
||||||
},
|
try {
|
||||||
{
|
if (isVendettaPlugin) {
|
||||||
label: "Uninstall",
|
// For Vendetta plugins
|
||||||
iconSource: findAssetId("TrashIcon"),
|
await VdPluginManager.removePlugin(plugin.id);
|
||||||
variant: "destructive",
|
} else {
|
||||||
action: () => {
|
// For Bunny plugins
|
||||||
}
|
await uninstallPlugin(plugin.id);
|
||||||
}
|
}
|
||||||
]}
|
showToast("Plugin uninstalled successfully");
|
||||||
>
|
} catch (e) {
|
||||||
{props => <IconButton
|
console.error("Failed to uninstall plugin:", e);
|
||||||
{...props}
|
showToast(
|
||||||
icon={findAssetId("MoreHorizontalIcon")}
|
`Failed to uninstall plugin: ${
|
||||||
variant="secondary"
|
e instanceof Error ? e.message : String(e)
|
||||||
size="sm"
|
}`,
|
||||||
/>}
|
);
|
||||||
</ContextMenu>
|
}
|
||||||
</View>
|
},
|
||||||
<View style={{ flexDirection: "row", justifyContent: "space-around", alignContent: "center" }}>
|
});
|
||||||
<PluginInfoIconButton
|
};
|
||||||
label="Configure"
|
|
||||||
variant="secondary"
|
return (
|
||||||
disabled={!plugin.getPluginSettingsComponent()}
|
<ActionSheet>
|
||||||
icon={findAssetId("WrenchIcon")}
|
<ScrollView contentContainerStyle={{ gap: 12, marginBottom: 12 }}>
|
||||||
onPress={() => {
|
<View
|
||||||
navigation.push("PUPU_CUSTOM_PAGE", {
|
style={{
|
||||||
title: plugin.name,
|
flexDirection: "row",
|
||||||
render: plugin.getPluginSettingsComponent(),
|
alignItems: "center",
|
||||||
});
|
gap: 8,
|
||||||
}}
|
paddingVertical: 24,
|
||||||
/>
|
justifyContent: "space-between",
|
||||||
<PluginInfoIconButton
|
width: "100%",
|
||||||
label="Refetch"
|
}}
|
||||||
variant="secondary"
|
>
|
||||||
icon={findAssetId("RetryIcon")}
|
<TitleComponent plugin={plugin} />
|
||||||
onPress={() => {
|
{/* Context menu removed as buttons are directly added to button row */}
|
||||||
startPlugin(plugin.id);
|
</View>
|
||||||
}}
|
<View
|
||||||
/>
|
style={{
|
||||||
<PluginInfoIconButton
|
flexDirection: "row",
|
||||||
label="Copy URL"
|
justifyContent: "center",
|
||||||
variant="secondary"
|
alignItems: "center",
|
||||||
icon={findAssetId("LinkIcon")}
|
flexWrap: "wrap",
|
||||||
onPress={() => {
|
gap: 12,
|
||||||
}}
|
paddingHorizontal: 4,
|
||||||
/>
|
}}
|
||||||
</View>
|
>
|
||||||
<Card>
|
<PluginInfoIconButton
|
||||||
<Text variant="text-md/semibold" color="text-primary" style={{ marginBottom: 4 }}>
|
label="Configure"
|
||||||
Description
|
variant="secondary"
|
||||||
</Text>
|
disabled={!plugin.getPluginSettingsComponent()}
|
||||||
<Text variant="text-md/medium">
|
icon={findAssetId("WrenchIcon")}
|
||||||
{plugin.description}
|
onPress={() => {
|
||||||
</Text>
|
navigation.push("PUPU_CUSTOM_PAGE", {
|
||||||
</Card>
|
title: plugin.name,
|
||||||
</ScrollView>
|
render: plugin.getPluginSettingsComponent(),
|
||||||
</ActionSheet>;
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PluginInfoIconButton
|
||||||
|
label="Refetch"
|
||||||
|
variant="secondary"
|
||||||
|
icon={findAssetId("RetryIcon")}
|
||||||
|
onPress={refetchPlugin}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<PluginInfoIconButton
|
||||||
|
label="Copy URL"
|
||||||
|
variant="secondary"
|
||||||
|
icon={findAssetId("LinkIcon")}
|
||||||
|
onPress={copyPluginUrl}
|
||||||
|
/>
|
||||||
|
<PluginInfoIconButton
|
||||||
|
label="Clear Data"
|
||||||
|
variant="secondary"
|
||||||
|
icon={findAssetId("FileIcon")}
|
||||||
|
onPress={clearPluginData}
|
||||||
|
/>
|
||||||
|
{!isCorePlugin && (
|
||||||
|
<PluginInfoIconButton
|
||||||
|
label="Uninstall"
|
||||||
|
variant="secondary"
|
||||||
|
icon={findAssetId("TrashIcon")}
|
||||||
|
onPress={uninstallPluginHandler}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Card>
|
||||||
|
<Text
|
||||||
|
variant="text-md/semibold"
|
||||||
|
color="text-primary"
|
||||||
|
style={{ marginBottom: 4 }}
|
||||||
|
>
|
||||||
|
Description
|
||||||
|
</Text>
|
||||||
|
<Text variant="text-md/medium">{plugin.description}</Text>
|
||||||
|
</Card>
|
||||||
|
</ScrollView>
|
||||||
|
</ActionSheet>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,54 +4,85 @@ import { findByNameLazy, findByProps } from "@metro";
|
||||||
import { FluxUtils } from "@metro/common";
|
import { FluxUtils } from "@metro/common";
|
||||||
import { Avatar, AvatarPile, Text } from "@metro/common/components";
|
import { Avatar, AvatarPile, Text } from "@metro/common/components";
|
||||||
import { UserStore } from "@metro/common/stores";
|
import { UserStore } from "@metro/common/stores";
|
||||||
import { View } from "react-native";
|
import { View, TouchableOpacity } from "react-native";
|
||||||
|
|
||||||
const showUserProfileActionSheet = findByNameLazy("showUserProfileActionSheet");
|
const showUserProfileActionSheet = findByNameLazy("showUserProfileActionSheet");
|
||||||
const { getUser: maybeFetchUser } = lazyDestructure(() => findByProps("getUser", "fetchProfile"));
|
const { getUser: maybeFetchUser } = lazyDestructure(() =>
|
||||||
|
findByProps("getUser", "fetchProfile"),
|
||||||
|
);
|
||||||
|
|
||||||
export default function TitleComponent({ plugin }: { plugin: UnifiedPluginModel; }) {
|
export default function TitleComponent({
|
||||||
const users: any[] = FluxUtils.useStateFromStoresArray([UserStore], () => {
|
plugin,
|
||||||
plugin.authors?.forEach(a => a.id && maybeFetchUser(a.id));
|
}: {
|
||||||
return plugin.authors?.map(a => UserStore.getUser(a.id));
|
plugin: UnifiedPluginModel;
|
||||||
});
|
}) {
|
||||||
|
const users: any[] = FluxUtils.useStateFromStoresArray([UserStore], () => {
|
||||||
|
plugin.authors?.forEach((a) => a.id && maybeFetchUser(a.id));
|
||||||
|
return plugin.authors?.map((a) => UserStore.getUser(a.id));
|
||||||
|
});
|
||||||
|
|
||||||
const { authors } = plugin;
|
const { authors } = plugin;
|
||||||
const authorTextNode = [];
|
const authorTextNode = [];
|
||||||
|
|
||||||
if (authors) {
|
if (authors) {
|
||||||
for (const author of authors) {
|
for (const author of authors) {
|
||||||
authorTextNode.push(<Text
|
authorTextNode.push(
|
||||||
onPress={() => showUserProfileActionSheet({ userId: author.id })}
|
<Text variant="text-md/medium" key={author.name}>
|
||||||
variant="text-md/medium"
|
{author.name}
|
||||||
>
|
</Text>,
|
||||||
{author.name}
|
);
|
||||||
</Text>);
|
|
||||||
|
|
||||||
authorTextNode.push(", ");
|
authorTextNode.push(", ");
|
||||||
}
|
|
||||||
|
|
||||||
authorTextNode.pop();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return <View style={{ gap: 4 }}>
|
authorTextNode.pop();
|
||||||
<View>
|
}
|
||||||
<Text variant="heading-xl/semibold">
|
|
||||||
{plugin.name}
|
const openFirstAuthorProfile = () => {
|
||||||
</Text>
|
// Find first author with ID
|
||||||
</View>
|
const authorWithId = authors?.find((a) => !!a.id);
|
||||||
<View style={{ flexDirection: "row", flexShrink: 1 }}>
|
if (authorWithId?.id) {
|
||||||
{authors?.length && <View style={{ flexDirection: "row", gap: 8, alignItems: "center", paddingVertical: 4, paddingHorizontal: 8, backgroundColor: "#00000016", borderRadius: 32 }}>
|
showUserProfileActionSheet({ userId: authorWithId.id });
|
||||||
{users.length && <AvatarPile
|
}
|
||||||
size="xxsmall"
|
};
|
||||||
names={plugin.authors?.map(a => a.name)}
|
|
||||||
totalCount={plugin.authors?.length}
|
return (
|
||||||
>
|
<View style={{ gap: 4 }}>
|
||||||
{users.map(a => <Avatar size="xxsmall" user={a} />)}
|
<View>
|
||||||
</AvatarPile>}
|
<Text variant="heading-xl/semibold">{plugin.name}</Text>
|
||||||
<Text variant="text-md/medium">
|
</View>
|
||||||
{authorTextNode}
|
<View style={{ flexDirection: "row", flexShrink: 1 }}>
|
||||||
</Text>
|
{authors?.length && (
|
||||||
</View>}
|
<TouchableOpacity
|
||||||
</View>
|
style={{
|
||||||
</View>;
|
flexDirection: "row",
|
||||||
|
gap: 8,
|
||||||
|
alignItems: "center",
|
||||||
|
paddingVertical: 4,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
backgroundColor: "#00000016",
|
||||||
|
borderRadius: 32,
|
||||||
|
}}
|
||||||
|
onPress={
|
||||||
|
authors.some((a) => !!a.id) ? openFirstAuthorProfile : undefined
|
||||||
|
}
|
||||||
|
disabled={!authors.some((a) => !!a.id)}
|
||||||
|
>
|
||||||
|
{users.length && (
|
||||||
|
<AvatarPile
|
||||||
|
size="xxsmall"
|
||||||
|
names={plugin.authors?.map((a) => a.name)}
|
||||||
|
totalCount={plugin.authors?.length}
|
||||||
|
>
|
||||||
|
{users.map((a) => (
|
||||||
|
<Avatar size="xxsmall" user={a} />
|
||||||
|
))}
|
||||||
|
</AvatarPile>
|
||||||
|
)}
|
||||||
|
<Text variant="text-md/medium">{authorTextNode}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,21 +2,21 @@ import { tokens } from "@metro/common";
|
||||||
import { createStyles } from "@ui/styles";
|
import { createStyles } from "@ui/styles";
|
||||||
|
|
||||||
export const usePluginCardStyles = createStyles({
|
export const usePluginCardStyles = createStyles({
|
||||||
smallIcon: {
|
smallIcon: {
|
||||||
tintColor: tokens.colors.LOGO_PRIMARY,
|
tintColor: tokens.colors.LOGO_PRIMARY,
|
||||||
height: 18,
|
height: 18,
|
||||||
width: 18,
|
width: 18,
|
||||||
},
|
},
|
||||||
badgeIcon: {
|
badgeIcon: {
|
||||||
tintColor: tokens.colors.LOGO_PRIMARY,
|
tintColor: tokens.colors.LOGO_PRIMARY,
|
||||||
height: 12,
|
height: 12,
|
||||||
width: 12,
|
width: 12,
|
||||||
},
|
},
|
||||||
badgesContainer: {
|
badgesContainer: {
|
||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
gap: 6,
|
gap: 6,
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
padding: 4
|
padding: 4,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,83 +1,51 @@
|
||||||
import { formatString, Strings } from "@core/i18n";
|
import { Strings } from "@core/i18n";
|
||||||
import AddonCard, { CardWrapper } from "@core/ui/components/AddonCard";
|
import AddonCard, { CardWrapper } from "@core/ui/components/AddonCard";
|
||||||
import { showConfirmationAlert } from "@core/vendetta/alerts";
|
import { VdThemeInfo, themes, selectTheme } from "@lib/addons/themes";
|
||||||
import { useProxy } from "@core/vendetta/storage";
|
|
||||||
import { fetchTheme, removeTheme, selectTheme, themes, VdThemeInfo } from "@lib/addons/themes";
|
|
||||||
import { findAssetId } from "@lib/api/assets";
|
import { findAssetId } from "@lib/api/assets";
|
||||||
import { settings } from "@lib/api/settings";
|
import { settings } from "@lib/api/settings";
|
||||||
import { clipboard } from "@metro/common";
|
import { showSheet } from "@lib/ui/sheets";
|
||||||
import { showToast } from "@ui/toasts";
|
import { NavigationNative, React } from "@metro/common";
|
||||||
|
|
||||||
function selectAndApply(value: boolean, theme: VdThemeInfo) {
|
|
||||||
try {
|
|
||||||
selectTheme(value ? theme : null);
|
|
||||||
} catch (e: any) {
|
|
||||||
console.error("Error while selectAndApply,", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ThemeCard({ item: theme }: CardWrapper<VdThemeInfo>) {
|
export default function ThemeCard({ item: theme }: CardWrapper<VdThemeInfo>) {
|
||||||
useProxy(theme);
|
const navigation = NavigationNative.useNavigation();
|
||||||
|
const [removed, setRemoved] = React.useState(false);
|
||||||
|
|
||||||
const [removed, setRemoved] = React.useState(false);
|
// This is needed because of React™
|
||||||
|
if (removed) return null;
|
||||||
|
|
||||||
// This is needed because of React™
|
const { authors } = theme.data;
|
||||||
if (removed) return null;
|
|
||||||
|
|
||||||
const { authors } = theme.data;
|
return (
|
||||||
|
<AddonCard
|
||||||
return (
|
headerLabel={theme.data.name}
|
||||||
<AddonCard
|
headerSublabel={
|
||||||
headerLabel={theme.data.name}
|
authors ? `by ${authors.map((i) => i.name).join(", ")}` : ""
|
||||||
headerSublabel={authors ? `by ${authors.map(i => i.name).join(", ")}` : ""}
|
}
|
||||||
descriptionLabel={theme.data.description ?? "No description."}
|
descriptionLabel={theme.data.description ?? "No description."}
|
||||||
toggleType={!settings.safeMode?.enabled ? "radio" : undefined}
|
toggleType={!settings.safeMode?.enabled ? "radio" : undefined}
|
||||||
toggleValue={() => themes[theme.id].selected}
|
toggleValue={() => themes[theme.id].selected}
|
||||||
onToggleChange={(v: boolean) => {
|
onToggleChange={(v: boolean) => {
|
||||||
selectAndApply(v, theme);
|
try {
|
||||||
}}
|
selectTheme(v ? theme : null);
|
||||||
overflowTitle={theme.data.name}
|
} catch (e: any) {
|
||||||
overflowActions={[
|
console.error("Error while selecting theme:", e);
|
||||||
{
|
}
|
||||||
icon: "ic_sync_24px",
|
}}
|
||||||
label: Strings.REFETCH,
|
overflowTitle={theme.data.name}
|
||||||
onPress: () => {
|
actions={[
|
||||||
fetchTheme(theme.id, theme.selected).then(() => {
|
{
|
||||||
showToast(Strings.THEME_REFETCH_SUCCESSFUL, findAssetId("toast_image_saved"));
|
icon: "CircleInformationIcon-primary",
|
||||||
}).catch(() => {
|
onPress: () => {
|
||||||
showToast(Strings.THEME_REFETCH_FAILED, findAssetId("Small"));
|
// Using dynamic import directly (without wrapping in function)
|
||||||
});
|
// This returns a Promise that resolves to the module
|
||||||
},
|
const importPromise = import("./sheets/ThemeInfoActionSheet");
|
||||||
},
|
showSheet("ThemeInfoActionSheet", importPromise, {
|
||||||
{
|
theme,
|
||||||
icon: "copy",
|
navigation,
|
||||||
label: Strings.COPY_URL,
|
});
|
||||||
onPress: () => {
|
},
|
||||||
clipboard.setString(theme.id);
|
},
|
||||||
showToast.showCopyToClipboard();
|
]}
|
||||||
}
|
/>
|
||||||
},
|
);
|
||||||
{
|
|
||||||
icon: "ic_message_delete",
|
|
||||||
label: Strings.DELETE,
|
|
||||||
isDestructive: true,
|
|
||||||
onPress: () => showConfirmationAlert({
|
|
||||||
title: Strings.HOLD_UP,
|
|
||||||
content: formatString("ARE_YOU_SURE_TO_DELETE_THEME", { name: theme.data.name }),
|
|
||||||
confirmText: Strings.DELETE,
|
|
||||||
cancelText: Strings.CANCEL,
|
|
||||||
confirmColor: "red",
|
|
||||||
onConfirm: () => {
|
|
||||||
removeTheme(theme.id).then(wasSelected => {
|
|
||||||
setRemoved(true);
|
|
||||||
if (wasSelected) selectAndApply(false, theme);
|
|
||||||
}).catch((e: Error) => {
|
|
||||||
showToast(e.message, findAssetId("Small"));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { useProxy } from "@core/vendetta/storage";
|
||||||
import {
|
import {
|
||||||
getCurrentTheme,
|
getCurrentTheme,
|
||||||
installTheme,
|
installTheme,
|
||||||
|
removeTheme,
|
||||||
themes,
|
themes,
|
||||||
VdThemeInfo,
|
VdThemeInfo,
|
||||||
} from "@lib/addons/themes";
|
} from "@lib/addons/themes";
|
||||||
|
|
@ -14,15 +15,237 @@ import { Author } from "@lib/addons/types";
|
||||||
import { findAssetId } from "@lib/api/assets";
|
import { findAssetId } from "@lib/api/assets";
|
||||||
import { settings } from "@lib/api/settings";
|
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 safeFetch from "@lib/utils/safeFetch";
|
||||||
|
import Search from "@ui/components/Search";
|
||||||
|
import { findByProps } from "@metro";
|
||||||
|
import { NavigationNative, React, clipboard } from "@metro/common";
|
||||||
|
import { showSheet } from "@lib/ui/sheets";
|
||||||
import {
|
import {
|
||||||
ActionSheet,
|
ActionSheet,
|
||||||
BottomSheetTitleHeader,
|
BottomSheetTitleHeader,
|
||||||
Button,
|
Button,
|
||||||
|
Card,
|
||||||
|
FlashList,
|
||||||
|
IconButton,
|
||||||
|
Stack,
|
||||||
TableRowGroup,
|
TableRowGroup,
|
||||||
TableSwitchRow,
|
TableSwitchRow,
|
||||||
TableRowIcon,
|
TableRowIcon,
|
||||||
|
Text,
|
||||||
|
AlertModal,
|
||||||
|
AlertActions,
|
||||||
|
AlertActionButton,
|
||||||
} from "@metro/common/components";
|
} from "@metro/common/components";
|
||||||
import { View } from "react-native";
|
import { View, Image } from "react-native";
|
||||||
|
|
||||||
|
const { useReducer } = React;
|
||||||
|
|
||||||
|
const { showSimpleActionSheet, hideActionSheet } = lazyDestructure(() =>
|
||||||
|
findByProps("showSimpleActionSheet"),
|
||||||
|
);
|
||||||
|
|
||||||
|
interface BaseAddonData {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
authors: string[];
|
||||||
|
installUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThemeData extends BaseAddonData {}
|
||||||
|
|
||||||
|
enum Sort {
|
||||||
|
DateNewest = "Date Added (Newest First)",
|
||||||
|
DateOldest = "Date Added (Oldest First)",
|
||||||
|
NameAZ = "Name (A-Z)",
|
||||||
|
NameZA = "Name (Z-A)",
|
||||||
|
}
|
||||||
|
|
||||||
|
const THEME_URL =
|
||||||
|
"https://raw.githubusercontent.com/kmmiio99o/theme-marketplace/refs/heads/main/themes.json";
|
||||||
|
|
||||||
|
function BrowserThemeCard({
|
||||||
|
theme,
|
||||||
|
installing,
|
||||||
|
setInstalling,
|
||||||
|
setRefreshTick,
|
||||||
|
}: {
|
||||||
|
theme: ThemeData;
|
||||||
|
installing: Set<string>;
|
||||||
|
setInstalling: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||||
|
setRefreshTick: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
}) {
|
||||||
|
const { name, description, authors, installUrl } = theme;
|
||||||
|
|
||||||
|
useProxy(themes);
|
||||||
|
|
||||||
|
// Use installUrl as the theme ID (themes are keyed by URL)
|
||||||
|
const themeId = installUrl;
|
||||||
|
const isInstalling = installing.has(themeId);
|
||||||
|
const isInstalled = themes[themeId];
|
||||||
|
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
||||||
|
const [rotation, setRotation] = React.useState(0);
|
||||||
|
const [pulse, setPulse] = React.useState(1);
|
||||||
|
|
||||||
|
const handleInstall = async () => {
|
||||||
|
if (isInstalling) return;
|
||||||
|
|
||||||
|
setInstalling((prev) => new Set([...prev, themeId]));
|
||||||
|
|
||||||
|
// Start animation
|
||||||
|
const animationInterval = setInterval(() => {
|
||||||
|
setRotation((prev) => (prev + 15) % 360);
|
||||||
|
setPulse((prev) => 0.8 + 0.2 * Math.sin(Date.now() / 200));
|
||||||
|
forceUpdate();
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await installTheme(themeId);
|
||||||
|
setRefreshTick((prev) => prev + 1);
|
||||||
|
forceUpdate();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to install theme:", e);
|
||||||
|
} finally {
|
||||||
|
clearInterval(animationInterval);
|
||||||
|
setRotation(0);
|
||||||
|
setPulse(1);
|
||||||
|
setInstalling((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(themeId);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUninstall = async () => {
|
||||||
|
try {
|
||||||
|
await removeTheme(themeId);
|
||||||
|
setRefreshTick((prev) => prev + 1);
|
||||||
|
forceUpdate();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to uninstall theme:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Stack spacing={16}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flexShrink: 1 }}>
|
||||||
|
<Text numberOfLines={1} variant="heading-lg/semibold">
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
<Text variant="text-md/semibold" color="text-muted">
|
||||||
|
by {authors?.join(", ") || "Unknown"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
||||||
|
<IconButton
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
icon={findAssetId("MoreHorizontalIcon")}
|
||||||
|
onPress={() => {
|
||||||
|
showSimpleActionSheet({
|
||||||
|
key: "ThemeShareSheet",
|
||||||
|
header: {
|
||||||
|
title: "Share Theme",
|
||||||
|
},
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: "Copy Install URL",
|
||||||
|
onPress: () => {
|
||||||
|
clipboard.setString(themeId);
|
||||||
|
// URL copied
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{isInstalled ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
text="Uninstall"
|
||||||
|
variant="destructive"
|
||||||
|
icon={findAssetId("TrashIcon")}
|
||||||
|
onPress={handleUninstall}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={{ opacity: pulse }}>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
text={isInstalling ? "Installing..." : "Install"}
|
||||||
|
variant="primary"
|
||||||
|
disabled={isInstalling}
|
||||||
|
loading={isInstalling}
|
||||||
|
icon={
|
||||||
|
isInstalling ? (
|
||||||
|
<View
|
||||||
|
style={{ transform: [{ rotate: `${rotation}deg` }] }}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
source={findAssetId("RetryIcon")}
|
||||||
|
style={{ width: 16, height: 16 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
findAssetId("DownloadIcon")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onPress={handleInstall}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text variant="text-md/medium">{description}</Text>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BrowseThemePageProps {
|
||||||
|
themes: ThemeData[];
|
||||||
|
installing: Set<string>;
|
||||||
|
setInstalling: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||||
|
setRefreshTick: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BrowseThemePage(props: BrowseThemePageProps) {
|
||||||
|
useProxy(themes);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AddonPage<ThemeData>
|
||||||
|
CardComponent={({ item }) => (
|
||||||
|
<BrowserThemeCard
|
||||||
|
theme={item}
|
||||||
|
installing={props.installing}
|
||||||
|
setInstalling={props.setInstalling}
|
||||||
|
setRefreshTick={props.setRefreshTick}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
title={Strings.THEMES}
|
||||||
|
searchKeywords={[
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
(t) => (t.authors || []).join(", "),
|
||||||
|
]}
|
||||||
|
sortOptions={{
|
||||||
|
"Date (Newest)": (a, b) => 0,
|
||||||
|
"Date (Oldest)": (a, b) => 0,
|
||||||
|
"Name (A-Z)": (a, b) => a.name.localeCompare(b.name),
|
||||||
|
"Name (Z-A)": (a, b) => b.name.localeCompare(a.name),
|
||||||
|
}}
|
||||||
|
items={props.themes}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Theme options have been changed from radio groups to individual switches
|
* Theme options have been changed from radio groups to individual switches
|
||||||
|
|
@ -32,111 +255,236 @@ import { View } from "react-native";
|
||||||
export default function Themes() {
|
export default function Themes() {
|
||||||
useProxy(settings);
|
useProxy(settings);
|
||||||
useProxy(themes);
|
useProxy(themes);
|
||||||
|
const navigation = NavigationNative.useNavigation();
|
||||||
|
|
||||||
|
const [mode, setMode] = React.useState<"installed" | "browse">("installed");
|
||||||
|
const [themesList, setThemesList] = React.useState<ThemeData[]>([]);
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
const [installing, setInstalling] = React.useState<Set<string>>(new Set());
|
||||||
|
const [refreshTick, setRefreshTick] = React.useState(0);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const headerButtons = [];
|
||||||
|
|
||||||
|
// Add mode toggle button
|
||||||
|
headerButtons.push(
|
||||||
|
<Button
|
||||||
|
key="mode-toggle"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
text={mode === "installed" ? "Browse" : "Installed"}
|
||||||
|
icon={findAssetId(mode === "installed" ? "LinkIcon" : "DownloadIcon")}
|
||||||
|
onPress={() => setMode(mode === "installed" ? "browse" : "installed")}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add options button (always visible)
|
||||||
|
headerButtons.push(
|
||||||
|
<IconButton
|
||||||
|
key="options"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
icon={findAssetId("MoreHorizontalIcon")}
|
||||||
|
onPress={() =>
|
||||||
|
showSheet("ThemeOptionsSheet", () => {
|
||||||
|
useObservable([colorsPref]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionSheet>
|
||||||
|
<BottomSheetTitleHeader title="Options" />
|
||||||
|
<View style={{ paddingVertical: 20, gap: 12 }}>
|
||||||
|
<TableRowGroup title="Override Theme Type">
|
||||||
|
<TableSwitchRow
|
||||||
|
label="Auto"
|
||||||
|
icon={<TableRowIcon source={findAssetId("RobotIcon")} />}
|
||||||
|
value={!colorsPref.type}
|
||||||
|
onValueChange={(enabled: boolean) => {
|
||||||
|
if (enabled) {
|
||||||
|
colorsPref.type = undefined;
|
||||||
|
} else {
|
||||||
|
colorsPref.type = "dark";
|
||||||
|
}
|
||||||
|
getCurrentTheme()?.data &&
|
||||||
|
updateBunnyColor(getCurrentTheme()!.data!, {
|
||||||
|
update: true,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TableSwitchRow
|
||||||
|
label="Dark"
|
||||||
|
icon={
|
||||||
|
<TableRowIcon source={findAssetId("ThemeDarkIcon")} />
|
||||||
|
}
|
||||||
|
value={colorsPref.type === "dark"}
|
||||||
|
onValueChange={(enabled: boolean) => {
|
||||||
|
colorsPref.type = enabled ? "dark" : undefined;
|
||||||
|
getCurrentTheme()?.data &&
|
||||||
|
updateBunnyColor(getCurrentTheme()!.data!, {
|
||||||
|
update: true,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TableSwitchRow
|
||||||
|
label="Light"
|
||||||
|
icon={
|
||||||
|
<TableRowIcon source={findAssetId("ThemeLightIcon")} />
|
||||||
|
}
|
||||||
|
value={colorsPref.type === "light"}
|
||||||
|
onValueChange={(enabled: boolean) => {
|
||||||
|
colorsPref.type = enabled ? "light" : undefined;
|
||||||
|
getCurrentTheme()?.data &&
|
||||||
|
updateBunnyColor(getCurrentTheme()!.data!, {
|
||||||
|
update: true,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TableRowGroup>
|
||||||
|
<TableRowGroup title="Chat Background">
|
||||||
|
<TableSwitchRow
|
||||||
|
label="Show Background"
|
||||||
|
icon={<TableRowIcon source={findAssetId("ImageIcon")} />}
|
||||||
|
value={!colorsPref.customBackground}
|
||||||
|
onValueChange={(enabled: boolean) => {
|
||||||
|
colorsPref.customBackground = enabled ? null : "hidden";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TableSwitchRow
|
||||||
|
label="Hide Background"
|
||||||
|
icon={<TableRowIcon source={findAssetId("DenyIcon")} />}
|
||||||
|
value={colorsPref.customBackground === "hidden"}
|
||||||
|
onValueChange={(enabled: boolean) => {
|
||||||
|
colorsPref.customBackground = enabled ? "hidden" : null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TableRowGroup>
|
||||||
|
</View>
|
||||||
|
</ActionSheet>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () => (
|
||||||
|
<View style={{ flexDirection: "row", gap: 8 }}>{headerButtons}</View>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [navigation, mode]);
|
||||||
|
|
||||||
|
const fetchThemes = React.useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await safeFetch(THEME_URL);
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
let themeList: ThemeData[] = [];
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
themeList = data;
|
||||||
|
} else if (data.OFFICIAL_THEMES) {
|
||||||
|
themeList = data.OFFICIAL_THEMES;
|
||||||
|
} else if (data.themes) {
|
||||||
|
themeList = data.themes;
|
||||||
|
} else if (data.THEMES) {
|
||||||
|
themeList = data.THEMES;
|
||||||
|
} else if (data.items) {
|
||||||
|
themeList = data.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
setThemesList(themeList);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
setThemesList([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (mode === "browse") {
|
||||||
|
fetchThemes();
|
||||||
|
}
|
||||||
|
}, [mode, fetchThemes, refreshTick]);
|
||||||
|
|
||||||
|
if (mode === "installed") {
|
||||||
|
return (
|
||||||
|
<AddonPage<VdThemeInfo>
|
||||||
|
title={Strings.THEMES}
|
||||||
|
searchKeywords={[
|
||||||
|
"data.name",
|
||||||
|
"data.description",
|
||||||
|
(p) => p.data.authors?.map((a: Author) => a.name).join(", ") ?? "",
|
||||||
|
]}
|
||||||
|
sortOptions={{
|
||||||
|
"Name (A-Z)": (a, b) => a.data.name.localeCompare(b.data.name),
|
||||||
|
"Name (Z-A)": (a, b) => b.data.name.localeCompare(a.data.name),
|
||||||
|
}}
|
||||||
|
installAction={{
|
||||||
|
label: "Install a theme",
|
||||||
|
fetchFn: installTheme,
|
||||||
|
}}
|
||||||
|
items={Object.values(themes)}
|
||||||
|
safeModeHint={{
|
||||||
|
message: formatString("SAFE_MODE_NOTICE_THEMES", {
|
||||||
|
enabled: Boolean(settings.safeMode?.currentThemeId),
|
||||||
|
}),
|
||||||
|
footer: settings.safeMode?.currentThemeId && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
text={Strings.DISABLE_THEME}
|
||||||
|
onPress={() => delete settings.safeMode?.currentThemeId}
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
CardComponent={ThemeCard}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Browse mode
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card style={{ gap: 8 }}>
|
||||||
|
<Text style={{ textAlign: "center" }} variant="heading-lg/bold">
|
||||||
|
An error occurred while fetching the repository
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{ textAlign: "center" }}
|
||||||
|
variant="text-sm/medium"
|
||||||
|
color="text-muted"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
text="Refetch"
|
||||||
|
onPress={fetchThemes}
|
||||||
|
icon={findAssetId("RetryIcon")}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AddonPage<VdThemeInfo>
|
<BrowseThemePage
|
||||||
title={Strings.THEMES}
|
themes={themesList}
|
||||||
searchKeywords={[
|
installing={installing}
|
||||||
"data.name",
|
setInstalling={setInstalling}
|
||||||
"data.description",
|
setRefreshTick={setRefreshTick}
|
||||||
(p) => p.data.authors?.map((a: Author) => a.name).join(", ") ?? "",
|
|
||||||
]}
|
|
||||||
sortOptions={{
|
|
||||||
"Name (A-Z)": (a, b) => a.data.name.localeCompare(b.data.name),
|
|
||||||
"Name (Z-A)": (a, b) => b.data.name.localeCompare(a.data.name),
|
|
||||||
}}
|
|
||||||
installAction={{
|
|
||||||
label: "Install a theme",
|
|
||||||
fetchFn: installTheme,
|
|
||||||
}}
|
|
||||||
items={Object.values(themes)}
|
|
||||||
safeModeHint={{
|
|
||||||
message: formatString("SAFE_MODE_NOTICE_THEMES", {
|
|
||||||
enabled: Boolean(settings.safeMode?.currentThemeId),
|
|
||||||
}),
|
|
||||||
footer: settings.safeMode?.currentThemeId && (
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
text={Strings.DISABLE_THEME}
|
|
||||||
onPress={() => delete settings.safeMode?.currentThemeId}
|
|
||||||
style={{ marginTop: 8 }}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
CardComponent={ThemeCard}
|
|
||||||
OptionsActionSheetComponent={() => {
|
|
||||||
useObservable([colorsPref]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ActionSheet>
|
|
||||||
<BottomSheetTitleHeader title="Options" />
|
|
||||||
<View style={{ paddingVertical: 20, gap: 12 }}>
|
|
||||||
{/* Changed from TableRadioGroup to individual TableSwitchRow components
|
|
||||||
for better UX - users can now toggle options individually */}
|
|
||||||
<TableRowGroup title="Override Theme Type">
|
|
||||||
<TableSwitchRow
|
|
||||||
label="Auto"
|
|
||||||
icon={<TableRowIcon source={findAssetId("RobotIcon")} />}
|
|
||||||
value={!colorsPref.type}
|
|
||||||
onValueChange={(enabled: boolean) => {
|
|
||||||
if (enabled) {
|
|
||||||
colorsPref.type = undefined;
|
|
||||||
} else {
|
|
||||||
colorsPref.type = "dark";
|
|
||||||
}
|
|
||||||
getCurrentTheme()?.data &&
|
|
||||||
updateBunnyColor(getCurrentTheme()!.data!, {
|
|
||||||
update: true,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<TableSwitchRow
|
|
||||||
label="Dark"
|
|
||||||
icon={<TableRowIcon source={findAssetId("ThemeDarkIcon")} />}
|
|
||||||
value={colorsPref.type === "dark"}
|
|
||||||
onValueChange={(enabled: boolean) => {
|
|
||||||
colorsPref.type = enabled ? "dark" : undefined;
|
|
||||||
getCurrentTheme()?.data &&
|
|
||||||
updateBunnyColor(getCurrentTheme()!.data!, {
|
|
||||||
update: true,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<TableSwitchRow
|
|
||||||
label="Light"
|
|
||||||
icon={<TableRowIcon source={findAssetId("ThemeLightIcon")} />}
|
|
||||||
value={colorsPref.type === "light"}
|
|
||||||
onValueChange={(enabled: boolean) => {
|
|
||||||
colorsPref.type = enabled ? "light" : undefined;
|
|
||||||
getCurrentTheme()?.data &&
|
|
||||||
updateBunnyColor(getCurrentTheme()!.data!, {
|
|
||||||
update: true,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</TableRowGroup>
|
|
||||||
<TableRowGroup title="Chat Background">
|
|
||||||
<TableSwitchRow
|
|
||||||
label="Show Background"
|
|
||||||
icon={<TableRowIcon source={findAssetId("ImageIcon")} />}
|
|
||||||
value={!colorsPref.customBackground}
|
|
||||||
onValueChange={(enabled: boolean) => {
|
|
||||||
colorsPref.customBackground = enabled ? null : "hidden";
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<TableSwitchRow
|
|
||||||
label="Hide Background"
|
|
||||||
icon={<TableRowIcon source={findAssetId("DenyIcon")} />}
|
|
||||||
value={colorsPref.customBackground === "hidden"}
|
|
||||||
onValueChange={(enabled: boolean) => {
|
|
||||||
colorsPref.customBackground = enabled ? "hidden" : null;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</TableRowGroup>
|
|
||||||
</View>
|
|
||||||
</ActionSheet>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,206 @@
|
||||||
|
import { findAssetId } from "@lib/api/assets";
|
||||||
|
import { purgeStorage } from "@lib/api/storage";
|
||||||
|
import { hideSheet } from "@lib/ui/sheets";
|
||||||
|
import { ActionSheet, Card, IconButton, Text } from "@metro/common/components";
|
||||||
|
import { clipboard } from "@metro/common";
|
||||||
|
import React, { ComponentProps, useEffect, useState } from "react";
|
||||||
|
import { ScrollView, View, TouchableOpacity } from "react-native";
|
||||||
|
import { showToast } from "@ui/toasts";
|
||||||
|
import { showConfirmationAlert } from "@core/vendetta/alerts";
|
||||||
|
import {
|
||||||
|
fetchTheme,
|
||||||
|
removeTheme,
|
||||||
|
selectTheme,
|
||||||
|
VdThemeInfo,
|
||||||
|
} from "@lib/addons/themes";
|
||||||
|
import { formatString, Strings } from "@core/i18n";
|
||||||
|
|
||||||
|
interface ThemeInfoActionSheetProps {
|
||||||
|
theme: VdThemeInfo;
|
||||||
|
navigation: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThemeInfoIconButton(props: ComponentProps<typeof IconButton>) {
|
||||||
|
const { onPress } = props;
|
||||||
|
props.onPress &&= () => {
|
||||||
|
hideSheet("ThemeInfoActionSheet");
|
||||||
|
onPress?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
return <IconButton {...props} label={props.label} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Theme title component for header
|
||||||
|
function TitleComponent({ theme }: { theme: VdThemeInfo }) {
|
||||||
|
const { authors } = theme.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ gap: 4 }}>
|
||||||
|
<View>
|
||||||
|
<Text variant="heading-xl/semibold">{theme.data.name}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={{ flexDirection: "row", flexShrink: 1 }}>
|
||||||
|
{authors && authors.length > 0 && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 8,
|
||||||
|
alignItems: "center",
|
||||||
|
paddingVertical: 4,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
backgroundColor: "#00000016",
|
||||||
|
borderRadius: 32,
|
||||||
|
}}
|
||||||
|
disabled={!authors.some((a) => a.id)}
|
||||||
|
>
|
||||||
|
<Text variant="text-md/medium">
|
||||||
|
by {authors.map((a) => a.name).join(", ")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ThemeInfoActionSheet({
|
||||||
|
theme,
|
||||||
|
navigation,
|
||||||
|
}: ThemeInfoActionSheetProps) {
|
||||||
|
// Use component state to track theme data
|
||||||
|
const [themeState, setThemeState] = useState({ ...theme });
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Periodic refresh to detect external changes to theme state
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setThemeState({ ...theme });
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
const copyThemeUrl = () => {
|
||||||
|
// Copy theme ID as URL
|
||||||
|
clipboard.setString(themeState.id);
|
||||||
|
if (typeof showToast?.showCopyToClipboard === "function") {
|
||||||
|
showToast.showCopyToClipboard();
|
||||||
|
} else {
|
||||||
|
showToast("Copied to clipboard");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refetchTheme = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await fetchTheme(themeState.id, themeState.selected);
|
||||||
|
showToast("Theme refreshed successfully");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to refresh theme:", e);
|
||||||
|
showToast("Failed to refresh theme");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeThemeHandler = () => {
|
||||||
|
showConfirmationAlert({
|
||||||
|
title: Strings.HOLD_UP,
|
||||||
|
content: formatString("ARE_YOU_SURE_TO_DELETE_THEME", {
|
||||||
|
name: themeState.data.name,
|
||||||
|
}),
|
||||||
|
confirmText: Strings.DELETE,
|
||||||
|
cancelText: Strings.CANCEL,
|
||||||
|
confirmColor: "red",
|
||||||
|
onConfirm: async () => {
|
||||||
|
hideSheet("ThemeInfoActionSheet");
|
||||||
|
try {
|
||||||
|
const wasSelected = await removeTheme(themeState.id);
|
||||||
|
if (wasSelected) selectTheme(null);
|
||||||
|
showToast("Theme removed successfully");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to remove theme:", e);
|
||||||
|
showToast("Failed to remove theme");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyTheme = () => {
|
||||||
|
try {
|
||||||
|
selectTheme(themeState);
|
||||||
|
hideSheet("ThemeInfoActionSheet");
|
||||||
|
showToast(`Applied theme: ${themeState.data.name}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to apply theme:", e);
|
||||||
|
showToast("Failed to apply theme");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionSheet>
|
||||||
|
<ScrollView contentContainerStyle={{ gap: 12, marginBottom: 12 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
paddingVertical: 24,
|
||||||
|
justifyContent: "space-between",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TitleComponent theme={themeState} />
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 16,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ThemeInfoIconButton
|
||||||
|
label="Apply"
|
||||||
|
variant="secondary"
|
||||||
|
icon={findAssetId("CheckmarkLargeIcon")}
|
||||||
|
onPress={applyTheme}
|
||||||
|
/>
|
||||||
|
<ThemeInfoIconButton
|
||||||
|
label="Refetch"
|
||||||
|
variant="secondary"
|
||||||
|
icon={findAssetId("RetryIcon")}
|
||||||
|
onPress={refetchTheme}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<ThemeInfoIconButton
|
||||||
|
label="Copy URL"
|
||||||
|
variant="secondary"
|
||||||
|
icon={findAssetId("LinkIcon")}
|
||||||
|
onPress={copyThemeUrl}
|
||||||
|
/>
|
||||||
|
<ThemeInfoIconButton
|
||||||
|
label="Uninstall"
|
||||||
|
variant="secondary"
|
||||||
|
icon={findAssetId("TrashIcon")}
|
||||||
|
onPress={removeThemeHandler}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Card>
|
||||||
|
<Text
|
||||||
|
variant="text-md/semibold"
|
||||||
|
color="text-primary"
|
||||||
|
style={{ marginBottom: 4 }}
|
||||||
|
>
|
||||||
|
Description
|
||||||
|
</Text>
|
||||||
|
<Text variant="text-md/medium">
|
||||||
|
{themeState.data.description || "No description provided."}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
</ScrollView>
|
||||||
|
</ActionSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,10 @@ import * as themes from "@lib/addons/themes";
|
||||||
import * as assets from "@lib/api/assets";
|
import * as assets from "@lib/api/assets";
|
||||||
import * as commands from "@lib/api/commands";
|
import * as commands from "@lib/api/commands";
|
||||||
import * as debug from "@lib/api/debug";
|
import * as debug from "@lib/api/debug";
|
||||||
import { getVendettaLoaderIdentity, isPyonLoader } from "@lib/api/native/loader";
|
import {
|
||||||
|
getVendettaLoaderIdentity,
|
||||||
|
isPyonLoader,
|
||||||
|
} from "@lib/api/native/loader";
|
||||||
import patcher from "@lib/api/patcher";
|
import patcher from "@lib/api/patcher";
|
||||||
import { loaderConfig, settings } from "@lib/api/settings";
|
import { loaderConfig, settings } from "@lib/api/settings";
|
||||||
import * as utils from "@lib/utils";
|
import * as utils from "@lib/utils";
|
||||||
|
|
@ -26,232 +29,272 @@ import { View } from "react-native";
|
||||||
import { VdPluginManager, VendettaPlugin } from "./plugins";
|
import { VdPluginManager, VendettaPlugin } from "./plugins";
|
||||||
|
|
||||||
export async function createVdPluginObject(plugin: VendettaPlugin) {
|
export async function createVdPluginObject(plugin: VendettaPlugin) {
|
||||||
return {
|
return {
|
||||||
...window.vendetta,
|
...window.vendetta,
|
||||||
plugin: {
|
plugin: {
|
||||||
id: plugin.id,
|
id: plugin.id,
|
||||||
manifest: plugin.manifest,
|
manifest: plugin.manifest,
|
||||||
// Wrapping this with wrapSync is NOT an option.
|
// Wrapping this with wrapSync is NOT an option.
|
||||||
storage: await createStorage<Record<string, any>>(storage.createMMKVBackend(plugin.id)),
|
storage: await createStorage<Record<string, any>>(
|
||||||
},
|
storage.createMMKVBackend(plugin.id),
|
||||||
logger: new LoggerClass(`Kettu » ${plugin.manifest.name}`),
|
),
|
||||||
};
|
},
|
||||||
|
logger: new LoggerClass(`ShiggyCord » ${plugin.manifest.name}`),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const initVendettaObject = (): any => {
|
export const initVendettaObject = (): any => {
|
||||||
// pitfall: this assumes the returning module(s) are the same within the same location
|
// pitfall: this assumes the returning module(s) are the same within the same location
|
||||||
// find(m => m.render?.name === "ActionSheet") - would work fine
|
// find(m => m.render?.name === "ActionSheet") - would work fine
|
||||||
// ["trackThis", "trackThat"].forEach(p => find(m => m[p])) - would not
|
// ["trackThis", "trackThat"].forEach(p => find(m => m[p])) - would not
|
||||||
const createStackBasedFilter = (fn: any) => {
|
const createStackBasedFilter = (fn: any) => {
|
||||||
return (filter: (m: any) => boolean) => {
|
return (filter: (m: any) => boolean) => {
|
||||||
return fn(metro.factories.createSimpleFilter(filter, cyrb64Hash(new Error().stack!)));
|
return fn(
|
||||||
};
|
metro.factories.createSimpleFilter(
|
||||||
|
filter,
|
||||||
|
cyrb64Hash(new Error().stack!),
|
||||||
|
),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const api = window.vendetta = {
|
const api = (window.vendetta = {
|
||||||
patcher: {
|
patcher: {
|
||||||
before: patcher.before,
|
before: patcher.before,
|
||||||
after: patcher.after,
|
after: patcher.after,
|
||||||
instead: patcher.instead
|
instead: patcher.instead,
|
||||||
|
},
|
||||||
|
metro: {
|
||||||
|
modules: window.modules,
|
||||||
|
find: createStackBasedFilter(metro.findExports),
|
||||||
|
findAll: createStackBasedFilter(metro.findAllExports),
|
||||||
|
findByProps: (...props: any[]) => {
|
||||||
|
// TODO: remove this hack to fix Decor
|
||||||
|
if (props.length === 1 && props[0] === "KeyboardAwareScrollView") {
|
||||||
|
props.push("listenToKeyboardEvents");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ret = metro.findByProps(...props);
|
||||||
|
if (ret == null) {
|
||||||
|
if (props.includes("ActionSheetTitleHeader")) {
|
||||||
|
const module = metro.findByProps("ActionSheetRow");
|
||||||
|
|
||||||
|
// returning a fake object probably wouldn't cause an issue,
|
||||||
|
// since the original object are full of getters anyway
|
||||||
|
return {
|
||||||
|
...module,
|
||||||
|
ActionSheetTitleHeader: module.BottomSheetTitleHeader,
|
||||||
|
ActionSheetContentContainer: ({ children }: any) => {
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
console.warn(
|
||||||
|
"Discord has removed 'ActionSheetContentContainer', please move into something else. This has been temporarily replaced with View",
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
return createElement(View, null, children);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
findByPropsAll: (...props: any) => metro.findByPropsAll(...props),
|
||||||
|
findByName: (name: string, defaultExp?: boolean | undefined) => {
|
||||||
|
// TODO: remove this hack to fix Decor
|
||||||
|
if (name === "create" && typeof defaultExp === "undefined") {
|
||||||
|
return metro.findByName("create", false).default;
|
||||||
|
}
|
||||||
|
|
||||||
|
return metro.findByName(name, defaultExp ?? true);
|
||||||
|
},
|
||||||
|
findByNameAll: (name: string, defaultExp: boolean = true) =>
|
||||||
|
metro.findByNameAll(name, defaultExp),
|
||||||
|
findByDisplayName: (displayName: string, defaultExp: boolean = true) =>
|
||||||
|
metro.findByDisplayName(displayName, defaultExp),
|
||||||
|
findByDisplayNameAll: (displayName: string, defaultExp: boolean = true) =>
|
||||||
|
metro.findByDisplayNameAll(displayName, defaultExp),
|
||||||
|
findByTypeName: (typeName: string, defaultExp: boolean = true) =>
|
||||||
|
metro.findByTypeName(typeName, defaultExp),
|
||||||
|
findByTypeNameAll: (typeName: string, defaultExp: boolean = true) =>
|
||||||
|
metro.findByTypeNameAll(typeName, defaultExp),
|
||||||
|
findByStoreName: (name: string) => metro.findByStoreName(name),
|
||||||
|
common: {
|
||||||
|
constants: common.constants,
|
||||||
|
channels: common.channels,
|
||||||
|
i18n: common.i18n,
|
||||||
|
url: common.url,
|
||||||
|
toasts: common.toasts,
|
||||||
|
stylesheet: {
|
||||||
|
createThemedStyleSheet,
|
||||||
},
|
},
|
||||||
metro: {
|
clipboard: common.clipboard,
|
||||||
modules: window.modules,
|
assets: common.assets,
|
||||||
find: createStackBasedFilter(metro.findExports),
|
invites: common.invites,
|
||||||
findAll: createStackBasedFilter(metro.findAllExports),
|
commands: common.commands,
|
||||||
findByProps: (...props: any[]) => {
|
navigation: common.navigation,
|
||||||
// TODO: remove this hack to fix Decor
|
navigationStack: common.navigationStack,
|
||||||
if (props.length === 1 && props[0] === "KeyboardAwareScrollView") {
|
NavigationNative: common.NavigationNative,
|
||||||
props.push("listenToKeyboardEvents");
|
Flux: common.Flux,
|
||||||
}
|
FluxDispatcher: common.FluxDispatcher,
|
||||||
|
React: common.React,
|
||||||
|
ReactNative: common.ReactNative,
|
||||||
|
moment: require("moment"),
|
||||||
|
chroma: require("chroma-js"),
|
||||||
|
lodash: require("lodash"),
|
||||||
|
util: require("util"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
constants: {
|
||||||
|
DISCORD_SERVER: "https://discord.gg/n9QQ4XhhJP",
|
||||||
|
GITHUB: "https://github.com/vendetta-mod",
|
||||||
|
PROXY_PREFIX: "https://vd-plugins.github.io/proxy",
|
||||||
|
HTTP_REGEX:
|
||||||
|
/^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_+.~#?&/=]*)$/,
|
||||||
|
HTTP_REGEX_MULTI:
|
||||||
|
/https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_+.~#?&//=]*)/g,
|
||||||
|
DISCORD_SERVER_ID: "1015931589865246730",
|
||||||
|
PLUGINS_CHANNEL_ID: "1091880384561684561",
|
||||||
|
THEMES_CHANNEL_ID: "1091880434939482202",
|
||||||
|
},
|
||||||
|
utils: {
|
||||||
|
findInReactTree: (tree: { [key: string]: any }, filter: any) =>
|
||||||
|
utils.findInReactTree(tree, filter),
|
||||||
|
findInTree: (tree: any, filter: any, options: any) =>
|
||||||
|
utils.findInTree(tree, filter, options),
|
||||||
|
safeFetch: (
|
||||||
|
input: RequestInfo | URL,
|
||||||
|
options?: RequestInit | undefined,
|
||||||
|
timeout?: number | undefined,
|
||||||
|
) => utils.safeFetch(input, options, timeout),
|
||||||
|
unfreeze: (obj: object) => (Object.isFrozen(obj) ? { ...obj } : obj),
|
||||||
|
without: (object: any, ...keys: any) => omit(object, keys),
|
||||||
|
},
|
||||||
|
debug: {
|
||||||
|
connectToDebugger: (url: string) => debug.connectToDebugger(url),
|
||||||
|
getDebugInfo: () => debug.getDebugInfo(),
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
components: {
|
||||||
|
Forms,
|
||||||
|
General: common.ReactNative,
|
||||||
|
Alert: commonComponents.LegacyAlert,
|
||||||
|
Button: commonComponents.CompatButton,
|
||||||
|
HelpMessage: (...props: any[]) => (
|
||||||
|
<commonComponents.HelpMessage {...props} />
|
||||||
|
),
|
||||||
|
SafeAreaView: (...props: any[]) => (
|
||||||
|
<commonComponents.SafeAreaView {...props} />
|
||||||
|
),
|
||||||
|
Summary: components.Summary,
|
||||||
|
ErrorBoundary: components.ErrorBoundary,
|
||||||
|
Codeblock: components.Codeblock,
|
||||||
|
Search: components.Search,
|
||||||
|
},
|
||||||
|
toasts: {
|
||||||
|
showToast: (content: string, asset?: number) =>
|
||||||
|
toasts.showToast(content, asset),
|
||||||
|
},
|
||||||
|
alerts: {
|
||||||
|
showConfirmationAlert: (options: any) =>
|
||||||
|
alerts.showConfirmationAlert(options),
|
||||||
|
showCustomAlert: (component: React.ComponentType<any>, props: any) =>
|
||||||
|
alerts.showCustomAlert(component, props),
|
||||||
|
showInputAlert: (options: any) => alerts.showInputAlert(options),
|
||||||
|
},
|
||||||
|
assets: {
|
||||||
|
all: new Proxy<any>(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
get(cache, p) {
|
||||||
|
if (typeof p !== "string") return undefined;
|
||||||
|
if (cache[p]) return cache[p];
|
||||||
|
|
||||||
const ret = metro.findByProps(...props);
|
for (const asset of assets.iterateAssets()) {
|
||||||
if (ret == null) {
|
if (asset.name) return (cache[p] = asset);
|
||||||
if (props.includes("ActionSheetTitleHeader")) {
|
}
|
||||||
const module = metro.findByProps("ActionSheetRow");
|
|
||||||
|
|
||||||
// returning a fake object probably wouldn't cause an issue,
|
|
||||||
// since the original object are full of getters anyway
|
|
||||||
return {
|
|
||||||
...module,
|
|
||||||
ActionSheetTitleHeader: module.BottomSheetTitleHeader,
|
|
||||||
ActionSheetContentContainer: ({ children }: any) => {
|
|
||||||
useEffect(() => console.warn("Discord has removed 'ActionSheetContentContainer', please move into something else. This has been temporarily replaced with View"), []);
|
|
||||||
return createElement(View, null, children);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ret;
|
|
||||||
},
|
},
|
||||||
findByPropsAll: (...props: any) => metro.findByPropsAll(...props),
|
ownKeys(cache) {
|
||||||
findByName: (name: string, defaultExp?: boolean | undefined) => {
|
const keys = new Set<string>();
|
||||||
// TODO: remove this hack to fix Decor
|
|
||||||
if (name === "create" && typeof defaultExp === "undefined") {
|
|
||||||
return metro.findByName("create", false).default;
|
|
||||||
}
|
|
||||||
|
|
||||||
return metro.findByName(name, defaultExp ?? true);
|
for (const asset of assets.iterateAssets()) {
|
||||||
|
cache[asset.name] = asset;
|
||||||
|
keys.add(asset.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...keys];
|
||||||
},
|
},
|
||||||
findByNameAll: (name: string, defaultExp: boolean = true) => metro.findByNameAll(name, defaultExp),
|
},
|
||||||
findByDisplayName: (displayName: string, defaultExp: boolean = true) => metro.findByDisplayName(displayName, defaultExp),
|
),
|
||||||
findByDisplayNameAll: (displayName: string, defaultExp: boolean = true) => metro.findByDisplayNameAll(displayName, defaultExp),
|
find: (filter: (a: any) => boolean) => assets.findAsset(filter),
|
||||||
findByTypeName: (typeName: string, defaultExp: boolean = true) => metro.findByTypeName(typeName, defaultExp),
|
getAssetByName: (name: string) => assets.findAsset(name),
|
||||||
findByTypeNameAll: (typeName: string, defaultExp: boolean = true) => metro.findByTypeNameAll(typeName, defaultExp),
|
getAssetByID: (id: number) => assets.findAsset(id),
|
||||||
findByStoreName: (name: string) => metro.findByStoreName(name),
|
getAssetIDByName: (name: string) => assets.findAssetId(name),
|
||||||
common: {
|
},
|
||||||
constants: common.constants,
|
semanticColors: color.semanticColors,
|
||||||
channels: common.channels,
|
rawColors: color.rawColors,
|
||||||
i18n: common.i18n,
|
},
|
||||||
url: common.url,
|
plugins: {
|
||||||
toasts: common.toasts,
|
plugins: VdPluginManager.plugins,
|
||||||
stylesheet: {
|
fetchPlugin: (source: string) => VdPluginManager.fetchPlugin(source),
|
||||||
createThemedStyleSheet
|
installPlugin: (source: string, enabled = true) =>
|
||||||
},
|
VdPluginManager.installPlugin(source, enabled),
|
||||||
clipboard: common.clipboard,
|
startPlugin: (id: string) => VdPluginManager.startPlugin(id),
|
||||||
assets: common.assets,
|
stopPlugin: (id: string, disable = true) =>
|
||||||
invites: common.invites,
|
VdPluginManager.stopPlugin(id, disable),
|
||||||
commands: common.commands,
|
removePlugin: (id: string) => VdPluginManager.removePlugin(id),
|
||||||
navigation: common.navigation,
|
getSettings: (id: string) => VdPluginManager.getSettings(id),
|
||||||
navigationStack: common.navigationStack,
|
},
|
||||||
NavigationNative: common.NavigationNative,
|
themes: {
|
||||||
Flux: common.Flux,
|
themes: themes.themes,
|
||||||
FluxDispatcher: common.FluxDispatcher,
|
fetchTheme: (id: string, selected?: boolean) =>
|
||||||
React: common.React,
|
themes.fetchTheme(id, selected),
|
||||||
ReactNative: common.ReactNative,
|
installTheme: (id: string) => themes.installTheme(id),
|
||||||
moment: require("moment"),
|
selectTheme: (id: string) =>
|
||||||
chroma: require("chroma-js"),
|
themes.selectTheme(id === "default" ? null : themes.themes[id]),
|
||||||
lodash: require("lodash"),
|
removeTheme: (id: string) => themes.removeTheme(id),
|
||||||
util: require("util")
|
getCurrentTheme: () => themes.getThemeFromLoader(),
|
||||||
}
|
updateThemes: () => themes.updateThemes(),
|
||||||
},
|
},
|
||||||
constants: {
|
commands: {
|
||||||
DISCORD_SERVER: "https://discord.gg/n9QQ4XhhJP",
|
registerCommand: commands.registerCommand,
|
||||||
GITHUB: "https://github.com/vendetta-mod",
|
},
|
||||||
PROXY_PREFIX: "https://vd-plugins.github.io/proxy",
|
storage: {
|
||||||
HTTP_REGEX: /^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_+.~#?&/=]*)$/,
|
createProxy: (target: any) => storage.createProxy(target),
|
||||||
HTTP_REGEX_MULTI: /https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_+.~#?&//=]*)/g,
|
useProxy: (_storage: any) => storage.useProxy(_storage),
|
||||||
DISCORD_SERVER_ID: "1015931589865246730",
|
createStorage: (backend: any) => storage.createStorage(backend),
|
||||||
PLUGINS_CHANNEL_ID: "1091880384561684561",
|
wrapSync: (store: any) => storage.wrapSync(store),
|
||||||
THEMES_CHANNEL_ID: "1091880434939482202",
|
awaitSyncWrapper: (store: any) => storage.awaitStorage(store),
|
||||||
},
|
createMMKVBackend: (store: string) => storage.createMMKVBackend(store),
|
||||||
utils: {
|
createFileBackend: (file: string) => {
|
||||||
findInReactTree: (tree: { [key: string]: any; }, filter: any) => utils.findInReactTree(tree, filter),
|
// Redirect path to vendetta_theme.json
|
||||||
findInTree: (tree: any, filter: any, options: any) => utils.findInTree(tree, filter, options),
|
if (isPyonLoader() && file === "vendetta_theme.json") {
|
||||||
safeFetch: (input: RequestInfo | URL, options?: RequestInit | undefined, timeout?: number | undefined) => utils.safeFetch(input, options, timeout),
|
file = "pyon/current-theme.json";
|
||||||
unfreeze: (obj: object) => Object.isFrozen(obj) ? ({ ...obj }) : obj,
|
}
|
||||||
without: (object: any, ...keys: any) => omit(object, keys)
|
|
||||||
},
|
|
||||||
debug: {
|
|
||||||
connectToDebugger: (url: string) => debug.connectToDebugger(url),
|
|
||||||
getDebugInfo: () => debug.getDebugInfo()
|
|
||||||
},
|
|
||||||
ui: {
|
|
||||||
components: {
|
|
||||||
Forms,
|
|
||||||
General: common.ReactNative,
|
|
||||||
Alert: commonComponents.LegacyAlert,
|
|
||||||
Button: commonComponents.CompatButton,
|
|
||||||
HelpMessage: (...props: any[]) => <commonComponents.HelpMessage {...props} />,
|
|
||||||
SafeAreaView: (...props: any[]) => <commonComponents.SafeAreaView {...props} />,
|
|
||||||
Summary: components.Summary,
|
|
||||||
ErrorBoundary: components.ErrorBoundary,
|
|
||||||
Codeblock: components.Codeblock,
|
|
||||||
Search: components.Search
|
|
||||||
},
|
|
||||||
toasts: {
|
|
||||||
showToast: (content: string, asset?: number) => toasts.showToast(content, asset)
|
|
||||||
},
|
|
||||||
alerts: {
|
|
||||||
showConfirmationAlert: (options: any) => alerts.showConfirmationAlert(options),
|
|
||||||
showCustomAlert: (component: React.ComponentType<any>, props: any) => alerts.showCustomAlert(component, props),
|
|
||||||
showInputAlert: (options: any) => alerts.showInputAlert(options)
|
|
||||||
},
|
|
||||||
assets: {
|
|
||||||
all: new Proxy<any>({}, {
|
|
||||||
get(cache, p) {
|
|
||||||
if (typeof p !== "string") return undefined;
|
|
||||||
if (cache[p]) return cache[p];
|
|
||||||
|
|
||||||
for (const asset of assets.iterateAssets()) {
|
return storage.createFileBackend(file);
|
||||||
if (asset.name) return cache[p] = asset;
|
},
|
||||||
}
|
},
|
||||||
},
|
settings,
|
||||||
ownKeys(cache) {
|
loader: {
|
||||||
const keys = new Set<string>();
|
identity: getVendettaLoaderIdentity() ?? void 0,
|
||||||
|
config: loaderConfig,
|
||||||
|
},
|
||||||
|
logger: {
|
||||||
|
log: (...message: any) => console.log(...message),
|
||||||
|
info: (...message: any) => console.info(...message),
|
||||||
|
warn: (...message: any) => console.warn(...message),
|
||||||
|
error: (...message: any) => console.error(...message),
|
||||||
|
time: (...message: any) => console.time(...message),
|
||||||
|
trace: (...message: any) => console.trace(...message),
|
||||||
|
verbose: (...message: any) => console.log(...message),
|
||||||
|
},
|
||||||
|
version: debug.versionHash,
|
||||||
|
unload: () => {
|
||||||
|
delete window.vendetta;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
for (const asset of assets.iterateAssets()) {
|
return () => api.unload();
|
||||||
cache[asset.name] = asset;
|
|
||||||
keys.add(asset.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...keys];
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
find: (filter: (a: any) => boolean) => assets.findAsset(filter),
|
|
||||||
getAssetByName: (name: string) => assets.findAsset(name),
|
|
||||||
getAssetByID: (id: number) => assets.findAsset(id),
|
|
||||||
getAssetIDByName: (name: string) => assets.findAssetId(name)
|
|
||||||
},
|
|
||||||
semanticColors: color.semanticColors,
|
|
||||||
rawColors: color.rawColors
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
plugins: VdPluginManager.plugins,
|
|
||||||
fetchPlugin: (source: string) => VdPluginManager.fetchPlugin(source),
|
|
||||||
installPlugin: (source: string, enabled = true) => VdPluginManager.installPlugin(source, enabled),
|
|
||||||
startPlugin: (id: string) => VdPluginManager.startPlugin(id),
|
|
||||||
stopPlugin: (id: string, disable = true) => VdPluginManager.stopPlugin(id, disable),
|
|
||||||
removePlugin: (id: string) => VdPluginManager.removePlugin(id),
|
|
||||||
getSettings: (id: string) => VdPluginManager.getSettings(id)
|
|
||||||
},
|
|
||||||
themes: {
|
|
||||||
themes: themes.themes,
|
|
||||||
fetchTheme: (id: string, selected?: boolean) => themes.fetchTheme(id, selected),
|
|
||||||
installTheme: (id: string) => themes.installTheme(id),
|
|
||||||
selectTheme: (id: string) => themes.selectTheme(id === "default" ? null : themes.themes[id]),
|
|
||||||
removeTheme: (id: string) => themes.removeTheme(id),
|
|
||||||
getCurrentTheme: () => themes.getThemeFromLoader(),
|
|
||||||
updateThemes: () => themes.updateThemes()
|
|
||||||
},
|
|
||||||
commands: {
|
|
||||||
registerCommand: commands.registerCommand
|
|
||||||
},
|
|
||||||
storage: {
|
|
||||||
createProxy: (target: any) => storage.createProxy(target),
|
|
||||||
useProxy: (_storage: any) => storage.useProxy(_storage),
|
|
||||||
createStorage: (backend: any) => storage.createStorage(backend),
|
|
||||||
wrapSync: (store: any) => storage.wrapSync(store),
|
|
||||||
awaitSyncWrapper: (store: any) => storage.awaitStorage(store),
|
|
||||||
createMMKVBackend: (store: string) => storage.createMMKVBackend(store),
|
|
||||||
createFileBackend: (file: string) => {
|
|
||||||
// Redirect path to vendetta_theme.json
|
|
||||||
if (isPyonLoader() && file === "vendetta_theme.json") {
|
|
||||||
file = "pyon/current-theme.json";
|
|
||||||
}
|
|
||||||
|
|
||||||
return storage.createFileBackend(file);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
settings,
|
|
||||||
loader: {
|
|
||||||
identity: getVendettaLoaderIdentity() ?? void 0,
|
|
||||||
config: loaderConfig,
|
|
||||||
},
|
|
||||||
logger: {
|
|
||||||
log: (...message: any) => console.log(...message),
|
|
||||||
info: (...message: any) => console.info(...message),
|
|
||||||
warn: (...message: any) => console.warn(...message),
|
|
||||||
error: (...message: any) => console.error(...message),
|
|
||||||
time: (...message: any) => console.time(...message),
|
|
||||||
trace: (...message: any) => console.trace(...message),
|
|
||||||
verbose: (...message: any) => console.log(...message)
|
|
||||||
},
|
|
||||||
version: debug.versionHash,
|
|
||||||
unload: () => {
|
|
||||||
delete window.vendetta;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return () => api.unload();
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,185 +1,225 @@
|
||||||
import { awaitStorage, createMMKVBackend, createStorage, purgeStorage, wrapSync } from "@core/vendetta/storage";
|
import {
|
||||||
|
awaitStorage,
|
||||||
|
createMMKVBackend,
|
||||||
|
createStorage,
|
||||||
|
purgeStorage,
|
||||||
|
wrapSync,
|
||||||
|
} from "@core/vendetta/storage";
|
||||||
import { Author } from "@lib/addons/types";
|
import { Author } from "@lib/addons/types";
|
||||||
import { settings } from "@lib/api/settings";
|
import { settings } from "@lib/api/settings";
|
||||||
import { safeFetch } from "@lib/utils";
|
import { safeFetch } from "@lib/utils";
|
||||||
import { BUNNY_PROXY_PREFIX, VD_PROXY_PREFIX } from "@lib/utils/constants";
|
import { BUNNY_PROXY_PREFIX, VD_PROXY_PREFIX } from "@lib/utils/constants";
|
||||||
import { logger,LoggerClass } from "@lib/utils/logger";
|
import { logger, LoggerClass } from "@lib/utils/logger";
|
||||||
|
|
||||||
type EvaledPlugin = {
|
type EvaledPlugin = {
|
||||||
onLoad?(): void;
|
onLoad?(): void;
|
||||||
onUnload(): void;
|
onUnload(): void;
|
||||||
settings: React.ComponentType<unknown>;
|
settings: React.ComponentType<unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
// See https://github.com/vendetta-mod/polymanifest
|
// See https://github.com/vendetta-mod/polymanifest
|
||||||
interface PluginManifest {
|
interface PluginManifest {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
authors: Author[];
|
authors: Author[];
|
||||||
main: string;
|
main: string;
|
||||||
hash: string;
|
hash: string;
|
||||||
// Vendor-specific field, contains our own data
|
// Vendor-specific field, contains our own data
|
||||||
vendetta?: {
|
vendetta?: {
|
||||||
icon?: string;
|
icon?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VendettaPlugin {
|
export interface VendettaPlugin {
|
||||||
id: string;
|
id: string;
|
||||||
manifest: PluginManifest;
|
manifest: PluginManifest;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
update: boolean;
|
update: boolean;
|
||||||
js: string;
|
js: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const plugins = wrapSync(createStorage<Record<string, VendettaPlugin>>(createMMKVBackend("VENDETTA_PLUGINS")));
|
const plugins = wrapSync(
|
||||||
|
createStorage<Record<string, VendettaPlugin>>(
|
||||||
|
createMMKVBackend("VENDETTA_PLUGINS"),
|
||||||
|
),
|
||||||
|
);
|
||||||
const pluginInstance: Record<string, EvaledPlugin> = {};
|
const pluginInstance: Record<string, EvaledPlugin> = {};
|
||||||
|
|
||||||
export const VdPluginManager = {
|
export const VdPluginManager = {
|
||||||
plugins,
|
plugins,
|
||||||
async pluginFetch(url: string) {
|
async pluginFetch(url: string) {
|
||||||
if (url.startsWith(VD_PROXY_PREFIX)) {
|
if (url.startsWith(VD_PROXY_PREFIX)) {
|
||||||
url = url
|
url = url
|
||||||
.replace("https://bunny-mod.github.io/plugins-proxy", BUNNY_PROXY_PREFIX)
|
.replace(
|
||||||
.replace(VD_PROXY_PREFIX, BUNNY_PROXY_PREFIX);
|
"https://bunny-mod.github.io/plugins-proxy",
|
||||||
}
|
BUNNY_PROXY_PREFIX,
|
||||||
|
)
|
||||||
|
.replace(VD_PROXY_PREFIX, BUNNY_PROXY_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
return await safeFetch(url, { cache: "no-store" });
|
return await safeFetch(url, { cache: "no-store" });
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetchPlugin(id: string) {
|
async fetchPlugin(id: string) {
|
||||||
if (!id.endsWith("/")) id += "/";
|
if (!id.endsWith("/")) id += "/";
|
||||||
const existingPlugin = plugins[id];
|
const existingPlugin = plugins[id];
|
||||||
|
|
||||||
let pluginManifest: PluginManifest;
|
let pluginManifest: PluginManifest;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
pluginManifest = await (await this.pluginFetch(id + "manifest.json")).json();
|
pluginManifest = await (
|
||||||
} catch {
|
await this.pluginFetch(id + "manifest.json")
|
||||||
throw new Error(`Failed to fetch manifest for ${id}`);
|
).json();
|
||||||
}
|
} catch {
|
||||||
|
throw new Error(`Failed to fetch manifest for ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
let pluginJs: string | undefined;
|
let pluginJs: string | undefined;
|
||||||
|
|
||||||
if (existingPlugin?.manifest.hash !== pluginManifest.hash) {
|
if (existingPlugin?.manifest.hash !== pluginManifest.hash) {
|
||||||
try {
|
try {
|
||||||
// by polymanifest spec, plugins should always specify their main file, but just in case
|
// by polymanifest spec, plugins should always specify their main file, but just in case
|
||||||
pluginJs = await (await this.pluginFetch(id + (pluginManifest.main || "index.js"))).text();
|
pluginJs = await (
|
||||||
} catch { } // Empty catch, checked below
|
await this.pluginFetch(id + (pluginManifest.main || "index.js"))
|
||||||
}
|
).text();
|
||||||
|
} catch {} // Empty catch, checked below
|
||||||
|
}
|
||||||
|
|
||||||
if (!pluginJs && !existingPlugin) throw new Error(`Failed to fetch JS for ${id}`);
|
if (!pluginJs && !existingPlugin)
|
||||||
|
throw new Error(`Failed to fetch JS for ${id}`);
|
||||||
|
|
||||||
plugins[id] = {
|
plugins[id] = {
|
||||||
id: id,
|
id: id,
|
||||||
manifest: pluginManifest,
|
manifest: pluginManifest,
|
||||||
enabled: existingPlugin?.enabled ?? false,
|
enabled: existingPlugin?.enabled ?? false,
|
||||||
update: existingPlugin?.update ?? true,
|
update: existingPlugin?.update ?? true,
|
||||||
js: pluginJs ?? existingPlugin.js,
|
js: pluginJs ?? existingPlugin.js,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
async installPlugin(id: string, enabled = true) {
|
async installPlugin(id: string, enabled = true) {
|
||||||
if (!id.endsWith("/")) id += "/";
|
if (!id.endsWith("/")) id += "/";
|
||||||
if (typeof id !== "string" || id in plugins) throw new Error("Plugin already installed");
|
if (typeof id !== "string" || id in plugins)
|
||||||
await this.fetchPlugin(id);
|
throw new Error("Plugin already installed");
|
||||||
if (enabled) await this.startPlugin(id);
|
await this.fetchPlugin(id);
|
||||||
},
|
if (enabled) await this.startPlugin(id);
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
async evalPlugin(plugin: VendettaPlugin) {
|
async evalPlugin(plugin: VendettaPlugin) {
|
||||||
const vendettaForPlugins = {
|
const vendettaForPlugins = {
|
||||||
...window.vendetta,
|
...window.vendetta,
|
||||||
plugin: {
|
plugin: {
|
||||||
id: plugin.id,
|
id: plugin.id,
|
||||||
manifest: plugin.manifest,
|
manifest: plugin.manifest,
|
||||||
// Wrapping this with wrapSync is NOT an option.
|
// Wrapping this with wrapSync is NOT an option.
|
||||||
storage: await createStorage<Record<string, any>>(createMMKVBackend(plugin.id)),
|
storage: await createStorage<Record<string, any>>(
|
||||||
},
|
createMMKVBackend(plugin.id),
|
||||||
logger: new LoggerClass(`Kettu » ${plugin.manifest.name}`),
|
),
|
||||||
};
|
},
|
||||||
const pluginString = `vendetta=>{return ${plugin.js}}\n//# sourceURL=${plugin.id}`;
|
logger: new LoggerClass(`ShiggyCord » ${plugin.manifest.name}`),
|
||||||
|
};
|
||||||
|
const pluginString = `vendetta=>{return ${plugin.js}}\n//# sourceURL=${plugin.id}`;
|
||||||
|
|
||||||
const raw = (0, eval)(pluginString)(vendettaForPlugins);
|
const raw = (0, eval)(pluginString)(vendettaForPlugins);
|
||||||
const ret = typeof raw === "function" ? raw() : raw;
|
const ret = typeof raw === "function" ? raw() : raw;
|
||||||
return ret?.default ?? ret ?? {};
|
return ret?.default ?? ret ?? {};
|
||||||
},
|
},
|
||||||
|
|
||||||
async startPlugin(id: string) {
|
async startPlugin(id: string) {
|
||||||
if (!id.endsWith("/")) id += "/";
|
if (!id.endsWith("/")) id += "/";
|
||||||
const plugin = plugins[id];
|
const plugin = plugins[id];
|
||||||
if (!plugin) throw new Error("Attempted to start non-existent plugin");
|
if (!plugin) throw new Error("Attempted to start non-existent plugin");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!settings.safeMode?.enabled) {
|
if (!settings.safeMode?.enabled) {
|
||||||
const pluginRet: EvaledPlugin = await this.evalPlugin(plugin);
|
const pluginRet: EvaledPlugin = await this.evalPlugin(plugin);
|
||||||
pluginInstance[id] = pluginRet;
|
pluginInstance[id] = pluginRet;
|
||||||
pluginRet.onLoad?.();
|
pluginRet.onLoad?.();
|
||||||
}
|
}
|
||||||
plugin.enabled = true;
|
plugin.enabled = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(`Plugin ${plugin.id} errored whilst loading, and will be unloaded`, e);
|
logger.error(
|
||||||
|
`Plugin ${plugin.id} errored whilst loading, and will be unloaded`,
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
pluginInstance[plugin.id]?.onUnload?.();
|
pluginInstance[plugin.id]?.onUnload?.();
|
||||||
} catch (e2) {
|
} catch (e2) {
|
||||||
logger.error(`Plugin ${plugin.id} errored whilst unloading`, e2);
|
logger.error(`Plugin ${plugin.id} errored whilst unloading`, e2);
|
||||||
}
|
}
|
||||||
|
|
||||||
delete pluginInstance[id];
|
delete pluginInstance[id];
|
||||||
plugin.enabled = false;
|
plugin.enabled = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
stopPlugin(id: string, disable = true) {
|
stopPlugin(id: string, disable = true) {
|
||||||
if (!id.endsWith("/")) id += "/";
|
if (!id.endsWith("/")) id += "/";
|
||||||
const plugin = plugins[id];
|
const plugin = plugins[id];
|
||||||
const pluginRet = pluginInstance[id];
|
const pluginRet = pluginInstance[id];
|
||||||
if (!plugin) throw new Error("Attempted to stop non-existent plugin");
|
if (!plugin) throw new Error("Attempted to stop non-existent plugin");
|
||||||
|
|
||||||
if (!settings.safeMode?.enabled) {
|
if (!settings.safeMode?.enabled) {
|
||||||
try {
|
try {
|
||||||
pluginRet?.onUnload?.();
|
pluginRet?.onUnload?.();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(`Plugin ${plugin.id} errored whilst unloading`, e);
|
logger.error(`Plugin ${plugin.id} errored whilst unloading`, e);
|
||||||
}
|
}
|
||||||
|
|
||||||
delete pluginInstance[id];
|
delete pluginInstance[id];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (disable) plugin.enabled = false;
|
if (disable) plugin.enabled = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
async removePlugin(id: string) {
|
async removePlugin(id: string) {
|
||||||
if (!id.endsWith("/")) id += "/";
|
if (!id.endsWith("/")) id += "/";
|
||||||
const plugin = plugins[id];
|
const plugin = plugins[id];
|
||||||
if (plugin.enabled) this.stopPlugin(id);
|
if (plugin.enabled) this.stopPlugin(id);
|
||||||
delete plugins[id];
|
delete plugins[id];
|
||||||
await purgeStorage(id);
|
await purgeStorage(id);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
async initPlugins() {
|
async initPlugins() {
|
||||||
await awaitStorage(settings, plugins);
|
await awaitStorage(settings, plugins);
|
||||||
const allIds = Object.keys(plugins);
|
const allIds = Object.keys(plugins);
|
||||||
|
|
||||||
if (!settings.safeMode?.enabled) {
|
if (!settings.safeMode?.enabled) {
|
||||||
// Loop over any plugin that is enabled, update it if allowed, then start it.
|
// Loop over any plugin that is enabled, update it if allowed, then start it.
|
||||||
await Promise.allSettled(allIds.filter(pl => plugins[pl].enabled).map(async pl => (plugins[pl].update && await this.fetchPlugin(pl).catch((e: Error) => logger.error(e.message)), await this.startPlugin(pl))));
|
await Promise.allSettled(
|
||||||
// Wait for the above to finish, then update all disabled plugins that are allowed to.
|
allIds
|
||||||
allIds.filter(pl => !plugins[pl].enabled && plugins[pl].update).forEach(pl => this.fetchPlugin(pl));
|
.filter((pl) => plugins[pl].enabled)
|
||||||
}
|
.map(
|
||||||
|
async (pl) => (
|
||||||
|
plugins[pl].update &&
|
||||||
|
(await this.fetchPlugin(pl).catch((e: Error) =>
|
||||||
|
logger.error(e.message),
|
||||||
|
)),
|
||||||
|
await this.startPlugin(pl)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// Wait for the above to finish, then update all disabled plugins that are allowed to.
|
||||||
|
allIds
|
||||||
|
.filter((pl) => !plugins[pl].enabled && plugins[pl].update)
|
||||||
|
.forEach((pl) => this.fetchPlugin(pl));
|
||||||
|
}
|
||||||
|
|
||||||
return () => this.stopAllPlugins();
|
return () => this.stopAllPlugins();
|
||||||
},
|
},
|
||||||
|
|
||||||
stopAllPlugins() {
|
stopAllPlugins() {
|
||||||
return Object.keys(pluginInstance).forEach(p => this.stopPlugin(p, false));
|
return Object.keys(pluginInstance).forEach((p) =>
|
||||||
},
|
this.stopPlugin(p, false),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
getSettings: (id: string) => pluginInstance[id]?.settings
|
getSettings: (id: string) => pluginInstance[id]?.settings,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
257
src/entry.ts
257
src/entry.ts
|
|
@ -5,142 +5,153 @@ const { instead } = require("spitroast");
|
||||||
// @ts-ignore - window is defined later in the bundle, so we assign it early
|
// @ts-ignore - window is defined later in the bundle, so we assign it early
|
||||||
globalThis.window = globalThis;
|
globalThis.window = globalThis;
|
||||||
|
|
||||||
async function initializeKettu() {
|
async function initializeShiggyCord() {
|
||||||
try {
|
try {
|
||||||
// Make 'freeze' and 'seal' do nothing
|
// Make 'freeze' and 'seal' do nothing
|
||||||
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();
|
await 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;
|
||||||
|
|
||||||
console.log(stack ?? e?.toString?.() ?? e);
|
console.log(stack ?? e?.toString?.() ?? e);
|
||||||
alert([
|
alert(
|
||||||
"Failed to load Kettu!\n",
|
[
|
||||||
`Build Number: ${ClientInfoManager.getConstants().Build}`,
|
"Failed to load ShiggyCord!\n",
|
||||||
`Kettu: ${version}`,
|
`Build Number: ${ClientInfoManager.getConstants().Build}`,
|
||||||
stack || e?.toString?.(),
|
`ShiggyCord: ${version}`,
|
||||||
].join("\n"));
|
stack || e?.toString?.(),
|
||||||
}
|
].join("\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof window.__r === "undefined") {
|
if (typeof window.__r === "undefined") {
|
||||||
// Used for storing the current require function for the global.__r getter defined below
|
// Used for storing the current require function for the global.__r getter defined below
|
||||||
var _requireFunc: any;
|
var _requireFunc: any;
|
||||||
|
|
||||||
// Calls from the native side are deferred until the index.ts(x) is loaded
|
// Calls from the native side are deferred until the index.ts(x) is loaded
|
||||||
interface DeferredQueue {
|
interface DeferredQueue {
|
||||||
object: any;
|
object: any;
|
||||||
method: string;
|
method: string;
|
||||||
resume?: (queue: DeferredQueue) => void;
|
resume?: (queue: DeferredQueue) => void;
|
||||||
args: any[];
|
args: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const deferredCalls: Array<DeferredQueue> = [];
|
const deferredCalls: Array<DeferredQueue> = [];
|
||||||
const unpatches: Array<() => void> = [];
|
const unpatches: Array<() => void> = [];
|
||||||
|
|
||||||
const deferMethodExecution = (
|
const deferMethodExecution = (
|
||||||
object: any,
|
object: any,
|
||||||
method: string,
|
method: string,
|
||||||
condition?: (...args: any[]) => boolean,
|
condition?: (...args: any[]) => boolean,
|
||||||
resume?: (queue: DeferredQueue) => void,
|
resume?: (queue: DeferredQueue) => void,
|
||||||
returnWith?: (queue: DeferredQueue) => any
|
returnWith?: (queue: DeferredQueue) => any,
|
||||||
) => {
|
) => {
|
||||||
const restore = instead(method, object, function (this: any, args: any[], original: any) {
|
const restore = instead(
|
||||||
if (!condition || condition(...args)) {
|
method,
|
||||||
const queue: DeferredQueue = { object, method, args, resume };
|
object,
|
||||||
deferredCalls.push(queue);
|
function (this: any, args: any[], original: any) {
|
||||||
return returnWith ? returnWith(queue) : undefined;
|
if (!condition || condition(...args)) {
|
||||||
}
|
const queue: DeferredQueue = { object, method, args, resume };
|
||||||
|
deferredCalls.push(queue);
|
||||||
// If the condition is not met, we execute the original method immediately
|
return returnWith ? returnWith(queue) : undefined;
|
||||||
return original.apply(this, args);
|
|
||||||
});
|
|
||||||
|
|
||||||
unpatches.push(restore);
|
|
||||||
}
|
|
||||||
|
|
||||||
const resumeDeferred = () => {
|
|
||||||
for (const queue of deferredCalls) {
|
|
||||||
const { object, method, args, resume } = queue;
|
|
||||||
|
|
||||||
if (resume) {
|
|
||||||
resume(queue);
|
|
||||||
} else {
|
|
||||||
object[method](...args);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
deferredCalls.length = 0;
|
// If the condition is not met, we execute the original method immediately
|
||||||
|
return original.apply(this, args);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
unpatches.push(restore);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resumeDeferred = () => {
|
||||||
|
for (const queue of deferredCalls) {
|
||||||
|
const { object, method, args, resume } = queue;
|
||||||
|
|
||||||
|
if (resume) {
|
||||||
|
resume(queue);
|
||||||
|
} else {
|
||||||
|
object[method](...args);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onceIndexRequired = (originalRequire: Metro.RequireFn) => {
|
deferredCalls.length = 0;
|
||||||
// We hold calls from the native side
|
};
|
||||||
if (window.__fbBatchedBridge) {
|
|
||||||
const batchedBridge = window.__fbBatchedBridge;
|
|
||||||
deferMethodExecution(
|
|
||||||
batchedBridge,
|
|
||||||
"callFunctionReturnFlushedQueue",
|
|
||||||
// If the call is to AppRegistry, we want to defer it because it is not yet registered (Revenge delays it)
|
|
||||||
// Same goes to the non-callable modules, which are not registered yet, so we ensure that only registered ones can get through
|
|
||||||
(...args) => args[0] === "AppRegistry" || !batchedBridge.getCallableModule(args[0]),
|
|
||||||
({ args }) => {
|
|
||||||
if (batchedBridge.getCallableModule(args[0])) {
|
|
||||||
batchedBridge.__callFunction(...args);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
() => batchedBridge.flushedQueue()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Introduced since RN New Architecture
|
const onceIndexRequired = (originalRequire: Metro.RequireFn) => {
|
||||||
if (window.RN$AppRegistry) {
|
// We hold calls from the native side
|
||||||
deferMethodExecution(window.RN$AppRegistry, "runApplication");
|
if (window.__fbBatchedBridge) {
|
||||||
}
|
const batchedBridge = window.__fbBatchedBridge;
|
||||||
|
deferMethodExecution(
|
||||||
const startDiscord = async () => {
|
batchedBridge,
|
||||||
await initializeKettu();
|
"callFunctionReturnFlushedQueue",
|
||||||
|
// If the call is to AppRegistry, we want to defer it because it is not yet registered (Revenge delays it)
|
||||||
for (const unpatch of unpatches) unpatch();
|
// Same goes to the non-callable modules, which are not registered yet, so we ensure that only registered ones can get through
|
||||||
unpatches.length = 0;
|
(...args) =>
|
||||||
|
args[0] === "AppRegistry" ||
|
||||||
originalRequire(0);
|
!batchedBridge.getCallableModule(args[0]),
|
||||||
resumeDeferred();
|
({ args }) => {
|
||||||
};
|
if (batchedBridge.getCallableModule(args[0])) {
|
||||||
|
batchedBridge.__callFunction(...args);
|
||||||
startDiscord();
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Object.defineProperties(globalThis, {
|
|
||||||
__r: {
|
|
||||||
configurable: true,
|
|
||||||
get: () => _requireFunc,
|
|
||||||
set(v) {
|
|
||||||
// _requireFunc is required here, because using 'this' here errors for some unknown reason
|
|
||||||
_requireFunc = function patchedRequire(a: number) {
|
|
||||||
// Initializing index.ts(x)
|
|
||||||
if (a === 0) {
|
|
||||||
if (window.modules instanceof Map) window.modules = Object.fromEntries(window.modules);
|
|
||||||
onceIndexRequired(v);
|
|
||||||
_requireFunc = v;
|
|
||||||
} else return v(a);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
__d: {
|
() => batchedBridge.flushedQueue(),
|
||||||
configurable: true,
|
);
|
||||||
get() {
|
}
|
||||||
// @ts-ignore - I got an error where 'Object' is undefined *sometimes*, which is literally never supposed to happen
|
|
||||||
if (window.Object && !window.modules) {
|
// Introduced since RN New Architecture
|
||||||
window.modules = window.__c?.();
|
if (window.RN$AppRegistry) {
|
||||||
}
|
deferMethodExecution(window.RN$AppRegistry, "runApplication");
|
||||||
return this.value;
|
}
|
||||||
},
|
|
||||||
set(v) { this.value = v; }
|
const startDiscord = async () => {
|
||||||
|
await initializeShiggyCord();
|
||||||
|
|
||||||
|
for (const unpatch of unpatches) unpatch();
|
||||||
|
unpatches.length = 0;
|
||||||
|
|
||||||
|
originalRequire(0);
|
||||||
|
resumeDeferred();
|
||||||
|
};
|
||||||
|
|
||||||
|
startDiscord();
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperties(globalThis, {
|
||||||
|
__r: {
|
||||||
|
configurable: true,
|
||||||
|
get: () => _requireFunc,
|
||||||
|
set(v) {
|
||||||
|
// _requireFunc is required here, because using 'this' here errors for some unknown reason
|
||||||
|
_requireFunc = function patchedRequire(a: number) {
|
||||||
|
// Initializing index.ts(x)
|
||||||
|
if (a === 0) {
|
||||||
|
if (window.modules instanceof Map)
|
||||||
|
window.modules = Object.fromEntries(window.modules);
|
||||||
|
onceIndexRequired(v);
|
||||||
|
_requireFunc = v;
|
||||||
|
} else return v(a);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
__d: {
|
||||||
|
configurable: true,
|
||||||
|
get() {
|
||||||
|
// @ts-ignore - I got an error where 'Object' is undefined *sometimes*, which is literally never supposed to happen
|
||||||
|
if (window.Object && !window.modules) {
|
||||||
|
window.modules = window.__c?.();
|
||||||
}
|
}
|
||||||
});
|
return this.value;
|
||||||
|
},
|
||||||
|
set(v) {
|
||||||
|
this.value = v;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
initializeKettu();
|
initializeShiggyCord();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
62
src/index.ts
62
src/index.ts
|
|
@ -17,39 +17,39 @@ import { patchSettings } from "@ui/settings";
|
||||||
import * as lib from "./lib";
|
import * as lib from "./lib";
|
||||||
|
|
||||||
export default async () => {
|
export default async () => {
|
||||||
// Load everything in parallel
|
// Load everything in parallel
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
initThemes(),
|
initThemes(),
|
||||||
injectFluxInterceptor(),
|
injectFluxInterceptor(),
|
||||||
patchSettings(),
|
patchSettings(),
|
||||||
patchLogHook(),
|
patchLogHook(),
|
||||||
patchCommands(),
|
patchCommands(),
|
||||||
patchJsx(),
|
patchJsx(),
|
||||||
initVendettaObject(),
|
initVendettaObject(),
|
||||||
initFetchI18nStrings(),
|
initFetchI18nStrings(),
|
||||||
initSettings(),
|
initSettings(),
|
||||||
initFixes(),
|
initFixes(),
|
||||||
// patchErrorBoundary(),
|
// patchErrorBoundary(),
|
||||||
updatePlugins()
|
updatePlugins(),
|
||||||
]).then(
|
]).then(
|
||||||
// Push them all to unloader
|
// Push them all to unloader
|
||||||
u => u.forEach(f => f && lib.unload.push(f))
|
(u) => u.forEach((f) => f && lib.unload.push(f)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assign window object
|
// Assign window object
|
||||||
window.bunny = lib;
|
window.bunny = lib;
|
||||||
|
|
||||||
// Once done, load Vendetta plugins
|
// Once done, load Vendetta plugins
|
||||||
VdPluginManager.initPlugins()
|
VdPluginManager.initPlugins()
|
||||||
.then(u => lib.unload.push(u))
|
.then((u) => lib.unload.push(u))
|
||||||
.catch(() => alert("Failed to initialize Vendetta plugins"));
|
.catch(() => alert("Failed to initialize Vendetta plugins"));
|
||||||
|
|
||||||
// And then, load Kettu plugins
|
// And then, load ShiggyCord plugins
|
||||||
initPlugins();
|
initPlugins();
|
||||||
|
|
||||||
// Update the fonts
|
// Update the fonts
|
||||||
updateFonts();
|
updateFonts();
|
||||||
|
|
||||||
// We good :)
|
// We good :)
|
||||||
logger.log("Kettu is ready!");
|
logger.log("ShiggyCord is ready!");
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,41 @@
|
||||||
import { createFileBackend, createMMKVBackend, createStorage, wrapSync } from "@core/vendetta/storage";
|
import {
|
||||||
|
createFileBackend,
|
||||||
|
createMMKVBackend,
|
||||||
|
createStorage,
|
||||||
|
wrapSync,
|
||||||
|
} from "@core/vendetta/storage";
|
||||||
import { getLoaderConfigPath } from "@lib/api/native/loader";
|
import { getLoaderConfigPath } from "@lib/api/native/loader";
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
debuggerUrl: string;
|
debuggerUrl: string;
|
||||||
developerSettings: boolean;
|
developerSettings: boolean;
|
||||||
enableDiscordDeveloperSettings: boolean;
|
enableDiscordDeveloperSettings: boolean;
|
||||||
safeMode?: {
|
safeMode?: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
currentThemeId?: string;
|
currentThemeId?: string;
|
||||||
};
|
};
|
||||||
enableEvalCommand?: boolean;
|
enableEvalCommand?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoaderConfig {
|
export interface LoaderConfig {
|
||||||
customLoadUrl: {
|
customLoadUrl: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
url: string;
|
url: string;
|
||||||
};
|
};
|
||||||
loadReactDevTools: boolean;
|
loadReactDevTools: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const settings = wrapSync(createStorage<Settings>(createMMKVBackend("VENDETTA_SETTINGS")));
|
export const settings = wrapSync(
|
||||||
|
createStorage<Settings>(createMMKVBackend("VENDETTA_SETTINGS")),
|
||||||
|
);
|
||||||
|
|
||||||
export const loaderConfig = wrapSync(createStorage<LoaderConfig>(
|
export const loaderConfig = wrapSync(
|
||||||
|
createStorage<LoaderConfig>(
|
||||||
createFileBackend(getLoaderConfigPath(), {
|
createFileBackend(getLoaderConfigPath(), {
|
||||||
customLoadUrl: {
|
customLoadUrl: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
url: "http://localhost:4040/kettu.js"
|
url: "http://localhost:4040/shiggycord.js",
|
||||||
}
|
},
|
||||||
})
|
}),
|
||||||
));
|
),
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -5,33 +5,33 @@ 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>;
|
rawTabsConfig?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const registeredSections = {} as {
|
export const registeredSections = {} as {
|
||||||
[key: string]: RowConfig[];
|
[key: string]: RowConfig[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function registerSection(section: { name: string; items: RowConfig[]; }) {
|
export function registerSection(section: { name: string; items: RowConfig[] }) {
|
||||||
registeredSections[section.name] = section.items;
|
registeredSections[section.name] = section.items;
|
||||||
return () => delete registeredSections[section.name];
|
return () => delete registeredSections[section.name];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
export function patchSettings() {
|
export function patchSettings() {
|
||||||
const unpatches = new Array<() => boolean>;
|
const unpatches = new Array<() => boolean>();
|
||||||
|
|
||||||
patchTabsUI(unpatches);
|
patchTabsUI(unpatches);
|
||||||
|
|
||||||
return () => unpatches.forEach(u => u());
|
return () => unpatches.forEach((u) => u());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,87 +12,93 @@ const settingConstants = findByPropsLazy("SETTING_RENDERER_CONFIG");
|
||||||
const SettingsOverviewScreen = findByNameLazy("SettingsOverviewScreen", false);
|
const SettingsOverviewScreen = findByNameLazy("SettingsOverviewScreen", false);
|
||||||
|
|
||||||
function useIsFirstRender() {
|
function useIsFirstRender() {
|
||||||
let firstRender = false;
|
let firstRender = false;
|
||||||
React.useEffect(() => void (firstRender = true), []);
|
React.useEffect(() => void (firstRender = true), []);
|
||||||
return firstRender;
|
return firstRender;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function patchTabsUI(unpatches: (() => void | boolean)[]) {
|
export function patchTabsUI(unpatches: (() => void | boolean)[]) {
|
||||||
const getRows = () => Object.values(registeredSections)
|
const getRows = () =>
|
||||||
.flatMap(sect => sect.map(row => ({
|
Object.values(registeredSections)
|
||||||
[row.key]: {
|
.flatMap((sect) =>
|
||||||
type: "pressable",
|
sect.map((row) => ({
|
||||||
title: row.title,
|
[row.key]: {
|
||||||
icon: row.icon,
|
type: "pressable",
|
||||||
IconComponent: () => <TableRow.Icon source={row.icon} />,
|
title: row.title,
|
||||||
usePredicate: row.usePredicate,
|
icon: row.icon,
|
||||||
useTrailing: row.useTrailing,
|
IconComponent: () => <TableRow.Icon source={row.icon} />,
|
||||||
onPress: wrapOnPress(row.onPress, null, row.render, row.title()),
|
usePredicate: row.usePredicate,
|
||||||
withArrow: true,
|
useTrailing: row.useTrailing,
|
||||||
...row.rawTabsConfig
|
onPress: wrapOnPress(row.onPress, null, row.render, row.title()),
|
||||||
}
|
withArrow: true,
|
||||||
})))
|
...row.rawTabsConfig,
|
||||||
.reduce((a, c) => Object.assign(a, c));
|
},
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.reduce((a, c) => Object.assign(a, c));
|
||||||
|
|
||||||
const origRendererConfig = settingConstants.SETTING_RENDERER_CONFIG;
|
const origRendererConfig = settingConstants.SETTING_RENDERER_CONFIG;
|
||||||
let rendererConfigValue = settingConstants.SETTING_RENDERER_CONFIG;
|
let rendererConfigValue = settingConstants.SETTING_RENDERER_CONFIG;
|
||||||
|
|
||||||
|
Object.defineProperty(settingConstants, "SETTING_RENDERER_CONFIG", {
|
||||||
|
enumerable: true,
|
||||||
|
configurable: true,
|
||||||
|
get: () => ({
|
||||||
|
...rendererConfigValue,
|
||||||
|
VendettaCustomPage: {
|
||||||
|
type: "route",
|
||||||
|
title: () => "Kettu",
|
||||||
|
screen: {
|
||||||
|
route: "VendettaCustomPage",
|
||||||
|
getComponent: () => CustomPageRenderer,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PUPU_CUSTOM_PAGE: {
|
||||||
|
type: "route",
|
||||||
|
title: () => "Kettu",
|
||||||
|
screen: {
|
||||||
|
route: "PUPU_CUSTOM_PAGE",
|
||||||
|
getComponent: () => CustomPageRenderer,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
BUNNY_CUSTOM_PAGE: {
|
||||||
|
type: "route",
|
||||||
|
title: () => "Kettu",
|
||||||
|
screen: {
|
||||||
|
route: "BUNNY_CUSTOM_PAGE",
|
||||||
|
getComponent: () => CustomPageRenderer,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...getRows(),
|
||||||
|
}),
|
||||||
|
set: (v) => (rendererConfigValue = v),
|
||||||
|
});
|
||||||
|
|
||||||
|
unpatches.push(() => {
|
||||||
Object.defineProperty(settingConstants, "SETTING_RENDERER_CONFIG", {
|
Object.defineProperty(settingConstants, "SETTING_RENDERER_CONFIG", {
|
||||||
enumerable: true,
|
value: origRendererConfig,
|
||||||
configurable: true,
|
writable: true,
|
||||||
get: () => ({
|
get: undefined,
|
||||||
...rendererConfigValue,
|
set: undefined,
|
||||||
VendettaCustomPage: {
|
|
||||||
type: "route",
|
|
||||||
title: () => "Kettu",
|
|
||||||
screen: {
|
|
||||||
route: "VendettaCustomPage",
|
|
||||||
getComponent: () => CustomPageRenderer
|
|
||||||
}
|
|
||||||
},
|
|
||||||
PUPU_CUSTOM_PAGE: {
|
|
||||||
type: "route",
|
|
||||||
title: () => "Kettu",
|
|
||||||
screen: {
|
|
||||||
route: "PUPU_CUSTOM_PAGE",
|
|
||||||
getComponent: () => CustomPageRenderer
|
|
||||||
}
|
|
||||||
},
|
|
||||||
BUNNY_CUSTOM_PAGE: {
|
|
||||||
type: "route",
|
|
||||||
title: () => "Kettu",
|
|
||||||
screen: {
|
|
||||||
route: "BUNNY_CUSTOM_PAGE",
|
|
||||||
getComponent: () => CustomPageRenderer
|
|
||||||
}
|
|
||||||
},
|
|
||||||
...getRows()
|
|
||||||
}),
|
|
||||||
set: v => rendererConfigValue = v,
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
unpatches.push(() => {
|
unpatches.push(
|
||||||
Object.defineProperty(settingConstants, "SETTING_RENDERER_CONFIG", {
|
after("default", SettingsOverviewScreen, (_, ret) => {
|
||||||
value: origRendererConfig,
|
if (useIsFirstRender()) return; // :shrug:
|
||||||
writable: true,
|
|
||||||
get: undefined,
|
const { sections } = findInReactTree(ret, (i) => i.props?.sections).props;
|
||||||
set: undefined
|
// Credit to @palmdevs - https://discord.com/channels/1196075698301968455/1243605828783571024/1307940348378742816
|
||||||
|
let index =
|
||||||
|
-~sections.findIndex((i: any) => i.settings.includes("ACCOUNT")) || 1;
|
||||||
|
|
||||||
|
Object.keys(registeredSections).forEach((sect) => {
|
||||||
|
sections.splice(index++, 0, {
|
||||||
|
label: sect,
|
||||||
|
title: sect,
|
||||||
|
settings: registeredSections[sect].map((a) => a.key),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
}),
|
||||||
unpatches.push(after("default", SettingsOverviewScreen, (_, ret) => {
|
);
|
||||||
if (useIsFirstRender()) return; // :shrug:
|
|
||||||
|
|
||||||
const { sections } = findInReactTree(ret, i => i.props?.sections).props;
|
|
||||||
// Credit to @palmdevs - https://discord.com/channels/1196075698301968455/1243605828783571024/1307940348378742816
|
|
||||||
let index = -~sections.findIndex((i: any) => i.settings.includes("ACCOUNT")) || 1;
|
|
||||||
|
|
||||||
Object.keys(registeredSections).forEach(sect => {
|
|
||||||
sections.splice(index++, 0, {
|
|
||||||
label: sect,
|
|
||||||
title: sect,
|
|
||||||
settings: registeredSections[sect].map(a => a.key)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
export const DISCORD_SERVER = "https://discord.gg/6cN7wKa8gp";
|
export const DISCORD_SERVER = "https://discord.gg/6cN7wKa8gp";
|
||||||
export const CODEBERG = "https://codeberg.org/cocobo1/Kettu";
|
export const GITHUB = "https://github.com/kmmiio99o/ShiggyCord";
|
||||||
export const GITHUB = "https://github.com/C0C0B01/Kettu";
|
export const HTTP_REGEX =
|
||||||
export const HTTP_REGEX = /^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_+.~#?&/=]*)$/;
|
/^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_+.~#?&/=]*)$/;
|
||||||
export const HTTP_REGEX_MULTI = /https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_+.~#?&//=]*)/g;
|
export const HTTP_REGEX_MULTI =
|
||||||
|
/https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_+.~#?&\/=]*)/g;
|
||||||
|
|
||||||
export const BUNNY_PROXY_PREFIX = "https://bn-plugins.github.io/vd-proxy";
|
export const BUNNY_PROXY_PREFIX = "https://bn-plugins.github.io/vd-proxy";
|
||||||
|
|
||||||
export const NEXPID_PLUGINS_REPO_URL = "https://revenge.nexpid.xyz";
|
export const NEXPID_PLUGINS_REPO_URL = "https://revenge.nexpid.xyz";
|
||||||
//export const OFFICIAL_PLUGINS_REPO_URL = "https://bn-plugins.github.io/dist/repo.json";
|
//export const OFFICIAL_PLUGINS_REPO_URL = "https://bn-plugins.github.io/dist/repo.json";
|
||||||
export const OFFICIAL_PLUGINS_REPO_URL = "https://raw.githubusercontent.com/Purple-EyeZ/Plugins-List/refs/heads/main/src/plugins-data.json";
|
export const OFFICIAL_PLUGINS_REPO_URL =
|
||||||
|
"https://raw.githubusercontent.com/Purple-EyeZ/Plugins-List/refs/heads/main/src/plugins-data.json";
|
||||||
|
|
||||||
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 = "1015931589865246730";
|
export const VD_DISCORD_SERVER_ID = "1015931589865246730";
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,14 @@ import { findByNameLazy } from "@metro/wrappers";
|
||||||
|
|
||||||
type LoggerFunction = (...messages: any[]) => void;
|
type LoggerFunction = (...messages: any[]) => void;
|
||||||
export interface Logger {
|
export interface Logger {
|
||||||
log: LoggerFunction;
|
log: LoggerFunction;
|
||||||
info: LoggerFunction;
|
info: LoggerFunction;
|
||||||
warn: LoggerFunction;
|
warn: LoggerFunction;
|
||||||
error: LoggerFunction;
|
error: LoggerFunction;
|
||||||
time: LoggerFunction;
|
time: LoggerFunction;
|
||||||
trace: LoggerFunction;
|
trace: LoggerFunction;
|
||||||
verbose: LoggerFunction;
|
verbose: LoggerFunction;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LoggerClass = findByNameLazy("Logger");
|
export const LoggerClass = findByNameLazy("Logger");
|
||||||
export const logger: Logger = new LoggerClass("Kettu");
|
export const logger: Logger = new LoggerClass("ShiggyCord");
|
||||||
|
|
|
||||||
2
src/modules.d.ts
vendored
2
src/modules.d.ts
vendored
|
|
@ -1,5 +1,5 @@
|
||||||
declare module "bunny-build-info" {
|
declare module "bunny-build-info" {
|
||||||
const version: string;
|
const version: "1.0.0";
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module "*.png" {
|
declare module "*.png" {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue