diff --git a/plugins/server-drawer/src/components/ContextMenuModal.tsx b/plugins/server-drawer/src/components/ContextMenuModal.tsx index 2d19795..a3153e7 100644 --- a/plugins/server-drawer/src/components/ContextMenuModal.tsx +++ b/plugins/server-drawer/src/components/ContextMenuModal.tsx @@ -1,17 +1,17 @@ import React from "react"; import { View, Text, Pressable, Image, Modal, StyleSheet, Dimensions, Animated } from "react-native"; import { semanticColors } from "@vendetta/ui"; -import { findByProps, findByStoreName } from "@vendetta/metro"; +import { resolveSemanticColor } from "../lib/color"; +import { lazy } from "../lib/lazy"; +import { rawFindByValidProps } from "../lib/rawFind"; +import { getHaptic } from "../lib/commonModules"; -const TextStyleSheet = findByProps("TextStyleSheet")?.TextStyleSheet; -const ThemeStore = findByStoreName("ThemeStore"); -const colorModule = findByProps("colors", "unsafe_rawColors"); -const colorResolver = colorModule?.internal ?? colorModule?.meta; -const Haptic = findByProps("triggerHapticFeedback", "HapticFeedbackTypes"); +const getTextStyleSheet = lazy(() => rawFindByValidProps({ + TextStyleSheet: (v) => v?.["text-md/bold"] !== undefined, +})?.TextStyleSheet); -function resolve(color: any): string { - const theme = ThemeStore?.theme; - return (color && colorResolver?.resolveSemanticColor(theme, color)) || "#000000"; +function resolve(token: any, fallback: string): string { + return resolveSemanticColor(token) ?? fallback; } const EDGE = 12; @@ -47,6 +47,9 @@ export function ContextMenuModal({ anchorY: number; onClose: () => void; }) { + const TextStyleSheet = getTextStyleSheet(); + const Haptic = getHaptic(); + const { width: winW, height: winH } = Dimensions.get("window"); const titleH = title ? PAD + 20 + DIVIDER_H : 0; @@ -59,10 +62,10 @@ export function ContextMenuModal({ if (top + menuH > winH - EDGE) top = anchorY - menuH - 10; if (top < EDGE) top = EDGE; - const bgContainer = resolve(semanticColors?.BACKGROUND_SURFACE_HIGHEST); - const borderColor = resolve(semanticColors?.BORDER_SUBTLE); - const textColor = resolve(semanticColors?.TEXT_STRONG); - const pressedColor = resolve(semanticColors?.BACKGROUND_MOD_SUBTLE); + const bgContainer = resolve(semanticColors?.BACKGROUND_SURFACE_HIGHEST, "#1e1f22"); + const borderColor = resolve(semanticColors?.BORDER_SUBTLE, "#3f4147"); + const textColor = resolve(semanticColors?.TEXT_STRONG, "#f2f3f5"); + const pressedColor = resolve(semanticColors?.BACKGROUND_MOD_SUBTLE, "rgba(255,255,255,0.06)"); const backdropOpacity = React.useRef(new Animated.Value(0)).current; const menuScale = React.useRef(new Animated.Value(0.5)).current; diff --git a/plugins/server-drawer/src/components/DmTile.tsx b/plugins/server-drawer/src/components/DmTile.tsx index f33974f..8d5f459 100644 --- a/plugins/server-drawer/src/components/DmTile.tsx +++ b/plugins/server-drawer/src/components/DmTile.tsx @@ -1,27 +1,33 @@ import React from "react"; import { View, Pressable, Image, StyleSheet } from "react-native"; import { getAssetIDByName } from "@vendetta/ui/assets"; -import { findByProps, findByName } from "@vendetta/metro"; +import { lazy } from "../lib/lazy"; +import { rawFindByFunctionProps, rawFindByName } from "../lib/rawFind"; +import { getFlux, getHaptic, getColorModule } from "../lib/commonModules"; const ICON = 48; const ChatIcon = getAssetIDByName("ChatIcon"); -const Haptic = findByProps("triggerHapticFeedback", "HapticFeedbackTypes"); -const ChannelActions = findByProps("selectPrivateChannel"); -const SelectedChannelStore = findByName("SelectedChannelStore"); -const Flux = findByProps("useStateFromStores"); -const NavContext = findByProps("getGuildId"); -const colors = findByProps("colors", "unsafe_rawColors")?.colors; + +const getChannelActions = lazy(() => rawFindByFunctionProps("selectPrivateChannel")); +const getSelectedChannelStore = lazy(() => rawFindByName("SelectedChannelStore")); +const getNavContext = lazy(() => rawFindByFunctionProps("getGuildId")); function openDms() { + const Haptic = getHaptic(); Haptic?.triggerHapticFeedback?.(Haptic.HapticFeedbackTypes.SOFT); + const ChannelActions = getChannelActions(); if (ChannelActions?.selectPrivateChannel) { - const lastChannelId = SelectedChannelStore?.getLastSelectedChannelId?.(); + const lastChannelId = getSelectedChannelStore()?.getLastSelectedChannelId?.(); ChannelActions.selectPrivateChannel(lastChannelId); } } export default function DmTile() { + const Flux = getFlux(); + const NavContext = getNavContext(); + const colors = getColorModule()?.colors; + const selected = Flux?.useStateFromStores?.( [NavContext], () => NavContext?.getGuildId?.() == null, diff --git a/plugins/server-drawer/src/components/FolderItem.tsx b/plugins/server-drawer/src/components/FolderItem.tsx index 9dbf634..8cbdf08 100644 --- a/plugins/server-drawer/src/components/FolderItem.tsx +++ b/plugins/server-drawer/src/components/FolderItem.tsx @@ -1,16 +1,17 @@ import React from "react"; import { View, Text, Animated, Pressable, Image, ViewStyle, StyleSheet } from "react-native"; -import { findByProps, findByStoreName } from "@vendetta/metro"; import { getAssetIDByName } from "@vendetta/ui/assets"; import { storage } from "@vendetta/plugin"; import { useProxy } from "@vendetta/storage"; import { useFolderExpanded, GuildNode } from "../utils/theme"; +import { lazy } from "../lib/lazy"; +import { rawFindByFunctionProps, rawFindByStoreName } from "../lib/rawFind"; +import { getFlux } from "../lib/commonModules"; import GuildIcon from "./GuildIcon"; import GuildItem from "./GuildItem"; -const GuildActions = findByProps("toggleGuildFolderExpand"); -const Flux = findByProps("useStateFromStores"); -const GuildReadStateStore = findByStoreName("GuildReadStateStore"); +const getGuildActions = lazy(() => rawFindByFunctionProps("toggleGuildFolderExpand")); +const getGuildReadStateStore = lazy(() => rawFindByStoreName("GuildReadStateStore")); const ICON = 48; const MINI = 16; @@ -30,9 +31,13 @@ function folderColor(color?: number | null): string { } function FolderBadge({ node }: { node: GuildNode }) { - // Same guard as useFolderExpanded in utils/theme.ts - GuildReadStateStore is a one-shot lookup - // with no retry, and passing an undefined store into useStateFromStores throws instead of - // failing safely, which would take the whole drawer down, not just this badge. + const Flux = getFlux(); + const GuildReadStateStore = getGuildReadStateStore(); + + // Guards against an undefined store, not just a falsy return value - confirmed live that + // passing [undefined] into useStateFromStores throws rather than failing safely, which used to + // take the whole drawer down (this only ever runs when there's an actual folder to render, so + // an account with folders showed nothing at all while one without rendered fine). const total = (Flux?.useStateFromStores && GuildReadStateStore) ? Flux.useStateFromStores( [GuildReadStateStore], () => { @@ -100,7 +105,7 @@ export default function FolderItem({ node, onPick }: { node: GuildNode; onPick: const open = useFolderExpanded(node.id); const toggle = () => { - GuildActions?.toggleGuildFolderExpand?.(node.id); + getGuildActions()?.toggleGuildFolderExpand?.(node.id); }; if (open) { diff --git a/plugins/server-drawer/src/components/GuildIcon.tsx b/plugins/server-drawer/src/components/GuildIcon.tsx index 9aaf6af..c203d1e 100644 --- a/plugins/server-drawer/src/components/GuildIcon.tsx +++ b/plugins/server-drawer/src/components/GuildIcon.tsx @@ -1,12 +1,19 @@ import React from "react"; import { View, Text, Image, StyleSheet } from "react-native"; -import { findByStoreName, findByProps } from "@vendetta/metro"; +import { lazy } from "../lib/lazy"; +import { rawFindByStoreName } from "../lib/rawFind"; +import { getFlux, getColorModule } from "../lib/commonModules"; -const GuildStore = findByStoreName("GuildStore"); -const colors = findByProps("colors", "unsafe_rawColors")?.colors; +const getGuildStore = lazy(() => rawFindByStoreName("GuildStore")); export default function GuildIcon({ id, size = 48 }: { id: string; size?: number }) { - const g = GuildStore?.getGuild(id); + const Flux = getFlux(); + const GuildStore = getGuildStore(); + const colors = getColorModule()?.colors; + // Guild data streams in progressively after connect, so a one-shot GuildStore.getGuild(id) + // taken at mount can miss it entirely and never recover - subscribing means the icon fills in + // as soon as its guild finishes loading instead of staying blank for the rest of the session. + const g = Flux?.useStateFromStores?.([GuildStore], () => GuildStore?.getGuild?.(id), [id]) ?? null; if (!g) return null; const rad = size >= 40 ? 16 : 8; if (g.icon) { diff --git a/plugins/server-drawer/src/components/GuildItem.tsx b/plugins/server-drawer/src/components/GuildItem.tsx index 43700e4..38f504a 100644 --- a/plugins/server-drawer/src/components/GuildItem.tsx +++ b/plugins/server-drawer/src/components/GuildItem.tsx @@ -1,22 +1,34 @@ import React from "react"; import { View, Text, Pressable, Animated, StyleSheet } from "react-native"; -import { find, findByProps, findByStoreName } from "@vendetta/metro"; import { storage } from "@vendetta/plugin"; import { useProxy } from "@vendetta/storage"; -import { GuildNode } from "../utils/theme"; +import { lazy } from "../lib/lazy"; +import { rawFind, rawFindByStoreName } from "../lib/rawFind"; +import { getFlux, getHaptic } from "../lib/commonModules"; +import { GuildNode, useTheme } from "../utils/theme"; import GuildIcon from "./GuildIcon"; import { ContextMenuModal, ContextMenuItem } from "./ContextMenuModal"; const ICON = 48; -const Flux = findByProps("useStateFromStores"); -const GuildReadStateStore = findByStoreName("GuildReadStateStore"); -const GuildStore = findByStoreName("GuildStore"); -const SortedGuildStore = findByStoreName("SortedGuildStore"); -const Haptic = findByProps("triggerHapticFeedback", "HapticFeedbackTypes"); -const colors = findByProps("colors", "unsafe_rawColors")?.colors; +const getGuildReadStateStore = lazy(() => rawFindByStoreName("GuildReadStateStore")); +const getGuildStore = lazy(() => rawFindByStoreName("GuildStore")); +const getSortedGuildStore = lazy(() => rawFindByStoreName("SortedGuildStore")); + +// Discord's own per-guild context menu (getGuildsBarGuildMenuItems) is a bare default export with +// no named export - matches the original plugin's own heuristic (a short function whose name +// mentions "guild"). Rendered through ContextMenuModal since there's no accessible way to mount +// Discord's own native menu component standalone outside its usual host, but every item/action in +// it is Discord's real menu, not a custom-built one. +const getMenuItemsFn = lazy(() => + rawFind((m) => m?.default?.name === "getGuildsBarGuildMenuItems")?.default + ?? rawFind((m) => typeof m?.default === "function" && m.default.length <= 2 && /guild/i.test(m.default.name ?? ""))?.default +); function Badge({ guildId }: { guildId: string }) { + const Flux = getFlux(); + const GuildReadStateStore = getGuildReadStateStore(); + const mentionCount = Flux?.useStateFromStores?.( [GuildReadStateStore], () => GuildReadStateStore?.getMentionCount?.(guildId) ?? 0, @@ -52,6 +64,10 @@ function Badge({ guildId }: { guildId: string }) { function GuildLabel({ guildId }: { guildId: string }) { useProxy(storage); + const theme = useTheme(); + + const Flux = getFlux(); + const GuildStore = getGuildStore(); const name = Flux?.useStateFromStores?.( [GuildStore], @@ -62,7 +78,7 @@ function GuildLabel({ guildId }: { guildId: string }) { if (storage.showGuildNames === false || !name) return null; return ( - + {name} ); @@ -84,15 +100,11 @@ export default function GuildItem({ node, onPick }: { node: GuildNode; onPick: ( const showMenu = React.useCallback(() => { const guildId = node.id as string; - const guild = GuildStore?.getGuild?.(guildId); - const menuItemsFn = find((m) => m?.default?.name === "getGuildsBarGuildMenuItems")?.default - ?? find((m) => typeof m?.default === "function" && m.default.length <= 2 && /guild/i.test(m.default.name ?? ""))?.default; + const guild = getGuildStore()?.getGuild?.(guildId); + const menuItemsFn = getMenuItemsFn(); + if (!guild || !menuItemsFn) return; - if (!guild || !menuItemsFn) { - return; - } - - const treeVersion = SortedGuildStore?.getGuildsTree?.()?.version; + const treeVersion = getSortedGuildStore()?.getGuildsTree?.()?.version; const rawItems = menuItemsFn(guildId, treeVersion); if (!rawItems?.length) return; @@ -117,6 +129,7 @@ export default function GuildItem({ node, onPick }: { node: GuildNode; onPick: ( }, [node.id]); const handleLongPress = React.useCallback(() => { + const Haptic = getHaptic(); Haptic?.triggerHapticFeedback?.(Haptic.HapticFeedbackTypes.IMPACT_MEDIUM); showMenu(); }, [showMenu]); diff --git a/plugins/server-drawer/src/components/ServerDrawerSheet.tsx b/plugins/server-drawer/src/components/ServerDrawerSheet.tsx index c46c77d..dede4d3 100644 --- a/plugins/server-drawer/src/components/ServerDrawerSheet.tsx +++ b/plugins/server-drawer/src/components/ServerDrawerSheet.tsx @@ -1,29 +1,23 @@ import React from "react"; import { View, Text, Pressable, Animated, Dimensions, StyleSheet, BackHandler } from "react-native"; -import { find, findByProps, findByStoreName } from "@vendetta/metro"; import { storage } from "@vendetta/plugin"; import { useProxy } from "@vendetta/storage"; +import { lazy } from "../lib/lazy"; +import { rawFind, rawFindByFunctionProps } from "../lib/rawFind"; +import { getFlux, getHaptic, getColorModule } from "../lib/commonModules"; import { GuildNode } from "../utils/theme"; import GuildItem from "./GuildItem"; import FolderItem from "./FolderItem"; import DmTile from "./DmTile"; -const Flux = findByProps("useStateFromStores"); -const SortedGuildStore = findByStoreName("SortedGuildStore"); -const RootNav = findByProps("getRootNavigationRef"); -const Haptic = findByProps("triggerHapticFeedback", "HapticFeedbackTypes"); -const Routing = findByProps("transitionToGuild"); +const getRootNav = lazy(() => rawFindByFunctionProps("getRootNavigationRef")); +const getRouting = lazy(() => rawFindByFunctionProps("transitionToGuild")); +const getCreateJoinGuildMod = lazy(() => rawFind((m: any) => typeof m?.handleCreateJoinGuildPress === "function")); +const getCirclePlusIcon = lazy(() => rawFind((m: any) => m?.CirclePlusIcon)?.CirclePlusIcon); -const CreateJoinGuildMod = find((m: any) => typeof m?.handleCreateJoinGuildPress === "function"); -const CirclePlusIcon = find((m: any) => m?.CirclePlusIcon)?.CirclePlusIcon; -const rawColors = findByProps("colors", "unsafe_rawColors")?.unsafe_rawColors; - -const createJoinBg = rawColors?.GREEN_360; -const createJoinIconColor = rawColors?.WHITE; - -const ExternalCoordinationMod = find((m: any) => m?.QuestDockExternalCoordinationContext); -const ExternalContext = ExternalCoordinationMod?.QuestDockExternalCoordinationContext; -const QuestDockMode = find((m: any) => m?.QuestDockMode?.COLLAPSED != null)?.QuestDockMode; +const getExternalContext = lazy(() => rawFind((m: any) => m?.QuestDockExternalCoordinationContext)?.QuestDockExternalCoordinationContext); +const getQuestDockMode = lazy(() => rawFind((m: any) => m?.QuestDockMode?.COLLAPSED != null)?.QuestDockMode); +const getSortedGuildStore = lazy(() => rawFind((m: any) => typeof m?.getName === "function" && m.getName.length === 0 && m.getName() === "SortedGuildStore")); const ICON = 48; const GAP = 6; @@ -32,11 +26,10 @@ const PAD = 12; // Confirmed live: an account with folders showed no drawer at all - not even the DM tile, other // servers, or the create/join button, which have nothing to do with folders. That points at an // uncaught throw somewhere in FolderItem's render taking the whole grid down with it, not just the -// one folder - React unmounts the nearest parent when a child throws during render unless -// something catches it, and nothing here did. A class component is the only way to actually catch -// a render-time throw (a try/catch around JSX doesn't do anything - the exception happens during -// React's own render pass, not synchronously in this function's body). Wrapping each folder -// individually means one broken folder disappears instead of every other server in the drawer. +// one folder. A try/catch around JSX can't catch this - the exception happens during React's own +// render pass, not synchronously in this function's body - only a class component's +// getDerivedStateFromError can. Wrapping each folder individually means one broken folder +// disappears instead of every other server in the drawer. class FolderErrorBoundary extends React.Component<{ children: React.ReactNode }, { errored: boolean }> { state = { errored: false }; static getDerivedStateFromError() { @@ -58,17 +51,21 @@ function CreateJoinButton() { }, [scale]); const onPress = React.useCallback(() => { - Haptic?.triggerHapticFeedback?.(Haptic.HapticFeedbackTypes.SOFT); - CreateJoinGuildMod?.handleCreateJoinGuildPress?.(); + const haptic = getHaptic(); + haptic?.triggerHapticFeedback?.(haptic.HapticFeedbackTypes.SOFT); + getCreateJoinGuildMod()?.handleCreateJoinGuildPress?.(); }, []); + const CirclePlusIcon = getCirclePlusIcon(); + const rawColors = getColorModule()?.unsafe_rawColors; + return ( - + {CirclePlusIcon ? ( - + ) : ( - {"+"} + {"+"} )} @@ -79,14 +76,20 @@ export default function ServerDrawerSheet({ gestureContext }: { gestureContext: useProxy(storage); const pick = React.useCallback((id: string) => { - Haptic?.triggerHapticFeedback(Haptic.HapticFeedbackTypes.SOFT); - if (Routing?.transitionToGuild) { - Routing.transitionToGuild(id, null); + const haptic = getHaptic(); + haptic?.triggerHapticFeedback?.(haptic.HapticFeedbackTypes.SOFT); + + const routing = getRouting(); + if (routing?.transitionToGuild) { + routing.transitionToGuild(id); } else { - RootNav?.getRootNavigationRef()?.navigate("guilds", { guildId: id }); + getRootNav()?.getRootNavigationRef?.()?.navigate("guilds", { guildId: id }); } }, []); + const Flux = getFlux(); + const SortedGuildStore = getSortedGuildStore(); + const nodes: GuildNode[] = Flux?.useStateFromStores?.( [SortedGuildStore], () => { @@ -104,12 +107,14 @@ export default function ServerDrawerSheet({ gestureContext }: { gestureContext: if (minH.get() !== h) minH.set(h); }, [minH]); + const ExternalContext = getExternalContext(); const extCtx = ExternalContext ? React.useContext(ExternalContext) as any : null; const setMode = extCtx?.setRestingQuestDockMode; const specs = ctx?.questDockWrapperSpecs; React.useEffect(() => { + const QuestDockMode = getQuestDockMode(); if (!setMode || !QuestDockMode || !specs) return; const sub = BackHandler.addEventListener("hardwareBackPress", () => { const h = specs.get()?.height ?? 56; diff --git a/plugins/server-drawer/src/index.ts b/plugins/server-drawer/src/index.ts index 29440b4..f348c01 100644 --- a/plugins/server-drawer/src/index.ts +++ b/plugins/server-drawer/src/index.ts @@ -1,41 +1,95 @@ +import { logger } from "@vendetta"; import { storage } from "@vendetta/plugin"; import { patchQuestDockRender, patchQuestDockBase, patchMobileQuestDock, patchGetQuestAsset } from "./patches/fakeQuestDock"; -import { patchQuestDockSlot, patchEmpty } from "./patches/contentPatch"; +import { patchExpanded, patchEmpty } from "./patches/contentPatch"; import { patchHideGuildsBar } from "./patches/hideGuildsBar"; import { patchCreateElement } from "./lib/createElementIntercept"; import { patchAutoCollapseFolders } from "./patches/autoCollapseFolders"; import Settings from "./ui/Settings"; -const TAG = "[ServerDrawer]"; let cleanups: (() => void)[] = []; +let retryHandle: ReturnType | undefined; +// Each of these functions already checks its own lookup and returns false instead of patching +// when it comes up empty - this wrapper adds one more layer so a lookup that throws outright +// can't take the rest of onLoad's patches down with it. +function tryPatch(name: string, fn: () => boolean): boolean { + try { + return fn(); + } catch (e) { + logger.error(`[ServerDrawer] "${name}" patch threw, skipping: ${e}`); + return false; + } +} + +// The Quest Dock (and the internals it depends on) isn't guaranteed to be registered in Metro yet +// at the exact moment onLoad runs. Confirmed live across many reload cycles tonight: without a +// retry loop, each of this plugin's independent module lookups is effectively an independent +// coin-flip on any given boot - not tied to any one account or feature, just whichever modules +// happened to already be registered when onLoad ran. Retrying whatever hasn't landed yet on a fast +// interval until everything's applied (or it gives up after ~10s) is what makes this reliable +// instead of "sometimes works." function applyAll() { - console.log(TAG, "onLoad"); - - let patched = 0; + const patchers: Record boolean> = { + ...(storage.fakeQuestDock !== false ? { + questDockRender: () => patchQuestDockRender(cleanups), + questDockBase: () => patchQuestDockBase(cleanups), + mobileQuestDock: () => patchMobileQuestDock(cleanups), + getQuestAsset: () => patchGetQuestAsset(cleanups), + } : {}), + expanded: () => patchExpanded(cleanups), + collapsed: () => patchEmpty("QuestDockContentCollapsed", cleanups), + enrolledHeader: () => patchEmpty("QuestDockEnrolledHeader", cleanups), + unenrolledHeader: () => patchEmpty("QuestDockUnenrolledHeader", cleanups), + enrolledBody: () => patchEmpty("QuestDockEnrolledBody", cleanups), + unenrolledBody: () => patchEmpty("QuestDockUnenrolledBody", cleanups), + autoCollapseFolders: () => patchAutoCollapseFolders(cleanups), + ...(storage.hideGuildsBar !== false ? { hideGuildsBar: () => patchHideGuildsBar(cleanups) } : {}), + }; patchCreateElement(cleanups); - patched++; - if (storage.fakeQuestDock !== false) { - if (patchQuestDockRender(cleanups)) patched++; - if (patchQuestDockBase(cleanups)) patched++; - if (patchMobileQuestDock(cleanups)) patched++; - if (patchGetQuestAsset(cleanups)) patched++; + const pending = new Set(Object.keys(patchers)); + const applied = new Set(); + + const attempt = () => { + for (const name of pending) { + if (tryPatch(name, patchers[name])) { + applied.add(name); + pending.delete(name); + } + } + + if (pending.size === 0 && retryHandle) { + clearInterval(retryHandle); + retryHandle = undefined; + } + }; + + attempt(); + + if (pending.size > 0) { + let ticks = 0; + retryHandle = setInterval(() => { + attempt(); + if (++ticks >= 50 && retryHandle) { // ~10s at 200ms + clearInterval(retryHandle); + retryHandle = undefined; + if (pending.size > 0) { + logger.error(`[ServerDrawer] Gave up waiting on: ${[...pending].join(", ")}`); + } + } + }, 200); } - if (patchQuestDockSlot("QuestDockContentExpanded", cleanups)) patched++; - if (patchEmpty("QuestDockContentCollapsed", cleanups)) patched++; - if (patchEmpty("QuestDockEnrolledHeader", cleanups)) patched++; - if (patchEmpty("QuestDockUnenrolledHeader", cleanups)) patched++; - if (patchEmpty("QuestDockEnrolledBody", cleanups)) patched++; - if (patchEmpty("QuestDockUnenrolledBody", cleanups)) patched++; - if (patchAutoCollapseFolders(cleanups)) patched++; - if (storage.hideGuildsBar !== false && patchHideGuildsBar(cleanups)) patched++; - console.log(TAG, `onLoad done - ${patched} patches applied, ${cleanups.length} cleanups`); + logger.log(`[ServerDrawer] onLoad: applied immediately - ${[...applied].join(", ") || "none"}${pending.size ? `, still waiting on ${[...pending].join(", ")}` : ""}`); } function unpatchAll() { + if (retryHandle) { + clearInterval(retryHandle); + retryHandle = undefined; + } for (const fn of cleanups) { try { fn(); diff --git a/plugins/server-drawer/src/lib/createElementIntercept.ts b/plugins/server-drawer/src/lib/createElementIntercept.ts index 1be1ed4..e6aaa83 100644 --- a/plugins/server-drawer/src/lib/createElementIntercept.ts +++ b/plugins/server-drawer/src/lib/createElementIntercept.ts @@ -1,54 +1,346 @@ import { React } from "@vendetta/metro/common"; +import { after } from "@vendetta/patcher"; interface Intercept { replacement: React.ComponentType; extraProps?: Record; + /** + * How many ancestor levels above this element are allowed to be collapsed to zero size along + * with it (0 = none). See `COLLAPSE_STYLE` below for why replacing a component with one that + * renders nothing is, on its own, not enough to reclaim its space. + */ + collapseAncestors?: number; +} + +interface PropsIntercept { + predicate: (props: any) => boolean; + replacement: React.ComponentType | null; +} + +interface PropsTransform { + predicate: (props: any) => boolean; + transform: (props: any) => any; +} + +interface TypeDetector { + key: string; + predicate: (type: any) => boolean; + onDetected: (type: any) => void; + persistent: boolean; } const intercepts = new Map, Intercept>(); -let origCreateElement: typeof React.createElement | null = null; +const propsIntercepts: PropsIntercept[] = []; +const propsTransforms: PropsTransform[] = []; +let typeDetectors: TypeDetector[] = []; +const detectorKeys = new Set(); let isPatched = false; +/** + * A React element created for something we're hiding, mapped to how many more ancestor levels may + * still be collapsed with it. Elements are plain objects and are created child-first (a parent's + * jsx() call can only run once its children exist), so a mark placed on a child is always already + * present by the time the parent's own call is intercepted - which is what makes walking the + * collapse *upward* possible at element-creation time at all. + */ +const collapseMarks = new WeakMap(); + +/** + * Zeroes an element out in every direction a parent could have sized it. `display: "none"` alone + * is enough on Yoga, but the explicit width/flex zeroes also cover a container that sets its + * child's size through a style array further up, and `overflow: hidden` stops anything absolutely + * positioned inside from still painting. + */ +const COLLAPSE_STYLE = { + display: "none" as const, + width: 0, + minWidth: 0, + maxWidth: 0, + flexGrow: 0, + flexShrink: 0, + flexBasis: 0, + margin: 0, + padding: 0, + borderWidth: 0, + overflow: "hidden" as const, +}; + export function registerIntercept( original: React.ComponentType, replacement: React.ComponentType, extraProps?: Record, + options?: { collapseAncestors?: number }, ) { - intercepts.set(original, { replacement, extraProps }); + intercepts.set(original, { replacement, extraProps, collapseAncestors: options?.collapseAncestors }); +} + +// Same idea as registerIntercept, but matches on a predicate over the element's props instead of +// an exact type reference - useful when the component has no reliable name to find it by, but +// always passes some distinguishing prop. Pass replacement: null to render nothing instead. +export function registerPropsIntercept(predicate: (props: any) => boolean, replacement: React.ComponentType | null) { + propsIntercepts.push({ predicate, replacement }); +} + +// Rewrites an element's props in place, keeping the same type - unlike registerPropsIntercept, +// this doesn't swap in a different component, it just lets the real one render with different +// input. +export function registerPropsTransform(predicate: (props: any) => boolean, transform: (props: any) => any) { + propsTransforms.push({ predicate, transform }); +} + +// Purely observational: the first time an element is created whose type matches predicate, calls +// onDetected(type) with the live reference. Doesn't touch rendering - pair it with +// registerIntercept/registerPropsIntercept in the callback for that. Exists for components that +// aren't findable by searching Metro's registry after the fact (races against first mount). +// +// key dedupes registrations - retrying registration every 200ms used to pile up duplicate +// detectors, each re-tested against every element created anywhere in the app. +// +// persistent keeps the detector alive for every future match instead of firing once. Some type +// references change across navigation (switching servers via this plugin's own drawer), so a +// one-shot detector would only ever catch the first, now-abandoned reference. +export function registerTypeDetector( + key: string, + predicate: (type: any) => boolean, + onDetected: (type: any) => void, + options?: { persistent?: boolean }, +) { + if (detectorKeys.has(key)) return; + detectorKeys.add(key); + typeDetectors.push({ key, predicate, onDetected, persistent: options?.persistent ?? false }); +} + +export function hasTypeDetector(key: string): boolean { + return detectorKeys.has(key); +} + +function runTypeDetectors(type: any) { + if (typeDetectors.length === 0) return; + + let consumed = false; + 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) continue; + + try { + detector.onDetected(type); + } catch { + // Same - a detector's own handler throwing shouldn't take anything else down. + } + if (!detector.persistent) { + detector.justFired = true; + consumed = true; + } + } + + // Only rebuild the list when a one-shot detector actually fired. The previous version rebuilt + // it on every single element creation app-wide, forever, because a persistent detector keeps + // the list from ever being empty - an allocation per element on the hottest path in the app. + if (consumed) { + typeDetectors = typeDetectors.filter((d) => d.persistent || !d.justFired); + } +} + +declare module "./createElementIntercept" {} +interface TypeDetector { justFired?: boolean } + +/** + * Returns the collapse depth a parent element should inherit from its children, or 0 for "leave + * this element alone." + * + * Returns the *maximum* collapse budget among real children. + */ +function inspectCollapseChild(child: any): number { + if (child == null || child === false || typeof child !== "object") return 0; + return collapseMarks.get(child) ?? 0; +} + +function inheritedCollapseDepth(props: any, rest: any[] = []): number { + let deepest = 0; + + const children = props?.children; + if (children != null) { + if (Array.isArray(children)) { + for (const child of children) deepest = Math.max(deepest, inspectCollapseChild(child)); + } else { + deepest = Math.max(deepest, inspectCollapseChild(children)); + } + } + + for (const child of rest) deepest = Math.max(deepest, inspectCollapseChild(child)); + return deepest; +} + +/** 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, rest: any[]): { type: any; props: any; collapse?: number } | null | undefined { + runTypeDetectors(type); + + let effectiveProps = props; + if (effectiveProps) { + for (const { predicate, transform } of propsTransforms) { + try { + if (predicate(effectiveProps, type, rest)) { + effectiveProps = transform(effectiveProps); + } + } catch { + // A bad transform shouldn't block every other element from rendering. + } + } + } + + if (effectiveProps) { + for (const { predicate, replacement } of propsIntercepts) { + try { + if (predicate(effectiveProps, type, rest)) { + return replacement ? { type: replacement, props: effectiveProps } : 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 ? { ...effectiveProps, ...entry.extraProps } : effectiveProps, + collapse: entry.collapseAncestors, + }; + } + + // Nothing to swap here, but if this element's only child is one we've already collapsed, this + // element is a wrapper that exists purely to hold it - zero it out too and pass the remaining + // budget further up. + const inherited = inheritedCollapseDepth(effectiveProps, rest); + if (inherited > 0) { + return { + type, + props: { ...effectiveProps, style: [effectiveProps?.style, COLLAPSE_STYLE] }, + collapse: inherited - 1, + }; + } + + if (effectiveProps !== props) { + return { type, props: effectiveProps }; + } + + return undefined; +} + +// Mutates the already-created element in place rather than substituting args before creation - +// after() only sees the return value, not the call itself. A raw property reassignment on the +// jsx-runtime object doesn't reliably reach real render calls; after() does. +function applyResolved(res: any, type: any, props: any, rest: any[]) { + if (!res || typeof res !== "object") return res; + const resolved = resolveReplacement(type, props, rest); + if (resolved === null) { + res.type = () => null; + return res; + } + if (resolved) { + res.type = resolved.type; + res.props = resolved.props ?? null; + if (resolved.collapse && resolved.collapse > 0) { + collapseMarks.set(res, resolved.collapse); + } + } + return res; } export function patchCreateElement(cleanups: (() => void)[]) { if (isPatched) return; - - origCreateElement = React.createElement; - if (!origCreateElement) { - console.warn("[ServerDrawer] React.createElement is null, skipping patch"); - return; - } - - const patched = function (type: any, props: any, ...rest: any[]) { - if (!origCreateElement) { - return React.createElement(type, props, ...rest); - } - const entry = intercepts.get(type); - if (entry) { - const newProps = entry.extraProps - ? { ...props, ...entry.extraProps } - : props; - return origCreateElement.call(React, entry.replacement, newProps, ...rest); - } - return origCreateElement.call(React, type, props, ...rest); - }; - - Object.assign(patched, origCreateElement); - React.createElement = patched as typeof React.createElement; isPatched = true; - cleanups.push(() => { - if (isPatched && origCreateElement) { - React.createElement = origCreateElement; - isPatched = false; + if (React.createElement) { + cleanups.push( + after("createElement", React, (args: any[], res: any) => applyResolved(res, args[0], args[1], args.slice(2))) + ); + } else { + console.warn("[ServerDrawer] React.createElement is null, skipping patch"); + } + + // Discord's bundle compiles JSX through the automatic runtime (jsx/jsxs/jsxDEV) instead of + // React.createElement, so patching createElement alone misses nearly everything. We scan for + // the runtime by shape rather than name since names get mangled, patch every copy we find (a + // bundle can have more than one), and keep scanning on an interval since Metro registers + // modules lazily - a copy that hasn't been required yet at boot just isn't there to find. + const patchedJsxRuntimes = new WeakSet(); + + function isJsxRuntime(m: any): boolean { + return typeof m?.jsx === "function" || typeof m?.jsxs === "function" || typeof m?.jsxDEV === "function"; + } + + function patchJsxObject(runtime: any) { + if (patchedJsxRuntimes.has(runtime)) return; + patchedJsxRuntimes.add(runtime); + for (const key of ["jsx", "jsxs", "jsxDEV"] as const) { + if (typeof runtime[key] !== "function") continue; + cleanups.push( + after(key, runtime, (args: any[], res: any) => applyResolved(res, args[0], args[1], args.slice(2))) + ); } + } + + function patchJsxModule(def: any) { + if (!def?.publicModule?.exports) return; + const exports = def.publicModule.exports; + + try { + if (isJsxRuntime(exports)) patchJsxObject(exports); + + const dflt = exports.default; + if (dflt != null && isJsxRuntime(dflt)) patchJsxObject(dflt); + } catch { + // Ignore one module's bad shape and continue scanning. + } + } + + function scanAndPatchJsxRuntimes() { + const modules = (window as any)?.modules; + if (!modules) return; + for (const id in modules) { + const def = modules[id]; + if (!def?.isInitialized) continue; + patchJsxModule(def); + } + } + + // Patch any jsx runtimes already loaded before Discord's first render pass starts, synchronously + // - an async scan would complete too late for the very first render of an early-mounting target. + scanAndPatchJsxRuntimes(); + + // Fast while the app is still booting and chunks are being pulled in, then slow, then stop - + // a full window.modules walk is not free and there's nothing left to catch once the UI has + // settled. + let ticks = 0; + let timer: ReturnType | undefined = setInterval(() => { + scanAndPatchJsxRuntimes(); + if (++ticks === 50 && timer) { // ~5s at 100ms + clearInterval(timer); + timer = setInterval(() => { + scanAndPatchJsxRuntimes(); + if (++ticks >= 75 && timer) { // + ~25s at 1s + clearInterval(timer); + timer = undefined; + } + }, 1000); + } + }, 100); + + cleanups.push(() => { + if (timer) clearInterval(timer); + timer = undefined; + isPatched = false; intercepts.clear(); + propsIntercepts.length = 0; + propsTransforms.length = 0; + typeDetectors = []; + detectorKeys.clear(); }); } diff --git a/plugins/server-drawer/src/patches/contentPatch.tsx b/plugins/server-drawer/src/patches/contentPatch.tsx index 5aeec26..1bbfb30 100644 --- a/plugins/server-drawer/src/patches/contentPatch.tsx +++ b/plugins/server-drawer/src/patches/contentPatch.tsx @@ -1,10 +1,15 @@ import { React } from "@vendetta/metro/common"; import { rawFind } from "../lib/rawFind"; import ServerDrawerSheet from "../components/ServerDrawerSheet"; -import { registerIntercept } from "../lib/createElementIntercept"; +import { registerIntercept, registerPropsTransform, registerTypeDetector } from "../lib/createElementIntercept"; const TAG = "[ServerDrawer]"; +// rawFind, not @vendetta/metro's find - Revenge's own find() permanently caches a "not found" +// result per search and never rescans, which makes retrying pointless after the first failed +// attempt. Confirmed live tonight across many reload cycles: without retry, roughly a third of the +// independent lookups this plugin needs fail on any given boot, at random - not specific to any +// one account or feature. let cachedGestureContext: any = null; function getGestureContext(): any { if (!cachedGestureContext) { @@ -13,51 +18,119 @@ function getGestureContext(): any { return cachedGestureContext; } +// Memo-wrapped components have no own .name/.displayName - only their nested .type does. function isNamed(name: string) { return (type: any) => type?.name === name || type?.displayName === name || type?.type?.name === name || type?.type?.displayName === name; } -export function patchQuestDockSlot(name: string, cleanups: (() => void)[]): boolean { - const mod = rawFind((m) => isNamed(name)(m?.type)); +const QUEST_DOCK_NAMES = [ + "QuestDock", + "QuestDockBase", + "QuestDockWithQuestContext", + "QuestDockMain", + "QuestDockContent", + "QuestDockExpanded", +]; + +function isQuestDock(type: any): boolean { + return QUEST_DOCK_NAMES.some((name) => isNamed(name)(type)); +} + +const SD_DRAWER_TEST_ID = "ServerDrawer"; + +function hasChildWithTestID(children: any, rest: any[], testID: string): boolean { + const inspect = (child: any) => child != null && typeof child === "object" && child.props?.testID === testID; + if (children != null) { + if (Array.isArray(children)) { if (children.some(inspect)) return true; } + else if (inspect(children)) return true; + } + for (const child of rest) if (inspect(child)) return true; + return false; +} + +function ServerDrawerSheetWrapper() { + return ; +} + +/** + * Two independent mechanisms, both active. Mutating the found module's own `.type` property makes + * the drawer render immediately if the target module happens to already be registered in Metro by + * the time this runs, but that's a real boot-time race. + * + * registerTypeDetector has no such timing dependency - it inspects the `type` argument passed + * directly to createElement/jsx at the moment something actually creates an element with it, + * whenever that happens, early or late. Combining both means the drawer shows as fast as possible + * when the race is won, and still shows correctly even when it's lost. + * + * Only QuestDockContentExpanded is swapped for the drawer - QuestDockContentCollapsed is patched to + * render nothing (see patchEmpty below), same as every other sub-section. Confirmed live that + * patching both to show the full drawer causes it to render twice: Discord creates both elements + * simultaneously in some cases (very likely a collapse/expand transition state), not just + * whichever one restingQuestDockMode currently selects. + */ +export function patchExpanded(cleanups: (() => void)[]): boolean { + const questDockTypes = new Set(); + + // Make the quest dock's wrapper(s) transparent - the ServerDrawerSheet content still renders on + // top, but the panel/drawer background that used to be behind the quest menu is removed. + registerPropsTransform( + (props: any, type: any, rest: any[]) => + questDockTypes.has(type) || + hasChildWithTestID(props?.children, rest, SD_DRAWER_TEST_ID), + (props: any) => ({ ...props, style: [props?.style, { backgroundColor: "transparent" }] }), + ); + + registerTypeDetector("ServerDrawer.QuestDockBg", isQuestDock, (real) => { + questDockTypes.add(real); + console.log(TAG, "PATCH: QuestDock wrapper set transparent", real?.name ?? real?.displayName); + }, { persistent: true }); + + registerTypeDetector("ServerDrawer.Expanded", isNamed("QuestDockContentExpanded"), (real) => { + registerIntercept(real, ServerDrawerSheetWrapper, { testID: SD_DRAWER_TEST_ID }); + console.log(TAG, "PATCH: QuestDockContentExpanded replaced (type detector)"); + }, { persistent: true }); + + const mod = rawFind((m) => isNamed("QuestDockContentExpanded")(m?.type)); if (!mod?.type) { - console.log(TAG, `WARN: ${name} not found (will retry)`); + console.log(TAG, "WARN: QuestDockContentExpanded module not found yet (will retry)"); return false; } + const orig = mod.type; - - registerIntercept(orig, ServerDrawerSheet, { gestureContext: getGestureContext() }); - - mod.type = function ServerDrawerPatch() { - return ; - }; + registerIntercept(orig, ServerDrawerSheetWrapper, { testID: SD_DRAWER_TEST_ID }); + mod.type = ServerDrawerSheetWrapper; cleanups.push(() => { mod.type = orig; }); - console.log(TAG, `PATCH: ${name} replaced`); + console.log(TAG, "PATCH: QuestDockContentExpanded replaced (module mutation)"); return true; } export function patchEmpty(name: string, cleanups: (() => void)[]): boolean { + registerTypeDetector(`ServerDrawer.Empty.${name}`, isNamed(name), (real) => { + registerIntercept(real, function EmptyPatch() { return null; }); + console.log(TAG, `PATCH: ${name} replaced (type detector)`); + }, { persistent: true }); + const mod = rawFind((m) => isNamed(name)(m?.type)); if (!mod?.type) { - console.log(TAG, `WARN: ${name} not found`); + console.log(TAG, `WARN: ${name} module not found yet (will retry)`); return false; } + const orig = mod.type; - const originalComponent = orig; - - mod.type = function EmptyPatch() { + function EmptyPatch() { return null; - }; - - if (originalComponent) { - try { - Object.defineProperties(mod.type, Object.getOwnPropertyDescriptors(originalComponent)); - } catch { - // Best-effort - not every descriptor can be copied onto another function. - } - mod.type.displayName = name; } + mod.type = EmptyPatch; + + try { + Object.defineProperties(mod.type, Object.getOwnPropertyDescriptors(orig)); + } catch { + // Best-effort compatibility shim, not something correctness depends on. + } + mod.type.displayName = name; cleanups.push(() => { mod.type = orig; }); + console.log(TAG, `PATCH: ${name} replaced (module mutation)`); return true; } diff --git a/plugins/server-drawer/src/patches/fakeQuestDock.ts b/plugins/server-drawer/src/patches/fakeQuestDock.ts index 85b112e..b1d2502 100644 --- a/plugins/server-drawer/src/patches/fakeQuestDock.ts +++ b/plugins/server-drawer/src/patches/fakeQuestDock.ts @@ -1,7 +1,12 @@ -import { findByProps } from "@vendetta/metro"; +import { rawFindByFunctionProps, rawFindByStoreName } from "../lib/rawFind"; const TAG = "[ServerDrawer]"; +// A real, always-available Discord CDN URL - confirmed live that an empty string still resolves +// "successfully" through Discord's buildUrl (concatenates onto the CDN base rather than being +// recognized as absolute) but produces a malformed URL, and a full external https:// URL gets +// double-prefixed the same way ("https://cdn.discordapp.com/https://..."). Neither crashes - +// getQuestAsset below is guarded regardless - so this is cosmetic, not load-bearing. const FALLBACK_ASSET_URL = "https://cdn.discordapp.com/embed/avatars/0.png"; function buildFallbackQuest(userId: string | undefined) { @@ -83,26 +88,27 @@ function buildFallbackQuest(userId: string | undefined) { }; } -// Each of these four does its own completely independent findByProps() lookup, matching the -// original plugin's four separate files (questDockRender.ts, questDockBase.ts, mobileQuestDock.ts, -// getQuestAsset.ts) exactly, rather than sharing one resolved module reference across all of them. -// Confirmed elsewhere in this repo (GuildsBar's own patch writeup) that a metro search can land on -// a different module copy than another search for a related property, even when both "succeed" - -// so patching useMobileQuestDock and useIsMobileQuestDockRendered off the same found object is not -// guaranteed to be the same reference the real render path actually reads from. +// Each of these four does its own completely independent rawFind lookup rather than sharing one +// resolved module reference across all of them - confirmed live (GuildsBar's own patch writeup) +// that a metro search can land on a different module copy than another search for a related +// property, even when both "succeed", so patching useMobileQuestDock and +// useIsMobileQuestDockRendered off the same found object was never guaranteed to be the same +// reference the real render path actually reads from. Matches the original plugin's four separate +// files (questDockRender.ts, questDockBase.ts, mobileQuestDock.ts, getQuestAsset.ts) in spirit, +// just retry-safe. export function patchMobileQuestDock(cleanups: (() => void)[]): boolean { - const mod = findByProps("useMobileQuestDock"); + const mod = rawFindByFunctionProps("useMobileQuestDock"); if (!mod?.useMobileQuestDock) { - console.log(TAG, "WARN: useMobileQuestDock not found"); + console.log(TAG, "WARN: useMobileQuestDock not found yet (will retry)"); return false; } const orig = mod.useMobileQuestDock; let cachedUserId: string | undefined; - mod.useMobileQuestDock = function (...args: any[]) { + mod.useMobileQuestDock = function (this: unknown, ...args: any[]) { const real = orig.apply(this, args); if (real) return real; if (cachedUserId === undefined) { - cachedUserId = findByProps("getCurrentUser")?.getCurrentUser?.()?.id; + cachedUserId = rawFindByStoreName("UserStore")?.getCurrentUser?.()?.id; } return buildFallbackQuest(cachedUserId); }; @@ -111,13 +117,13 @@ export function patchMobileQuestDock(cleanups: (() => void)[]): boolean { } export function patchQuestDockRender(cleanups: (() => void)[]): boolean { - const mod = findByProps("useIsMobileQuestDockRendered"); + const mod = rawFindByFunctionProps("useIsMobileQuestDockRendered"); if (!mod?.useIsMobileQuestDockRendered) { - console.log(TAG, "WARN: useIsMobileQuestDockRendered not found"); + console.log(TAG, "WARN: useIsMobileQuestDockRendered not found yet (will retry)"); return false; } const orig = mod.useIsMobileQuestDockRendered; - mod.useIsMobileQuestDockRendered = function (...args: any[]) { + mod.useIsMobileQuestDockRendered = function (this: unknown, ...args: any[]) { orig.apply(this, args); return true; }; @@ -126,13 +132,13 @@ export function patchQuestDockRender(cleanups: (() => void)[]): boolean { } export function patchQuestDockBase(cleanups: (() => void)[]): boolean { - const mod = findByProps("useIsMobileQuestDockRenderedBase"); + const mod = rawFindByFunctionProps("useIsMobileQuestDockRenderedBase"); if (!mod?.useIsMobileQuestDockRenderedBase) { - console.log(TAG, "WARN: useIsMobileQuestDockRenderedBase not found"); + console.log(TAG, "WARN: useIsMobileQuestDockRenderedBase not found yet (will retry)"); return false; } const orig = mod.useIsMobileQuestDockRenderedBase; - mod.useIsMobileQuestDockRenderedBase = function (...args: any[]) { + mod.useIsMobileQuestDockRenderedBase = function (this: unknown, ...args: any[]) { orig.apply(this, args); return true; }; @@ -141,13 +147,13 @@ export function patchQuestDockBase(cleanups: (() => void)[]): boolean { } export function patchGetQuestAsset(cleanups: (() => void)[]): boolean { - const mod = findByProps("getQuestAsset"); + const mod = rawFindByFunctionProps("getQuestAsset"); if (!mod?.getQuestAsset) { - console.log(TAG, "WARN: getQuestAsset not found"); + console.log(TAG, "WARN: getQuestAsset not found yet (will retry)"); return false; } const orig = mod.getQuestAsset; - mod.getQuestAsset = function (...args: any[]) { + mod.getQuestAsset = function (this: unknown, ...args: any[]) { try { return orig.apply(this, args); } catch { diff --git a/plugins/server-drawer/src/patches/hideGuildsBar.tsx b/plugins/server-drawer/src/patches/hideGuildsBar.tsx index 9de508f..efb3926 100644 --- a/plugins/server-drawer/src/patches/hideGuildsBar.tsx +++ b/plugins/server-drawer/src/patches/hideGuildsBar.tsx @@ -1,43 +1,86 @@ -import { find, findByName } from "@vendetta/metro"; -import { registerIntercept } from "../lib/createElementIntercept"; +import React from "react"; +import { View } from "react-native"; +import { registerPropsTransform, registerTypeDetector, registerIntercept } from "../lib/createElementIntercept"; +import { captureFiberRef } from "../lib/fiberCapture"; const TAG = "[ServerDrawer]"; +const SD_NOTHING_TEST_ID = "ServerDrawerNothing"; function Nothing() { - return null; + return React.createElement(View, { + ref: captureFiberRef, + style: { + display: "none", + width: 0, + minWidth: 0, + maxWidth: 0, + flexGrow: 0, + flexShrink: 0, + flexBasis: 0, + margin: 0, + padding: 0, + borderWidth: 0, + overflow: "hidden", + }, + }); } -function findGuildsBar(): any { - const byName = findByName("GuildsBar"); - if (byName) return { default: byName }; +// Confirmed live (Key Inspector's fiber capture): the parent creates GuildsBar via its OUTER +// React.memo wrapper object, which has no own .name/.displayName at all - those only exist on the +// memo's inner function, at type.type.name/type.type.displayName. +function isGuildsBar(type: any): boolean { + return type?.name === "GuildsBar" || type?.displayName === "GuildsBar" || + type?.type?.name === "GuildsBar" || type?.type?.displayName === "GuildsBar"; +} - let mod = find((m) => { - try { return m?.default?.type?.name === "GuildsBar"; } catch { return false; } - }); - if (mod?.default) return mod; - - mod = find((m) => { - try { return m?.default?.displayName === "GuildsBar"; } catch { return false; } - }); - if (mod?.default) return mod; - - return null; +function hasChildWithTestID(children: any, rest: any[], testID: string): boolean { + const inspect = (child: any) => child != null && typeof child === "object" && child.props?.testID === testID; + if (children != null) { + if (Array.isArray(children)) { if (children.some(inspect)) return true; } + else if (inspect(children)) return true; + } + for (const child of rest) if (inspect(child)) return true; + return false; } export function patchHideGuildsBar(cleanups: (() => void)[]): boolean { - const mod = findGuildsBar(); - if (!mod?.default) { - console.log(TAG, "WARN: GuildsBar not found"); - return false; - } - const orig = mod.default; + // Hard-enforce the GuildsBar parent's visibility by detecting the hidden Nothing child and + // zeroing the rail wrapper itself. Confirmed live that the immediate parent carries an + // explicit width: 72 in its own style, so display: none alone left the space reserved - + // zeroing width/flex directly, the same way collapseAncestors does further up, actually + // reclaims it. + registerPropsTransform( + (props: any, _type: any, rest: any[]) => + hasChildWithTestID(props?.children, rest, SD_NOTHING_TEST_ID), + (props: any) => ({ + ...props, + style: [ + props?.style, + { + display: "none", + width: 0, + minWidth: 0, + maxWidth: 0, + flexGrow: 0, + flexShrink: 0, + flexBasis: 0, + margin: 0, + padding: 0, + borderWidth: 0, + overflow: "hidden", + }, + ], + }), + ); - registerIntercept(orig, Nothing); - - mod.default = function HiddenGuildsBar() { - return null; - }; - mod.default.displayName = "GuildsBar"; - cleanups.push(() => { mod.default = orig; }); + registerTypeDetector("ServerDrawer.HideGuildsBar", isGuildsBar, (realGuildsBar) => { + registerIntercept(realGuildsBar, Nothing, { testID: SD_NOTHING_TEST_ID }, { collapseAncestors: 6 }); + console.log(TAG, "PATCH: found a real GuildsBar reference, now rendering nothing"); + }, { persistent: true }); + cleanups.push(() => { + // No per-call unregister needed - createElementIntercept clears all transforms/intercepts + // together when its own patch unwinds. + }); + console.log(TAG, "PATCH: watching for the real GuildsBar to appear"); return true; } diff --git a/plugins/server-drawer/src/utils/theme.ts b/plugins/server-drawer/src/utils/theme.ts index 26d4606..5ea43ed 100644 --- a/plugins/server-drawer/src/utils/theme.ts +++ b/plugins/server-drawer/src/utils/theme.ts @@ -1,24 +1,25 @@ -import { findByProps, findByStoreName } from "@vendetta/metro"; +import { lazy } from "../lib/lazy"; +import { rawFindByStoreName } from "../lib/rawFind"; +import { getFlux, getColorModule } from "../lib/commonModules"; -const Flux = findByProps("useStateFromStores"); -const colors = findByProps("colors", "unsafe_rawColors")?.colors; -const ExpandedGuildFolderStore = findByStoreName("ExpandedGuildFolderStore"); +const getExpandedGuildFolderStore = lazy(() => rawFindByStoreName("ExpandedGuildFolderStore")); export function useTheme() { + const colors = getColorModule()?.colors; return { - text: colors?.TEXT_NORMAL ?? "#dbdee1", + text: colors?.TEXT_NORMAL ?? colors?.TEXT_DEFAULT ?? "#dbdee1", folder: "#5865f2", hover: colors?.STATE_LAYER_PRESS ?? "rgba(255,255,255,0.06)", }; } export function useFolderExpanded(folderId: string | number): boolean { - // Confirmed live: an account with folders and one without exercise different code paths (this - // hook is only ever called by FolderItem, which only exists when there's a folder to render) - - // an account with no folders rendered fine while one with folders showed nothing at all. - // ExpandedGuildFolderStore is a one-shot findByStoreName at module-eval time with no retry, so - // if it hadn't registered yet, this passed [undefined] into useStateFromStores - which throws - // rather than returning safely, taking down the whole drawer render, not just this one folder. + const Flux = getFlux(); + const ExpandedGuildFolderStore = getExpandedGuildFolderStore(); + // Guards against an undefined store, not just a falsy return value - confirmed live that + // passing [undefined] into useStateFromStores throws rather than failing safely, which used to + // take the whole drawer down (this only ever runs when there's an actual folder to render, so + // an account with folders showed nothing at all while one without rendered fine). if (!Flux?.useStateFromStores || !ExpandedGuildFolderStore) return false; return Flux.useStateFromStores( [ExpandedGuildFolderStore],