├── .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 |