ServerDrawer: restore retry-loop infrastructure with everything learned tonight

This commit is contained in:
Ally Founder 2026-08-11 11:13:34 -04:00
parent 2a2d0c942a
commit 88d3210fae
12 changed files with 723 additions and 215 deletions

View file

@ -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<any>({
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;

View file

@ -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<any>("selectPrivateChannel"));
const getSelectedChannelStore = lazy(() => rawFindByName<any>("SelectedChannelStore"));
const getNavContext = lazy(() => rawFindByFunctionProps<any>("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,

View file

@ -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<any>("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) {

View file

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

View file

@ -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 (
<Text numberOfLines={2} style={[lb.label, { color: colors?.TEXT_NORMAL ?? "#dbdee1" }]}>
<Text numberOfLines={2} style={[lb.label, { color: theme.text }]}>
{name}
</Text>
);
@ -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]);

View file

@ -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 (
<Pressable onPress={onPress} onPressIn={scaleDown} onPressOut={scaleUp}>
<Animated.View style={[st.createJoin, { backgroundColor: createJoinBg, transform: [{ scale }] }]}>
<Animated.View style={[st.createJoin, { backgroundColor: rawColors?.GREEN_360, transform: [{ scale }] }]}>
{CirclePlusIcon ? (
<CirclePlusIcon size="md" color={createJoinIconColor} />
<CirclePlusIcon size="md" color={rawColors?.WHITE} />
) : (
<Text style={[st.createJoinFallback, { color: createJoinIconColor }]}>{"+"}</Text>
<Text style={[st.createJoinFallback, { color: rawColors?.WHITE }]}>{"+"}</Text>
)}
</Animated.View>
</Pressable>
@ -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;

View file

@ -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<typeof setInterval> | 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<string, () => 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<string>();
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();

View file

@ -1,54 +1,346 @@
import { React } from "@vendetta/metro/common";
import { after } from "@vendetta/patcher";
interface Intercept {
replacement: React.ComponentType<any>;
extraProps?: Record<string, any>;
/**
* 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<any> | 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<React.ComponentType<any>, Intercept>();
let origCreateElement: typeof React.createElement | null = null;
const propsIntercepts: PropsIntercept[] = [];
const propsTransforms: PropsTransform[] = [];
let typeDetectors: TypeDetector[] = [];
const detectorKeys = new Set<string>();
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<object, number>();
/**
* 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<any>,
replacement: React.ComponentType<any>,
extraProps?: Record<string, any>,
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<any> | 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<any>();
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<typeof setInterval> | 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();
});
}

View file

@ -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 <ServerDrawerSheet gestureContext={getGestureContext()} />;
}
/**
* 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<any>();
// 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 <ServerDrawerSheet gestureContext={getGestureContext()} />;
};
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;
}

View file

@ -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<any>("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<any>("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<any>("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<any>("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<any>("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 {

View file

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

View file

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