diff --git a/plugins/server-drawer/src/components/ContextMenuModal.tsx b/plugins/server-drawer/src/components/ContextMenuModal.tsx index 1da159d..7ba1a10 100644 --- a/plugins/server-drawer/src/components/ContextMenuModal.tsx +++ b/plugins/server-drawer/src/components/ContextMenuModal.tsx @@ -2,7 +2,7 @@ import React from "react"; import { View, Text, Pressable, Image, Modal, StyleSheet, Dimensions, Animated } from "react-native"; import { semanticColors } from "@vendetta/ui"; import { findByProps } from "@vendetta/metro"; -import { resolveSemanticColor } from "@shared/lib/color"; +import { resolveSemanticColor } from "../lib/color"; const TextStyleSheet = findByProps("TextStyleSheet")?.TextStyleSheet; const Haptic = findByProps("triggerHapticFeedback", "HapticFeedbackTypes"); diff --git a/plugins/server-drawer/src/components/GuildItem.tsx b/plugins/server-drawer/src/components/GuildItem.tsx index 1567e6e..5a0e55f 100644 --- a/plugins/server-drawer/src/components/GuildItem.tsx +++ b/plugins/server-drawer/src/components/GuildItem.tsx @@ -5,8 +5,8 @@ import { clipboard } from "@vendetta/metro/common"; import { storage } from "@vendetta/plugin"; import { useProxy } from "@vendetta/storage"; import { showToast } from "@vendetta/ui/toasts"; -import { lazy } from "@shared/lib/lazy"; -import { rawFindByProps } from "@shared/lib/rawFind"; +import { lazy } from "../lib/lazy"; +import { rawFindByProps } from "../lib/rawFind"; import { GuildNode } from "../utils/theme"; import GuildIcon from "./GuildIcon"; import { ContextMenuModal, ContextMenuItem } from "./ContextMenuModal"; diff --git a/plugins/server-drawer/src/components/MarkAllReadButton.tsx b/plugins/server-drawer/src/components/MarkAllReadButton.tsx index 4c1fad1..6604729 100644 --- a/plugins/server-drawer/src/components/MarkAllReadButton.tsx +++ b/plugins/server-drawer/src/components/MarkAllReadButton.tsx @@ -3,8 +3,8 @@ import { Pressable, Image, StyleSheet } from "react-native"; import { findByProps, findByStoreName } from "@vendetta/metro"; import { getAssetIDByName } from "@vendetta/ui/assets"; import { showToast } from "@vendetta/ui/toasts"; -import { lazy } from "@shared/lib/lazy"; -import { rawFindByProps } from "@shared/lib/rawFind"; +import { lazy } from "../lib/lazy"; +import { rawFindByProps } from "../lib/rawFind"; const ICON = 48; diff --git a/plugins/server-drawer/src/components/ServerDrawerSheet.tsx b/plugins/server-drawer/src/components/ServerDrawerSheet.tsx index b9466b2..37c7d04 100644 --- a/plugins/server-drawer/src/components/ServerDrawerSheet.tsx +++ b/plugins/server-drawer/src/components/ServerDrawerSheet.tsx @@ -1,8 +1,8 @@ import React from "react"; import { View, Text, Pressable, Animated, Dimensions, StyleSheet, BackHandler } from "react-native"; import { find, findByProps, findByStoreName } from "@vendetta/metro"; -import { lazy } from "@shared/lib/lazy"; -import { rawFind, rawFindByProps } from "@shared/lib/rawFind"; +import { lazy } from "../lib/lazy"; +import { rawFind, rawFindByProps } from "../lib/rawFind"; import { GuildNode } from "../utils/theme"; import GuildItem from "./GuildItem"; import FolderItem from "./FolderItem"; diff --git a/plugins/server-drawer/src/index.ts b/plugins/server-drawer/src/index.ts index c78f5b6..90f979a 100644 --- a/plugins/server-drawer/src/index.ts +++ b/plugins/server-drawer/src/index.ts @@ -7,7 +7,7 @@ import { patchMobileQuestDock } from "./patches/mobileQuestDock"; import { patchGetQuestAsset } from "./patches/getQuestAsset"; import { patchExpanded, patchEmpty } from "./patches/contentPatch"; import { patchHideGuildsBar } from "./patches/hideGuildsBar"; -import { patchCreateElement } from "@shared/lib/createElementIntercept"; +import { patchCreateElement } from "./lib/createElementIntercept"; import { patchAutoCollapseFolders } from "./patches/autoCollapseFolders"; import Settings from "./ui/Settings"; diff --git a/plugins/server-drawer/src/lib/color.ts b/plugins/server-drawer/src/lib/color.ts new file mode 100644 index 0000000..9bfad9f --- /dev/null +++ b/plugins/server-drawer/src/lib/color.ts @@ -0,0 +1,60 @@ +import { find, findByStoreName } from "@vendetta/metro"; +import { semanticColors } from "@vendetta/ui"; + +export const HEX_REGEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i; + +const ThemeStore = findByStoreName("ThemeStore"); + +// @vendetta/ui exposes the semanticColors token map but not a resolver function for it. +const rawResolveSemanticColor: (theme: any, semanticColor: any) => string | undefined = + find((m: any) => m?.default?.internal?.resolveSemanticColor)?.default?.internal?.resolveSemanticColor ?? + find((m: any) => m?.meta?.resolveSemanticColor)?.meta?.resolveSemanticColor ?? + (() => undefined); + +/** Resolves a semanticColors[TOKEN_NAME] descriptor to a real color for the current theme. */ +export function resolveSemanticColor(token: any, theme: any = ThemeStore?.theme): string | undefined { + if (!token) return undefined; + try { + return rawResolveSemanticColor(theme, token); + } catch { + return undefined; + } +} + +/** + * Same as resolveSemanticColor, but tries a list of token names in order and falls back to a + * hardcoded hex color if every one of them is missing or fails to resolve - Discord renames these + * tokens across versions often enough that a single hardcoded name isn't reliable on its own. + */ +export function resolveSemanticColorSafe(tokenNames: string[], fallbackHex: string, theme: any = ThemeStore?.theme): string { + for (const name of tokenNames) { + const token = (semanticColors as Record | undefined)?.[name]; + if (!token) continue; + const resolved = resolveSemanticColor(token, theme); + if (resolved) return resolved; + } + return fallbackHex; +} + +export function isValidHex(value: string | undefined | null): value is string { + return !!value && HEX_REGEX.test(value); +} + +/** Expands short form #abc to #aabbcc; leaves 6-digit hex untouched. */ +export function normalizeHex(hex: string): string { + if (hex.length === 4) { + return "#" + hex.slice(1).split("").map((c) => c + c).join(""); + } + return hex; +} + +export function interpolateColor(color1: string, color2: string, percentage: number): string { + const hexToRgb = (hex: string) => (normalizeHex(hex).match(/\w\w/g) ?? ["00", "00", "00"]).map((x) => parseInt(x, 16)); + const rgbToHex = (rgb: number[]) => "#" + rgb.map((x) => x.toString(16).padStart(2, "0")).join(""); + + const rgb1 = hexToRgb(color1); + const rgb2 = hexToRgb(color2); + const interpolated = rgb1.map((c1, i) => Math.round(c1 + (rgb2[i] - c1) * percentage)); + + return rgbToHex(interpolated); +} diff --git a/plugins/server-drawer/src/lib/createElementIntercept.ts b/plugins/server-drawer/src/lib/createElementIntercept.ts new file mode 100644 index 0000000..b1e157a --- /dev/null +++ b/plugins/server-drawer/src/lib/createElementIntercept.ts @@ -0,0 +1,171 @@ +import { React } from "@vendetta/metro/common"; +import { rawFind } from "./rawFind"; +import { waitFor } from "./waitFor"; + +interface Intercept { + replacement: React.ComponentType; + extraProps?: Record; +} + +interface PropsIntercept { + predicate: (props: any) => boolean; + replacement: React.ComponentType | null; +} + +interface TypeDetector { + predicate: (type: any) => boolean; + onDetected: (type: any) => void; +} + +const intercepts = new Map, Intercept>(); +const propsIntercepts: PropsIntercept[] = []; +let typeDetectors: TypeDetector[] = []; +let isPatched = false; + +export function registerIntercept( + original: React.ComponentType, + replacement: React.ComponentType, + extraProps?: Record, +) { + intercepts.set(original, { replacement, extraProps }); +} + +/** + * Same idea as registerIntercept, but matches by a predicate over the element's props instead of + * an exact type reference - useful when the component itself has no reliable name to find it by + * (no named export, and a stripped/mangled runtime function name), but it always passes some + * distinguishing literal prop (e.g. a nativeID) that survives into the compiled bundle unchanged. + * Pass `replacement: null` to render nothing at all instead of swapping in a component. + */ +export function registerPropsIntercept(predicate: (props: any) => boolean, replacement: React.ComponentType | null) { + propsIntercepts.push({ predicate, replacement }); +} + +/** + * Purely observational, one-shot: the first time an element is created whose `type` matches + * `predicate`, calls `onDetected(type)` with the real, live type reference and then stops + * watching - it never alters what actually renders. + * + * This exists for components that can't be found reliably by searching Metro's module registry + * after the fact (findByTypeName/rawFindByTypeName), because that races against whenever the + * component first mounts - if it mounts before the search finds it, and it never mounts again for + * the rest of the session (true for some React.memo'd chrome components), the search losing that + * race means the patch never gets a chance to apply at all, permanently. Intercepting element + * creation instead sidesteps the race entirely: by the time ANYTHING calls createElement/jsx with + * this component as `type`, that reference already exists and is already the real one about to be + * used - there's no way to observe the call any earlier than this. + */ +export function registerTypeDetector(predicate: (type: any) => boolean, onDetected: (type: any) => void) { + typeDetectors.push({ predicate, onDetected }); +} + +function runTypeDetectors(type: any) { + if (typeDetectors.length === 0) return; + const remaining: TypeDetector[] = []; + for (const detector of typeDetectors) { + let matched = false; + try { + matched = detector.predicate(type); + } catch { + // A bad predicate shouldn't block every other detector or every element from rendering. + } + if (matched) { + try { + detector.onDetected(type); + } catch { + // Same - a detector's own handler throwing shouldn't take anything else down. + } + } else { + remaining.push(detector); + } + } + typeDetectors = remaining; +} + +/** Returns a replacement type if this element should be intercepted, `null` to render nothing, or `undefined` to pass through unchanged. */ +function resolveReplacement(type: any, props: any): { type: any; props: any } | null | undefined { + runTypeDetectors(type); + + if (props) { + for (const { predicate, replacement } of propsIntercepts) { + try { + if (predicate(props)) { + return replacement ? { type: replacement, props } : null; + } + } catch { + // A bad predicate shouldn't block every other element from rendering. + } + } + } + + const entry = intercepts.get(type); + if (entry) { + return { type: entry.replacement, props: entry.extraProps ? { ...props, ...entry.extraProps } : props }; + } + + return undefined; +} + +export function patchCreateElement(cleanups: (() => void)[]) { + if (isPatched) return; + + const origCreateElement = React.createElement; + if (!origCreateElement) { + console.warn("[ServerDrawer] React.createElement is null, skipping patch"); + return; + } + + const patchedCreateElement = function (type: any, props: any, ...rest: any[]) { + const resolved = resolveReplacement(type, props); + if (resolved === null) return null; + if (resolved) return origCreateElement.call(React, resolved.type, resolved.props, ...rest); + return origCreateElement.call(React, type, props, ...rest); + }; + + Object.assign(patchedCreateElement, origCreateElement); + React.createElement = patchedCreateElement as typeof React.createElement; + isPatched = true; + + // Modern React/RN builds (Discord's included) compile JSX through the automatic runtime + // (jsx/jsxs from react/jsx-runtime) instead of React.createElement - patching only + // createElement misses every one of Discord's own render calls entirely. Found by shape + // (jsx/jsxs/Fragment together), not by name, for the same reason everything else in this file + // avoids name-based lookups. + // + // The jsx-runtime lookup is retried (not a single one-shot rawFind) because if that module + // hasn't been required yet at the exact moment this runs - very early in boot, when a plugin's + // onLoad first calls this - jsx/jsxs would never get patched for the rest of the session at + // all, silently. Since Discord renders almost everything through jsx/jsxs and not + // createElement, that would make every registerTypeDetector/registerPropsIntercept consumer's + // patch a near-total no-op whenever that race was lost. + const restoreJsx: (() => void)[] = []; + const jsxHandle = waitFor( + () => rawFind((m: any) => typeof m?.jsx === "function" && typeof m?.jsxs === "function" && "Fragment" in m), + (jsxRuntime: any) => { + for (const key of ["jsx", "jsxs"] as const) { + const orig = jsxRuntime[key]; + if (typeof orig !== "function") continue; + + jsxRuntime[key] = function (type: any, props: any, ...rest: any[]) { + const resolved = resolveReplacement(type, props); + if (resolved === null) return null; + if (resolved) return orig.call(this, resolved.type, resolved.props, ...rest); + return orig.call(this, type, props, ...rest); + }; + restoreJsx.push(() => { jsxRuntime[key] = orig; }); + } + } + ); + + cleanups.push(() => { + jsxHandle.cancel(); + if (isPatched) { + React.createElement = origCreateElement; + isPatched = false; + } + restoreJsx.forEach((fn) => fn()); + intercepts.clear(); + propsIntercepts.length = 0; + typeDetectors = []; + }); +} diff --git a/plugins/server-drawer/src/lib/lazy.ts b/plugins/server-drawer/src/lib/lazy.ts new file mode 100644 index 0000000..e0e4889 --- /dev/null +++ b/plugins/server-drawer/src/lib/lazy.ts @@ -0,0 +1,24 @@ +/** + * Wraps a metro lookup so it isn't run until first actually used, and retries on every call until + * it succeeds once (after which the result is cached forever). A plugin's top-level module code + * runs as soon as its bundle is required, which can be before Discord's own code has required an + * internal module it depends on - a one-shot findByX() at module scope can permanently cache + * undefined even though the real module becomes available moments later. Only meant for values + * used inside event handlers or plain (non-hook) render logic - wrapping something used as a + * React hook (e.g. passed to useContext) in this can violate the Rules of Hooks, since the + * resolved-or-not status is now allowed to change between renders of the same component instance. + */ +export function lazy(resolve: () => T | null | undefined): () => T | undefined { + let cached: T | undefined; + let resolved = false; + + return () => { + if (resolved) return cached; + const value = resolve(); + if (value != null) { + cached = value; + resolved = true; + } + return value ?? undefined; + }; +} diff --git a/plugins/server-drawer/src/lib/rawFind.ts b/plugins/server-drawer/src/lib/rawFind.ts new file mode 100644 index 0000000..725763a --- /dev/null +++ b/plugins/server-drawer/src/lib/rawFind.ts @@ -0,0 +1,58 @@ +// Self-contained on purpose - this plugin does not import from @shared/*. Revenge's own +// findByProps/findByName/findByTypeName/findByStoreName (and even the bare `find`) cache their +// result per search - including a NEGATIVE result. Once a search comes up empty, it's marked "not +// found" and every later call with the same search never rescans window.modules again, even after +// the real module registers (confirmed by reading Revenge's own source: metro/internals/modules.ts's +// getModules() returns early on a cached NOT_FOUND flag). That makes retry loops pointless for any +// search whose first attempt happens to run before the target is registered - every retry after +// that first miss hits the same poisoned cache instead of actually looking again. +// +// This walks window.modules directly to bypass that cache - but critically, it never forces a +// module to initialize (never calls window.__r on something that hasn't run yet). Metro only ever +// runs a module's factory once and caches whatever it produced forever, so force-evaluating one +// before its own dependencies are ready can permanently wedge it into a broken state for the rest +// of the session - no amount of retrying fixes that, since the factory never runs again. This only +// inspects modules Metro's own isInitialized flag says have already been required by something +// else, which is entirely passive and safe to poll repeatedly - it just waits for Discord's own +// code to naturally reach the module we want. +declare const window: any; + +export function rawFind(predicate: (exports: any) => boolean): T | undefined { + const modules = window?.modules; + if (!modules) return undefined; + + for (const id in modules) { + const def = modules[id]; + if (!def?.isInitialized) continue; + + const exports = def.publicModule?.exports; + if (!exports) continue; + + try { + if (predicate(exports)) return exports; + if (exports.default != null && predicate(exports.default)) return exports.default; + } catch { + // A predicate throwing on one module's shape shouldn't stop the scan. + } + } + + return undefined; +} + +export function rawFindByTypeName(name: string): T | undefined { + return rawFind((m) => m?.name === name || m?.displayName === name || m?.type?.name === name || m?.type?.displayName === name); +} + +export function rawFindByName(name: string): T | undefined { + return rawFind((m) => m?.name === name || m?.displayName === name); +} + +export function rawFindByProps(...props: string[]): T | undefined { + return rawFind((m) => props.every((p) => m?.[p] !== undefined)); +} + +// Matches Revenge's own byStoreName filter (metro/filters.ts): a zero-arg getName() returning +// the store's name. +export function rawFindByStoreName(name: string): T | undefined { + return rawFind((m) => typeof m?.getName === "function" && m.getName.length === 0 && m.getName() === name); +} diff --git a/plugins/server-drawer/src/lib/waitFor.ts b/plugins/server-drawer/src/lib/waitFor.ts new file mode 100644 index 0000000..37d78ae --- /dev/null +++ b/plugins/server-drawer/src/lib/waitFor.ts @@ -0,0 +1,45 @@ +export interface WaitForHandle { + /** Stops polling. Safe to call even after `find` has already resolved. */ + cancel(): void; +} + +/** + * Polls `find` every `intervalMs` (default 100) until it returns a truthy value, then calls + * `onFound` with it and stops polling. + * + * Needed because a plugin's onLoad can run before every Metro module/component it depends on has + * actually been required - on a cold app start that's a real race, not an edge case. A one-shot + * `findByX(...)` lookup at that exact moment can come back undefined and, without a retry, never + * try again for the rest of the session. `waitFor` checks immediately (so there's no added delay + * once the target is already registered) and keeps retrying otherwise. + * + * Returns a handle whose `cancel()` stops the poll - call it from a plugin's unpatch/onUnload so a + * pending lookup doesn't keep firing (or apply a patch) after the plugin's been turned off. + */ +export function waitFor(find: () => T | null | undefined, onFound: (value: T) => void, intervalMs = 100): WaitForHandle { + let cancelled = false; + let timer: ReturnType | undefined; + + const immediate = find(); + if (immediate) { + onFound(immediate); + return { cancel() {} }; + } + + timer = setInterval(() => { + if (cancelled) return; + const value = find(); + if (value) { + cancelled = true; + clearInterval(timer); + onFound(value); + } + }, intervalMs); + + return { + cancel() { + cancelled = true; + if (timer) clearInterval(timer); + } + }; +} diff --git a/plugins/server-drawer/src/patches/autoCollapseFolders.ts b/plugins/server-drawer/src/patches/autoCollapseFolders.ts index ca5e2eb..17f609c 100644 --- a/plugins/server-drawer/src/patches/autoCollapseFolders.ts +++ b/plugins/server-drawer/src/patches/autoCollapseFolders.ts @@ -1,6 +1,6 @@ import { FluxDispatcher } from "@vendetta/metro/common"; import { storage } from "@vendetta/plugin"; -import { rawFindByStoreName } from "@shared/lib/rawFind"; +import { rawFindByStoreName } from "../lib/rawFind"; // Adapted from fres621's BetterFolders (autoCollapse.ts) - ported as a ServerDrawer feature // instead of a standalone plugin because BetterFolders' other half (hideIcons) patches a diff --git a/plugins/server-drawer/src/patches/contentPatch.tsx b/plugins/server-drawer/src/patches/contentPatch.tsx index a30e4e5..fbaec3d 100644 --- a/plugins/server-drawer/src/patches/contentPatch.tsx +++ b/plugins/server-drawer/src/patches/contentPatch.tsx @@ -1,7 +1,7 @@ import { React } from "@vendetta/metro/common"; -import { rawFind } from "@shared/lib/rawFind"; +import { rawFind } from "../lib/rawFind"; import ServerDrawerSheet from "../components/ServerDrawerSheet"; -import { registerIntercept } from "@shared/lib/createElementIntercept"; +import { registerIntercept } from "../lib/createElementIntercept"; const TAG = "[ServerDrawer]"; diff --git a/plugins/server-drawer/src/patches/getQuestAsset.ts b/plugins/server-drawer/src/patches/getQuestAsset.ts index afa8e54..65ae97c 100644 --- a/plugins/server-drawer/src/patches/getQuestAsset.ts +++ b/plugins/server-drawer/src/patches/getQuestAsset.ts @@ -1,4 +1,4 @@ -import { rawFindByProps } from "@shared/lib/rawFind"; +import { rawFindByProps } from "../lib/rawFind"; const TAG = "[ServerDrawer]"; diff --git a/plugins/server-drawer/src/patches/hideGuildsBar.tsx b/plugins/server-drawer/src/patches/hideGuildsBar.tsx index b288694..fde3dbe 100644 --- a/plugins/server-drawer/src/patches/hideGuildsBar.tsx +++ b/plugins/server-drawer/src/patches/hideGuildsBar.tsx @@ -1,4 +1,4 @@ -import { registerPropsIntercept } from "@shared/lib/createElementIntercept"; +import { registerPropsIntercept } from "../lib/createElementIntercept"; const TAG = "[ServerDrawer]"; diff --git a/plugins/server-drawer/src/patches/mobileQuestDock.ts b/plugins/server-drawer/src/patches/mobileQuestDock.ts index 1157022..90a55f2 100644 --- a/plugins/server-drawer/src/patches/mobileQuestDock.ts +++ b/plugins/server-drawer/src/patches/mobileQuestDock.ts @@ -1,4 +1,4 @@ -import { rawFindByProps } from "@shared/lib/rawFind"; +import { rawFindByProps } from "../lib/rawFind"; // rawFindByProps, not findByProps - see questDockRender.ts for why (retried lookup, and // Revenge's own findByProps permanently caches a "not found" result and never rescans). diff --git a/plugins/server-drawer/src/patches/questDockBase.ts b/plugins/server-drawer/src/patches/questDockBase.ts index bc72518..9900c93 100644 --- a/plugins/server-drawer/src/patches/questDockBase.ts +++ b/plugins/server-drawer/src/patches/questDockBase.ts @@ -1,4 +1,4 @@ -import { rawFindByProps } from "@shared/lib/rawFind"; +import { rawFindByProps } from "../lib/rawFind"; // rawFindByProps, not findByProps - see questDockRender.ts for why (retried lookup, and // Revenge's own findByProps permanently caches a "not found" result and never rescans). diff --git a/plugins/server-drawer/src/patches/questDockRender.ts b/plugins/server-drawer/src/patches/questDockRender.ts index d75b631..81d5435 100644 --- a/plugins/server-drawer/src/patches/questDockRender.ts +++ b/plugins/server-drawer/src/patches/questDockRender.ts @@ -1,4 +1,4 @@ -import { rawFindByProps } from "@shared/lib/rawFind"; +import { rawFindByProps } from "../lib/rawFind"; // rawFindByProps, not findByProps - this gets retried by index.ts's fast-retry loop, and // Revenge's own findByProps permanently caches a "not found" result and never rescans (see diff --git a/plugins/server-drawer/src/patches/questDockVisible.ts b/plugins/server-drawer/src/patches/questDockVisible.ts index a6c232a..abe62c6 100644 --- a/plugins/server-drawer/src/patches/questDockVisible.ts +++ b/plugins/server-drawer/src/patches/questDockVisible.ts @@ -1,4 +1,4 @@ -import { rawFindByProps } from "@shared/lib/rawFind"; +import { rawFindByProps } from "../lib/rawFind"; // A third gate alongside useIsMobileQuestDockRendered/useIsMobileQuestDockRenderedBase, found by // reading the component that actually consumes all three together