├── .github
├── ISSUE_TEMPLATE
│ └── bug-report.yml
└── workflows
│ └── build.yml
├── .gitignore
├── .prettierrc
├── .vscode
└── settings.json
├── FUNDING.yml
├── LICENSE
├── README.md
├── build.mjs
├── dist
└── vendetta.js
├── package-lock.json
├── package.json
├── pnpm-lock.yaml
├── src
├── def.d.ts
├── entry.ts
├── index.ts
├── lib
│ ├── badge
│ │ ├── badgeComponent.tsx
│ │ ├── index.tsx
│ │ └── types.ts
│ ├── command
│ │ ├── debug.tsx
│ │ ├── index.ts
│ │ ├── pluginslist.tsx
│ │ └── reload.tsx
│ ├── commands.ts
│ ├── constants.ts
│ ├── debug.ts
│ ├── emitter.ts
│ ├── fixes.ts
│ ├── logger.ts
│ ├── metro
│ │ ├── common.ts
│ │ └── filters.ts
│ ├── native.ts
│ ├── patcher.ts
│ ├── plugins.ts
│ ├── polyfills.ts
│ ├── preinit.ts
│ ├── settings.ts
│ ├── storage
│ │ ├── backends.ts
│ │ └── index.ts
│ ├── themes.ts
│ ├── tweak
│ │ ├── enableExperiments.ts
│ │ ├── fixConnecting.ts
│ │ ├── index.tsx
│ │ ├── removeChatButtons.ts
│ │ └── removePrompts.ts
│ ├── utils
│ │ ├── findInReactTree.ts
│ │ ├── findInTree.ts
│ │ ├── index.ts
│ │ ├── safeFetch.ts
│ │ ├── unfreeze.ts
│ │ └── without.ts
│ └── windowObject.ts
└── ui
│ ├── alerts.ts
│ ├── assets.ts
│ ├── color.ts
│ ├── components
│ ├── Codeblock.tsx
│ ├── ErrorBoundary.tsx
│ ├── InputAlert.tsx
│ ├── Search.tsx
│ ├── Summary.tsx
│ └── index.ts
│ ├── quickInstall
│ ├── forumPost.tsx
│ ├── index.ts
│ └── url.tsx
│ ├── settings
│ ├── components
│ │ ├── AddonHubButton.tsx
│ │ ├── AddonPage.tsx
│ │ ├── AssetDisplay.tsx
│ │ ├── Card.tsx
│ │ ├── Dropdown.tsx
│ │ ├── InstallButton.tsx
│ │ ├── PluginCard.tsx
│ │ ├── SettingsSection.tsx
│ │ ├── ThemeCard.tsx
│ │ ├── TweakCard.tsx
│ │ └── Version.tsx
│ ├── data.tsx
│ ├── index.ts
│ ├── pages
│ │ ├── AddonHub.tsx
│ │ ├── Addons.tsx
│ │ ├── AssetBrowser.tsx
│ │ ├── General.tsx
│ │ ├── Plugins.tsx
│ │ ├── Themes.tsx
│ │ └── Tweaks.tsx
│ └── patches
│ │ ├── panels.tsx
│ │ └── you.tsx
│ └── toasts.ts
└── tsconfig.json
/.github/ISSUE_TEMPLATE/bug-report.yml:
--------------------------------------------------------------------------------
1 | name: Bug Report
2 | description: File a bug report to help improve Opti.
3 | title: "(bug) "
4 | labels: ["bug"]
5 | assignees:
6 | - byeoon
7 | body:
8 | - type: markdown
9 | attributes:
10 | value: |
11 | Thanks for taking the time to fill out this bug report!
12 | - type: textarea
13 | id: what-happened
14 | attributes:
15 | label: What happened?
16 | description: Explain what happened before the bug / crash occurred.
17 | placeholder: Explain here.
18 | value: "A bug happened!"
19 | validations:
20 | required: true
21 | - type: dropdown
22 | id: version
23 | attributes:
24 | label: OS
25 | description: What OS were you using?
26 | options:
27 | - Android (Jailbroken)
28 | - Android (Jailed)
29 | - iOS (Jailbroken)
30 | - iOS (Jailed)
31 | default: 0
32 | validations:
33 | required: true
34 | - type: dropdown
35 | id: loader
36 | attributes:
37 | label: Opti Loader
38 | description: Which way did you load Opti?
39 | options:
40 | - OptiXposed (Android)
41 | - OptiManager (Android)
42 | - OptiTweak (iOS)
43 | - Sideloaded w/ other mods (iOS / Android)
44 | default: 0
45 | validations:
46 | required: true
47 | - type: textarea
48 | id: logs
49 | attributes:
50 | label: Relevant log output
51 | description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
52 | render: shell
53 | - type: checkboxes
54 | id: terms
55 | attributes:
56 | label: Code of Conduct
57 | description: Make sure to follow the code of conduct.
58 | options:
59 | - label: I agree to follow this project's Code of Conduct
60 | required: true
61 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Build
2 | on:
3 | push:
4 | branches: [rewrite]
5 |
6 | jobs:
7 | build:
8 | name: Build and push
9 | runs-on: ubuntu-latest
10 |
11 | steps:
12 | - uses: actions/checkout@v4
13 |
14 | - uses: actions/checkout@v4
15 | with:
16 | repository: "opti-mod/builds"
17 | path: "builds"
18 | token: ${{ secrets.TOKENTHING }}
19 |
20 | - uses: actions/setup-node@v4
21 | with:
22 | node-version: "22"
23 |
24 | - uses: pnpm/action-setup@v4
25 | with:
26 | version: "9"
27 |
28 | - name: Install dependencies
29 | run: |
30 | pnpm i
31 |
32 | - name: Build
33 | run: pnpm build
34 |
35 | - name: Push builds
36 | run: |
37 | cp -r dist/* $GITHUB_WORKSPACE/builds || true
38 | cd $GITHUB_WORKSPACE/builds
39 | git config --local user.email "actions@github.com"
40 | git config --local user.name "GitHub Actions"
41 | git add .
42 | git commit -m "[Main] Build $GITHUB_SHA" || exit 0
43 | git push
44 |
45 | - name: Purge CDN cache
46 | run: |
47 | curl https://purge.jsdelivr.net/gh/opti-mod/builds
48 |
49 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | yarn.lock
3 | .DS_Store
4 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 240,
3 | "tabWidth": 4,
4 | "singleQuote": false,
5 | "jsxSingleQuote": false,
6 | "bracketSpacing": true,
7 | "useTabs": false
8 | }
9 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | }
--------------------------------------------------------------------------------
/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [maisymoe, wingio]
2 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | BSD 3-Clause License
2 |
3 | Copyright (c) 2023, Team Opti
4 |
5 | Redistribution and use in source and binary forms, with or without
6 | modification, are permitted provided that the following conditions are met:
7 |
8 | 1. Redistributions of source code must retain the above copyright notice, this
9 | list of conditions and the following disclaimer.
10 |
11 | 2. Redistributions in binary form must reproduce the above copyright notice,
12 | this list of conditions and the following disclaimer in the documentation
13 | and/or other materials provided with the distribution.
14 |
15 | 3. Neither the name of the copyright holder nor the names of its
16 | contributors may be used to endorse or promote products derived from
17 | this software without specific prior written permission.
18 |
19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Opti
2 | An *opti*mized Discord experience for mobile.
3 |
4 | ## Installing
5 |
6 | ### Android
7 | * Root - [OptiXposed](https://github.com/opti-mod/OptiXposed/releases/latest)
8 | * Non-root - [OptiManager](https://github.com/opti-mod/OptiManager/releases/latest)
9 | - Manager not working? No problem! Pre-built APKs are provided [here](https://discord.k6.tf/).
10 | - The minimum Android version required is 9. It will not work on any lower version.
11 |
12 | ### iOS
13 | * Jailbroken - [VendettaTweak](https://github.com/vendetta-mod/VendettaTweak)
14 | - You can get prebuilt `.deb` files from GitHub Actions - we support rootful and rootless jailbreaks!
15 | * Jailed - You can get IPAs from [the thread](https://discord.com/channels/1015931589865246730/1087295482667208766) in our [Discord server](https://discord.gg/n9QQ4XhhJP) or from our [host](https://discord.k6.tf/ios/).
16 | * Sideloading with Enmity - You can sideload Opti with Enmity by using VendettaCompat and inserting this url: https://raw.githubusercontent.com/Opti-mod/builds/refs/heads/main/vendetta.js
17 |
18 | ## Contributing
19 | 1. Install an Opti loader with loader config support (any mentioned in the [Installing](#installing) section).
20 |
21 | 2. Go to Settings > General and enable Developer Settings.
22 |
23 | 3. Clone the repo:
24 | ```
25 | git clone https://github.com/opti-mod/Opti
26 | ```
27 |
28 | 4. Install dependencies:
29 | ```
30 | pnpm i
31 | ```
32 | `npm` or `yarn` should also work.
33 |
34 | 5. Build Opti's code:
35 | ```
36 | pnpm build
37 | ```
38 | `npm` or `yarn` should also work.
39 |
40 | 6. In the newly created `dist` directory, run a HTTP server. I recommend [http-server](https://www.npmjs.com/package/http-server).
41 |
42 | 7. Go to Opti settings and under `Load from custom url`, input the IP address and port of the server (e.g. e.g. `http://192.168.1.236:4040`) in the new input box labelled `OPTI URL`. uh. maybe i shouldnt have removed developer settings.
43 |
44 | 8. Restart Discord. Upon reload, you should notice that your device will download Opti's bundled code from your server, rather than GitHub.
45 |
46 | 9. Make your changes, rebuild, reload, go wild!
47 |
--------------------------------------------------------------------------------
/build.mjs:
--------------------------------------------------------------------------------
1 | import { build } from "esbuild";
2 | import alias from "esbuild-plugin-alias";
3 | import swc from "@swc/core";
4 | import { promisify } from "util";
5 | import { exec as _exec } from "child_process";
6 | import fs from "fs/promises";
7 | import path from "path";
8 | const exec = promisify(_exec);
9 |
10 | const tsconfig = JSON.parse(await fs.readFile("./tsconfig.json"));
11 | const aliases = Object.fromEntries(Object.entries(tsconfig.compilerOptions.paths).map(([alias, [target]]) => [alias, path.resolve(target)]));
12 | const commit = (await exec("git rev-parse HEAD")).stdout.trim().substring(0, 7) || "custom";
13 |
14 | try {
15 | await build({
16 | entryPoints: ["./src/entry.ts"],
17 | outfile: "./dist/vendetta.js",
18 | minify: true,
19 | bundle: true,
20 | format: "iife",
21 | target: "esnext",
22 | plugins: [
23 | {
24 | name: "swc",
25 | setup: (build) => {
26 | build.onLoad({ filter: /\.[jt]sx?/ }, async (args) => {
27 | // This actually works for dependencies as well!!
28 | const result = await swc.transformFile(args.path, {
29 | jsc: {
30 | externalHelpers: true,
31 | },
32 | env: {
33 | targets: "defaults",
34 | include: [
35 | "transform-classes",
36 | "transform-arrow-functions",
37 | ],
38 | },
39 | });
40 | return { contents: result.code };
41 | });
42 | },
43 | },
44 | alias(aliases),
45 | ],
46 | define: {
47 | __vendettaVersion: `"${commit}"`,
48 | },
49 | footer: {
50 | js: "//# sourceURL=Vendetta",
51 | },
52 | legalComments: "none",
53 | });
54 |
55 | console.log("Build successful!");
56 | } catch (e) {
57 | console.error("Build failed...", e);
58 | process.exit(1);
59 | }
60 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "opti",
3 | "version": "1.0.0",
4 | "description": "An optimized Discord experience for mobile.",
5 | "scripts": {
6 | "build": "node build.mjs"
7 | },
8 | "keywords": [
9 | "discord",
10 | "android",
11 | "ios",
12 | "react native"
13 | ],
14 | "author": "byeoon",
15 | "license": "BSD-3-Clause",
16 | "devDependencies": {
17 | "@react-native-clipboard/clipboard": "1.10.0",
18 | "@swc/core": "1.3.50",
19 | "@types/chroma-js": "^2.4.0",
20 | "@types/lodash": "^4.14.194",
21 | "@types/react": "18.0.35",
22 | "@types/react-native": "0.70.6",
23 | "esbuild": "^0.17.16",
24 | "esbuild-plugin-alias": "^0.2.1",
25 | "moment": "2.22.2",
26 | "typescript": "^5.0.4"
27 | },
28 | "dependencies": {
29 | "@swc/helpers": "0.5.0",
30 | "http": "^0.0.1-security",
31 | "https": "^1.0.0",
32 | "pnpm": "^9.12.1",
33 | "spitroast": "^1.4.3"
34 | },
35 | "pnpm": {
36 | "peerDependencyRules": {
37 | "ignoreMissing": [
38 | "react",
39 | "react-native"
40 | ]
41 | }
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/def.d.ts:
--------------------------------------------------------------------------------
1 | import * as _spitroast from "spitroast";
2 | import _React from "react";
3 | import _RN from "react-native";
4 | import _Clipboard from "@react-native-clipboard/clipboard";
5 | import _moment from "moment";
6 | import _chroma from "chroma-js";
7 | import _lodash from "lodash";
8 |
9 | type MetroModules = { [id: number]: any };
10 |
11 | // Component types
12 | interface SummaryProps {
13 | label: string;
14 | icon?: string;
15 | noPadding?: boolean;
16 | noAnimation?: boolean;
17 | children: JSX.Element | JSX.Element[];
18 | }
19 |
20 | interface ErrorBoundaryProps {
21 | children: JSX.Element | JSX.Element[];
22 | }
23 |
24 | interface CodeblockProps {
25 | selectable?: boolean;
26 | style?: _RN.TextStyle;
27 | children?: string;
28 | }
29 |
30 | interface SearchProps {
31 | onChangeText?: (v: string) => void;
32 | placeholder?: string;
33 | style?: _RN.TextStyle;
34 | }
35 |
36 | // Helper types for API functions
37 | type PropIntellisense
= Record
& Record;
38 | type PropsFinder = (...props: T[]) => PropIntellisense;
39 | type PropsFinderAll = (...props: T[]) => PropIntellisense[];
40 |
41 | type LoggerFunction = (...messages: any[]) => void;
42 | interface Logger {
43 | log: LoggerFunction;
44 | info: LoggerFunction;
45 | warn: LoggerFunction;
46 | error: LoggerFunction;
47 | time: LoggerFunction;
48 | trace: LoggerFunction;
49 | verbose: LoggerFunction;
50 | }
51 |
52 | type SearchTree = Record;
53 | type SearchFilter = (tree: SearchTree) => boolean;
54 | interface FindInTreeOptions {
55 | walkable?: string[];
56 | ignore?: string[];
57 | maxDepth?: number;
58 | }
59 |
60 | interface Asset {
61 | name: string;
62 | id: number;
63 | }
64 |
65 | export enum ButtonColors {
66 | BRAND = "brand",
67 | RED = "red",
68 | GREEN = "green",
69 | PRIMARY = "primary",
70 | TRANSPARENT = "transparent",
71 | GREY = "grey",
72 | LIGHTGREY = "lightgrey",
73 | WHITE = "white",
74 | LINK = "link"
75 | }
76 |
77 | interface ConfirmationAlertOptions {
78 | title?: string;
79 | content: string | JSX.Element | (string | JSX.Element)[];
80 | confirmText?: string;
81 | confirmColor?: ButtonColors;
82 | onConfirm: () => void;
83 | secondaryConfirmText?: string;
84 | onConfirmSecondary?: () => void;
85 | cancelText?: string;
86 | onCancel?: () => void;
87 | isDismissable?: boolean;
88 | }
89 |
90 | interface InputAlertProps {
91 | title?: string;
92 | confirmText?: string;
93 | confirmColor?: ButtonColors;
94 | onConfirm: (input: string) => (void | Promise);
95 | cancelText?: string;
96 | placeholder?: string;
97 | initialValue?: string;
98 | secureTextEntry?: boolean;
99 | }
100 |
101 | interface Author {
102 | name: string;
103 | id?: string;
104 | }
105 |
106 | // See https://github.com/vendetta-mod/polymanifest
107 | interface PluginManifest {
108 | name: string;
109 | description: string;
110 | authors: Author[];
111 | main: string;
112 | hash: string;
113 | // Vendor-specific field, contains our own data
114 | vendetta?: {
115 | icon?: string;
116 | version?: string;
117 | opti?: boolean;
118 | };
119 | }
120 |
121 | interface Plugin {
122 | id: string;
123 | manifest: PluginManifest;
124 | enabled: boolean;
125 | update: boolean;
126 | js: string;
127 | }
128 |
129 | interface Tweak {
130 | id: string;
131 | name: string;
132 | enabled: boolean;
133 | }
134 |
135 | interface ThemeData {
136 | name: string;
137 | description?: string;
138 | authors: Author[];
139 | spec: number;
140 | semanticColors?: Record;
141 | rawColors?: Record;
142 | background?: {
143 | url: string;
144 | blur?: number;
145 | /**
146 | * The alpha value of the background.
147 | * `CHAT_BACKGROUND` of semanticColors alpha value will be ignored when this is specified
148 | */
149 | alpha?: number;
150 | }
151 | }
152 |
153 | interface Theme {
154 | id: string;
155 | selected: boolean;
156 | data: ThemeData;
157 | }
158 |
159 | interface Settings {
160 | debuggerUrl: string;
161 | developerSettings: boolean;
162 |
163 | tweaks: {
164 | hideButtons: boolean;
165 | removePrompts: boolean;
166 | externalbadges: boolean;
167 | };
168 | }
169 |
170 | export interface ApplicationCommand {
171 | description?: string;
172 | name?: string;
173 | options?: ApplicationCommandOption[];
174 | execute: (args: any[], ctx: CommandContext) => CommandResult | void | Promise | Promise;
175 | id?: string;
176 | applicationId?: string;
177 | displayName?: string;
178 | displayDescription?: string;
179 | inputType?: ApplicationCommandInputType;
180 | type?: ApplicationCommandType;
181 | __isOpti?: boolean;
182 | }
183 |
184 | export enum ApplicationCommandInputType {
185 | BUILT_IN,
186 | BUILT_IN_TEXT,
187 | BUILT_IN_INTEGRATION,
188 | BOT,
189 | PLACEHOLDER,
190 | }
191 |
192 | export interface ApplicationCommandOption {
193 | name: string;
194 | description: string;
195 | required?: boolean;
196 | type: ApplicationCommandOptionType;
197 | displayName?: string;
198 | displayDescription?: string;
199 | }
200 |
201 | export enum ApplicationCommandOptionType {
202 | SUB_COMMAND = 1,
203 | SUB_COMMAND_GROUP,
204 | STRING,
205 | INTEGER,
206 | BOOLEAN,
207 | USER,
208 | CHANNEL,
209 | ROLE,
210 | MENTIONABLE,
211 | NUMBER,
212 | ATTACHMENT,
213 | }
214 |
215 | export enum ApplicationCommandType {
216 | CHAT = 1,
217 | USER,
218 | MESSAGE,
219 | }
220 |
221 | interface CommandContext {
222 | channel: any;
223 | guild: any;
224 | }
225 |
226 | interface CommandResult {
227 | content: string;
228 | tts?: boolean;
229 | }
230 |
231 | interface RNConstants extends _RN.PlatformConstants {
232 | // Android
233 | Version: number;
234 | Release: string;
235 | Serial: string;
236 | Fingerprint: string;
237 | Model: string;
238 | Brand: string;
239 | Manufacturer: string;
240 | ServerHost?: string;
241 |
242 | // iOS
243 | forceTouchAvailable: boolean;
244 | interfaceIdiom: string;
245 | osVersion: string;
246 | systemName: string;
247 | }
248 |
249 | /**
250 | * A key-value storage based upon `SharedPreferences` on Android.
251 | *
252 | * These types are based on Android though everything should be the same between
253 | * platforms.
254 | */
255 | interface MMKVManager {
256 | /**
257 | * Get the value for the given `key`, or null
258 | * @param key The key to fetch
259 | */
260 | getItem: (key: string) => Promise;
261 | /**
262 | * Deletes the value for the given `key`
263 | * @param key The key to delete
264 | */
265 | removeItem: (key: string) => void;
266 | /**
267 | * Sets the value of `key` to `value`
268 | */
269 | setItem: (key: string, value: string) => void;
270 | /**
271 | * Goes through every item in storage and returns it, excluding the
272 | * keys specified in `exclude`.
273 | * @param exclude A list of items to exclude from result
274 | */
275 | refresh: (exclude: string[]) => Promise>;
276 | /**
277 | * You will be murdered if you use this function.
278 | * Clears ALL of Discord's settings.
279 | */
280 | clear: () => void;
281 | }
282 |
283 | interface FileManager {
284 | /**
285 | * @param path **Full** path to file
286 | */
287 | fileExists: (path: string) => Promise;
288 | /**
289 | * Allowed URI schemes on Android: `file://`, `content://` ([See here](https://developer.android.com/reference/android/content/ContentResolver#accepts-the-following-uri-schemes:_3))
290 | */
291 | getSize: (uri: string) => Promise;
292 | /**
293 | * @param path **Full** path to file
294 | * @param encoding Set to `base64` in order to encode response
295 | */
296 | readFile(path: string, encoding: "base64" | "utf8"): Promise;
297 | saveFileToGallery?(uri: string, fileName: string, fileType: "PNG" | "JPEG"): Promise;
298 | /**
299 | * Beware! This function has differing functionality on iOS and Android.
300 | * @param storageDir Either `cache` or `documents`.
301 | * @param path Path in `storageDir`, parents are recursively created.
302 | * @param data The data to write to the file
303 | * @param encoding Set to `base64` if `data` is base64 encoded.
304 | * @returns Promise that resolves to path of the file once it got written
305 | */
306 | writeFile(storageDir: "cache" | "documents", path: string, data: string, encoding: "base64" | "utf8"): Promise;
307 | removeFile(storageDir: "cache" | "documents", path: string): Promise;
308 | getConstants: () => {
309 | /**
310 | * The path the `documents` storage dir (see {@link writeFile}) represents.
311 | */
312 | DocumentsDirPath: string;
313 | CacheDirPath: string;
314 | };
315 | /**
316 | * Will apparently cease to exist some time in the future so please use {@link getConstants} instead.
317 | * @deprecated
318 | */
319 | DocumentsDirPath: string;
320 | }
321 |
322 | type EmitterEvent = "SET" | "GET" | "DEL";
323 |
324 | interface EmitterListenerData {
325 | path: string[];
326 | value?: any;
327 | }
328 |
329 | type EmitterListener = (
330 | event: EmitterEvent,
331 | data: EmitterListenerData | any
332 | ) => any;
333 |
334 | type EmitterListeners = Record>
335 |
336 | interface Emitter {
337 | listeners: EmitterListeners;
338 | on: (event: EmitterEvent, listener: EmitterListener) => void;
339 | off: (event: EmitterEvent, listener: EmitterListener) => void;
340 | once: (event: EmitterEvent, listener: EmitterListener) => void;
341 | emit: (event: EmitterEvent, data: EmitterListenerData) => void;
342 | }
343 |
344 | interface StorageBackend {
345 | get: () => unknown | Promise;
346 | set: (data: unknown) => void | Promise;
347 | }
348 |
349 | interface LoaderConfig {
350 | customLoadUrl: {
351 | enabled: boolean;
352 | url: string;
353 | };
354 | loadReactDevTools: boolean;
355 | }
356 |
357 | interface LoaderIdentity {
358 | name: string;
359 | features: {
360 | loaderConfig?: boolean;
361 | devtools?: {
362 | prop: string;
363 | version: string;
364 | },
365 | themes?: {
366 | prop: string;
367 | }
368 | }
369 | }
370 |
371 | interface DiscordStyleSheet {
372 | [index: string]: any,
373 | createStyles: >(sheet: T | (() => T)) => () => T;
374 | createThemedStyleSheet: typeof import("react-native").StyleSheet.create;
375 | }
376 |
377 | interface VendettaObject {
378 | patcher: {
379 | after: typeof _spitroast.after;
380 | before: typeof _spitroast.before;
381 | instead: typeof _spitroast.instead;
382 | };
383 | metro: {
384 | find: (filter: (m: any) => boolean) => any;
385 | findAll: (filter: (m: any) => boolean) => any[];
386 | findByProps: PropsFinder;
387 | findByPropsAll: PropsFinderAll;
388 | findByName: (name: string, defaultExp?: boolean) => any;
389 | findByNameAll: (name: string, defaultExp?: boolean) => any[];
390 | findByDisplayName: (displayName: string, defaultExp?: boolean) => any;
391 | findByDisplayNameAll: (displayName: string, defaultExp?: boolean) => any[];
392 | findByTypeName: (typeName: string, defaultExp?: boolean) => any;
393 | findByTypeNameAll: (typeName: string, defaultExp?: boolean) => any[];
394 | findByStoreName: (name: string) => any;
395 | common: {
396 | constants: PropIntellisense<"Fonts" | "Permissions">;
397 | channels: PropIntellisense<"getVoiceChannelId">;
398 | i18n: PropIntellisense<"Messages">;
399 | url: PropIntellisense<"openURL">;
400 | toasts: PropIntellisense<"open" | "close">;
401 | stylesheet: DiscordStyleSheet;
402 | clipboard: typeof _Clipboard;
403 | assets: PropIntellisense<"registerAsset">;
404 | invites: PropIntellisense<"acceptInviteAndTransitionToInviteChannel">;
405 | commands: PropIntellisense<"getBuiltInCommands">;
406 | navigation: PropIntellisense<"pushLazy">;
407 | navigationStack: PropIntellisense<"createStackNavigator">;
408 | NavigationNative: PropIntellisense<"NavigationContainer">;
409 | // You may ask: "Why not just install Flux's types?"
410 | // Answer: Discord have a (presumably proprietary) fork. It's wildly different.
411 | Flux: PropIntellisense<"connectStores">;
412 | FluxDispatcher: PropIntellisense<"_currentDispatchActionType">;
413 | React: typeof _React;
414 | ReactNative: typeof _RN;
415 | moment: typeof _moment;
416 | chroma: typeof _chroma;
417 | lodash: typeof _lodash;
418 | util: PropIntellisense<"inspect" | "isNullOrUndefined">;
419 | };
420 | };
421 | constants: {
422 | DISCORD_SERVER: string;
423 | DISCORD_SERVER_ID: string;
424 | PLUGINS_CHANNEL_ID: string;
425 | THEMES_CHANNEL_ID: string;
426 | GITHUB: string;
427 | BADGES: string;
428 | PROXY_PREFIX: string;
429 | HTTP_REGEX: RegExp;
430 | HTTP_REGEX_MULTI: RegExp;
431 | };
432 | utils: {
433 | findInReactTree: (tree: SearchTree, filter: SearchFilter) => any;
434 | findInTree: (tree: SearchTree, filter: SearchFilter, options: FindInTreeOptions) => any;
435 | safeFetch: (input: RequestInfo | URL, options?: RequestInit, timeout?: number) => Promise;
436 | unfreeze: (obj: object) => object;
437 | without: (object: O, ...keys: K) => Omit;
438 | };
439 | debug: {
440 | connectToDebugger: (url: string) => void;
441 | getDebugInfo: () => void;
442 | }
443 | ui: {
444 | components: {
445 | // Discord
446 | Forms: PropIntellisense<"Form" | "FormSection">;
447 | General: PropIntellisense<"Button" | "Text" | "View">;
448 | Alert: _React.ComponentType;
449 | Button: _React.ComponentType & { Looks: any, Colors: ButtonColors, Sizes: any };
450 | HelpMessage: _React.ComponentType;
451 | SafeAreaView: typeof _RN.SafeAreaView;
452 | // Vendetta
453 | Summary: _React.ComponentType;
454 | ErrorBoundary: _React.ComponentType;
455 | Codeblock: _React.ComponentType;
456 | Search: _React.ComponentType;
457 | }
458 | toasts: {
459 | showToast: (content: string, asset?: number) => void;
460 | };
461 | alerts: {
462 | showConfirmationAlert: (options: ConfirmationAlertOptions) => void;
463 | showCustomAlert: (component: _React.ComponentType, props: any) => void;
464 | showInputAlert: (options: InputAlertProps) => void;
465 | };
466 | assets: {
467 | all: Record;
468 | find: (filter: (a: any) => void) => Asset | null | undefined;
469 | getAssetByName: (name: string) => Asset;
470 | getAssetByID: (id: number) => Asset;
471 | getAssetIDByName: (name: string) => number;
472 | };
473 | // TODO: Make a vain attempt to type these
474 | semanticColors: Record;
475 | rawColors: Record;
476 | };
477 | plugins: {
478 | plugins: Record;
479 | fetchPlugin: (id: string) => Promise;
480 | installPlugin: (id: string, enabled?: boolean) => Promise;
481 | startPlugin: (id: string) => Promise;
482 | stopPlugin: (id: string, disable?: boolean) => void;
483 | removePlugin: (id: string) => void;
484 | getSettings: (id: string) => JSX.Element;
485 | };
486 | themes: {
487 | themes: Record;
488 | fetchTheme: (id: string, selected?: boolean) => Promise;
489 | installTheme: (id: string) => Promise;
490 | selectTheme: (id: string) => Promise;
491 | removeTheme: (id: string) => Promise;
492 | getCurrentTheme: () => Theme | null;
493 | updateThemes: () => Promise;
494 | };
495 | commands: {
496 | registerCommand: (command: ApplicationCommand) => () => void;
497 | };
498 | storage: {
499 | createProxy: (target: T) => { proxy: T, emitter: Emitter };
500 | useProxy: (storage: T) => T;
501 | createStorage: (backend: StorageBackend) => Promise>;
502 | wrapSync: >(store: T) => Awaited;
503 | awaitSyncWrapper: (store: any) => Promise;
504 | createMMKVBackend: (store: string) => StorageBackend;
505 | createFileBackend: (file: string) => StorageBackend;
506 | };
507 | settings: Settings;
508 | loader: {
509 | identity?: LoaderIdentity;
510 | config: LoaderConfig;
511 | };
512 | logger: Logger;
513 | version: string;
514 | unload: () => void;
515 | }
516 |
517 | interface VendettaPluginObject {
518 | id: string;
519 | manifest: PluginManifest;
520 | storage: Record;
521 | }
522 |
523 | declare global {
524 | type React = typeof _React;
525 | const __vendettaVersion: string;
526 |
527 | interface Window {
528 | [key: PropertyKey]: any;
529 | modules: MetroModules;
530 | vendetta: VendettaObject;
531 | React: typeof _React;
532 | __vendetta_loader?: LoaderIdentity;
533 | }
534 | }
535 |
--------------------------------------------------------------------------------
/src/entry.ts:
--------------------------------------------------------------------------------
1 | import { ClientInfoManager } from "@lib/native";
2 | import { getDebugInfo } from "./lib/debug";
3 |
4 | console.log("Opti has loaded!");
5 | Object.freeze = Object;
6 | Object.seal = Object;
7 |
8 | import(".").then((m) => m.default()).catch((e) => {
9 | console.log(e?.stack ?? e.toString());
10 | alert(["Opti failed to initialize. Some parts may not function properly.\n",
11 | `Build Number: ${ClientInfoManager.Build}`,
12 | `Opti Version: ${__vendettaVersion}`,
13 | e?.stack || e.toString(),
14 | ].join("\n"));
15 | });
16 |
17 | if(getDebugInfo().discord.version == 223) {
18 | alert("You are running on Discord v223. This version is known to have many crashes and issues with modded clients. Continue at your own risk.");
19 | }
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import { patchLogHook } from "@lib/debug";
2 | import { patchCommands } from "@lib/commands";
3 | import { initPlugins } from "@lib/plugins";
4 | import { patchChatBackground } from "@lib/themes";
5 | import { patchAssets } from "@ui/assets";
6 | import { initCustomCommands } from "./lib/command";
7 | import initQuickInstall from "@ui/quickInstall";
8 | import initSettings from "@ui/settings";
9 | import initFixes from "@lib/fixes";
10 | import logger from "@lib/logger";
11 | import windowObject from "@lib/windowObject";
12 | import loadTweaks from "./lib/tweak";
13 |
14 |
15 | export default async () => {
16 | const unloads = await Promise.all([
17 | patchLogHook(),
18 | patchAssets(),
19 | patchCommands(),
20 | patchChatBackground(),
21 | initFixes(),
22 | initSettings(),
23 | initQuickInstall(),
24 | ]);
25 | try {
26 | window.vendetta = await windowObject(unloads);
27 | }
28 | catch {
29 | logger.log("Opti has failed to load.");
30 | }
31 |
32 | unloads.push(await initPlugins());
33 | unloads.push(await loadTweaks());
34 | unloads.push(await initCustomCommands());
35 | // todo add badge unload here
36 | logger.log("Opti has loaded!");
37 | }
38 |
--------------------------------------------------------------------------------
/src/lib/badge/badgeComponent.tsx:
--------------------------------------------------------------------------------
1 | import { BadgeComponents } from "./types";
2 | import { ReactNative as RN, stylesheet, toasts, React } from "@metro/common";
3 |
4 | const { View, Image, TouchableOpacity } = RN;
5 | export const BadgeComponent = ({ name, image, size, margin, custom }: BadgeComponents) => {
6 |
7 | const styles = stylesheet.createThemedStyleSheet({
8 | container: {
9 | flexDirection: "row",
10 | alignItems: "center",
11 | flexWrap: "wrap",
12 | justifyContent: "flex-end",
13 | },
14 | img: {
15 | width: size,
16 | height: size,
17 | resizeMode: "contain",
18 | marginHorizontal: margin
19 | }
20 | });
21 |
22 | const renderBadge = () => {
23 | if (custom) {
24 | return (custom)
25 | } else {
26 | return (
27 | toasts.open({ content: name, source: { uri: image } })}>
28 |
29 |
30 | )
31 | }
32 | }
33 |
34 | return (
35 |
36 | {renderBadge()}
37 |
38 | )
39 | }
--------------------------------------------------------------------------------
/src/lib/badge/index.tsx:
--------------------------------------------------------------------------------
1 | // Thank you so fucking much
2 | // https://github.com/WolfPlugs/vendetta-plugins/tree/master/plugins/globalBadges
3 |
4 | import { findByName } from "@metro/filters";
5 | import { after } from "@lib/patcher";
6 | import { ReactNative as RN, React } from "@metro/common";
7 |
8 | import { BadgeProps, CustomBadges, BadgeCache } from "./types";
9 | import { BadgeComponent } from "./badgeComponent";
10 | import settings from "../settings";
11 |
12 |
13 | const { View } = RN;
14 |
15 | const cache = new Map();
16 | const REFRESH_INTERVAL = 1000 * 60 * 30;
17 |
18 | let unpatch: () => boolean;
19 | let unpatch2: () => boolean;
20 | let cachUser;
21 | export function loadBadges() {
22 | const profileBadges = findByName("ProfileBadges", false);
23 | unpatch = after("default", profileBadges, (args, res) => {
24 | let mem = res;
25 |
26 | const [, updateForce] = React.useReducer(x => x = !x, false);
27 |
28 | const user = args[0]?.user;
29 | if (user === undefined) return;
30 |
31 | cachUser = cache.get(user.id);
32 | if (cachUser === undefined) {
33 | fetchbadges(user.id, updateForce);
34 | return;
35 | }
36 |
37 | const style = mem?.props?.style
38 |
39 | // Credits here to @acquitelol
40 | // https://github.com/enmity-mod/enmity/blob/8ff15a8fffc5a1ad4d41c5e8f8a02e6876a760ec/src/core/patches/badges.tsx#L81-L95
41 | if (!mem) {
42 | mem = ;
53 |
54 | mem.props.children = [];
55 | }
56 |
57 | const pushBadge = ({ name, image, custom = false }: BadgeProps) => {
58 | const RenderableBadge = () => r.paddingVertical && r.paddingHorizontal) ? 16 : 22 : 16}
63 | margin={Array.isArray(style) ? 4 : 6}
64 | />;
65 |
66 | // i really dont know what storage.left is...
67 | // update: with 2 minutes of using my brain, its if badges show up on the left or not.
68 | const pushOrUnpush = "PUSH"; // storage.left
69 | if (mem?.props?.badges) pushOrUnpush ? mem.props.badges = [, ...mem.props.badges] : mem.props.badges = [...mem.props.badges, ];
70 | else pushOrUnpush ? mem.props.children = [, ...mem.props.children] : mem.props.children = [...mem.props.children, ];
71 | };
72 |
73 | Object.entries(cachUser?.badges).forEach(([key, value]): any => {
74 |
75 | if (settings.tweaks.externalbadges?.valueOf() == false) {
76 | if (key == "opti") return;
77 | }
78 |
79 | switch (key) {
80 | case "customBadgesArray":
81 | if (value) {
82 | value.badges.map((badge: CustomBadges) => {
83 | pushBadge({
84 | name: badge.name,
85 | image: badge.badge,
86 | });
87 | });
88 | }
89 | break;
90 | case "opti":
91 | if (value?.developer) {
92 | pushBadge({
93 | name: "Opti Developer",
94 | image: "https://raw.githubusercontent.com/Opti-mod/assets/main/BadgeDeveloper.png",
95 | });
96 | }
97 | if (value?.contributor) {
98 | pushBadge({
99 | name: "Opti Contributor",
100 | image: "https://raw.githubusercontent.com/Opti-mod/assets/main/BadgeContributor.png",
101 | });
102 | }
103 | if (value?.supporter) {
104 | pushBadge({
105 | name: "Opti Supporter",
106 | image: "https://raw.githubusercontent.com/Opti-mod/assets/main/BadgeSupporter.png",
107 | });
108 | }
109 | break;
110 | default:
111 | break;
112 | }
113 | })
114 | });
115 | }
116 | export function unloadBadges() {
117 | unpatch?.();
118 | unpatch2?.();
119 | }
120 |
121 | async function fetchbadges(userId: string, updateForce: any) {
122 | if (
123 | !cache.has(userId) ||
124 | cache.get(userId)!.lastFetch + REFRESH_INTERVAL < Date.now()
125 | ) {
126 |
127 | const res = await fetch(
128 | `https://raw.githubusercontent.com/Opti-mod/badges/main/${userId}.json`
129 | );
130 | const body = (await res.json()) as CustomBadges;
131 | const result: BadgeCache =
132 | res.status === 200 || res.status === 404
133 | ? { badges: body || {}, lastFetch: Date.now() }
134 | : (cache.delete(userId), { badges: body, lastFetch: Date.now() });
135 |
136 | cache.set(userId, result);
137 | updateForce();
138 | }
139 |
140 | return cache.get(userId)!.badges;
141 | }
--------------------------------------------------------------------------------
/src/lib/badge/types.ts:
--------------------------------------------------------------------------------
1 | export interface CustomBadges {
2 | badge: string;
3 | name: string;
4 | customBadgesArray: {
5 | badge: string;
6 | name: string;
7 | };
8 | opti: {
9 | developer: boolean;
10 | contributor: boolean;
11 | supporter: boolean;
12 | };
13 | }
14 |
15 | export interface BadgeProps {
16 | name: string;
17 | image: string;
18 | custom?: any;
19 | }
20 |
21 | export interface BadgeComponents {
22 | name: string;
23 | image: string;
24 | size: number;
25 | margin: number;
26 | custom?: object;
27 | }
28 |
29 | export type BadgeCache = {
30 | badges: CustomBadges;
31 | lastFetch: number;
32 | };
--------------------------------------------------------------------------------
/src/lib/command/debug.tsx:
--------------------------------------------------------------------------------
1 | import { ApplicationCommand, ApplicationCommandInputType } from "@types";
2 | import { Messages } from "../metro/common";
3 | import { getDebugInfo } from "../debug";
4 |
5 | const debugInfo = getDebugInfo();
6 | export default [
7 | {
8 | name: 'debug',
9 | description: 'Prints Optis debug information to chat.',
10 | inputType: ApplicationCommandInputType.BUILT_IN,
11 | __isOpti: true,
12 | execute(_, ctx) {
13 | const content = `**Opti Debug Info [Main Branch]**
14 | > **Opti Version**: ${debugInfo.vendetta.version}
15 | > **Discord Version**: ${debugInfo.discord.version} (Build ${debugInfo.discord.build})
16 | > **Device**: ${debugInfo.device.brand} (${debugInfo.os.name} ${debugInfo.os.version})
17 | > **Codename/Machine ID**: ${debugInfo.device.codename}`
18 | Messages.sendMessage(ctx.channel.id, { content: content });
19 | },
20 | },
21 | ] as ApplicationCommand[]
22 |
23 |
24 |
--------------------------------------------------------------------------------
/src/lib/command/index.ts:
--------------------------------------------------------------------------------
1 | import { registerCommand } from "../commands";
2 | import debug from "./debug";
3 | import pluginslist from "./pluginslist";
4 | import reload from "./reload";
5 |
6 | export function initCustomCommands(): void {
7 | const customCommands = [
8 | ...debug,
9 | ...reload,
10 | ...pluginslist
11 | ];
12 | registerCommand(customCommands);
13 | }
14 |
15 | export default { initCustomCommands };
--------------------------------------------------------------------------------
/src/lib/command/pluginslist.tsx:
--------------------------------------------------------------------------------
1 | import { ApplicationCommand } from "@/def";
2 | import { Messages } from "../metro/common";
3 | import { getDebugInfo } from "../debug";
4 | import { getDisabledPlugins, getPluginList, getPlugins } from "../plugins";
5 | export default [
6 | {
7 | name: 'plugins list',
8 | description: 'Lists all Opti plugins.',
9 | execute(_, ctx) {
10 | const content = `**Enabled Plugins (${getPlugins()}):**
11 | > ${getPluginList()}
12 | **Disabled Plugins:**
13 | > ${getDisabledPlugins()}`
14 | Messages.sendMessage(ctx.channel.id, { content: content });
15 | },
16 | },
17 | ] as ApplicationCommand[]
18 |
19 |
20 |
--------------------------------------------------------------------------------
/src/lib/command/reload.tsx:
--------------------------------------------------------------------------------
1 | import { ApplicationCommand } from "@/def";
2 | import { BundleUpdaterManager } from "../native";
3 |
4 | export default [
5 | {
6 | name: 'reload',
7 | description: 'Reload Discord.',
8 | execute() {
9 | BundleUpdaterManager.reload();
10 | },
11 | },
12 | ] as ApplicationCommand[]
13 |
14 |
15 |
--------------------------------------------------------------------------------
/src/lib/commands.ts:
--------------------------------------------------------------------------------
1 | import { ApplicationCommand, ApplicationCommandInputType, ApplicationCommandType } from "@types";
2 | import { commands as commandsModule } from "@metro/common";
3 | import { after } from "@lib/patcher";
4 |
5 | let commands: ApplicationCommand[] = [];
6 |
7 | export function patchCommands() {
8 | const unpatch = after("getBuiltInCommands", commandsModule, ([type], res: ApplicationCommand[]) => {
9 | if (type === ApplicationCommandType.CHAT) return[...res, ...commands];
10 | });
11 |
12 | return () => {
13 | commands = [];
14 | unpatch();
15 | };
16 | }
17 |
18 | export function registerCommand(command: ApplicationCommand[]): void {
19 | for(const commandE in command) {
20 | const builtInCommands = commandsModule.getBuiltInCommands(ApplicationCommandType.CHAT, true, false);
21 | builtInCommands.sort((a: ApplicationCommand, b: ApplicationCommand) => parseInt(b.id!) - parseInt(a.id!));
22 | const lastCommand = builtInCommands[builtInCommands.length - 1];
23 | const cmd = command[commandE];
24 |
25 | command[commandE] = {
26 | id: (parseInt(lastCommand.id, 10) - 1).toString(),
27 | displayName: cmd.name,
28 | displayDescription: cmd.description,
29 | type: ApplicationCommandType.CHAT,
30 | inputType: ApplicationCommandInputType.BUILT_IN,
31 | ...cmd,
32 | __isOpti: true,
33 | };
34 | }
35 | commands.push(...command);
36 | }
37 |
38 |
--------------------------------------------------------------------------------
/src/lib/constants.ts:
--------------------------------------------------------------------------------
1 | export const DISCORD_SERVER = "https://discord.gg/zm5MWBPeRp";
2 | export const DISCORD_SERVER_ID = "1228081962883747880";
3 | export const PLUGINS_CHANNEL_ID = "1228464451846672465";
4 | export const THEMES_CHANNEL_ID = "1228464459295756358";
5 | export const GITHUB = "https://github.com/opti-mod";
6 | export const BADGES = 'https://raw.githubusercontent.com/opti-mod/badges/main/';
7 | export const PROXY_PREFIX = "https://opti-mod.github.io/proxy/";
8 | export const VENDETTA_PROXY = "https://vd-plugins.github.io/proxy/";
9 |
10 | export const HTTP_REGEX = /^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&\/=]*)$/;
11 | 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;
--------------------------------------------------------------------------------
/src/lib/debug.ts:
--------------------------------------------------------------------------------
1 | import { RNConstants } from "@types";
2 | import { ReactNative as RN } from "@metro/common";
3 | import { after } from "@lib/patcher";
4 | import { ClientInfoManager, DeviceManager } from "@lib/native";
5 | import { getAssetIDByName } from "@ui/assets";
6 | import { showToast } from "@ui/toasts";
7 | import logger from "@lib/logger";
8 | export let socket: WebSocket;
9 |
10 | export function connectToDebugger(url: string) {
11 | if (socket !== undefined && socket.readyState !== WebSocket.CLOSED) socket.close();
12 |
13 | if (!url) {
14 | showToast("Invalid debugger URL!", getAssetIDByName("Small"));
15 | return;
16 | }
17 |
18 | socket = new WebSocket(`ws://${url}`);
19 |
20 | socket.addEventListener("open", () => showToast("Connected to debugger.", getAssetIDByName("Check")));
21 | socket.addEventListener("message", (message: any) => {
22 | try {
23 | (0, eval)(message.data);
24 | } catch (e) {
25 | console.error(e);
26 | }
27 | });
28 |
29 | socket.addEventListener("error", (err: any) => {
30 | console.log(`Debugger error: ${err.message}`);
31 | showToast("An error occurred with the debugger connection!", getAssetIDByName("Small"));
32 | });
33 | }
34 |
35 | export function patchLogHook() {
36 | const unpatch = after("nativeLoggingHook", globalThis, (args) => {
37 | if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ message: args[0], level: args[1] }));
38 | logger.log(args[0]);
39 | });
40 |
41 | return () => {
42 | socket && socket.close();
43 | unpatch();
44 | }
45 | }
46 |
47 | export const versionHash: string = __vendettaVersion;
48 |
49 | export function getDebugInfo() {
50 | // Hermes
51 | const hermesProps = window.HermesInternal.getRuntimeProperties();
52 | const hermesVer = hermesProps["OSS Release Version"];
53 | const padding = "for RN ";
54 |
55 | // RN
56 | const PlatformConstants = RN.Platform.constants as RNConstants;
57 | const rnVer = PlatformConstants.reactNativeVersion;
58 |
59 | return {
60 | vendetta: {
61 | version: versionHash,
62 | loader: window.__vendetta_loader?.name ?? "Unknown",
63 | },
64 | discord: {
65 | version: ClientInfoManager.Version,
66 | build: ClientInfoManager.Build,
67 | },
68 | react: {
69 | version: React.version,
70 | nativeVersion: hermesVer.startsWith(padding) ? hermesVer.substring(padding.length) : `${rnVer.major}.${rnVer.minor}.${rnVer.patch}`,
71 | },
72 | hermes: {
73 | version: hermesVer,
74 | buildType: hermesProps["Build"],
75 | bytecodeVersion: hermesProps["Bytecode Version"],
76 | },
77 | ...RN.Platform.select(
78 | {
79 | android: {
80 | os: {
81 | name: "Android",
82 | version: PlatformConstants.Release,
83 | sdk: PlatformConstants.Version
84 | },
85 | },
86 | ios: {
87 | os: {
88 | name: PlatformConstants.systemName,
89 | version: PlatformConstants.osVersion
90 | },
91 | }
92 | }
93 | )!,
94 | ...RN.Platform.select(
95 | {
96 | android: {
97 | device: {
98 | manufacturer: PlatformConstants.Manufacturer,
99 | brand: PlatformConstants.Brand,
100 | model: PlatformConstants.Model,
101 | codename: DeviceManager.device
102 | }
103 | },
104 | ios: {
105 | device: {
106 | manufacturer: DeviceManager.deviceManufacturer,
107 | brand: DeviceManager.deviceBrand,
108 | model: DeviceManager.deviceModel,
109 | codename: DeviceManager.device
110 | }
111 | }
112 | }
113 | )!
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/src/lib/emitter.ts:
--------------------------------------------------------------------------------
1 | import { Emitter, EmitterEvent, EmitterListener, EmitterListenerData, EmitterListeners } from "@types";
2 |
3 | export enum Events {
4 | GET = "GET",
5 | SET = "SET",
6 | DEL = "DEL",
7 | };
8 |
9 | export default function createEmitter(): Emitter {
10 | return {
11 | listeners: Object.values(Events).reduce((acc, val: string) => ((acc[val] = new Set()), acc), {}) as EmitterListeners,
12 |
13 | on(event: EmitterEvent, listener: EmitterListener) {
14 | if (!this.listeners[event].has(listener)) this.listeners[event].add(listener);
15 | },
16 |
17 | off(event: EmitterEvent, listener: EmitterListener) {
18 | this.listeners[event].delete(listener);
19 | },
20 |
21 | once(event: EmitterEvent, listener: EmitterListener) {
22 | const once = (event: EmitterEvent, data: EmitterListenerData) => {
23 | this.off(event, once);
24 | listener(event, data);
25 | };
26 | this.on(event, once);
27 | },
28 |
29 | emit(event: EmitterEvent, data: EmitterListenerData) {
30 | for (const listener of this.listeners[event]) listener(event, data);
31 | },
32 | };
33 | }
34 |
--------------------------------------------------------------------------------
/src/lib/fixes.ts:
--------------------------------------------------------------------------------
1 | import { moment } from "@metro/common";
2 | import { findByProps, findByStoreName } from "@metro/filters";
3 | import logger from "@lib/logger";
4 | import { after } from "@lib/patcher";
5 |
6 | const ThemeManager = findByProps("updateTheme", "overrideTheme");
7 | const AMOLEDThemeManager = findByProps("setAMOLEDThemeEnabled");
8 | const ThemeStore = findByStoreName("ThemeStore");
9 | const UnsyncedUserSettingsStore = findByStoreName("UnsyncedUserSettingsStore");
10 | const FluxDispatcher = findByProps("_currentDispatchActionType", "_subscriptions", "_actionHandlers", "_waitQueue");
11 |
12 | function onDispatch({ locale }: { locale: string }) {
13 | // Theming
14 | // Based on https://github.com/Aliucord/AliucordRN/blob/main/src/ui/patchTheme.ts
15 | try {
16 | if (ThemeManager) {
17 | ThemeManager.overrideTheme(ThemeStore?.theme ?? "dark");
18 | if (AMOLEDThemeManager && UnsyncedUserSettingsStore.useAMOLEDTheme === 2) AMOLEDThemeManager.setAMOLEDThemeEnabled(true);
19 | }
20 | } catch (e) {
21 | logger.error("Failed to fix theme...", e);
22 | }
23 |
24 | // Timestamps
25 | try {
26 | moment.locale(locale.toLowerCase());
27 | } catch (e) {
28 | logger.error("Failed to fix timestamps...", e);
29 | }
30 |
31 | // We're done here!
32 | FluxDispatcher.unsubscribe("I18N_LOAD_SUCCESS", onDispatch);
33 | }
34 |
35 | export default () => FluxDispatcher.subscribe("I18N_LOAD_SUCCESS", onDispatch);
36 |
37 |
--------------------------------------------------------------------------------
/src/lib/logger.ts:
--------------------------------------------------------------------------------
1 | import { Logger } from "@types";
2 | import { findByProps } from "@metro/filters";
3 |
4 | export const logModule = findByProps("setLogFn").default;
5 | const logger: Logger = new logModule("Opti");
6 |
7 | export default logger;
--------------------------------------------------------------------------------
/src/lib/metro/common.ts:
--------------------------------------------------------------------------------
1 | import { find, findByProps, findByStoreName } from "@metro/filters";
2 | import { DiscordStyleSheet } from "@types";
3 | import { ReactNative as RN } from "@lib/preinit";
4 | import type { StyleSheet } from "react-native";
5 |
6 | const ThemeStore = findByStoreName("ThemeStore");
7 | const colorModule = findByProps("colors", "unsafe_rawColors");
8 | const colorResolver = colorModule?.internal ?? colorModule?.meta;
9 |
10 | // Reimplementation of Discord's createThemedStyleSheet, which was removed since 204201
11 | // Not exactly a 1:1 reimplementation, but sufficient to keep compatibility with existing plugins
12 | function createThemedStyleSheet>(sheet: T) {
13 | if (!colorModule) return;
14 | for (const key in sheet) {
15 | // @ts-ignore
16 | sheet[key] = new Proxy(RN.StyleSheet.flatten(sheet[key]), {
17 | get(target, prop, receiver) {
18 | const res = Reflect.get(target, prop, receiver);
19 | return colorResolver.isSemanticColor(res)
20 | ? colorResolver.resolveSemanticColor(ThemeStore.theme, res)
21 | : res
22 | }
23 | });
24 | }
25 |
26 | return sheet;
27 | }
28 |
29 | // Discord
30 | export const constants = findByProps("Fonts", "Permissions");
31 | export const channels = findByProps("getVoiceChannelId");
32 | export const i18n = findByProps("Messages");
33 | export const url = findByProps("openURL", "openDeeplink");
34 | export const toasts = find(m => m.open && m.close && !m.startDrag && !m.init && !m.openReplay && !m.setAlwaysOnTop && !m.setAccountFlag);
35 | export const User = findByProps("getCurrentUser");
36 | export const AsyncUsers = findByProps("getUser", "fetchProfile");
37 | export const Profiles = findByProps("showUserProfile");
38 |
39 | // Compatible with pre-204201 versions since createThemedStyleSheet is undefined.
40 | export const stylesheet = {
41 | ...find(m => m.createStyles && !m.ActionSheet),
42 | createThemedStyleSheet,
43 | ...findByProps("createThemedStyleSheet") as {},
44 | } as DiscordStyleSheet;
45 |
46 | export const clipboard = findByProps("setString", "getString", "hasString") as typeof import("@react-native-clipboard/clipboard").default;
47 | export const assets = findByProps("registerAsset");
48 | export const invites = findByProps("acceptInviteAndTransitionToInviteChannel");
49 | export const commands = findByProps("getBuiltInCommands");
50 | export const navigation = findByProps("pushLazy");
51 | export const navigationStack = findByProps("createStackNavigator");
52 | export const NavigationNative = findByProps("NavigationContainer");
53 | export const Messages = findByProps("sendBotMessage");
54 | // Flux
55 | export const Flux = findByProps("connectStores");
56 | export const FluxDispatcher = findByProps("_currentDispatchActionType");
57 |
58 | // React
59 | export const React = window.React as typeof import("react");
60 | export { ReactNative } from "@lib/preinit";
61 |
62 | // Moment
63 | export const moment = findByProps("isMoment") as typeof import("moment");
64 |
65 | // chroma.js
66 | export { chroma } from "@lib/preinit";
67 |
68 | // Lodash
69 | export const lodash = findByProps("forEachRight") as typeof import("lodash");
70 |
71 | // The node:util polyfill for RN
72 | // TODO: Find types for this
73 | export const util = findByProps("inspect", "isNullOrUndefined");
--------------------------------------------------------------------------------
/src/lib/metro/filters.ts:
--------------------------------------------------------------------------------
1 | import { MetroModules, PropsFinder, PropsFinderAll } from "@types";
2 | import { getDebugInfo } from "../debug";
3 |
4 | // Metro require
5 | declare const __r: (moduleId: number) => any;
6 |
7 | // Internal Metro error reporting logic
8 | const originalHandler = window.ErrorUtils.getGlobalHandler();
9 | window.ErrorUtils.setGlobalHandler(null);
10 |
11 | // Function to blacklist a module, preventing it from being searched again
12 | const blacklist = (id: number) => Object.defineProperty(window.modules, id, {
13 | value: window.modules[id],
14 | enumerable: false,
15 | configurable: true,
16 | writable: true
17 | });
18 |
19 | // Blacklist any "bad-actor" modules, e.g. the dreaded null proxy, the window itself, or undefined modules
20 | for (const key in window.modules) {
21 | const id = Number(key);
22 | const module = window.modules[id]?.publicModule?.exports;
23 |
24 | if (!module || module === window || module["proxygone"] === null) {
25 | blacklist(id);
26 | continue;
27 | }
28 | }
29 |
30 |
31 | // Function to filter through modules
32 | // does not work on 223
33 | // credit to SerStars for the patch
34 | // const origToString = Function.prototype.toString;
35 | const filterModules = (modules: MetroModules, single = false) => (filter: (m: any) => boolean) => {
36 | const found = [];
37 |
38 | for (const key in modules) {
39 | const id = Number(key);
40 | const module = modules[id]?.publicModule?.exports;
41 |
42 | // HACK: Override the function used to report fatal JavaScript errors (that crash the app) to prevent module-requiring side effects
43 | // Credit to @pylixonly (492949202121261067) for the initial version of this fix
44 | if (!modules[id].isInitialized) try {
45 | if(getDebugInfo().discord.version >= 224) {
46 | const orig = Function.prototype.toString;
47 | Object.defineProperty(Function.prototype, 'toString', {
48 | value: orig,
49 | configurable: true,
50 | writable: false
51 | });
52 |
53 | __r(id as any as number);
54 |
55 | Object.defineProperty(Function.prototype, 'toString', {
56 | value: orig,
57 | configurable: true,
58 | writable: true
59 | });
60 | }
61 | window.ErrorUtils.setGlobalHandler(() => {});
62 | __r(id);
63 | window.ErrorUtils.setGlobalHandler(originalHandler);
64 | } catch {}
65 |
66 | if (!module) {
67 | blacklist(id);
68 | continue;
69 | }
70 |
71 | if (module.default && module.__esModule && filter(module.default)) {
72 | if (single) return module.default;
73 | found.push(module.default);
74 | }
75 |
76 | if (filter(module)) {
77 | if (single) return module;
78 | else found.push(module);
79 | }
80 | }
81 |
82 | if (!single) return found;
83 | }
84 |
85 | export const modules = window.modules;
86 | export const find = filterModules(modules, true);
87 | export const findAll = filterModules(modules);
88 |
89 | const propsFilter = (props: (string | symbol)[]) => (m: any) => props.every((p) => m[p] !== undefined);
90 | const nameFilter = (name: string, defaultExp: boolean) => (defaultExp ? (m: any) => m?.name === name : (m: any) => m?.default?.name === name);
91 | const dNameFilter = (displayName: string, defaultExp: boolean) => (defaultExp ? (m: any) => m?.displayName === displayName : (m: any) => m?.default?.displayName === displayName);
92 | const tNameFilter = (typeName: string, defaultExp: boolean) => (defaultExp ? (m: any) => m?.type?.name === typeName : (m: any) => m?.default?.type?.name === typeName);
93 | const storeFilter = (name: string) => (m: any) => m.getName && m.getName.length === 0 && m.getName() === name;
94 |
95 | export const findByProps: PropsFinder = (...props) => find(propsFilter(props));
96 | export const findByPropsAll: PropsFinderAll = (...props) => findAll(propsFilter(props));
97 |
98 | export const findByName = (name: string, defaultExp = true) => find(nameFilter(name, defaultExp));
99 | export const findByNameAll = (name: string, defaultExp = true) => findAll(nameFilter(name, defaultExp));
100 | export const findByDisplayName = (displayName: string, defaultExp = true) => find(dNameFilter(displayName, defaultExp));
101 | export const findByDisplayNameAll = (displayName: string, defaultExp = true) => findAll(dNameFilter(displayName, defaultExp));
102 | export const findByTypeName = (typeName: string, defaultExp = true) => find(tNameFilter(typeName, defaultExp));
103 | export const findByTypeNameAll = (typeName: string, defaultExp = true) => findAll(tNameFilter(typeName, defaultExp));
104 | export const findByStoreName = (name: string) => find(storeFilter(name));
105 |
--------------------------------------------------------------------------------
/src/lib/native.ts:
--------------------------------------------------------------------------------
1 | import { MMKVManager as _MMKVManager, FileManager as _FileManager } from "@types";
2 | const nmp = window.nativeModuleProxy;
3 |
4 | export const MMKVManager = nmp.MMKVManager as _MMKVManager;
5 | //! 173.10 renamed this to RTNFileManager.
6 | export const FileManager = (nmp.DCDFileManager ?? nmp.RTNFileManager) as _FileManager;
7 | //! 173.13 renamed this to RTNClientInfoManager.
8 | export const ClientInfoManager = nmp.InfoDictionaryManager ?? nmp.RTNClientInfoManager;
9 | //! 173.14 renamed this to RTNDeviceManager.
10 | export const DeviceManager = nmp.DCDDeviceManager ?? nmp.RTNDeviceManager;
11 | export const BundleUpdaterManager = nmp.BundleUpdaterManager;
--------------------------------------------------------------------------------
/src/lib/patcher.ts:
--------------------------------------------------------------------------------
1 | import * as _spitroast from "spitroast";
2 |
3 | export * from "spitroast";
4 | export default { ..._spitroast };
--------------------------------------------------------------------------------
/src/lib/plugins.ts:
--------------------------------------------------------------------------------
1 | import { PluginManifest, Plugin } from "@types";
2 | import { safeFetch } from "@lib/utils";
3 | import { awaitSyncWrapper, createMMKVBackend, createStorage, purgeStorage, wrapSync } from "@lib/storage";
4 | import { allSettled } from "@lib/polyfills";
5 | import logger, { logModule } from "@lib/logger";
6 | import settings from "@lib/settings";
7 |
8 | type EvaledPlugin = {
9 | onLoad?(): void;
10 | onUnload(): void;
11 | settings: JSX.Element;
12 | };
13 | export const plugins = wrapSync(createStorage>(createMMKVBackend("VENDETTA_PLUGINS")));
14 | const loadedPlugins: Record = {};
15 | export let pluginsList = new Array();
16 | export let stoppedPlugins = new Array();
17 |
18 | export async function fetchPlugin(id: string) {
19 | if (!id.endsWith("/")) id += "/";
20 | const existingPlugin = plugins[id];
21 |
22 | let pluginManifest: PluginManifest;
23 |
24 | try {
25 | pluginManifest = await (await safeFetch(id + "manifest.json", { cache: "no-store" })).json();
26 | } catch {
27 | throw new Error(`Failed to fetch manifest for ${id}`);
28 | }
29 |
30 | let pluginJs: string | undefined;
31 |
32 | if (existingPlugin?.manifest.hash !== pluginManifest.hash) {
33 | try {
34 | // by polymanifest spec, plugins should always specify their main file, but just in case
35 | pluginJs = await (await safeFetch(id + (pluginManifest.main || "index.js"), { cache: "no-store" })).text();
36 | } catch {} // Empty catch, checked below
37 | }
38 |
39 | if (!pluginJs && !existingPlugin) throw new Error(`Failed to fetch JS for ${id}`);
40 |
41 | plugins[id] = {
42 | id: id,
43 | manifest: pluginManifest,
44 | enabled: existingPlugin?.enabled ?? false,
45 | update: existingPlugin?.update ?? true,
46 | js: pluginJs ?? existingPlugin.js,
47 | };
48 | }
49 |
50 | export async function installPlugin(id: string, enabled = true) {
51 | if (!id.endsWith("/")) id += "/";
52 | if (typeof id !== "string" || id in plugins) throw new Error("Plugin already installed");
53 | await fetchPlugin(id);
54 | if (enabled) await startPlugin(id);
55 | }
56 |
57 | export async function evalPlugin(plugin: Plugin) {
58 | const vendettaForPlugins = {
59 | ...window.vendetta,
60 | plugin: {
61 | id: plugin.id,
62 | manifest: plugin.manifest,
63 | // Wrapping this with wrapSync is NOT an option.
64 | storage: await createStorage>(createMMKVBackend(plugin.id)),
65 | },
66 | logger: new logModule(`Opti » ${plugin.manifest.name}`),
67 | };
68 | const pluginString = `vendetta=>{return ${plugin.js}}\n//# sourceURL=${plugin.id}`;
69 | const raw = (0, eval)(pluginString)(vendettaForPlugins);
70 | const ret = typeof raw == "function" ? raw() : raw;
71 | return ret?.default ?? ret ?? {};
72 | }
73 |
74 | export async function startPlugin(id: string) {
75 | if (!id.endsWith("/")) id += "/";
76 | const plugin = plugins[id];
77 | if (!plugin) throw new Error("Attempted to start non-existent plugin");
78 | try {
79 | const pluginRet: EvaledPlugin = await evalPlugin(plugin);
80 | loadedPlugins[id] = pluginRet;
81 | pluginRet.onLoad?.();
82 | pluginsList.push(" " + plugin.manifest.name);
83 | plugin.enabled = true;
84 | } catch (e) {
85 | stoppedPlugins.push(" " + plugin.id);
86 | logger.error(`Plugin ${plugin.id} errored whilst loading, and will be unloaded`, e);
87 |
88 | try {
89 | loadedPlugins[plugin.id]?.onUnload?.();
90 | } catch (e2) {
91 | logger.error(`Plugin ${plugin.id} errored whilst unloading`, e2);
92 | }
93 |
94 | delete loadedPlugins[id];
95 | plugin.enabled = false;
96 | }
97 | }
98 |
99 | export function stopPlugin(id: string, disable = true) {
100 | if (!id.endsWith("/")) id += "/";
101 | const plugin = plugins[id];
102 | const pluginRet = loadedPlugins[id];
103 | if (!plugin) throw new Error("Attempted to stop non-existent plugin");
104 | try {
105 | pluginRet?.onUnload?.();
106 | } catch (e) {
107 | logger.error(`Plugin ${plugin.id} errored whilst unloading`, e);
108 | }
109 |
110 | delete loadedPlugins[id];
111 | disable && (plugin.enabled = false);
112 | }
113 |
114 | export async function removePlugin(id: string) {
115 | if (!id.endsWith("/")) id += "/";
116 | const plugin = plugins[id];
117 | if (plugin.enabled) stopPlugin(id);
118 | delete plugins[id];
119 | await purgeStorage(id);
120 | }
121 |
122 | export async function initPlugins() {
123 | await awaitSyncWrapper(settings);
124 | await awaitSyncWrapper(plugins);
125 | const allIds = Object.keys(plugins);
126 | // Loop over any plugin that is enabled, update it if allowed, then start it.
127 | await allSettled(allIds.filter(pl => plugins[pl].enabled).map(async (pl) => (plugins[pl].update && await fetchPlugin(pl).catch((e: Error) => logger.error(e.message)), await startPlugin(pl))));
128 | // Wait for the above to finish, then update all disabled plugins that are allowed to.
129 | allIds.filter(pl => !plugins[pl].enabled && plugins[pl].update).forEach(pl => fetchPlugin(pl));
130 |
131 | return stopAllPlugins;
132 | }
133 |
134 | const stopAllPlugins = () => Object.keys(loadedPlugins).forEach(p => stopPlugin(p, false));
135 |
136 | export function getPlugins() {
137 | var num = 0;
138 | Object.keys(loadedPlugins).forEach(p => num++);
139 | return num;
140 | }
141 |
142 | export function getPluginList() {
143 | return pluginsList.sort();
144 | }
145 |
146 | export function getDisabledPlugins() {
147 | return stoppedPlugins.sort();
148 | }
149 |
150 | export const getSettings = (id: string) => loadedPlugins[id]?.settings;
151 |
--------------------------------------------------------------------------------
/src/lib/polyfills.ts:
--------------------------------------------------------------------------------
1 | //! Starting from 202.4, Promise.allSettled may be undefined due to conflicting then/promise versions, so we use our own.
2 | const allSettledFulfill = (value: T) => ({ status: "fulfilled", value });
3 | const allSettledReject = (reason: T) => ({ status: "rejected", reason });
4 | const mapAllSettled = (item: T) => Promise.resolve(item).then(allSettledFulfill, allSettledReject);
5 | export const allSettled = (iterator: T) => Promise.all(Array.from(iterator).map(mapAllSettled));
6 |
--------------------------------------------------------------------------------
/src/lib/preinit.ts:
--------------------------------------------------------------------------------
1 | import { initThemes } from "@lib/themes";
2 | import { instead } from "@lib/patcher";
3 |
4 | // Hoist required modules
5 | // This used to be in filters.ts, but things became convoluted
6 |
7 | const basicFind = (filter: (m: any) => any | string) => {
8 | for (const key in window.modules) {
9 | const exp = window.modules[key]?.publicModule.exports;
10 | if (exp && filter(exp)) return exp;
11 | }
12 | }
13 |
14 | const requireNativeComponent = basicFind(m => m?.default?.name === "requireNativeComponent");
15 |
16 | if (requireNativeComponent) {
17 | // > "Tried to register two views with the same name DCDVisualEffectView"
18 | // This serves as a workaround for the crashing You tab on Android starting from version 192.x
19 | // How? We simply ignore it.
20 | instead("default", requireNativeComponent, (args, orig) => {
21 | try {
22 | return orig(...args);
23 | } catch {
24 | return args[0];
25 | }
26 | })
27 | }
28 |
29 | // Hoist React on window
30 | window.React = basicFind(m => m.createElement) as typeof import("react");
31 |
32 | // Export ReactNative
33 | export const ReactNative = basicFind(m => m.AppRegistry) as typeof import("react-native");
34 |
35 | // Export chroma.js
36 | export const chroma = basicFind(m => m.brewer) as typeof import("chroma-js");
37 |
38 | // Themes
39 | if (window.__vendetta_loader?.features.themes) {
40 | try {
41 | initThemes();
42 | } catch (e) {
43 | console.error("[Opti] Failed to initialize themes...", e);
44 | }
45 | }
--------------------------------------------------------------------------------
/src/lib/settings.ts:
--------------------------------------------------------------------------------
1 | import { LoaderConfig, Settings } from "@types";
2 | import { createFileBackend, createMMKVBackend, createStorage, wrapSync } from "@lib/storage";
3 |
4 | export default wrapSync(createStorage(createMMKVBackend("VENDETTA_SETTINGS")));
5 | export const loaderConfig = wrapSync(createStorage(createFileBackend("vendetta_loader.json")));
6 |
--------------------------------------------------------------------------------
/src/lib/storage/backends.ts:
--------------------------------------------------------------------------------
1 | import { StorageBackend } from "@types";
2 | import { MMKVManager, FileManager } from "@lib/native";
3 | import { ReactNative as RN } from "@metro/common";
4 |
5 | const ILLEGAL_CHARS_REGEX = /[<>:"\/\\|?*]/g;
6 |
7 | const filePathFixer = (file: string): string => RN.Platform.select({
8 | default: file,
9 | ios: FileManager.saveFileToGallery ? file : `Documents/${file}`,
10 | });
11 |
12 | const getMMKVPath = (name: string): string => {
13 | if (ILLEGAL_CHARS_REGEX.test(name)) {
14 | // Replace forbidden characters with hyphens
15 | name = name.replace(ILLEGAL_CHARS_REGEX, '-').replace(/-+/g, '-');
16 | }
17 |
18 | return `vd_mmkv/${name}`;
19 | }
20 |
21 | export const purgeStorage = async (store: string) => {
22 | if (await MMKVManager.getItem(store)) {
23 | MMKVManager.removeItem(store);
24 | }
25 |
26 | const mmkvPath = getMMKVPath(store);
27 | if (await FileManager.fileExists(`${FileManager.getConstants().DocumentsDirPath}/${mmkvPath}`)) {
28 | await FileManager.removeFile?.("documents", mmkvPath);
29 | }
30 | }
31 |
32 | export const createMMKVBackend = (store: string) => {
33 | const mmkvPath = getMMKVPath(store);
34 | return createFileBackend(mmkvPath, (async () => {
35 | try {
36 | const path = `${FileManager.getConstants().DocumentsDirPath}/${mmkvPath}`;
37 | if (await FileManager.fileExists(path)) return;
38 |
39 | let oldData = await MMKVManager.getItem(store) ?? "{}";
40 |
41 | // From the testing on Android, it seems to return this if the data is too large
42 | if (oldData === "!!LARGE_VALUE!!") {
43 | const cachePath = `${FileManager.getConstants().CacheDirPath}/mmkv/${store}`;
44 | if (await FileManager.fileExists(cachePath)) {
45 | oldData = await FileManager.readFile(cachePath, "utf8")
46 | } else {
47 | console.log(`${store}: Experienced data loss :(`);
48 | oldData = "{}";
49 | }
50 | }
51 |
52 | await FileManager.writeFile("documents", filePathFixer(mmkvPath), oldData, "utf8");
53 | if (await MMKVManager.getItem(store) !== null) {
54 | MMKVManager.removeItem(store);
55 | console.log(`Successfully migrated ${store} store from MMKV storage to fs`);
56 | }
57 | } catch (err) {
58 | console.error("Failed to migrate to fs from MMKVManager ", err)
59 | }
60 | })());
61 | }
62 |
63 | export const createFileBackend = (file: string, migratePromise?: Promise): StorageBackend => {
64 | let created: boolean;
65 | return {
66 | get: async () => {
67 | await migratePromise;
68 | const path = `${FileManager.getConstants().DocumentsDirPath}/${file}`;
69 | if (!created && !(await FileManager.fileExists(path))) return (created = true), FileManager.writeFile("documents", filePathFixer(file), "{}", "utf8");
70 | return JSON.parse(await FileManager.readFile(path, "utf8"));
71 | },
72 | set: async (data) => {
73 | await migratePromise;
74 | await FileManager.writeFile("documents", filePathFixer(file), JSON.stringify(data), "utf8");
75 | }
76 | };
77 | };
78 |
--------------------------------------------------------------------------------
/src/lib/storage/index.ts:
--------------------------------------------------------------------------------
1 | import { Emitter, StorageBackend } from "@types";
2 | import createEmitter from "@lib/emitter";
3 |
4 | const emitterSymbol = Symbol.for("vendetta.storage.emitter");
5 | const syncAwaitSymbol = Symbol.for("vendetta.storage.accessor");
6 | const storageErrorSymbol = Symbol.for("vendetta.storage.error");
7 |
8 | export function createProxy(target: any = {}): { proxy: any; emitter: Emitter } {
9 | const emitter = createEmitter();
10 |
11 | function createProxy(target: any, path: string[]): any {
12 | return new Proxy(target, {
13 | get(target, prop: string) {
14 | if ((prop as unknown) === emitterSymbol) return emitter;
15 |
16 | const newPath = [...path, prop];
17 | const value: any = target[prop];
18 |
19 | if (value !== undefined && value !== null) {
20 | emitter.emit("GET", {
21 | path: newPath,
22 | value,
23 | });
24 | if (typeof value === "object") {
25 | return createProxy(value, newPath);
26 | }
27 | return value;
28 | }
29 |
30 | return value;
31 | },
32 |
33 | set(target, prop: string, value) {
34 | target[prop] = value;
35 | emitter.emit("SET", {
36 | path: [...path, prop],
37 | value,
38 | });
39 | // we do not care about success, if this actually does fail we have other problems
40 | return true;
41 | },
42 |
43 | deleteProperty(target, prop: string) {
44 | const success = delete target[prop];
45 | if (success)
46 | emitter.emit("DEL", {
47 | path: [...path, prop],
48 | });
49 | return success;
50 | },
51 | });
52 | }
53 |
54 | return {
55 | proxy: createProxy(target, []),
56 | emitter,
57 | };
58 | }
59 |
60 | export function useProxy(storage: T & { [key: symbol]: any }): T {
61 | if (storage[storageErrorSymbol]) throw storage[storageErrorSymbol];
62 |
63 | const emitter = storage[emitterSymbol] as Emitter;
64 |
65 | if (!emitter) throw new Error("InvalidArgumentExcpetion - storage[emitterSymbol] is " + typeof emitter);
66 |
67 | const [, forceUpdate] = React.useReducer((n) => ~n, 0);
68 |
69 | React.useEffect(() => {
70 | const listener = () => forceUpdate();
71 |
72 | emitter.on("SET", listener);
73 | emitter.on("DEL", listener);
74 |
75 | return () => {
76 | emitter.off("SET", listener);
77 | emitter.off("DEL", listener);
78 | };
79 | }, []);
80 |
81 | return storage;
82 | }
83 |
84 | export async function createStorage(backend: StorageBackend): Promise> {
85 | const data = await backend.get();
86 | const { proxy, emitter } = createProxy(data);
87 |
88 | const handler = () => backend.set(proxy);
89 | emitter.on("SET", handler);
90 | emitter.on("DEL", handler);
91 |
92 | return proxy;
93 | }
94 |
95 | export function wrapSync>(store: T): Awaited {
96 | let awaited: any = undefined;
97 | let error: any = undefined;
98 |
99 | const awaitQueue: (() => void)[] = [];
100 | const awaitInit = (cb: () => void) => (awaited ? cb() : awaitQueue.push(cb));
101 |
102 | store.then((v) => {
103 | awaited = v;
104 | awaitQueue.forEach((cb) => cb());
105 | }).catch((e) => {
106 | error = e;
107 | });
108 |
109 | return new Proxy({} as Awaited, {
110 | ...Object.fromEntries(
111 | Object.getOwnPropertyNames(Reflect)
112 | // @ts-expect-error
113 | .map((k) => [k, (t: T, ...a: any[]) => Reflect[k](awaited ?? t, ...a)])
114 | ),
115 | get(target, prop, recv) {
116 | if (prop === storageErrorSymbol) return error;
117 | if (prop === syncAwaitSymbol) return awaitInit;
118 | return Reflect.get(awaited ?? target, prop, recv);
119 | },
120 | });
121 | }
122 |
123 | export const awaitSyncWrapper = (store: any) => new Promise((res) => store[syncAwaitSymbol](res));
124 |
125 | export * from "@lib/storage/backends";
126 |
--------------------------------------------------------------------------------
/src/lib/themes.ts:
--------------------------------------------------------------------------------
1 | import { Theme, ThemeData } from "@types";
2 | import { ReactNative as RN, chroma } from "@metro/common";
3 | import { findInReactTree, safeFetch } from "@lib/utils";
4 | import { findByName, findByProps } from "@metro/filters";
5 | import { instead, after } from "@lib/patcher";
6 | import { createFileBackend, createMMKVBackend, createStorage, wrapSync, awaitSyncWrapper } from "@lib/storage";
7 | import logger from "./logger";
8 |
9 | //! As of 173.10, early-finding this does not work.
10 | // Somehow, this is late enough, though?
11 | export const color = findByProps("SemanticColor");
12 |
13 | export const themes = wrapSync(createStorage>(createMMKVBackend("VENDETTA_THEMES")));
14 |
15 | const semanticAlternativeMap: Record = {
16 | "BG_BACKDROP": "BACKGROUND_FLOATING",
17 | "BG_BASE_PRIMARY": "BACKGROUND_PRIMARY",
18 | "BG_BASE_SECONDARY": "BACKGROUND_SECONDARY",
19 | "BG_BASE_TERTIARY": "BACKGROUND_SECONDARY_ALT",
20 | "BG_MOD_FAINT": "BACKGROUND_MODIFIER_ACCENT",
21 | "BG_MOD_STRONG": "BACKGROUND_MODIFIER_ACCENT",
22 | "BG_MOD_SUBTLE": "BACKGROUND_MODIFIER_ACCENT",
23 | "BG_SURFACE_OVERLAY": "BACKGROUND_FLOATING",
24 | "BG_SURFACE_OVERLAY_TMP": "BACKGROUND_FLOATING",
25 | "BG_SURFACE_RAISED": "BACKGROUND_MOBILE_PRIMARY"
26 | }
27 |
28 | async function writeTheme(theme: Theme | {}) {
29 | if (typeof theme !== "object") throw new Error("Theme must be an object");
30 |
31 | // Save the current theme as vendetta_theme.json. When supported by loader,
32 | // this json will be written to window.__vendetta_theme and be used to theme the native side.
33 | await createFileBackend("vendetta_theme.json").set(theme);
34 | }
35 |
36 | export function patchChatBackground() {
37 | const currentBackground = getCurrentTheme()?.data?.background;
38 | if (!currentBackground) return;
39 |
40 | const MessagesWrapperConnected = findByName("MessagesWrapperConnected", false);
41 | if (!MessagesWrapperConnected) return;
42 | const { MessagesWrapper } = findByProps("MessagesWrapper");
43 | if (!MessagesWrapper) return;
44 |
45 | const patches = [
46 | after("default", MessagesWrapperConnected, (_, ret) => React.createElement(RN.ImageBackground, {
47 | style: { flex: 1, height: "100%" },
48 | source: { uri: currentBackground.url },
49 | blurRadius: typeof currentBackground.blur === "number" ? currentBackground.blur : 0,
50 | children: ret,
51 | })),
52 | after("render", MessagesWrapper.prototype, (_, ret) => {
53 | const Messages = findInReactTree(ret, (x) => "HACK_fixModalInteraction" in x?.props && x?.props?.style);
54 | if (Messages)
55 | Messages.props.style = Object.assign(
56 | RN.StyleSheet.flatten(Messages.props.style ?? {}),
57 | {
58 | backgroundColor: "#0000"
59 | }
60 | );
61 | else
62 | logger.error("Didn't find Messages when patching MessagesWrapper!");
63 | })
64 | ];
65 |
66 | return () => patches.forEach(x => x());
67 | }
68 |
69 | function normalizeToHex(colorString: string): string {
70 | if (chroma.valid(colorString)) return chroma(colorString).hex();
71 |
72 | const color = Number(RN.processColor(colorString));
73 |
74 | return chroma.rgb(
75 | color >> 16 & 0xff, // red
76 | color >> 8 & 0xff, // green
77 | color & 0xff, // blue
78 | color >> 24 & 0xff // alpha
79 | ).hex();
80 | }
81 |
82 | // Process data for some compatiblity with native side
83 | function processData(data: ThemeData) {
84 | if (data.semanticColors) {
85 | const semanticColors = data.semanticColors;
86 |
87 | for (const key in semanticColors) {
88 | for (const index in semanticColors[key]) {
89 | semanticColors[key][index] &&= normalizeToHex(semanticColors[key][index] as string);
90 | }
91 | }
92 | }
93 |
94 | if (data.rawColors) {
95 | const rawColors = data.rawColors;
96 |
97 | for (const key in rawColors) {
98 | data.rawColors[key] = normalizeToHex(rawColors[key]);
99 | }
100 |
101 | if (RN.Platform.OS === "android") applyAndroidAlphaKeys(rawColors);
102 | }
103 |
104 | return data;
105 | }
106 |
107 | function applyAndroidAlphaKeys(rawColors: Record) {
108 | // these are native Discord Android keys
109 | const alphaMap: Record = {
110 | "BLACK_ALPHA_60": ["BLACK", 0.6],
111 | "BRAND_NEW_360_ALPHA_20": ["BRAND_360", 0.2],
112 | "BRAND_NEW_360_ALPHA_25": ["BRAND_360", 0.25],
113 | "BRAND_NEW_500_ALPHA_20": ["BRAND_500", 0.2],
114 | "PRIMARY_DARK_500_ALPHA_20": ["PRIMARY_500", 0.2],
115 | "PRIMARY_DARK_700_ALPHA_60": ["PRIMARY_700", 0.6],
116 | "STATUS_GREEN_500_ALPHA_20": ["GREEN_500", 0.2],
117 | "STATUS_RED_500_ALPHA_20": ["RED_500", 0.2],
118 | };
119 |
120 | for (const key in alphaMap) {
121 | const [colorKey, alpha] = alphaMap[key];
122 | if (!rawColors[colorKey]) continue;
123 | rawColors[key] = chroma(rawColors[colorKey]).alpha(alpha).hex();
124 | }
125 | }
126 |
127 | export async function fetchTheme(id: string, selected = false) {
128 | let themeJSON: any;
129 |
130 | try {
131 | themeJSON = await (await safeFetch(id, { cache: "no-store" })).json();
132 | } catch {
133 | throw new Error(`Failed to fetch theme at ${id}`);
134 | }
135 |
136 | themes[id] = {
137 | id: id,
138 | selected: selected,
139 | data: processData(themeJSON),
140 | };
141 |
142 | if (selected) {
143 | writeTheme(themes[id]);
144 | // actually add theme?>?????
145 | }
146 | }
147 |
148 |
149 |
150 | export async function installTheme(id: string) {
151 | if (typeof id !== "string" || id in themes) throw new Error("Theme already installed");
152 | await fetchTheme(id);
153 | }
154 |
155 | export async function selectTheme(id: string) {
156 | if (id === "default") return await writeTheme({});
157 | const selectedThemeId = Object.values(themes).find(i => i.selected)?.id;
158 |
159 | if (selectedThemeId) themes[selectedThemeId].selected = false;
160 | themes[id].selected = true;
161 | await writeTheme(themes[id]);
162 | }
163 |
164 | export async function removeTheme(id: string) {
165 | const theme = themes[id];
166 | if (theme.selected) await selectTheme("default");
167 | delete themes[id];
168 |
169 | return theme.selected;
170 | }
171 |
172 | export function getCurrentTheme(): Theme | null {
173 | const themeProp = window.__vendetta_loader?.features?.themes?.prop;
174 | if (!themeProp) return null;
175 | return window[themeProp] || null;
176 | }
177 |
178 | export async function updateThemes() {
179 | await awaitSyncWrapper(themes);
180 | const currentTheme = getCurrentTheme();
181 | await Promise.allSettled(Object.keys(themes).map(id => fetchTheme(id, currentTheme?.id === id)));
182 | }
183 |
184 | export async function initThemes() {
185 | const selectedTheme = getCurrentTheme();
186 | if (!selectedTheme) return;
187 |
188 | const oldRaw = color.default.unsafe_rawColors;
189 |
190 | color.default.unsafe_rawColors = new Proxy(oldRaw, {
191 | get: (_, colorProp: string) => {
192 | if (!selectedTheme) return Reflect.get(oldRaw, colorProp);
193 |
194 | return selectedTheme.data?.rawColors?.[colorProp] ?? Reflect.get(oldRaw, colorProp);
195 | }
196 | });
197 |
198 | instead("resolveSemanticColor", color.default.meta ?? color.default.internal, (args, orig) => {
199 | if (!selectedTheme) return orig(...args);
200 |
201 | const [theme, propIndex] = args;
202 | const [name, colorDef] = extractInfo(theme, propIndex);
203 |
204 | const themeIndex = theme === "amoled" ? 2 : theme === "light" ? 1 : 0;
205 |
206 | //! As of 192.7, Tabs v2 uses BG_ semantic colors instead of BACKGROUND_ ones
207 | const alternativeName = semanticAlternativeMap[name] ?? name;
208 |
209 | const semanticColorVal = (selectedTheme.data?.semanticColors?.[name] ?? selectedTheme.data?.semanticColors?.[alternativeName])?.[themeIndex];
210 | if (name === "CHAT_BACKGROUND" && typeof selectedTheme.data?.background?.alpha === "number") {
211 | return chroma(semanticColorVal || "black").alpha(1 - selectedTheme.data.background.alpha).hex();
212 | }
213 |
214 | if (semanticColorVal) return semanticColorVal;
215 |
216 | const rawValue = selectedTheme.data?.rawColors?.[colorDef.raw];
217 | if (rawValue) {
218 | // Set opacity if needed
219 | return colorDef.opacity === 1 ? rawValue : chroma(rawValue).alpha(colorDef.opacity).hex();
220 | }
221 |
222 | // Fallback to default
223 | return orig(...args);
224 | });
225 |
226 | await updateThemes();
227 | }
228 |
229 | function extractInfo(themeMode: string, colorObj: any): [name: string, colorDef: any] {
230 | // @ts-ignore - assigning to extractInfo._sym
231 | const propName = colorObj[extractInfo._sym ??= Object.getOwnPropertySymbols(colorObj)[0]];
232 | const colorDef = color.SemanticColor[propName];
233 |
234 | return [propName, colorDef[themeMode.toLowerCase()]];
235 | }
236 |
237 | export function getThemes()
238 | {
239 | var num = 0;
240 | Object.keys(themes).forEach(p => num++);
241 | return num;
242 | }
--------------------------------------------------------------------------------
/src/lib/tweak/enableExperiments.ts:
--------------------------------------------------------------------------------
1 | import { findByProps } from "../metro/filters";
2 | import { User } from "../metro/common";
3 |
4 | const FluxDispatcher = findByProps("_currentDispatchActionType");
5 | const SerializedExperimentStore = findByProps("getSerializedState");
6 |
7 | export function enableExperiments() {
8 | // rosie from rosiecord https://github.com/acquitelol/enable-staging/blob/mistress/src/index.ts
9 | try {
10 | User.getCurrentUser().flags |= 1;
11 | (User as any)._dispatcher._actionHandlers
12 | ._computeOrderedActionHandlers("OVERLAY_INITIALIZE")
13 | //@ts-ignore
14 | .forEach(m => {
15 | m.name.includes("Experiment") &&
16 | m.actionHandler({
17 | serializedExperimentStore: SerializedExperimentStore.getSerializedState(),
18 | user: { flags: 1 },
19 | });
20 | });
21 | } catch(e) {
22 | const err = new Error()
23 | console.error(err.stack);
24 | }
25 | }
26 |
27 | export function unloadEnableExperiments() {
28 | FluxDispatcher.unsubscribe("CONNECTION_OPEN");
29 | }
30 |
--------------------------------------------------------------------------------
/src/lib/tweak/fixConnecting.ts:
--------------------------------------------------------------------------------
1 | import { findByProps, findByStoreName } from "../metro/filters";
2 | import { after } from "@lib/patcher";
3 |
4 | let unpatch: () => boolean;
5 |
6 | export function fixConnection() {
7 | // Fix Connecting - https://github.com/m4fn3/FixConnecting/blob/master/src/index.tsx
8 | let sessionStart = findByProps("startSession");
9 | let sessionStore = findByStoreName("AuthenticationStore");
10 | const FluxDispatcher = findByProps("_currentDispatchActionType", "_subscriptions", "_actionHandlers", "_waitQueue");
11 |
12 | try {
13 | unpatch = after("startSession", sessionStart, (args, res) => {
14 | setTimeout(() => {
15 | let session_id = sessionStore.getSessionId()
16 | if (!session_id) {
17 | FluxDispatcher?.dispatch({ type: 'APP_STATE_UPDATE', state: 'active' })
18 | console.log("Successfully patched infinite connecting.");
19 | }
20 | }, 200)
21 | })
22 | }
23 | catch {
24 | console.log("Failed to patch infinite connection, please reload the app.");
25 | }
26 | }
--------------------------------------------------------------------------------
/src/lib/tweak/index.tsx:
--------------------------------------------------------------------------------
1 | import settings from "@lib/settings";
2 | import { hideDumbButtons, unloadHideButtons } from "@/lib/tweak/removeChatButtons";
3 | import { removePrompts, unloadRemovePrompts } from "./removePrompts";
4 | import { loadBadges } from "../badge/index";
5 | import { fixConnection } from "./fixConnecting";
6 |
7 | export default function loadTweaks()
8 | {
9 | //@ts-ignore
10 | settings.tweaks ??= {};
11 | console.log("TweakManager has successfully initialized.");
12 |
13 | // To prevent potential crashing.
14 | if(settings.tweaks.hideButtons == undefined)
15 | settings.tweaks.hideButtons = false;
16 |
17 | if(settings.tweaks.removePrompts == undefined)
18 | settings.tweaks.removePrompts = false;
19 |
20 | if(settings.tweaks.externalbadges == undefined)
21 | settings.tweaks.externalbadges = true;
22 |
23 | (settings.tweaks.hideButtons ? hideDumbButtons : unloadHideButtons)();
24 | (settings.tweaks.removePrompts ? removePrompts : unloadRemovePrompts)();
25 | fixConnection();
26 | loadBadges();
27 | }
28 |
--------------------------------------------------------------------------------
/src/lib/tweak/removeChatButtons.ts:
--------------------------------------------------------------------------------
1 | import { after } from "../patcher";
2 | import { findByName } from "../metro/filters";
3 | import { getAssetIDByName } from "@/ui/assets";
4 | import { findInReactTree } from "../utils";
5 |
6 | const ChatInput = findByName("ChatInput");
7 | let unpatch: () => boolean;
8 |
9 | // credit to https://github.com/amsyarasyiq/letup/blob/main/plugins/HideGiftButton/src/index.ts
10 | export function hideDumbButtons() {
11 | console.log("TweakManager has loaded RemoveChatButtons.");
12 | const blockList = ["ic_thread_normal_24px", "ic_gift", "AppsIcon"].map(n => getAssetIDByName(n));
13 |
14 | unpatch = after("render", ChatInput.prototype, (_, res) => {
15 | let voiceBlock = findInReactTree(res, r => r.props?.canSendVoiceMessage);
16 | if (voiceBlock) {
17 | voiceBlock.props.canSendVoiceMessage = false
18 | }
19 | const input = findInReactTree(res, t => "forceAnimateButtons" in t.props && t.props.actions);
20 | //@ts-ignore it works
21 | input.props.actions = input.props.actions.filter(a => !blockList.includes(a.source));
22 | });
23 | }
24 |
25 | export function unloadHideButtons() {
26 | unpatch;
27 | }
28 |
29 |
--------------------------------------------------------------------------------
/src/lib/tweak/removePrompts.ts:
--------------------------------------------------------------------------------
1 | import { instead } from "../patcher";
2 | import { findByProps } from "../metro/filters";
3 | import { i18n } from "../metro/common";
4 | import { after } from "../patcher";
5 | import { findByStoreName } from "../metro/filters";
6 |
7 | const MaskedLink = findByStoreName("MaskedLinkStore");
8 | let unpatch: () => boolean;
9 | let patches: Function[] = [];
10 |
11 | export function removePrompts() {
12 | console.log("TweakManager has loaded RemovePrompts.");
13 | const prompt = findByProps("show", "openLazy");
14 |
15 | unpatch = instead("show", prompt, (args, res) => {
16 | if (args?.[0]?.title === i18n.Messages.DELETE_MESSAGE || args?.[0]?.title === i18n.Messages.PIN_MESSAGE) {
17 | args[0].onConfirm?.();
18 | }
19 | else {
20 | res(...args);
21 | }
22 | });
23 |
24 | patches.push(after("isTrustedDomain", MaskedLink, () => {
25 | return true;
26 | }));
27 | }
28 |
29 | export function unloadRemovePrompts() {
30 | for (const unpatch of patches)
31 | unpatch();
32 |
33 | unpatch;
34 | }
35 |
36 |
--------------------------------------------------------------------------------
/src/lib/utils/findInReactTree.ts:
--------------------------------------------------------------------------------
1 | import { SearchFilter } from "@types";
2 | import { findInTree } from "@lib/utils";
3 |
4 | export default (tree: { [key: string]: any }, filter: SearchFilter): any => findInTree(tree, filter, {
5 | walkable: ["props", "children", "child", "sibling"],
6 | });
--------------------------------------------------------------------------------
/src/lib/utils/findInTree.ts:
--------------------------------------------------------------------------------
1 | // This has been completely reimplemented at this point, but the disclaimer at the end of disclaimers still counts.
2 | // https://github.com/Cordwood/Cordwood/blob/91c0b971bbf05e112927df75415df99fa105e1e7/src/lib/utils/findInTree.ts
3 |
4 | import { FindInTreeOptions, SearchTree, SearchFilter } from "@types";
5 |
6 | function treeSearch(tree: SearchTree, filter: SearchFilter, opts: Required, depth: number): any {
7 | if (depth > opts.maxDepth) return;
8 | if (!tree) return;
9 |
10 | try {
11 | if (filter(tree)) return tree;
12 | } catch {}
13 |
14 | if (Array.isArray(tree)) {
15 | for (const item of tree) {
16 | if (typeof item !== "object" || item === null) continue;
17 |
18 | try {
19 | const found = treeSearch(item, filter, opts, depth + 1);
20 | if (found) return found;
21 | } catch {}
22 | }
23 | } else if (typeof tree === "object") {
24 | for (const key of Object.keys(tree)) {
25 | if (typeof tree[key] !== "object" || tree[key] === null) continue;
26 | if (opts.walkable.length && !opts.walkable.includes(key)) continue;
27 | if (opts.ignore.includes(key)) continue;
28 |
29 | try {
30 | const found = treeSearch(tree[key], filter, opts, depth + 1);
31 | if (found) return found;
32 | } catch {}
33 | }
34 | }
35 | }
36 |
37 | export default (
38 | tree: SearchTree,
39 | filter: SearchFilter,
40 | {
41 | walkable = [],
42 | ignore = [],
43 | maxDepth = 100
44 | }: FindInTreeOptions = {},
45 | ): any | undefined => treeSearch(tree, filter, { walkable, ignore, maxDepth }, 0);
46 |
--------------------------------------------------------------------------------
/src/lib/utils/index.ts:
--------------------------------------------------------------------------------
1 | // Makes mass-importing utils cleaner, chosen over moving utils to one file
2 |
3 | export { default as findInReactTree } from "@lib/utils/findInReactTree";
4 | export { default as findInTree } from "@lib/utils/findInTree";
5 | export { default as safeFetch } from "@lib/utils/safeFetch";
6 | export { default as unfreeze } from "@lib/utils/unfreeze";
7 | export { default as without } from "@lib/utils/without";
--------------------------------------------------------------------------------
/src/lib/utils/safeFetch.ts:
--------------------------------------------------------------------------------
1 | // A really basic fetch wrapper which throws on non-ok response codes
2 |
3 | export default async function safeFetch(input: RequestInfo | URL, options?: RequestInit, timeout = 10000) {
4 | const req = await fetch(input, {
5 | signal: timeoutSignal(timeout),
6 | ...options
7 | });
8 |
9 | if (!req.ok) throw new Error("Request returned non-ok");
10 | return req;
11 | }
12 |
13 | function timeoutSignal(ms: number): AbortSignal {
14 | const controller = new AbortController();
15 | setTimeout(() => controller.abort(`Timed out after ${ms}ms`), ms);
16 | return controller.signal;
17 | }
18 |
--------------------------------------------------------------------------------
/src/lib/utils/unfreeze.ts:
--------------------------------------------------------------------------------
1 | // https://stackoverflow.com/a/68339174
2 |
3 | export default function unfreeze(obj: object) {
4 | if (Object.isFrozen(obj)) return Object.assign({}, obj);
5 | return obj;
6 | }
--------------------------------------------------------------------------------
/src/lib/utils/without.ts:
--------------------------------------------------------------------------------
1 | export default function without(object: O, ...keys: K): Omit {
2 | const cloned = { ...object };
3 | keys.forEach((k) => delete cloned[k]);
4 | return cloned;
5 | }
--------------------------------------------------------------------------------
/src/lib/windowObject.ts:
--------------------------------------------------------------------------------
1 | import { VendettaObject } from "@types";
2 | import patcher from "@lib/patcher";
3 | import logger from "@lib/logger";
4 | import settings, { loaderConfig } from "@lib/settings";
5 | import * as constants from "@lib/constants";
6 | import * as debug from "@lib/debug";
7 | import * as plugins from "@lib/plugins";
8 | import * as themes from "@lib/themes";
9 | import * as commands from "@lib/commands";
10 | import * as storage from "@lib/storage";
11 | import * as metro from "@metro/filters";
12 | import * as common from "@metro/common";
13 | import * as components from "@ui/components";
14 | import * as toasts from "@ui/toasts";
15 | import * as alerts from "@ui/alerts";
16 | import * as assets from "@ui/assets";
17 | import * as color from "@ui/color";
18 | import * as utils from "@lib/utils";
19 |
20 | export default async (unloads: any[]): Promise => ({
21 | patcher: utils.without(patcher, "unpatchAll"),
22 | metro: { ...metro, common: { ...common } },
23 | constants,
24 | utils,
25 | debug: utils.without(debug, "versionHash", "patchLogHook"),
26 | ui: {
27 | components,
28 | toasts,
29 | alerts,
30 | assets,
31 | ...color,
32 | },
33 | plugins: utils.without(plugins, "initPlugins", "evalPlugin"),
34 | themes: utils.without(themes, "initThemes"),
35 | commands: utils.without(commands, "patchCommands"),
36 | storage,
37 | settings,
38 | loader: {
39 | identity: window.__vendetta_loader,
40 | config: loaderConfig,
41 | },
42 | logger,
43 | version: debug.versionHash,
44 | unload: () => {
45 | unloads.filter(i => typeof i === "function").forEach(p => p());
46 | // @ts-expect-error explode
47 | delete window.vendetta;
48 | },
49 | });
50 |
--------------------------------------------------------------------------------
/src/ui/alerts.ts:
--------------------------------------------------------------------------------
1 | import { ConfirmationAlertOptions, InputAlertProps } from "@types";
2 | import { findByProps } from "@metro/filters";
3 | import InputAlert from "@ui/components/InputAlert";
4 |
5 | const Alerts = findByProps("openLazy", "close");
6 |
7 | interface InternalConfirmationAlertOptions extends Omit {
8 | content?: ConfirmationAlertOptions["content"];
9 | body?: ConfirmationAlertOptions["content"];
10 | };
11 |
12 | export function showConfirmationAlert(options: ConfirmationAlertOptions) {
13 | const internalOptions = options as InternalConfirmationAlertOptions;
14 |
15 | internalOptions.body = options.content;
16 | delete internalOptions.content;
17 |
18 | internalOptions.isDismissable ??= true;
19 |
20 | return Alerts.show(internalOptions);
21 | };
22 |
23 | export const showCustomAlert = (component: React.ComponentType, props: any) => Alerts.openLazy({
24 | importer: async () => () => React.createElement(component, props),
25 | });
26 |
27 | export const showInputAlert = (options: InputAlertProps) => showCustomAlert(InputAlert, options);
28 |
--------------------------------------------------------------------------------
/src/ui/assets.ts:
--------------------------------------------------------------------------------
1 | import { Asset } from "@types";
2 | import { assets } from "@metro/common";
3 | import { after } from "@lib/patcher";
4 |
5 | export const all: Record = {};
6 |
7 | export function patchAssets() {
8 | const unpatch = after("registerAsset", assets, (args: Asset[], id: number) => {
9 | const asset = args[0];
10 | all[asset.name] = { ...asset, id: id };
11 | });
12 |
13 | for (let id = 1; ; id++) {
14 | const asset = assets.getAssetByID(id);
15 | if (!asset) break;
16 | if (all[asset.name]) continue;
17 | all[asset.name] = { ...asset, id: id };
18 | };
19 |
20 | return unpatch;
21 | }
22 |
23 | export const find = (filter: (a: any) => void): Asset | null | undefined => Object.values(all).find(filter);
24 | export const getAssetByName = (name: string): Asset => all[name];
25 | export const getAssetByID = (id: number): Asset => assets.getAssetByID(id);
26 | export const getAssetIDByName = (name: string) => all[name]?.id;
--------------------------------------------------------------------------------
/src/ui/color.ts:
--------------------------------------------------------------------------------
1 | import { constants } from "@metro/common";
2 | import { color } from "@lib/themes";
3 |
4 | //! This module is only found on 165.0+, under the assumption that iOS 165.0 is the same as Android 165.0.
5 | //* In 167.1, most if not all traces of the old color modules were removed.
6 | //* In 168.6, Discord restructured EVERYTHING again. SemanticColor on this module no longer works when passed to a stylesheet. We must now use what you see below.
7 | //* In 173.10, Discord restructured a lot of the app. These changes included making the color module impossible to early-find.
8 | //? To stop duplication, it is now exported in our theming code.
9 | //? These comments are preserved for historical purposes.
10 | // const colorModule = findByProps("colors", "meta");
11 |
12 | //? SemanticColor and default.colors are effectively ThemeColorMap
13 | export const semanticColors = (color?.default?.colors ?? constants?.ThemeColorMap);
14 |
15 | //? RawColor and default.unsafe_rawColors are effectively Colors
16 | //* Note that constants.Colors does still appear to exist on newer versions despite Discord not internally using it - what the fuck?
17 | export const rawColors = (color?.default?.unsafe_rawColors ?? constants?.Colors);
--------------------------------------------------------------------------------
/src/ui/components/Codeblock.tsx:
--------------------------------------------------------------------------------
1 | import { CodeblockProps } from "@types";
2 | import { ReactNative as RN, stylesheet, constants } from "@metro/common";
3 | import { semanticColors } from "@ui/color";
4 |
5 | const styles = stylesheet.createThemedStyleSheet({
6 | codeBlock: {
7 | fontFamily: constants.Fonts.CODE_SEMIBOLD,
8 | fontSize: 12,
9 | textAlignVertical: "center",
10 | backgroundColor: semanticColors.BACKGROUND_SECONDARY,
11 | color: semanticColors.TEXT_NORMAL,
12 | borderWidth: 1,
13 | borderRadius: 4,
14 | borderColor: semanticColors.BACKGROUND_TERTIARY,
15 | padding: 10,
16 | },
17 | });
18 |
19 | // iOS doesn't support the selectable property on RN.Text...
20 | const InputBasedCodeblock = ({ style, children }: CodeblockProps) =>
21 | const TextBasedCodeblock = ({ selectable, style, children }: CodeblockProps) => {children}
22 |
23 | export default function Codeblock({ selectable, style, children }: CodeblockProps) {
24 | if (!selectable) return ;
25 |
26 | return RN.Platform.select({
27 | ios: ,
28 | default: ,
29 | });
30 | }
--------------------------------------------------------------------------------
/src/ui/components/ErrorBoundary.tsx:
--------------------------------------------------------------------------------
1 | import { ErrorBoundaryProps } from "@types";
2 | import { React, ReactNative as RN, stylesheet, clipboard } from "@metro/common";
3 | import { Forms, Button, Codeblock, } from "@ui/components";
4 |
5 | interface ErrorBoundaryState {
6 | hasErr: boolean;
7 | errText?: string;
8 | errName?: string;
9 | errCause?: string;
10 | errStack?: string;
11 | }
12 |
13 | const styles = stylesheet.createThemedStyleSheet({
14 | view: {
15 | flex: 1,
16 | flexDirection: "column",
17 | margin: 10,
18 | },
19 | title: {
20 | fontSize: 20,
21 | textAlign: "center",
22 | marginBottom: 5,
23 | },
24 | br: {
25 | fontSize: 0,
26 | padding: 1
27 | },
28 | });
29 |
30 | export default class ErrorBoundary extends React.Component {
31 | constructor(props: ErrorBoundaryProps) {
32 | super(props);
33 | this.state = { hasErr: false };
34 | }
35 |
36 | static getDerivedStateFromError = (error: Error) => ({ hasErr: true, errText: error.message, errName: error.name, errCause: error.cause, errStack: error.stack });
37 |
38 | render() {
39 | if (!this.state.hasErr) return this.props.children;
40 |
41 | return (
42 |
43 | Opti has encountered an error.
44 | {this.state.errStack}
45 |
46 |
62 | )
63 | }
64 | }
--------------------------------------------------------------------------------
/src/ui/components/InputAlert.tsx:
--------------------------------------------------------------------------------
1 | import { InputAlertProps } from "@types";
2 | import { findByProps } from "@metro/filters";
3 | import { Forms, Alert } from "@ui/components";
4 |
5 | const { FormInput } = Forms;
6 | const Alerts = findByProps("openLazy", "close");
7 |
8 | export default function InputAlert({ title, confirmText, confirmColor, onConfirm, cancelText, placeholder, initialValue = "", secureTextEntry }: InputAlertProps) {
9 | const [value, setValue] = React.useState(initialValue);
10 | const [error, setError] = React.useState("");
11 |
12 | function onConfirmWrapper() {
13 | const asyncOnConfirm = Promise.resolve(onConfirm(value))
14 |
15 | asyncOnConfirm.then(() => {
16 | Alerts.close();
17 | }).catch((e: Error) => {
18 | setError(e.message);
19 | });
20 | };
21 |
22 | return (
23 | Alerts.close()}
31 | >
32 | {
36 | setValue(typeof v === "string" ? v : v.text);
37 | if (error) setError("");
38 | }}
39 | returnKeyType="done"
40 | onSubmitEditing={onConfirmWrapper}
41 | error={error || undefined}
42 | secureTextEntry={secureTextEntry}
43 | autoFocus={true}
44 | showBorder={true}
45 | style={{ paddingVertical: 5, alignSelf: "stretch", paddingHorizontal: 0 }}
46 | />
47 |
48 | );
49 | };
--------------------------------------------------------------------------------
/src/ui/components/Search.tsx:
--------------------------------------------------------------------------------
1 | import { SearchProps } from "@types";
2 | import { stylesheet } from "@metro/common";
3 | import { findByName } from "@metro/filters";
4 |
5 | const Search = findByName("StaticSearchBarContainer");
6 |
7 | const styles = stylesheet.createThemedStyleSheet({
8 | search: {
9 | margin: 0,
10 | padding: 0,
11 | borderBottomWidth: 0,
12 | backgroundColor: "none",
13 | }
14 | });
15 |
16 | export default ({ onChangeText, placeholder, style }: SearchProps) =>
--------------------------------------------------------------------------------
/src/ui/components/Summary.tsx:
--------------------------------------------------------------------------------
1 | import { SummaryProps } from "@types";
2 | import { ReactNative as RN } from "@metro/common";
3 | import { getAssetIDByName } from "@ui/assets";
4 | import { Forms } from "@ui/components";
5 |
6 | export default function Summary({ label, icon, noPadding = false, noAnimation = false, children }: SummaryProps) {
7 | const { FormRow, FormDivider } = Forms;
8 | const [hidden, setHidden] = React.useState(true);
9 |
10 | return (
11 | <>
12 | }
15 | trailing={}
16 | onPress={() => {
17 | setHidden(!hidden);
18 | if (!noAnimation) RN.LayoutAnimation.configureNext(RN.LayoutAnimation.Presets.easeInEaseOut);
19 | }}
20 | />
21 | {!hidden && <>
22 |
23 | {children}
24 | >}
25 | >
26 | )
27 | }
--------------------------------------------------------------------------------
/src/ui/components/index.ts:
--------------------------------------------------------------------------------
1 | import { ReactNative as RN } from "@metro/common";
2 | import { findByDisplayName, findByName, findByProps } from "@metro/filters";
3 |
4 | // Discord
5 | export const Forms = findByProps("Form", "FormSection");
6 | export const General = findByProps("Button", "Text", "View");
7 | export const Alert = findByDisplayName("FluxContainer(Alert)");
8 | export const Button = findByProps("Looks", "Colors", "Sizes") as React.ComponentType & { Looks: any, Colors: any, Sizes: any };
9 | export const HelpMessage = findByName("HelpMessage");
10 | // React Native's included SafeAreaView only adds padding on iOS.
11 | export const SafeAreaView = findByProps("useSafeAreaInsets").SafeAreaView as typeof RN.SafeAreaView;
12 |
13 | // Vendetta
14 | export { default as Summary } from "@ui/components/Summary";
15 | export { default as ErrorBoundary } from "@ui/components/ErrorBoundary";
16 | export { default as Codeblock } from "@ui/components/Codeblock";
17 | export { default as Search } from "@ui/components/Search";
--------------------------------------------------------------------------------
/src/ui/quickInstall/forumPost.tsx:
--------------------------------------------------------------------------------
1 | import { findByName, findByProps } from "@metro/filters";
2 | import { DISCORD_SERVER_ID, PLUGINS_CHANNEL_ID, THEMES_CHANNEL_ID, HTTP_REGEX_MULTI, PROXY_PREFIX } from "@lib/constants";
3 | import { after } from "@lib/patcher";
4 | import { installPlugin } from "@lib/plugins";
5 | import { installTheme } from "@lib/themes";
6 | import { findInReactTree } from "@lib/utils";
7 | import { getAssetIDByName } from "@ui/assets";
8 | import { showToast } from "@ui/toasts";
9 | import { Forms } from "@ui/components";
10 |
11 | const ForumPostLongPressActionSheet = findByName("ForumPostLongPressActionSheet", false);
12 | const { FormRow, FormIcon } = Forms;
13 |
14 | const { useFirstForumPostMessage } = findByProps("useFirstForumPostMessage");
15 | const { hideActionSheet } = findByProps("openLazy", "hideActionSheet");
16 |
17 | export default () => after("default", ForumPostLongPressActionSheet, ([{ thread }], res) => {
18 | if (thread.guild_id !== DISCORD_SERVER_ID) return;
19 |
20 | // Determine what type of addon this is.
21 | let postType: "Plugin" | "Theme";
22 | if (thread.parent_id === PLUGINS_CHANNEL_ID) {
23 | postType = "Plugin";
24 | } else if (thread.parent_id === THEMES_CHANNEL_ID && window.__vendetta_loader?.features.themes) {
25 | postType = "Theme";
26 | } else return;
27 |
28 | const { firstMessage } = useFirstForumPostMessage(thread);
29 |
30 | let urls = firstMessage?.content?.match(HTTP_REGEX_MULTI);
31 | if (!urls) return;
32 |
33 | if (postType === "Plugin") {
34 | urls = urls.filter((url: string) => url.startsWith(PROXY_PREFIX));
35 | } else {
36 | urls = urls.filter((url: string) => url.endsWith(".json"));
37 | };
38 |
39 | const url = urls[0];
40 | if (!url) return;
41 |
42 | const actions = findInReactTree(res, (t) => t?.[0]?.key);
43 | const ActionsSection = actions[0].type;
44 |
45 | actions.unshift(
46 | }
48 | label={`Install ${postType}`}
49 | onPress={() =>
50 | (postType === "Plugin" ? installPlugin : installTheme)(url).then(() => {
51 | showToast(`Successfully installed ${thread.name}`, getAssetIDByName("Check"));
52 | }).catch((e: Error) => {
53 | showToast(e.message, getAssetIDByName("Small"));
54 | }).finally(() => hideActionSheet())
55 | }
56 | />
57 | );
58 | });
59 |
--------------------------------------------------------------------------------
/src/ui/quickInstall/index.ts:
--------------------------------------------------------------------------------
1 | import patchForumPost from "@ui/quickInstall/forumPost";
2 | import patchUrl from "@ui/quickInstall/url";
3 |
4 | export default function initQuickInstall() {
5 | const patches = new Array;
6 |
7 | patches.push(patchForumPost());
8 | patches.push(patchUrl());
9 |
10 | return () => patches.forEach(p => p());
11 | };
12 |
--------------------------------------------------------------------------------
/src/ui/quickInstall/url.tsx:
--------------------------------------------------------------------------------
1 | import { findByProps, find } from "@metro/filters";
2 | import { ReactNative as RN, channels, url } from "@metro/common";
3 | import { PROXY_PREFIX, THEMES_CHANNEL_ID, VENDETTA_PROXY } from "@lib/constants";
4 | import { after, instead } from "@lib/patcher";
5 | import { installPlugin } from "@lib/plugins";
6 | import { installTheme } from "@lib/themes";
7 | import { showConfirmationAlert } from "@ui/alerts";
8 | import { getAssetIDByName } from "@ui/assets";
9 | import { showToast } from "@ui/toasts";
10 |
11 | const showSimpleActionSheet = find((m) => m?.showSimpleActionSheet && !Object.getOwnPropertyDescriptor(m, "showSimpleActionSheet")?.get);
12 | const handleClick = findByProps("handleClick");
13 | const { openURL } = url;
14 | const { getChannelId } = channels;
15 | const { getChannel } = findByProps("getChannel");
16 |
17 | const { TextStyleSheet } = findByProps("TextStyleSheet");
18 |
19 | function typeFromUrl(url: string) {
20 | if (url.startsWith(PROXY_PREFIX) || url.startsWith(VENDETTA_PROXY)) {
21 | return "Plugin";
22 | } else if (url.endsWith(".json") && window.__vendetta_loader?.features.themes) {
23 | return "Theme";
24 | } else return;
25 | }
26 |
27 | function installWithToast(type: "Plugin" | "Theme", url: string) {
28 | (type === "Plugin" ? installPlugin : installTheme)(url)
29 | .then(() => {
30 | showToast("Successfully installed", getAssetIDByName("Check"));
31 | })
32 | .catch((e: Error) => {
33 | showToast(e.message, getAssetIDByName("Small"));
34 | });
35 | }
36 |
37 | export default () => {
38 | const patches = new Array();
39 |
40 | patches.push(
41 | after("showSimpleActionSheet", showSimpleActionSheet, (args) => {
42 | if (args[0].key !== "LongPressUrl") return;
43 | const {
44 | header: { title: url },
45 | options,
46 | } = args[0];
47 |
48 | const urlType = typeFromUrl(url);
49 | if (!urlType) return;
50 |
51 | options.push({
52 | label: `Install ${urlType}`,
53 | onPress: () => installWithToast(urlType, url),
54 | });
55 | })
56 | );
57 |
58 | patches.push(
59 | instead("handleClick", handleClick, async function (this: any, args, orig) {
60 | const { href: url } = args[0];
61 |
62 | const urlType = typeFromUrl(url);
63 | if (!urlType) return orig.apply(this, args);
64 |
65 | // Make clicking on theme links only work in #themes, should there be a theme proxy in the future, this can be removed.
66 | if (urlType === "Theme" && getChannel(getChannelId())?.parent_id !== THEMES_CHANNEL_ID) return orig.apply(this, args);
67 |
68 | showConfirmationAlert({
69 | title: "Hold Up",
70 | content: ["This link is a ", {urlType}, ", would you like to install it?"],
71 | onConfirm: () => installWithToast(urlType, url),
72 | confirmText: "Install",
73 | cancelText: "Cancel",
74 | secondaryConfirmText: "Open in Browser",
75 | onConfirmSecondary: () => openURL(url),
76 | });
77 | })
78 | );
79 |
80 | return () => patches.forEach((p) => p());
81 | };
82 |
--------------------------------------------------------------------------------
/src/ui/settings/components/AddonHubButton.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNative as RN, stylesheet, NavigationNative } from "@metro/common";
2 | import AddonHub from "@ui/settings/pages/AddonHub";
3 | import { getAssetIDByName } from "@ui/assets";
4 | import { semanticColors } from "@ui/color";
5 |
6 | const styles = stylesheet.createThemedStyleSheet({
7 | icon: {
8 | marginRight: 10,
9 | tintColor: semanticColors.HEADER_PRIMARY,
10 | },
11 | });
12 |
13 | interface InstallButtonProps {
14 | alertTitle: string;
15 | installFunction: (id: string) => Promise;
16 | }
17 |
18 | export default function InstallButton({ alertTitle, installFunction: fetchFunction }: InstallButtonProps) {
19 | const navigation = NavigationNative.useNavigation();
20 | return (
21 |
22 | navigation.push("VendettaCustomPage", {
23 | title: "Addons Hub",
24 | render: AddonHub,
25 | })
26 |
27 | }>
28 |
29 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/src/ui/settings/components/AddonPage.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNative as RN } from "@metro/common";
2 | import { useProxy } from "@lib/storage";
3 | import { HelpMessage, ErrorBoundary, Search } from "@ui/components";
4 | import { CardWrapper } from "@ui/settings/components/Card";
5 | import settings from "@lib/settings";
6 |
7 | interface AddonPageProps {
8 | items: Record;
9 | card: React.ComponentType>;
10 | }
11 |
12 | export default function AddonPage({ items, card: CardComponent }: AddonPageProps) {
13 | //@ts-ignore
14 | useProxy(settings)
15 | useProxy(items);
16 | const [search, setSearch] = React.useState("");
17 |
18 | return (
19 |
20 |
22 |
23 |
24 | setSearch(v.toLowerCase())}
27 | placeholder="Search"
28 | />
29 | >}
30 | style={{ paddingHorizontal: 10, paddingTop: 20 }}
31 | contentContainerStyle={{ paddingBottom: 20 }}
32 | data={Object.values(items).filter(i => i.id?.toLowerCase().includes(search))}
33 | renderItem={({ item, index }) => }
34 | />
35 |
36 | )
37 | }
--------------------------------------------------------------------------------
/src/ui/settings/components/AssetDisplay.tsx:
--------------------------------------------------------------------------------
1 | import { Asset } from "@types";
2 | import { ReactNative as RN, clipboard } from "@metro/common";
3 | import { showToast } from "@ui/toasts";
4 | import { getAssetIDByName } from "@ui/assets";
5 | import { Forms } from "@ui/components";
6 |
7 | interface AssetDisplayProps { asset: Asset }
8 |
9 | const { FormRow } = Forms;
10 |
11 | export default function AssetDisplay({ asset }: AssetDisplayProps) {
12 | return (
13 | }
16 | onPress={() => {
17 | clipboard.setString(asset.name);
18 | showToast("Copied asset name to clipboard.", getAssetIDByName("toast_copy_link"));
19 | }}
20 | />
21 | )
22 | }
--------------------------------------------------------------------------------
/src/ui/settings/components/Card.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNative as RN, constants, stylesheet } from "@metro/common";
2 | import { findByProps } from "@metro/filters";
3 | import { getAssetIDByName } from "@ui/assets";
4 | import { semanticColors } from "@ui/color";
5 | import { Forms } from "@ui/components";
6 |
7 | const { FormRow, FormSwitch, FormRadio, FormDivider } = Forms;
8 | const { hideActionSheet } = findByProps("openLazy", "hideActionSheet");
9 | const { showSimpleActionSheet } = findByProps("showSimpleActionSheet");
10 |
11 | // TODO: These styles work weirdly. iOS has cramped text, Android with low DPI probably does too. Fix?
12 | const styles = stylesheet.createThemedStyleSheet({
13 | card: {
14 | backgroundColor: semanticColors.BACKGROUND_SECONDARY,
15 | borderRadius: 7,
16 | borderColor: semanticColors.BACKGROUND_TERTIARY,
17 | borderWidth: 1,
18 | },
19 | header: {
20 | padding: 0,
21 | backgroundColor: semanticColors.PRIMARY_DARK,
22 | color: semanticColors.HEADER_PRIMARY,
23 | borderTopLeftRadius: 5,
24 | borderTopRightRadius: 5,
25 | },
26 | headertitle: {
27 | fontFamily: constants.Fonts.PRIMARY_BOLD,
28 | fontSize: 17,
29 | color: semanticColors.HEADER_PRIMARY,
30 | },
31 | description: {
32 | color: semanticColors.TEXT_MUTED,
33 | fontSize: 13,
34 | },
35 | authors: {
36 | color: semanticColors.HEADER_SECONDARY,
37 | fontSize: 14,
38 | },
39 | actions: {
40 | flexDirection: "row-reverse",
41 | alignItems: "center",
42 | },
43 | icon: {
44 | width: 22,
45 | height: 22,
46 | marginLeft: 5,
47 | tintColor: semanticColors?.INTERACTIVE_NORMAL,
48 | },
49 | })
50 |
51 | interface Action {
52 | icon: string;
53 | onPress: () => void;
54 | }
55 |
56 | interface OverflowAction extends Action {
57 | label: string;
58 | isDestructive?: boolean;
59 | }
60 |
61 | export interface CardWrapper {
62 | item: T;
63 | index: number;
64 | }
65 |
66 | interface CardProps {
67 | index?: number;
68 | headerLabel: string | React.ComponentType | (string | JSX.Element)[];
69 | headerIcon?: string;
70 | toggleType?: "switch" | "radio";
71 | toggleValue?: boolean;
72 | onToggleChange?: (v: boolean) => void;
73 | descriptionLabel?: string | React.ComponentType;
74 | actions?: Action[];
75 | overflowTitle?: string;
76 | overflowActions?: OverflowAction[];
77 | color?: string;
78 | optiLogo?: boolean;
79 |
80 | }
81 |
82 | export default function Card(props: CardProps) {
83 | let pressableState = props.toggleValue ?? false;
84 |
85 | return (
86 |
87 |
90 |
91 |
93 | {props.headerLabel}
94 |
95 | }
96 | leading={props.headerIcon && }
97 | trailing={props.toggleType && (props.toggleType === "switch" ?
98 | ()
103 | :
104 | ( {
105 | pressableState = !pressableState;
106 | props.onToggleChange?.(pressableState)
107 | }}>
108 |
111 | )
112 | )}
113 | optiLogo={ props.optiLogo &&
114 |
115 |
116 |
117 | }
118 | />
119 |
120 |
122 |
124 | {props.descriptionLabel}
125 |
126 | }
127 | trailing={
128 |
129 | {props.overflowActions && showSimpleActionSheet({
131 | key: "CardOverflow",
132 | header: {
133 | title: props.overflowTitle,
134 | icon: props.headerIcon && ,
135 | onClose: () => hideActionSheet(),
136 | },
137 | options: props.overflowActions?.map(i => ({ ...i, icon: getAssetIDByName(i.icon) })),
138 | })}
139 | >
140 |
141 | }
142 | {props.actions?.map(({ icon, onPress }) => (
143 |
146 |
147 |
148 | ))}
149 |
150 | }
151 | />
152 |
153 | )
154 | }
155 |
--------------------------------------------------------------------------------
/src/ui/settings/components/Dropdown.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNative as RN } from '@metro/common'
2 | import type { ViewStyle } from 'react-native'
3 |
4 | interface OverflowItem {
5 | label: string;
6 | IconComponent?: React.ComponentType;
7 | iconSource?: number;
8 | action: () => any;
9 | }
10 |
11 | interface OverflowProps {
12 | items: OverflowItem[] | Array,
13 | title?: string;
14 | iconSource?: number;
15 | scale?: number;
16 | style?: ViewStyle;
17 | }
18 |
19 | export default function Overflow({items, title, iconSource, scale = 1, style = {}} : OverflowProps) {
20 | return
21 | // will continue working on it
22 | }
--------------------------------------------------------------------------------
/src/ui/settings/components/InstallButton.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNative as RN, stylesheet, clipboard } from "@metro/common";
2 | import { HTTP_REGEX_MULTI } from "@lib/constants";
3 | import { showInputAlert } from "@ui/alerts";
4 | import { getAssetIDByName } from "@ui/assets";
5 | import { semanticColors } from "@ui/color";
6 |
7 | const styles = stylesheet.createThemedStyleSheet({
8 | icon: {
9 | marginRight: 10,
10 | tintColor: semanticColors.HEADER_PRIMARY,
11 | },
12 | });
13 |
14 | interface InstallButtonProps {
15 | alertTitle: string;
16 | installFunction: (id: string) => Promise;
17 | }
18 |
19 | export default function InstallButton({ alertTitle, installFunction: fetchFunction }: InstallButtonProps) {
20 | return (
21 |
22 | clipboard.getString().then((content) =>
23 | showInputAlert({
24 | title: alertTitle,
25 | initialValue: content.match(HTTP_REGEX_MULTI)?.[0] ?? "",
26 | placeholder: "https://example.com/",
27 | onConfirm: (input: string) => fetchFunction(input),
28 | confirmText: "Install",
29 | cancelText: "Cancel",
30 | })
31 | )
32 | }>
33 |
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/src/ui/settings/components/PluginCard.tsx:
--------------------------------------------------------------------------------
1 | import { ButtonColors, Plugin } from "@types";
2 | import { AsyncUsers, NavigationNative, Profiles, User, clipboard } from "@metro/common";
3 | import { removePlugin, startPlugin, stopPlugin, getSettings, fetchPlugin } from "@lib/plugins";
4 | import { MMKVManager } from "@lib/native";
5 | import { getAssetIDByName } from "@ui/assets";
6 | import { showToast } from "@ui/toasts";
7 | import { showConfirmationAlert } from "@ui/alerts";
8 | import Card, { CardWrapper } from "@ui/settings/components/Card";
9 |
10 | async function stopThenStart(plugin: Plugin, callback: Function) {
11 | if (plugin.enabled) stopPlugin(plugin.id, false);
12 | callback();
13 | if (plugin.enabled) await startPlugin(plugin.id);
14 | }
15 |
16 | export default function PluginCard({ item: plugin, index }: CardWrapper) {
17 | const settings = getSettings(plugin.id);
18 | const navigation = NavigationNative.useNavigation();
19 | const [removed, setRemoved] = React.useState(false);
20 | const authors = plugin.manifest.authors;
21 | let ver = plugin.manifest.vendetta?.version ?? "";
22 | if (removed) return null;
23 |
24 | // \nby ${plugin.manifest.authors.map(i => i.name).join(", ")}
25 | return (
26 | {
33 | try {
34 | if (v) startPlugin(plugin.id); else stopPlugin(plugin.id);
35 | } catch (e) {
36 | showToast((e as Error).message, getAssetIDByName("Small"));
37 | }
38 | }}
39 | descriptionLabel={`${plugin.manifest.description} \n\nAuthors: ${authors.map(i => i.name).join(", ")}`}
40 | overflowTitle={plugin.manifest.name}
41 | overflowActions={[
42 | {
43 | label: "View Creator Profile",
44 | icon: "ic_profile_24px",
45 | onPress: () => {
46 | if (!User.getUser(plugin.manifest.authors[0]?.id)) {
47 | AsyncUsers.fetchProfile(plugin.manifest.authors[0]?.id).then(() => {
48 | Profiles.showUserProfile({ userId: plugin.manifest.authors[0]?.id });
49 | })}
50 | else
51 | {
52 | Profiles.showUserProfile({ userId: plugin.manifest.authors[0]?.id });
53 | };
54 | }
55 | },
56 | {
57 | icon:"RetryIcon",
58 | label: "Refetch",
59 | onPress: async () => {
60 | stopThenStart(plugin, () => {
61 | fetchPlugin(plugin.id).then(async () => {
62 | showToast("Successfully refetched plugin.", getAssetIDByName("toast_image_saved"));
63 | }).catch(() => {
64 | showToast("Failed to refetch plugin!", getAssetIDByName("Small"));
65 | })
66 | });
67 | },
68 | },
69 | {
70 | icon: "copy",
71 | label: "Copy URL",
72 | onPress: () => {
73 | clipboard.setString(plugin.id);
74 | showToast("Copied plugin URL to clipboard.", getAssetIDByName("toast_copy_link"));
75 | }
76 | },
77 | {
78 | icon: "ic_download_24px",
79 | label: plugin.update ? "Disable updates" : "Enable updates",
80 | onPress: () => {
81 | plugin.update = !plugin.update;
82 | showToast(`${plugin.update ? "Enabled" : "Disabled"} updates for ${plugin.manifest.name}.`, getAssetIDByName("toast_image_saved"));
83 | }
84 | },
85 | {
86 | icon: "ic_duplicate",
87 | label: "Clear Data",
88 | isDestructive: true,
89 | onPress: () => showConfirmationAlert({
90 | title: "Wait!",
91 | content: `Are you sure you wish to clear the data of ${plugin.manifest.name}?`,
92 | confirmText: "Clear",
93 | cancelText: "Cancel",
94 | confirmColor: ButtonColors.RED,
95 | onConfirm: () => {
96 | stopThenStart(plugin, () => {
97 | try {
98 | MMKVManager.removeItem(plugin.id);
99 | showToast(`Cleared data for ${plugin.manifest.name}.`, getAssetIDByName("trash"));
100 | } catch {
101 | showToast(`Failed to clear data for ${plugin.manifest.name}!`, getAssetIDByName("Small"));
102 | }
103 | });
104 | }
105 | }),
106 | },
107 | {
108 | icon: "ic_message_delete",
109 | label: "Delete",
110 | isDestructive: true,
111 | onPress: () => showConfirmationAlert({
112 | title: "Wait!",
113 | content: `Are you sure you wish to delete ${plugin.manifest.name}? This will clear all of the plugin's data.`,
114 | confirmText: "Delete",
115 | cancelText: "Cancel",
116 | confirmColor: ButtonColors.RED,
117 | onConfirm: () => {
118 | try {
119 | removePlugin(plugin.id);
120 | setRemoved(true);
121 | } catch (e) {
122 | showToast((e as Error).message, getAssetIDByName("Small"));
123 | }
124 | }
125 | }),
126 | },
127 | ]}
128 | actions={[
129 | ...(settings ? [{
130 | icon: "settings",
131 | onPress: () => navigation.push("VendettaCustomPage", {
132 | title: plugin.manifest.name,
133 | render: settings,
134 | })
135 | }] : []),
136 | ]}
137 | />
138 | )
139 | }
140 |
--------------------------------------------------------------------------------
/src/ui/settings/components/SettingsSection.tsx:
--------------------------------------------------------------------------------
1 | import { NavigationNative } from "@metro/common";
2 | import { useProxy } from "@lib/storage";
3 | import { getAssetIDByName } from "@ui/assets";
4 | import { getRenderableScreens } from "@ui/settings/data";
5 | import { ErrorBoundary, Forms } from "@ui/components";
6 | import settings from "@lib/settings";
7 |
8 | const { FormRow, FormSection, FormDivider, FormText } = Forms;
9 |
10 | export default function SettingsSection() {
11 | const navigation = NavigationNative.useNavigation();
12 | //@ts-ignore
13 | useProxy(settings);
14 |
15 | const screens = getRenderableScreens()
16 |
17 | return (
18 |
19 |
20 | {screens.map((s, i) => (
21 | <>
22 | }
25 | trailing={FormRow.Arrow}
26 | onPress={() => navigation.push(s.key)}
27 | />
28 | {i !== screens.length - 1 && }
29 | >
30 | ))}
31 |
32 |
33 | )
34 | }
--------------------------------------------------------------------------------
/src/ui/settings/components/ThemeCard.tsx:
--------------------------------------------------------------------------------
1 | import { ButtonColors, Theme } from "@types";
2 | import { AsyncUsers, Profiles, User, clipboard } from "@metro/common";
3 | import { fetchTheme, removeTheme, selectTheme } from "@lib/themes";
4 | import { useProxy } from "@lib/storage";
5 | import { BundleUpdaterManager } from "@lib/native";
6 | import { getAssetIDByName } from "@ui/assets";
7 | import { showConfirmationAlert } from "@ui/alerts";
8 | import { showToast } from "@ui/toasts";
9 | import settings from "@lib/settings";
10 | import Card, { CardWrapper } from "@ui/settings/components/Card";
11 |
12 | async function selectAndReload(value: boolean, id: string) {
13 | await selectTheme(value ? id : "default");
14 | BundleUpdaterManager.reload();
15 | }
16 |
17 | export default function ThemeCard({ item: theme, index }: CardWrapper) {
18 | //@ts-ignore
19 | useProxy(settings);
20 | const [removed, setRemoved] = React.useState(false);
21 | const authors = theme.data.authors;
22 | if (removed) return null;
23 |
24 | // ${authors ? `\nby ${authors.map(i => i.name).join(", ")}` : ""}
25 | return (
26 | {
34 | selectAndReload(v, theme.id);
35 | }}
36 | overflowTitle={theme.data.name}
37 | overflowActions={[
38 | {
39 | label: "View Creator Profile",
40 | icon: "ic_profile_24px",
41 | onPress: () => {
42 | if (!User.getUser(theme.data.authors[0]?.id)) {
43 | AsyncUsers.fetchProfile(theme.data.authors[0]?.id).then(() => {
44 | Profiles.showUserProfile({ userId: theme.data.authors[0]?.id });
45 | })}
46 | else
47 | {
48 | Profiles.showUserProfile({ userId: theme.data.authors[0]?.id });
49 | };
50 | }
51 | },
52 | {
53 | icon: "ic_sync_24px",
54 | label: "Refetch",
55 | onPress: () => {
56 | fetchTheme(theme.id, theme.selected).then(() => {
57 | if (theme.selected) {
58 | showConfirmationAlert({
59 | title: "Theme refetched",
60 | content: "A reload is required to see the changes. Do you want to reload now?",
61 | confirmText: "Reload",
62 | cancelText: "Cancel",
63 | confirmColor: ButtonColors.RED,
64 | onConfirm: () => BundleUpdaterManager.reload(),
65 | })
66 | } else {
67 | showToast("Successfully refetched theme.", getAssetIDByName("toast_image_saved"));
68 | }
69 | }).catch(() => {
70 | showToast("Failed to refetch theme!", getAssetIDByName("Small"));
71 | });
72 | },
73 | },
74 | {
75 | icon: "copy",
76 | label: "Copy URL",
77 | onPress: () => {
78 | clipboard.setString(theme.id);
79 | showToast("Copied shader URL to clipboard.", getAssetIDByName("toast_copy_link"));
80 | }
81 | },
82 | {
83 | icon: "ic_message_delete",
84 | label: "Delete",
85 | isDestructive: true,
86 | onPress: () => showConfirmationAlert({
87 | title: "Wait!",
88 | content: `Are you sure you wish to delete ${theme.data.name}?`,
89 | confirmText: "Delete",
90 | cancelText: "Cancel",
91 | confirmColor: ButtonColors.RED,
92 | onConfirm: () => {
93 | removeTheme(theme.id).then((wasSelected) => {
94 | setRemoved(true);
95 | if (wasSelected) selectAndReload(false, theme.id);
96 | }).catch((e: Error) => {
97 | showToast(e.message, getAssetIDByName("Small"));
98 | });
99 | }
100 | })
101 | },
102 | ]}
103 | />
104 | )
105 | }
106 |
--------------------------------------------------------------------------------
/src/ui/settings/components/TweakCard.tsx:
--------------------------------------------------------------------------------
1 | import { ButtonColors, Theme } from "@types";
2 | import { AsyncUsers, Profiles, User, clipboard } from "@metro/common";
3 | import { selectTheme } from "@lib/themes";
4 | import { useProxy } from "@lib/storage";
5 | import { showConfirmationAlert } from "@ui/alerts";
6 | import { showToast } from "@ui/toasts";
7 | import settings from "@lib/settings";
8 | import Card, { CardWrapper } from "@ui/settings/components/Card";
9 |
10 | async function selectAndReload(value: boolean, id: string) {
11 | await selectTheme(value ? id : "default");
12 | }
13 |
14 | export default function ThemeCard({ item: theme, index }: CardWrapper) {
15 | //@ts-ignore
16 | useProxy(settings);
17 | const [removed, setRemoved] = React.useState(false);
18 | if (removed) return null;
19 |
20 | // ${authors ? `\nby ${authors.map(i => i.name).join(", ")}` : ""}
21 | return (
22 | {
30 | selectAndReload(v, theme.id);
31 | }}
32 | overflowTitle={theme.data.name}
33 | overflowActions={[
34 | {
35 | label: "View Creator Profile",
36 | icon: "ic_profile_24px",
37 | onPress: () => {
38 | if (!User.getUser(theme.data.authors[0]?.id)) {
39 | AsyncUsers.fetchProfile(theme.data.authors[0]?.id).then(() => {
40 | Profiles.showUserProfile({ userId: theme.data.authors[0]?.id });
41 | })}
42 | else
43 | {
44 | Profiles.showUserProfile({ userId: theme.data.authors[0]?.id });
45 | };
46 | }
47 | }
48 | ]}
49 | />
50 | )
51 | }
52 |
--------------------------------------------------------------------------------
/src/ui/settings/components/Version.tsx:
--------------------------------------------------------------------------------
1 | import { clipboard } from "@metro/common";
2 | import { getAssetIDByName } from "@ui/assets";
3 | import { showToast } from "@ui/toasts";
4 | import { Forms } from "@ui/components";
5 |
6 | interface VersionProps {
7 | label: string;
8 | version: string;
9 | leading?: JSX.Element;
10 | icon: string;
11 | }
12 |
13 | const { FormRow, FormText } = Forms;
14 |
15 | export default function Version({ label, version, icon }: VersionProps) {
16 | return (
17 | }
20 | trailing={{version}}
21 | onPress={() => {
22 | clipboard.setString(`${label} - ${version}`);
23 | showToast("Copied version to clipboard.", getAssetIDByName("toast_copy_link"));
24 | }}
25 | />
26 | )
27 | }
--------------------------------------------------------------------------------
/src/ui/settings/data.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNative as RN, NavigationNative, stylesheet, lodash } from "@metro/common";
2 | import { installPlugin } from "@lib/plugins";
3 | import { installTheme } from "@lib/themes";
4 | import { showConfirmationAlert } from "@ui/alerts";
5 | import { semanticColors } from "@ui/color";
6 | import { showToast } from "@ui/toasts";
7 | import { without } from "@lib/utils";
8 | import { getAssetIDByName } from "@ui/assets";
9 | import ErrorBoundary from "@ui/components/ErrorBoundary";
10 | import InstallButton from "@ui/settings/components/InstallButton";
11 | import AddonHubButton from "@ui/settings/components/AddonHubButton";
12 | import General from "@ui/settings/pages/General";
13 | import { PROXY_PREFIX, VENDETTA_PROXY } from "@/lib/constants";
14 | import { Forms } from "@ui/components";
15 | import Addons from "@ui/settings/pages/Addons"
16 | import { getDebugInfo } from "@/lib/debug";
17 |
18 | const { FormRow, FormSwitchRow, FormSection, FormDivider, FormInput } = Forms;
19 |
20 | interface Screen {
21 | [index: string]: any;
22 | key: string;
23 | title: string;
24 | icon?: JSX.Element | string;
25 | trailing?: string;
26 | shouldRender?: () => boolean;
27 | options?: Record;
28 | render: React.ComponentType;
29 | }
30 |
31 | const styles = stylesheet.createThemedStyleSheet({ container: { flex: 1, backgroundColor: semanticColors.BACKGROUND_MOBILE_PRIMARY } });
32 | const formatKey = (key: string, youKeys: boolean) => youKeys ? lodash.snakeCase(key).toUpperCase() : key;
33 | // If a function is passed, it is called with the screen object, and the return value is mapped. If a string is passed, we map to the value of the property with that name on the screen. Else, just map to the given data.
34 | // Question: Isn't this overengineered?
35 | // Answer: Maybe.
36 | const keyMap = (screens: Screen[], data: string | ((s: Screen) => any) | null) => Object.fromEntries(screens.map(s => [s.key, typeof data === "function" ? data(s) : typeof data === "string" ? s[data] : data]));
37 | export const getScreens = (youKeys = false): Screen[] => [
38 | {
39 | key: formatKey("VendettaSettings", youKeys),
40 | title: "Opti",
41 | icon: 'ic_settings_boost_24px',
42 | render: General,
43 | },
44 | {
45 | key: formatKey("VendettaAddons", youKeys),
46 | title: "Addons",
47 | icon: 'CirclePlusIcon-primary',
48 | render: Addons,
49 | options: {
50 | headerRight: () => (
51 | <>
52 | {
55 |
56 | if(input.endsWith(".json")) {
57 | return await installTheme(input);
58 | }
59 | if (!input.startsWith(PROXY_PREFIX) || !input.startsWith(VENDETTA_PROXY))
60 | setImmediate(() => showConfirmationAlert({
61 | title: "Unproxied Plugin",
62 | content: "The plugin you are trying to install has not been proxied. Want to download it anyways?",
63 | confirmText: "Install",
64 | onConfirm: () =>
65 | installPlugin(input)
66 | .then(() => showToast("Installed plugin", getAssetIDByName("Check")))
67 | .catch((x) => showToast(x?.message ?? `${x}`, getAssetIDByName("Small"))),
68 | cancelText: "Cancel",
69 | }));
70 | else return await installPlugin(input);
71 | }}
72 |
73 | />
74 | {
77 |
78 | }}
79 | />
80 | >
81 |
82 | ),
83 |
84 |
85 | }
86 | },
87 | {
88 | key: formatKey("VendettaCustomPage", youKeys),
89 | title: "Opti Page",
90 | shouldRender: () => false,
91 | render: ({ render: PageView, noErrorBoundary, ...options }: { render: React.ComponentType; noErrorBoundary: boolean } & Record) => {
92 | const navigation = NavigationNative.useNavigation();
93 |
94 | navigation.addListener("focus", () => navigation.setOptions(without(options, "render", "noErrorBoundary")));
95 | return noErrorBoundary ? :
96 | },
97 | },
98 | ];
99 |
100 | export const getRenderableScreens = (youKeys = false) => getScreens(youKeys).filter(s => s.shouldRender?.() ?? true);
101 |
102 | export const getPanelsScreens = () => keyMap(getScreens(), (s) => ({
103 | title: s.title,
104 | render: s.render,
105 | ...s.options,
106 | }));
107 |
108 | export const getYouData = () => {
109 | const screens = getScreens(true);
110 |
111 | return {
112 | getLayout: () => ({
113 | title: "Opti",
114 | label: "Opti",
115 | settings: getRenderableScreens(true).map(s => s.key)
116 | }),
117 | titleConfig: keyMap(screens, "title"),
118 | relationships: keyMap(screens, null),
119 | rendererConfigs: keyMap(screens, (s) => {
120 | const WrappedComponent = React.memo(({ navigation, route }: any) => {
121 | navigation.addListener("focus", () => navigation.setOptions(s.options));
122 | return
123 | });
124 |
125 | return {
126 | type: "route",
127 | title: () => s.title,
128 | icon: s.icon ? getAssetIDByName("" + s.icon) : s.icon,
129 | screen: {
130 | route: lodash.chain(s.key).camelCase().upperFirst().value(),
131 | getComponent: () => WrappedComponent,
132 | }
133 | }
134 | }),
135 | };
136 | };
137 |
--------------------------------------------------------------------------------
/src/ui/settings/index.ts:
--------------------------------------------------------------------------------
1 | import patchPanels from "@ui/settings/patches/panels";
2 | import patchYou from "@ui/settings/patches/you";
3 |
4 | export default function initSettings() {
5 | const patches = [
6 | patchPanels(),
7 | patchYou(),
8 | ]
9 |
10 | return () => patches.forEach(p => p?.());
11 | }
12 |
--------------------------------------------------------------------------------
/src/ui/settings/pages/AddonHub.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNative as RN, stylesheet } from "@metro/common";
2 | import { all } from "@ui/assets";
3 | import { Forms, Search, ErrorBoundary } from "@ui/components";
4 | import AssetDisplay from "@ui/settings/components/AssetDisplay";
5 | import AddonPage from "@ui/settings/components/AddonPage";
6 | import PluginCard from "@ui/settings/components/PluginCard";
7 | import { Plugin, Theme } from "@types";
8 | import { useProxy } from "@lib/storage";
9 | import { plugins } from "@lib/plugins";
10 | import { themes } from "@lib/themes";
11 | import ThemeCard from "../components/ThemeCard";
12 | const { FormDivider } = Forms;
13 | const { BadgableTabBar } = findByProps("BadgableTabBar");
14 | import { findByProps } from "@/lib/metro/filters";
15 | export default function AssetBrowser() {
16 |
17 | const styles = stylesheet.createThemedStyleSheet({
18 | bar: {
19 | padding: 8,
20 | flex: 1,
21 | },
22 | });
23 |
24 | const [search, setSearch] = React.useState("");
25 | const [activeTab, setActiveTab] = React.useState("plugins");
26 |
27 | const tabs = [
28 | {
29 | id: 'plugins',
30 | title: 'Plugins',
31 | page: () => items={plugins} card={PluginCard} />
32 | },
33 | {
34 | id: 'shaders',
35 | title: 'Shaders',
36 | page: () => items={themes} card={ThemeCard} />
37 | }
38 |
39 | ];
40 |
41 | return (
42 |
43 |
44 | setActiveTab(tab)}
48 | />
49 |
50 | setSearch(v)}
53 | placeholder="Search Addons"
54 | />
55 |
56 | a.name.includes(search) || a.id.toString() === search)}
58 | renderItem={({ item }) => }
59 | ItemSeparatorComponent={FormDivider}
60 | keyExtractor={item => item.name}
61 | />
62 |
63 |
64 | )
65 | }
--------------------------------------------------------------------------------
/src/ui/settings/pages/Addons.tsx:
--------------------------------------------------------------------------------
1 | import { Plugin, Theme } from "@types";
2 | import { useProxy } from "@lib/storage";
3 | import { plugins } from "@lib/plugins";
4 | import { themes } from "@lib/themes";
5 | import settings from "@lib/settings";
6 | import AddonPage from "@ui/settings/components/AddonPage";
7 | import PluginCard from "@ui/settings/components/PluginCard";
8 | import { ReactNative as RN, stylesheet } from "@metro/common";
9 | import ThemeCard from "../components/ThemeCard";
10 | import { findByProps } from "@/lib/metro/filters";
11 | import TweakManager from "@/ui/settings/pages/Tweaks";
12 |
13 | const { BadgableTabBar } = findByProps("BadgableTabBar");
14 |
15 | const styles = stylesheet.createThemedStyleSheet({
16 | bar: {
17 | padding: 8,
18 | },
19 | });
20 |
21 | export default function Addons() {
22 | //@ts-ignore
23 | useProxy(settings)
24 | const [activeTab, setActiveTab] = React.useState("plugins");
25 |
26 | const tabs = [
27 | {
28 | id: 'plugins',
29 | title: 'Plugins',
30 | page: () => items={plugins} card={PluginCard} />
31 | },
32 | {
33 | id: 'shaders',
34 | title: 'Shaders',
35 | page: () => items={themes} card={ThemeCard} />
36 | },
37 | {
38 | id: 'tweaks',
39 | title: 'Tweaks',
40 | page: () =>
41 | }
42 |
43 | ];
44 |
45 |
46 | return <>
47 |
48 |
49 | setActiveTab(tab)}
53 | />
54 |
55 | {React.createElement(tabs.find(tab => tab.id === activeTab).page)}
56 | >
57 | }
58 |
--------------------------------------------------------------------------------
/src/ui/settings/pages/AssetBrowser.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNative as RN } from "@metro/common";
2 | import { all } from "@ui/assets";
3 | import { Forms, Search, ErrorBoundary } from "@ui/components";
4 | import AssetDisplay from "@ui/settings/components/AssetDisplay";
5 |
6 | const { FormDivider } = Forms;
7 |
8 | export default function AssetBrowser() {
9 | const [search, setSearch] = React.useState("");
10 |
11 | return (
12 |
13 |
14 | setSearch(v)}
17 | placeholder="Search Assets"
18 | />
19 | a.name.includes(search) || a.id.toString() === search)}
21 | renderItem={({ item }) => }
22 | ItemSeparatorComponent={FormDivider}
23 | keyExtractor={item => item.name}
24 | />
25 |
26 |
27 | )
28 | }
--------------------------------------------------------------------------------
/src/ui/settings/pages/General.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNative as RN, url, NavigationNative, clipboard } from "@metro/common";
2 | import { DISCORD_SERVER, GITHUB } from "@lib/constants";
3 | import { getDebugInfo } from "@lib/debug";
4 | import { findByProps } from "@metro/filters";
5 | import { useProxy } from "@lib/storage";
6 | import { BundleUpdaterManager } from "@lib/native";
7 | import { getAssetIDByName } from "@ui/assets";
8 | import { Forms, Summary, ErrorBoundary } from "@ui/components";
9 | import settings from "@lib/settings";
10 | import { loaderConfig } from "@lib/settings";
11 | import AssetBrowser from "@ui/settings/pages/AssetBrowser";
12 | import Version from "@ui/settings/components/Version";
13 | import { connectToDebugger } from "@lib/debug";
14 |
15 | const { FormRow, FormSwitchRow, FormSection, FormDivider, FormInput, FormText } = Forms;
16 | const debugInfo = getDebugInfo();
17 |
18 | export default function General() {
19 | const navigation = NavigationNative.useNavigation();
20 | //@ts-ignore
21 | useProxy(settings);
22 | //@ts-ignore
23 | useProxy(loaderConfig);
24 |
25 | const versions = [
26 | {
27 | label: "Discord Version",
28 | version: `${debugInfo.discord.version} (${debugInfo.discord.build})`,
29 | icon: "Discord",
30 | },
31 | {
32 | label: "React",
33 | version: debugInfo.react.version,
34 | icon: "ic_category_16px",
35 | },
36 | {
37 | label: "React Native",
38 | version: debugInfo.react.nativeVersion,
39 | icon: "mobile",
40 | },
41 | {
42 | label: "Bytecode",
43 | version: debugInfo.hermes.bytecodeVersion,
44 | icon: "ic_server_security_24px",
45 | },
46 | ];
47 |
48 | const platformInfo = [
49 | {
50 | label: "Loader",
51 | version: debugInfo.vendetta.loader,
52 | icon: "ic_download_24px",
53 | },
54 | {
55 | label: "OS",
56 | version: `${debugInfo.os.name} ${debugInfo.os.version}`,
57 | icon: "ic_cog_24px"
58 | },
59 | ...(debugInfo.os.sdk ? [{
60 | label: "SDK",
61 | version: debugInfo.os.sdk,
62 | icon: "ic_profile_badge_verified_developer_color"
63 | }] : []),
64 | {
65 | label: "Manufacturer",
66 | version: debugInfo.device.manufacturer,
67 | icon: "ic_badge_staff"
68 | },
69 | {
70 | label: "Brand",
71 | version: debugInfo.device.brand,
72 | icon: "ic_settings_boost_24px"
73 | },
74 | {
75 | label: "Model",
76 | version: debugInfo.device.model,
77 | icon: "ic_phonelink_24px"
78 | },
79 | {
80 | label: RN.Platform.select({ android: "Codename", ios: "Machine ID" })!,
81 | version: debugInfo.device.codename,
82 | icon: "ic_compose_24px"
83 | }
84 | ];
85 |
86 | return (
87 |
88 |
89 |
90 | }
93 | trailing={{debugInfo.vendetta.version}}
94 | onPress={() => clipboard.setString(debugInfo.vendetta.version)}
95 | />
96 |
97 | }
100 | trailing={FormRow.Arrow}
101 | onPress={() => url.openDeeplink(DISCORD_SERVER)}
102 | />
103 |
104 | }
107 | trailing={FormRow.Arrow}
108 | onPress={() => url.openURL(GITHUB)}
109 | />
110 |
111 |
112 | }
115 | onPress={() => BundleUpdaterManager.reload()}
116 | />
117 |
118 | }
121 | trailing={FormRow.Arrow}
122 | onPress={() => navigation.push("VendettaCustomPage", {
123 | title: "Asset Browser",
124 | render: AssetBrowser,
125 | })}
126 | />
127 |
128 |
129 |
130 |
131 |
132 | {versions.map((v, i) => (
133 | <>
134 |
135 | {i !== versions.length - 1 && }
136 | >
137 | ))}
138 |
139 |
140 |
141 | {platformInfo.map((p, i) => (
142 | <>
143 |
144 | {i !== platformInfo.length - 1 && }
145 | >
146 | ))}
147 |
148 |
149 |
150 |
151 | settings.debuggerUrl = v}
154 | placeholder="127.0.0.1:9090"
155 | title="Debugger URL"
156 | />
157 |
158 | }
161 | onPress={() => connectToDebugger(settings.debuggerUrl)}
162 | />
163 | {window.__vendetta_rdc && <>
164 |
165 | }
168 | onPress={() => window.__vendetta_rdc?.connectToDevTools({
169 | host: settings.debuggerUrl.split(":")?.[0],
170 | resolveRNStyle: RN.StyleSheet.flatten,
171 | })}
172 | />
173 | >}
174 | {window.__vendetta_loader?.features.loaderConfig && <>
175 | }
179 | value={loaderConfig.customLoadUrl.enabled}
180 | onValueChange={(v: boolean) => {
181 | loaderConfig.customLoadUrl.enabled = v;
182 | }}
183 | />
184 |
185 | {loaderConfig.customLoadUrl.enabled && <>
186 | loaderConfig.customLoadUrl.url = v}
189 | placeholder="http://localhost:4040/vendetta.js"
190 | title="OPTI URL"
191 | />
192 |
193 | >}
194 | {window.__vendetta_loader.features.devtools && }
198 | value={loaderConfig.loadReactDevTools}
199 | onValueChange={(v: boolean) => {
200 | loaderConfig.loadReactDevTools = v;
201 | }}
202 | />}
203 |
204 | >}
205 |
206 |
207 |
208 |
209 |
210 |
211 | )
212 | }
--------------------------------------------------------------------------------
/src/ui/settings/pages/Plugins.tsx:
--------------------------------------------------------------------------------
1 | import { Plugin } from "@types";
2 | import { useProxy } from "@lib/storage";
3 | import { plugins } from "@lib/plugins";
4 | import settings from "@lib/settings";
5 | import AddonPage from "@ui/settings/components/AddonPage";
6 | import PluginCard from "@ui/settings/components/PluginCard";
7 |
8 | export default function Plugins() {
9 | //@ts-ignore
10 | useProxy(settings)
11 | return (
12 |
13 | items={plugins}
14 | card={PluginCard}
15 | />
16 | )
17 | }
--------------------------------------------------------------------------------
/src/ui/settings/pages/Themes.tsx:
--------------------------------------------------------------------------------
1 | import { Theme, ButtonColors } from "@types";
2 | import { useProxy } from "@lib/storage";
3 | import { themes } from "@lib/themes";
4 | import { Button } from "@ui/components";
5 | import settings from "@lib/settings";
6 | import AddonPage from "@ui/settings/components/AddonPage";
7 | import ThemeCard from "@ui/settings/components/ThemeCard";
8 |
9 | export default function Themes() {
10 | //@ts-ignore
11 | useProxy(settings);
12 | return (
13 |
14 | items={themes}
15 | card={ThemeCard}
16 | />
17 | )
18 | }
--------------------------------------------------------------------------------
/src/ui/settings/pages/Tweaks.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNative as RN } from "@metro/common";
2 | import { Forms, ErrorBoundary } from "@ui/components";
3 | import { getAssetIDByName } from "@ui/assets";
4 | import { hideDumbButtons, unloadHideButtons } from "@/lib/tweak/removeChatButtons";
5 | import settings from "@lib/settings";
6 | import { useProxy } from "@lib/storage";
7 | import { removePrompts, unloadRemovePrompts } from "@/lib/tweak/removePrompts";
8 |
9 | const { FormDivider, FormRow } = Forms;
10 |
11 | export default function AssetBrowser() {
12 | //@ts-ignore
13 | useProxy(settings);
14 | //@ts-ignore
15 | settings.tweaks ??= {};
16 |
17 | return (
18 |
19 |
20 |
23 |
24 | }
28 | onPress={() => {
29 | settings.tweaks.hideButtons ??= false;
30 | settings.tweaks.hideButtons = !settings.tweaks.hideButtons;
31 | (settings.tweaks.hideButtons ? hideDumbButtons : unloadHideButtons)();
32 | }
33 | }
34 | />
35 |
36 | }
40 | onPress={() => {
41 | settings.tweaks.removePrompts ??= true;
42 | settings.tweaks.removePrompts = !settings.tweaks.removePrompts;
43 | (settings.tweaks.removePrompts ? removePrompts : unloadRemovePrompts)();
44 | }
45 | }
46 | />
47 |
48 | }
52 | onPress={() => {
53 | settings.tweaks.externalbadges ??= true;
54 | settings.tweaks.externalbadges = !settings.tweaks.externalbadges;
55 | }
56 | }
57 | />
58 |
59 |
60 |
61 | )
62 | }
--------------------------------------------------------------------------------
/src/ui/settings/patches/panels.tsx:
--------------------------------------------------------------------------------
1 | import { i18n } from "@metro/common";
2 | import { findByName } from "@metro/filters";
3 | import { after } from "@lib/patcher";
4 | import { findInReactTree } from "@lib/utils";
5 | import { getPanelsScreens } from "@ui/settings/data";
6 | import SettingsSection from "@ui/settings/components/SettingsSection";
7 |
8 | const screensModule = findByName("getScreens", false);
9 | const settingsModule = findByName("UserSettingsOverviewWrapper", false);
10 |
11 | export default function patchPanels() {
12 | const patches = new Array;
13 |
14 | patches.push(after("default", screensModule, (_, existingScreens) => ({
15 | ...existingScreens,
16 | ...getPanelsScreens(),
17 | })));
18 |
19 | after("default", settingsModule, (_, ret) => {
20 | const Overview = findInReactTree(ret.props.children, i => i.type && i.type.name === "UserSettingsOverview");
21 |
22 | // Upload logs button gone
23 | patches.push(after("renderSupportAndAcknowledgements", Overview.type.prototype, (_, { props: { children } }) => {
24 | const index = children.findIndex((c: any) => c?.type?.name === "UploadLogsButton");
25 | if (index !== -1) children.splice(index, 1);
26 | }));
27 |
28 | // TODO: Rewrite this whole patch, the index hasn't been properly found for months now
29 | patches.push(after("render", Overview.type.prototype, (_, { props: { children } }) => {
30 | const titles = [i18n.Messages["BILLING_SETTINGS"], i18n.Messages["PREMIUM_SETTINGS"]];
31 | //! Fix for Android 174201 and iOS 42188
32 | children = findInReactTree(children, i => i.children?.[1].type?.name === "FormSection").children;
33 | const index = children.findIndex((c: any) => titles.includes(c?.props.label));
34 | children.splice(index === -1 ? 4 : index, 0, );
35 | }));
36 | }, true);
37 |
38 | return () => patches.forEach(p => p());
39 | }
40 |
--------------------------------------------------------------------------------
/src/ui/settings/patches/you.tsx:
--------------------------------------------------------------------------------
1 | import { findByProps } from "@metro/filters";
2 | import { after, before } from "@lib/patcher";
3 | import { getRenderableScreens, getScreens, getYouData } from "@ui/settings/data";
4 | import { i18n } from "@lib/metro/common";
5 |
6 | export default function patchYou() {
7 | const patches = new Array;
8 |
9 | newYouPatch(patches) || oldYouPatch(patches);
10 | return () => patches.forEach(p => p?.());
11 | }
12 |
13 | function oldYouPatch(patches: Function[]) {
14 | const layoutModule = findByProps("useOverviewSettings");
15 | const titleConfigModule = findByProps("getSettingTitleConfig");
16 | const miscModule = findByProps("SETTING_RELATIONSHIPS", "SETTING_RENDERER_CONFIGS");
17 |
18 | // Checks for 189.4 and above
19 | // When dropping support for 189.3 and below, following can be done: (unless Discord changes things again)
20 | // const gettersModule = findByProps("getSettingListItems");
21 | const OLD_GETTER_FUNCTION = "getSettingSearchListItems";
22 | const NEW_GETTER_FUNCTION = "getSettingListItems";
23 | const oldGettersModule = findByProps(OLD_GETTER_FUNCTION);
24 | const usingNewGettersModule = !oldGettersModule;
25 | const getterFunctionName = usingNewGettersModule ? NEW_GETTER_FUNCTION : OLD_GETTER_FUNCTION;
26 | const gettersModule = oldGettersModule ?? findByProps(NEW_GETTER_FUNCTION);
27 |
28 | if (!gettersModule || !layoutModule) return;
29 |
30 | const screens = getScreens(true);
31 | const renderableScreens = getRenderableScreens(true);
32 | const data = getYouData();
33 |
34 | patches.push(after("useOverviewSettings", layoutModule, (_, ret) => manipulateSections(ret, data.getLayout())));
35 |
36 | patches.push(after("getSettingTitleConfig", titleConfigModule, (_, ret) => ({
37 | ...ret,
38 | ...data.titleConfig,
39 | })));
40 |
41 | patches.push(after(getterFunctionName, gettersModule, ([settings], ret) => [
42 | ...(renderableScreens.filter(s => settings.includes(s.key))).map(s => ({
43 | type: "setting_search_result",
44 | ancestorRendererData: data.rendererConfigs[s.key],
45 | setting: s.key,
46 | title: data.titleConfig[s.key],
47 | breadcrumbs: ["Opti"],
48 | icon: data.rendererConfigs[s.key].icon,
49 | })),
50 | // .filter can be removed when dropping support for 189.3 and below (unless Discord changes things again)
51 | ...ret.filter((i: any) => (usingNewGettersModule || !screens.map(s => s.key).includes(i.setting)))
52 | ].map((item, index, parent) => ({ ...item, index, total: parent.length }))));
53 |
54 | const oldRelationships = miscModule.SETTING_RELATIONSHIPS;
55 | const oldRendererConfigs = miscModule.SETTING_RENDERER_CONFIGS;
56 |
57 | miscModule.SETTING_RELATIONSHIPS = { ...oldRelationships, ...data.relationships };
58 | miscModule.SETTING_RENDERER_CONFIGS = { ...oldRendererConfigs, ...data.rendererConfigs };
59 |
60 | patches.push(() => {
61 | miscModule.SETTING_RELATIONSHIPS = oldRelationships;
62 | miscModule.SETTING_RENDERER_CONFIGS = oldRendererConfigs;
63 | });
64 |
65 | return true;
66 | }
67 |
68 | function newYouPatch(patches: Function[]) {
69 | const settingsListComponents = findByProps("SearchableSettingsList");
70 | const settingConstantsModule = findByProps("SETTING_RENDERER_CONFIG");
71 | const gettersModule = findByProps("getSettingListItems");
72 |
73 | if (!gettersModule || !settingsListComponents || !settingConstantsModule) return false;
74 |
75 | const screens = getScreens(true);
76 | const data = getYouData();
77 |
78 | patches.push(before("type", settingsListComponents.SearchableSettingsList, ([{ sections }]) => manipulateSections(sections, data.getLayout())));
79 |
80 | patches.push(after("getSettingListSearchResultItems", gettersModule, (_, ret) => {
81 | ret.forEach((s: any) => screens.some(b => b.key === s.setting) && (s.breadcrumbs = ["Opti"]))
82 | }));
83 |
84 | const oldRendererConfig = settingConstantsModule.SETTING_RENDERER_CONFIG;
85 | settingConstantsModule.SETTING_RENDERER_CONFIG = { ...oldRendererConfig, ...data.rendererConfigs };
86 |
87 | patches.push(() => {
88 | settingConstantsModule.SETTING_RENDERER_CONFIG = oldRendererConfig;
89 | });
90 |
91 | return true;
92 | }
93 |
94 | const isLabel = (i: any, name: string) => i?.label === name || i?.title === name;
95 |
96 | function manipulateSections(sections: any[], layout: any) {
97 | if (!Array.isArray(sections) || sections.find((i: any) => isLabel(i, "Opti"))) return;
98 |
99 | // Add our settings
100 | const accountSettingsIndex = sections.findIndex((i: any) => isLabel(i, i18n.Messages.ACCOUNT_SETTINGS));
101 | sections.splice(accountSettingsIndex + 1, 0, layout);
102 |
103 | // Upload Logs button be gone
104 | const supportCategory = sections.find((i: any) => isLabel(i, i18n.Messages.SUPPORT));
105 | if (supportCategory) supportCategory.settings = supportCategory.settings.filter((s: string) => s !== "UPLOAD_DEBUG_LOGS")
106 | }
--------------------------------------------------------------------------------
/src/ui/toasts.ts:
--------------------------------------------------------------------------------
1 | import { findByProps } from "@metro/filters";
2 | import { toasts } from "@metro/common";
3 |
4 | const { uuid4 } = findByProps("uuid4");
5 |
6 | export const showToast = (content: string, asset?: number) => toasts.open({
7 | //? In build 182205/44707, Discord changed their toasts, source is no longer used, rather icon, and a key is needed.
8 | // TODO: We could probably have the developer specify a key themselves, but this works to fix toasts
9 | key: `vd-toast-${uuid4()}`,
10 | content: content,
11 | source: asset,
12 | icon: asset,
13 | });
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["src"],
3 | "exclude": ["node_modules"],
4 | "compilerOptions": {
5 | "baseUrl": ".",
6 | "sourceMap": true,
7 | "module": "ESNext",
8 | "target": "ESNext",
9 | "moduleResolution": "node",
10 | "esModuleInterop": true,
11 | "jsx": "react",
12 | "strict": true,
13 | "forceConsistentCasingInFileNames": true,
14 | "resolveJsonModule": true,
15 | "paths": {
16 | "@/*": ["src/*"],
17 | "@types": ["src/def.d.ts"],
18 | "@lib/*": ["src/lib/*"],
19 | "@metro/*": ["src/lib/metro/*"],
20 | "@ui/*": ["src/ui/*"]
21 | }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------