├── .vscode └── settings.json ├── FUNDING.yml ├── .gitignore ├── src ├── lib │ ├── patcher.ts │ ├── utils │ │ ├── unfreeze.ts │ │ ├── without.ts │ │ ├── findInReactTree.ts │ │ ├── index.ts │ │ ├── safeFetch.ts │ │ └── findInTree.ts │ ├── logger.ts │ ├── settings.ts │ ├── command │ │ ├── reload.tsx │ │ ├── index.ts │ │ ├── pluginslist.tsx │ │ └── debug.tsx │ ├── polyfills.ts │ ├── native.ts │ ├── badge │ │ ├── types.ts │ │ ├── badgeComponent.tsx │ │ └── index.tsx │ ├── constants.ts │ ├── tweak │ │ ├── removePrompts.ts │ │ ├── index.tsx │ │ ├── enableExperiments.ts │ │ ├── fixConnecting.ts │ │ └── removeChatButtons.ts │ ├── emitter.ts │ ├── fixes.ts │ ├── commands.ts │ ├── preinit.ts │ ├── windowObject.ts │ ├── storage │ │ ├── backends.ts │ │ └── index.ts │ ├── metro │ │ ├── common.ts │ │ └── filters.ts │ ├── debug.ts │ ├── plugins.ts │ └── themes.ts ├── ui │ ├── settings │ │ ├── index.ts │ │ ├── pages │ │ │ ├── Plugins.tsx │ │ │ ├── Themes.tsx │ │ │ ├── AssetBrowser.tsx │ │ │ ├── Addons.tsx │ │ │ ├── AddonHub.tsx │ │ │ ├── Tweaks.tsx │ │ │ └── General.tsx │ │ ├── components │ │ │ ├── Dropdown.tsx │ │ │ ├── AssetDisplay.tsx │ │ │ ├── Version.tsx │ │ │ ├── AddonHubButton.tsx │ │ │ ├── SettingsSection.tsx │ │ │ ├── InstallButton.tsx │ │ │ ├── AddonPage.tsx │ │ │ ├── TweakCard.tsx │ │ │ ├── ThemeCard.tsx │ │ │ ├── Card.tsx │ │ │ └── PluginCard.tsx │ │ ├── patches │ │ │ ├── panels.tsx │ │ │ └── you.tsx │ │ └── data.tsx │ ├── quickInstall │ │ ├── index.ts │ │ ├── forumPost.tsx │ │ └── url.tsx │ ├── toasts.ts │ ├── components │ │ ├── Search.tsx │ │ ├── index.ts │ │ ├── Summary.tsx │ │ ├── Codeblock.tsx │ │ ├── InputAlert.tsx │ │ └── ErrorBoundary.tsx │ ├── assets.ts │ ├── alerts.ts │ └── color.ts ├── entry.ts ├── index.ts └── def.d.ts ├── .prettierrc ├── tsconfig.json ├── package.json ├── .github ├── workflows │ └── build.yml └── ISSUE_TEMPLATE │ └── bug-report.yml ├── LICENSE └── README.md /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | } -------------------------------------------------------------------------------- /FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [maisymoe, wingio] 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | yarn.lock 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /src/lib/patcher.ts: -------------------------------------------------------------------------------- 1 | import * as _spitroast from "spitroast"; 2 | 3 | export * from "spitroast"; 4 | export default { ..._spitroast }; -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 240, 3 | "tabWidth": 4, 4 | "singleQuote": false, 5 | "jsxSingleQuote": false, 6 | "bracketSpacing": true, 7 | "useTabs": false 8 | } 9 | -------------------------------------------------------------------------------- /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/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/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/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/ui/settings/index.ts: -------------------------------------------------------------------------------- 1 | import patchPanels from "@ui/settings/patches/panels"; 2 | import patchYou from "@ui/settings/patches/you"; 3 | 4 | export default function initSettings() { 5 | const patches = [ 6 | patchPanels(), 7 | patchYou(), 8 | ] 9 | 10 | return () => patches.forEach(p => p?.()); 11 | } 12 | -------------------------------------------------------------------------------- /src/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/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/ui/quickInstall/index.ts: -------------------------------------------------------------------------------- 1 | import patchForumPost from "@ui/quickInstall/forumPost"; 2 | import patchUrl from "@ui/quickInstall/url"; 3 | 4 | export default function initQuickInstall() { 5 | const patches = new Array; 6 | 7 | patches.push(patchForumPost()); 8 | patches.push(patchUrl()); 9 | 10 | return () => patches.forEach(p => p()); 11 | }; 12 | -------------------------------------------------------------------------------- /src/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/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/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/ui/settings/pages/Plugins.tsx: -------------------------------------------------------------------------------- 1 | import { Plugin } from "@types"; 2 | import { useProxy } from "@lib/storage"; 3 | import { plugins } from "@lib/plugins"; 4 | import settings from "@lib/settings"; 5 | import AddonPage from "@ui/settings/components/AddonPage"; 6 | import PluginCard from "@ui/settings/components/PluginCard"; 7 | 8 | export default function Plugins() { 9 | //@ts-ignore 10 | useProxy(settings) 11 | return ( 12 | 13 | items={plugins} 14 | card={PluginCard} 15 | /> 16 | ) 17 | } -------------------------------------------------------------------------------- /src/ui/toasts.ts: -------------------------------------------------------------------------------- 1 | import { findByProps } from "@metro/filters"; 2 | import { toasts } from "@metro/common"; 3 | 4 | const { uuid4 } = findByProps("uuid4"); 5 | 6 | export const showToast = (content: string, asset?: number) => toasts.open({ 7 | //? In build 182205/44707, Discord changed their toasts, source is no longer used, rather icon, and a key is needed. 8 | // TODO: We could probably have the developer specify a key themselves, but this works to fix toasts 9 | key: `vd-toast-${uuid4()}`, 10 | content: content, 11 | source: asset, 12 | icon: asset, 13 | }); -------------------------------------------------------------------------------- /src/ui/components/Search.tsx: -------------------------------------------------------------------------------- 1 | import { SearchProps } from "@types"; 2 | import { stylesheet } from "@metro/common"; 3 | import { findByName } from "@metro/filters"; 4 | 5 | const Search = findByName("StaticSearchBarContainer"); 6 | 7 | const styles = stylesheet.createThemedStyleSheet({ 8 | search: { 9 | margin: 0, 10 | padding: 0, 11 | borderBottomWidth: 0, 12 | backgroundColor: "none", 13 | } 14 | }); 15 | 16 | export default ({ onChangeText, placeholder, style }: SearchProps) => -------------------------------------------------------------------------------- /src/ui/settings/pages/Themes.tsx: -------------------------------------------------------------------------------- 1 | import { Theme, ButtonColors } from "@types"; 2 | import { useProxy } from "@lib/storage"; 3 | import { themes } from "@lib/themes"; 4 | import { Button } from "@ui/components"; 5 | import settings from "@lib/settings"; 6 | import AddonPage from "@ui/settings/components/AddonPage"; 7 | import ThemeCard from "@ui/settings/components/ThemeCard"; 8 | 9 | export default function Themes() { 10 | //@ts-ignore 11 | useProxy(settings); 12 | return ( 13 | 14 | items={themes} 15 | card={ThemeCard} 16 | /> 17 | ) 18 | } -------------------------------------------------------------------------------- /src/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/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/ui/settings/components/Dropdown.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNative as RN } from '@metro/common' 2 | import type { ViewStyle } from 'react-native' 3 | 4 | interface OverflowItem { 5 | label: string; 6 | IconComponent?: React.ComponentType; 7 | iconSource?: number; 8 | action: () => any; 9 | } 10 | 11 | interface OverflowProps { 12 | items: OverflowItem[] | Array, 13 | title?: string; 14 | iconSource?: number; 15 | scale?: number; 16 | style?: ViewStyle; 17 | } 18 | 19 | export default function Overflow({items, title, iconSource, scale = 1, style = {}} : OverflowProps) { 20 | return 21 | // will continue working on it 22 | } -------------------------------------------------------------------------------- /src/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/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 | }; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src"], 3 | "exclude": ["node_modules"], 4 | "compilerOptions": { 5 | "baseUrl": ".", 6 | "sourceMap": true, 7 | "module": "ESNext", 8 | "target": "ESNext", 9 | "moduleResolution": "node", 10 | "esModuleInterop": true, 11 | "jsx": "react", 12 | "strict": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "resolveJsonModule": true, 15 | "paths": { 16 | "@/*": ["src/*"], 17 | "@types": ["src/def.d.ts"], 18 | "@lib/*": ["src/lib/*"], 19 | "@metro/*": ["src/lib/metro/*"], 20 | "@ui/*": ["src/ui/*"] 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /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/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/ui/settings/components/AssetDisplay.tsx: -------------------------------------------------------------------------------- 1 | import { Asset } from "@types"; 2 | import { ReactNative as RN, clipboard } from "@metro/common"; 3 | import { showToast } from "@ui/toasts"; 4 | import { getAssetIDByName } from "@ui/assets"; 5 | import { Forms } from "@ui/components"; 6 | 7 | interface AssetDisplayProps { asset: Asset } 8 | 9 | const { FormRow } = Forms; 10 | 11 | export default function AssetDisplay({ asset }: AssetDisplayProps) { 12 | return ( 13 | } 16 | onPress={() => { 17 | clipboard.setString(asset.name); 18 | showToast("Copied asset name to clipboard.", getAssetIDByName("toast_copy_link")); 19 | }} 20 | /> 21 | ) 22 | } -------------------------------------------------------------------------------- /src/ui/settings/components/Version.tsx: -------------------------------------------------------------------------------- 1 | import { clipboard } from "@metro/common"; 2 | import { getAssetIDByName } from "@ui/assets"; 3 | import { showToast } from "@ui/toasts"; 4 | import { Forms } from "@ui/components"; 5 | 6 | interface VersionProps { 7 | label: string; 8 | version: string; 9 | leading?: JSX.Element; 10 | icon: string; 11 | } 12 | 13 | const { FormRow, FormText } = Forms; 14 | 15 | export default function Version({ label, version, icon }: VersionProps) { 16 | return ( 17 | } 20 | trailing={{version}} 21 | onPress={() => { 22 | clipboard.setString(`${label} - ${version}`); 23 | showToast("Copied version to clipboard.", getAssetIDByName("toast_copy_link")); 24 | }} 25 | /> 26 | ) 27 | } -------------------------------------------------------------------------------- /src/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/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/components/index.ts: -------------------------------------------------------------------------------- 1 | import { ReactNative as RN } from "@metro/common"; 2 | import { findByDisplayName, findByName, findByProps } from "@metro/filters"; 3 | 4 | // Discord 5 | export const Forms = findByProps("Form", "FormSection"); 6 | export const General = findByProps("Button", "Text", "View"); 7 | export const Alert = findByDisplayName("FluxContainer(Alert)"); 8 | export const Button = findByProps("Looks", "Colors", "Sizes") as React.ComponentType & { Looks: any, Colors: any, Sizes: any }; 9 | export const HelpMessage = findByName("HelpMessage"); 10 | // React Native's included SafeAreaView only adds padding on iOS. 11 | export const SafeAreaView = findByProps("useSafeAreaInsets").SafeAreaView as typeof RN.SafeAreaView; 12 | 13 | // Vendetta 14 | export { default as Summary } from "@ui/components/Summary"; 15 | export { default as ErrorBoundary } from "@ui/components/ErrorBoundary"; 16 | export { default as Codeblock } from "@ui/components/Codeblock"; 17 | export { default as Search } from "@ui/components/Search"; -------------------------------------------------------------------------------- /src/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/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/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/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/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/ui/settings/pages/AssetBrowser.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNative as RN } from "@metro/common"; 2 | import { all } from "@ui/assets"; 3 | import { Forms, Search, ErrorBoundary } from "@ui/components"; 4 | import AssetDisplay from "@ui/settings/components/AssetDisplay"; 5 | 6 | const { FormDivider } = Forms; 7 | 8 | export default function AssetBrowser() { 9 | const [search, setSearch] = React.useState(""); 10 | 11 | return ( 12 | 13 | 14 | setSearch(v)} 17 | placeholder="Search Assets" 18 | /> 19 | a.name.includes(search) || a.id.toString() === search)} 21 | renderItem={({ item }) => } 22 | ItemSeparatorComponent={FormDivider} 23 | keyExtractor={item => item.name} 24 | /> 25 | 26 | 27 | ) 28 | } -------------------------------------------------------------------------------- /src/ui/settings/components/AddonHubButton.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNative as RN, stylesheet, NavigationNative } from "@metro/common"; 2 | import AddonHub from "@ui/settings/pages/AddonHub"; 3 | import { getAssetIDByName } from "@ui/assets"; 4 | import { semanticColors } from "@ui/color"; 5 | 6 | const styles = stylesheet.createThemedStyleSheet({ 7 | icon: { 8 | marginRight: 10, 9 | tintColor: semanticColors.HEADER_PRIMARY, 10 | }, 11 | }); 12 | 13 | interface InstallButtonProps { 14 | alertTitle: string; 15 | installFunction: (id: string) => Promise; 16 | } 17 | 18 | export default function InstallButton({ alertTitle, installFunction: fetchFunction }: InstallButtonProps) { 19 | const navigation = NavigationNative.useNavigation(); 20 | return ( 21 | 22 | navigation.push("VendettaCustomPage", { 23 | title: "Addons Hub", 24 | render: AddonHub, 25 | }) 26 | 27 | }> 28 | 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/ui/components/Summary.tsx: -------------------------------------------------------------------------------- 1 | import { SummaryProps } from "@types"; 2 | import { ReactNative as RN } from "@metro/common"; 3 | import { getAssetIDByName } from "@ui/assets"; 4 | import { Forms } from "@ui/components"; 5 | 6 | export default function Summary({ label, icon, noPadding = false, noAnimation = false, children }: SummaryProps) { 7 | const { FormRow, FormDivider } = Forms; 8 | const [hidden, setHidden] = React.useState(true); 9 | 10 | return ( 11 | <> 12 | } 15 | trailing={} 16 | onPress={() => { 17 | setHidden(!hidden); 18 | if (!noAnimation) RN.LayoutAnimation.configureNext(RN.LayoutAnimation.Presets.easeInEaseOut); 19 | }} 20 | /> 21 | {!hidden && <> 22 | 23 | {children} 24 | } 25 | 26 | ) 27 | } -------------------------------------------------------------------------------- /src/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/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); -------------------------------------------------------------------------------- /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/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/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/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/ui/settings/components/SettingsSection.tsx: -------------------------------------------------------------------------------- 1 | import { NavigationNative } from "@metro/common"; 2 | import { useProxy } from "@lib/storage"; 3 | import { getAssetIDByName } from "@ui/assets"; 4 | import { getRenderableScreens } from "@ui/settings/data"; 5 | import { ErrorBoundary, Forms } from "@ui/components"; 6 | import settings from "@lib/settings"; 7 | 8 | const { FormRow, FormSection, FormDivider, FormText } = Forms; 9 | 10 | export default function SettingsSection() { 11 | const navigation = NavigationNative.useNavigation(); 12 | //@ts-ignore 13 | useProxy(settings); 14 | 15 | const screens = getRenderableScreens() 16 | 17 | return ( 18 | 19 | 20 | {screens.map((s, i) => ( 21 | <> 22 | } 25 | trailing={FormRow.Arrow} 26 | onPress={() => navigation.push(s.key)} 27 | /> 28 | {i !== screens.length - 1 && } 29 | 30 | ))} 31 | 32 | 33 | ) 34 | } -------------------------------------------------------------------------------- /src/ui/settings/components/InstallButton.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNative as RN, stylesheet, clipboard } from "@metro/common"; 2 | import { HTTP_REGEX_MULTI } from "@lib/constants"; 3 | import { showInputAlert } from "@ui/alerts"; 4 | import { getAssetIDByName } from "@ui/assets"; 5 | import { semanticColors } from "@ui/color"; 6 | 7 | const styles = stylesheet.createThemedStyleSheet({ 8 | icon: { 9 | marginRight: 10, 10 | tintColor: semanticColors.HEADER_PRIMARY, 11 | }, 12 | }); 13 | 14 | interface InstallButtonProps { 15 | alertTitle: string; 16 | installFunction: (id: string) => Promise; 17 | } 18 | 19 | export default function InstallButton({ alertTitle, installFunction: fetchFunction }: InstallButtonProps) { 20 | return ( 21 | 22 | clipboard.getString().then((content) => 23 | showInputAlert({ 24 | title: alertTitle, 25 | initialValue: content.match(HTTP_REGEX_MULTI)?.[0] ?? "", 26 | placeholder: "https://example.com/", 27 | onConfirm: (input: string) => fetchFunction(input), 28 | confirmText: "Install", 29 | cancelText: "Cancel", 30 | }) 31 | ) 32 | }> 33 | 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/ui/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/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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/ui/settings/components/AddonPage.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNative as RN } from "@metro/common"; 2 | import { useProxy } from "@lib/storage"; 3 | import { HelpMessage, ErrorBoundary, Search } from "@ui/components"; 4 | import { CardWrapper } from "@ui/settings/components/Card"; 5 | import settings from "@lib/settings"; 6 | 7 | interface AddonPageProps { 8 | items: Record; 9 | card: React.ComponentType>; 10 | } 11 | 12 | export default function AddonPage({ items, card: CardComponent }: AddonPageProps) { 13 | //@ts-ignore 14 | useProxy(settings) 15 | useProxy(items); 16 | const [search, setSearch] = React.useState(""); 17 | 18 | return ( 19 | 20 | 22 | 23 | 24 | setSearch(v.toLowerCase())} 27 | placeholder="Search" 28 | /> 29 | } 30 | style={{ paddingHorizontal: 10, paddingTop: 20 }} 31 | contentContainerStyle={{ paddingBottom: 20 }} 32 | data={Object.values(items).filter(i => i.id?.toLowerCase().includes(search))} 33 | renderItem={({ item, index }) => } 34 | /> 35 | 36 | ) 37 | } -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /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/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/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/settings/pages/Addons.tsx: -------------------------------------------------------------------------------- 1 | import { Plugin, Theme } from "@types"; 2 | import { useProxy } from "@lib/storage"; 3 | import { plugins } from "@lib/plugins"; 4 | import { themes } from "@lib/themes"; 5 | import settings from "@lib/settings"; 6 | import AddonPage from "@ui/settings/components/AddonPage"; 7 | import PluginCard from "@ui/settings/components/PluginCard"; 8 | import { ReactNative as RN, stylesheet } from "@metro/common"; 9 | import ThemeCard from "../components/ThemeCard"; 10 | import { findByProps } from "@/lib/metro/filters"; 11 | import TweakManager from "@/ui/settings/pages/Tweaks"; 12 | 13 | const { BadgableTabBar } = findByProps("BadgableTabBar"); 14 | 15 | const styles = stylesheet.createThemedStyleSheet({ 16 | bar: { 17 | padding: 8, 18 | }, 19 | }); 20 | 21 | export default function Addons() { 22 | //@ts-ignore 23 | useProxy(settings) 24 | const [activeTab, setActiveTab] = React.useState("plugins"); 25 | 26 | const tabs = [ 27 | { 28 | id: 'plugins', 29 | title: 'Plugins', 30 | page: () => items={plugins} card={PluginCard} /> 31 | }, 32 | { 33 | id: 'shaders', 34 | title: 'Shaders', 35 | page: () => items={themes} card={ThemeCard} /> 36 | }, 37 | { 38 | id: 'tweaks', 39 | title: 'Tweaks', 40 | page: () => 41 | } 42 | 43 | ]; 44 | 45 | 46 | return <> 47 | 48 | 49 | setActiveTab(tab)} 53 | /> 54 | 55 | {React.createElement(tabs.find(tab => tab.id === activeTab).page)} 56 | 57 | } 58 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/ui/components/InputAlert.tsx: -------------------------------------------------------------------------------- 1 | import { InputAlertProps } from "@types"; 2 | import { findByProps } from "@metro/filters"; 3 | import { Forms, Alert } from "@ui/components"; 4 | 5 | const { FormInput } = Forms; 6 | const Alerts = findByProps("openLazy", "close"); 7 | 8 | export default function InputAlert({ title, confirmText, confirmColor, onConfirm, cancelText, placeholder, initialValue = "", secureTextEntry }: InputAlertProps) { 9 | const [value, setValue] = React.useState(initialValue); 10 | const [error, setError] = React.useState(""); 11 | 12 | function onConfirmWrapper() { 13 | const asyncOnConfirm = Promise.resolve(onConfirm(value)) 14 | 15 | asyncOnConfirm.then(() => { 16 | Alerts.close(); 17 | }).catch((e: Error) => { 18 | setError(e.message); 19 | }); 20 | }; 21 | 22 | return ( 23 | Alerts.close()} 31 | > 32 | { 36 | setValue(typeof v === "string" ? v : v.text); 37 | if (error) setError(""); 38 | }} 39 | returnKeyType="done" 40 | onSubmitEditing={onConfirmWrapper} 41 | error={error || undefined} 42 | secureTextEntry={secureTextEntry} 43 | autoFocus={true} 44 | showBorder={true} 45 | style={{ paddingVertical: 5, alignSelf: "stretch", paddingHorizontal: 0 }} 46 | /> 47 | 48 | ); 49 | }; -------------------------------------------------------------------------------- /src/ui/settings/patches/panels.tsx: -------------------------------------------------------------------------------- 1 | import { i18n } from "@metro/common"; 2 | import { findByName } from "@metro/filters"; 3 | import { after } from "@lib/patcher"; 4 | import { findInReactTree } from "@lib/utils"; 5 | import { getPanelsScreens } from "@ui/settings/data"; 6 | import SettingsSection from "@ui/settings/components/SettingsSection"; 7 | 8 | const screensModule = findByName("getScreens", false); 9 | const settingsModule = findByName("UserSettingsOverviewWrapper", false); 10 | 11 | export default function patchPanels() { 12 | const patches = new Array; 13 | 14 | patches.push(after("default", screensModule, (_, existingScreens) => ({ 15 | ...existingScreens, 16 | ...getPanelsScreens(), 17 | }))); 18 | 19 | after("default", settingsModule, (_, ret) => { 20 | const Overview = findInReactTree(ret.props.children, i => i.type && i.type.name === "UserSettingsOverview"); 21 | 22 | // Upload logs button gone 23 | patches.push(after("renderSupportAndAcknowledgements", Overview.type.prototype, (_, { props: { children } }) => { 24 | const index = children.findIndex((c: any) => c?.type?.name === "UploadLogsButton"); 25 | if (index !== -1) children.splice(index, 1); 26 | })); 27 | 28 | // TODO: Rewrite this whole patch, the index hasn't been properly found for months now 29 | patches.push(after("render", Overview.type.prototype, (_, { props: { children } }) => { 30 | const titles = [i18n.Messages["BILLING_SETTINGS"], i18n.Messages["PREMIUM_SETTINGS"]]; 31 | //! Fix for Android 174201 and iOS 42188 32 | children = findInReactTree(children, i => i.children?.[1].type?.name === "FormSection").children; 33 | const index = children.findIndex((c: any) => titles.includes(c?.props.label)); 34 | children.splice(index === -1 ? 4 : index, 0, ); 35 | })); 36 | }, true); 37 | 38 | return () => patches.forEach(p => p()); 39 | } 40 | -------------------------------------------------------------------------------- /src/ui/settings/components/TweakCard.tsx: -------------------------------------------------------------------------------- 1 | import { ButtonColors, Theme } from "@types"; 2 | import { AsyncUsers, Profiles, User, clipboard } from "@metro/common"; 3 | import { selectTheme } from "@lib/themes"; 4 | import { useProxy } from "@lib/storage"; 5 | import { showConfirmationAlert } from "@ui/alerts"; 6 | import { showToast } from "@ui/toasts"; 7 | import settings from "@lib/settings"; 8 | import Card, { CardWrapper } from "@ui/settings/components/Card"; 9 | 10 | async function selectAndReload(value: boolean, id: string) { 11 | await selectTheme(value ? id : "default"); 12 | } 13 | 14 | export default function ThemeCard({ item: theme, index }: CardWrapper) { 15 | //@ts-ignore 16 | useProxy(settings); 17 | const [removed, setRemoved] = React.useState(false); 18 | if (removed) return null; 19 | 20 | // ${authors ? `\nby ${authors.map(i => i.name).join(", ")}` : ""} 21 | return ( 22 | { 30 | selectAndReload(v, theme.id); 31 | }} 32 | overflowTitle={theme.data.name} 33 | overflowActions={[ 34 | { 35 | label: "View Creator Profile", 36 | icon: "ic_profile_24px", 37 | onPress: () => { 38 | if (!User.getUser(theme.data.authors[0]?.id)) { 39 | AsyncUsers.fetchProfile(theme.data.authors[0]?.id).then(() => { 40 | Profiles.showUserProfile({ userId: theme.data.authors[0]?.id }); 41 | })} 42 | else 43 | { 44 | Profiles.showUserProfile({ userId: theme.data.authors[0]?.id }); 45 | }; 46 | } 47 | } 48 | ]} 49 | /> 50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 |