ServerDrawer: make it self-contained, no @shared/lib imports

This commit is contained in:
everestmcarthur 2026-08-01 23:11:40 -04:00
parent 0b3ddfd6a9
commit eb6c57b12a
18 changed files with 375 additions and 17 deletions

View file

@ -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");

View file

@ -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";

View file

@ -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;

View file

@ -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";

View file

@ -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";

View file

@ -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<string, any> | 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);
}

View file

@ -0,0 +1,171 @@
import { React } from "@vendetta/metro/common";
import { rawFind } from "./rawFind";
import { waitFor } from "./waitFor";
interface Intercept {
replacement: React.ComponentType<any>;
extraProps?: Record<string, any>;
}
interface PropsIntercept {
predicate: (props: any) => boolean;
replacement: React.ComponentType<any> | null;
}
interface TypeDetector {
predicate: (type: any) => boolean;
onDetected: (type: any) => void;
}
const intercepts = new Map<React.ComponentType<any>, Intercept>();
const propsIntercepts: PropsIntercept[] = [];
let typeDetectors: TypeDetector[] = [];
let isPatched = false;
export function registerIntercept(
original: React.ComponentType<any>,
replacement: React.ComponentType<any>,
extraProps?: Record<string, any>,
) {
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<any> | 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 = [];
});
}

View file

@ -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<T>(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;
};
}

View file

@ -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<T = any>(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<T = any>(name: string): T | undefined {
return rawFind<T>((m) => m?.name === name || m?.displayName === name || m?.type?.name === name || m?.type?.displayName === name);
}
export function rawFindByName<T = any>(name: string): T | undefined {
return rawFind<T>((m) => m?.name === name || m?.displayName === name);
}
export function rawFindByProps<T = any>(...props: string[]): T | undefined {
return rawFind<T>((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<T = any>(name: string): T | undefined {
return rawFind<T>((m) => typeof m?.getName === "function" && m.getName.length === 0 && m.getName() === name);
}

View file

@ -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<T>(find: () => T | null | undefined, onFound: (value: T) => void, intervalMs = 100): WaitForHandle {
let cancelled = false;
let timer: ReturnType<typeof setInterval> | 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);
}
};
}

View file

@ -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

View file

@ -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]";

View file

@ -1,4 +1,4 @@
import { rawFindByProps } from "@shared/lib/rawFind";
import { rawFindByProps } from "../lib/rawFind";
const TAG = "[ServerDrawer]";

View file

@ -1,4 +1,4 @@
import { registerPropsIntercept } from "@shared/lib/createElementIntercept";
import { registerPropsIntercept } from "../lib/createElementIntercept";
const TAG = "[ServerDrawer]";

View file

@ -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).

View file

@ -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).

View file

@ -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

View file

@ -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