ServerDrawer: diagnostic pass - match original's infra exactly

This commit is contained in:
Ally Founder 2026-08-11 08:57:18 -04:00
parent b094d5d24f
commit e6c05a98d8
11 changed files with 169 additions and 701 deletions

View file

@ -1,20 +1,17 @@
import React from "react";
import { View, Text, Pressable, Image, Modal, StyleSheet, Dimensions, Animated } from "react-native";
import { semanticColors } from "@vendetta/ui";
import { resolveSemanticColor } from "../lib/color";
import { lazy } from "../lib/lazy";
import { rawFindByValidProps } from "../lib/rawFind";
import { getHaptic } from "../lib/commonModules";
import { findByProps, findByStoreName } from "@vendetta/metro";
// getHaptic comes from lib/commonModules.ts - see that file for the decoy-module writeup.
// TextStyleSheet is object-shaped (a style lookup table), so it needs the same shape-validated
// lookup rather than a plain "does this property exist" check.
const getTextStyleSheet = lazy(() => rawFindByValidProps<any>({
TextStyleSheet: (v) => v?.["text-md/bold"] !== undefined,
})?.TextStyleSheet);
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");
function resolve(token: any, fallback: string): string {
return resolveSemanticColor(token) ?? fallback;
function resolve(color: any): string {
const theme = ThemeStore?.theme;
return (color && colorResolver?.resolveSemanticColor(theme, color)) || "#000000";
}
const EDGE = 12;
@ -50,9 +47,6 @@ 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;
@ -65,10 +59,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, "#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 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 backdropOpacity = React.useRef(new Animated.Value(0)).current;
const menuScale = React.useRef(new Animated.Value(0.5)).current;

View file

@ -1,42 +1,27 @@
import React from "react";
import { View, Pressable, Image, StyleSheet } from "react-native";
import { getAssetIDByName } from "@vendetta/ui/assets";
import { lazy } from "../lib/lazy";
import { rawFindByFunctionProps, rawFindByName } from "../lib/rawFind";
import { getFlux, getHaptic, getColorModule } from "../lib/commonModules";
import { findByProps, findByName } from "@vendetta/metro";
const ICON = 48;
const ChatIcon = getAssetIDByName("ChatIcon");
// getFlux/getHaptic/getColorModule come from lib/commonModules.ts - see that file for the
// decoy-module writeup. The rest switch from eager, module-load-time findByProps/findByName (the
// caching finders, permanently wrong if they run before the target module registers) to lazy +
// raw variants. NavContext is findByProps("getGuildId") - a navigation-context helper, NOT the
// real guild data store (that one is reached via findByStoreName elsewhere in this plugin) -
// named accordingly here to avoid confusing the two.
const getChannelActions = lazy(() => rawFindByFunctionProps<any>("selectPrivateChannel"));
const getSelectedChannelStore = lazy(() => rawFindByName<any>("SelectedChannelStore"));
const getNavContext = lazy(() => rawFindByFunctionProps<any>("getGuildId"));
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;
function openDms() {
const Haptic = getHaptic();
Haptic?.triggerHapticFeedback?.(Haptic.HapticFeedbackTypes.SOFT);
const ChannelActions = getChannelActions();
if (ChannelActions?.selectPrivateChannel) {
const lastChannelId = getSelectedChannelStore()?.getLastSelectedChannelId?.();
const lastChannelId = SelectedChannelStore?.getLastSelectedChannelId?.();
ChannelActions.selectPrivateChannel(lastChannelId);
}
}
// Was previously a full replacement for the native left server rail (rendered instead of it) -
// moved into the drawer's own grid instead, as the first tile, so DMs live in the same place as
// every other server rather than in a leftover spot from the sidebar this plugin otherwise hides.
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,21 +1,16 @@
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";
// getFlux comes from lib/commonModules.ts - see that file for the decoy-module writeup.
// GuildActions/GuildReadStateStore switch from eager, module-load-time findByProps/findByStoreName
// (the caching finders) to lazy + raw variants for the same reasons established elsewhere in this
// plugin - timing (permanent-negative-cache) for the store, and decoy-avoidance for the function.
const getGuildActions = lazy(() => rawFindByFunctionProps<any>("toggleGuildFolderExpand"));
const getGuildReadStateStore = lazy(() => rawFindByStoreName("GuildReadStateStore"));
const GuildActions = findByProps("toggleGuildFolderExpand");
const Flux = findByProps("useStateFromStores");
const GuildReadStateStore = findByStoreName("GuildReadStateStore");
const ICON = 48;
const MINI = 16;
@ -35,9 +30,6 @@ function folderColor(color?: number | null): string {
}
function FolderBadge({ node }: { node: GuildNode }) {
const Flux = getFlux();
const GuildReadStateStore = getGuildReadStateStore();
const total = Flux?.useStateFromStores?.(
[GuildReadStateStore],
() => {
@ -105,7 +97,7 @@ export default function FolderItem({ node, onPick }: { node: GuildNode; onPick:
const open = useFolderExpanded(node.id);
const toggle = () => {
getGuildActions()?.toggleGuildFolderExpand?.(node.id);
GuildActions?.toggleGuildFolderExpand?.(node.id);
};
if (open) {

View file

@ -1,23 +1,12 @@
import React from "react";
import { View, Text, Image, StyleSheet } from "react-native";
import { lazy } from "../lib/lazy";
import { rawFindByStoreName } from "../lib/rawFind";
import { getFlux, getColorModule } from "../lib/commonModules";
import { findByStoreName, findByProps } from "@vendetta/metro";
// getFlux/getColorModule come from lib/commonModules.ts - see that file for the decoy-module
// writeup. GuildStore switches from an eager, module-load-time findByStoreName (the caching
// finder, permanently wrong if it runs before the store module has registered) to lazy +
// rawFindByStoreName for the same timing reason established elsewhere in this plugin.
const getGuildStore = lazy(() => rawFindByStoreName("GuildStore"));
const GuildStore = findByStoreName("GuildStore");
const colors = findByProps("colors", "unsafe_rawColors")?.colors;
export default function GuildIcon({ id, size = 48 }: { id: string; size?: number }) {
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;
const g = GuildStore?.getGuild(id);
if (!g) return null;
const rad = size >= 40 ? 16 : 8;
if (g.icon) {

View file

@ -1,35 +1,22 @@
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 { lazy } from "../lib/lazy";
import { rawFind, rawFindByStoreName } from "../lib/rawFind";
import { getFlux, getHaptic } from "../lib/commonModules";
import { GuildNode, useTheme } from "../utils/theme";
import { GuildNode } from "../utils/theme";
import GuildIcon from "./GuildIcon";
import { ContextMenuModal, ContextMenuItem } from "./ContextMenuModal";
const ICON = 48;
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"), kept as-is since it's the only lead either version has ever had for finding
// it. Rendered through ContextMenuModal rather than Discord's own native menu component - there's
// no accessible way to mount that component standalone outside its usual native modal 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
);
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;
function Badge({ guildId }: { guildId: string }) {
const Flux = getFlux();
const GuildReadStateStore = getGuildReadStateStore();
const mentionCount = Flux?.useStateFromStores?.(
[GuildReadStateStore],
() => GuildReadStateStore?.getMentionCount?.(guildId) ?? 0,
@ -65,10 +52,6 @@ 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],
@ -79,7 +62,7 @@ function GuildLabel({ guildId }: { guildId: string }) {
if (storage.showGuildNames === false || !name) return null;
return (
<Text numberOfLines={2} style={[lb.label, { color: theme.text }]}>
<Text numberOfLines={2} style={[lb.label, { color: colors?.TEXT_NORMAL ?? "#dbdee1" }]}>
{name}
</Text>
);
@ -101,11 +84,15 @@ export default function GuildItem({ node, onPick }: { node: GuildNode; onPick: (
const showMenu = React.useCallback(() => {
const guildId = node.id as string;
const guild = getGuildStore()?.getGuild?.(guildId);
const menuItemsFn = getMenuItemsFn();
if (!guild || !menuItemsFn) return;
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 treeVersion = getSortedGuildStore()?.getGuildsTree?.()?.version;
if (!guild || !menuItemsFn) {
return;
}
const treeVersion = SortedGuildStore?.getGuildsTree?.()?.version;
const rawItems = menuItemsFn(guildId, treeVersion);
if (!rawItems?.length) return;
@ -130,7 +117,6 @@ 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,25 +1,29 @@
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";
// getFlux/getHaptic/getColorModule come from lib/commonModules.ts - see that file for the decoy
// module writeup. These three below are specific to this file only.
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 Flux = findByProps("useStateFromStores");
const SortedGuildStore = findByStoreName("SortedGuildStore");
const RootNav = findByProps("getRootNavigationRef");
const Haptic = findByProps("triggerHapticFeedback", "HapticFeedbackTypes");
const Routing = findByProps("transitionToGuild");
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 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 ICON = 48;
const GAP = 6;
@ -35,21 +39,17 @@ function CreateJoinButton() {
}, [scale]);
const onPress = React.useCallback(() => {
const haptic = getHaptic();
haptic?.triggerHapticFeedback?.(haptic.HapticFeedbackTypes.SOFT);
getCreateJoinGuildMod()?.handleCreateJoinGuildPress?.();
Haptic?.triggerHapticFeedback?.(Haptic.HapticFeedbackTypes.SOFT);
CreateJoinGuildMod?.handleCreateJoinGuildPress?.();
}, []);
const CirclePlusIcon = getCirclePlusIcon();
const rawColors = getColorModule()?.unsafe_rawColors;
return (
<Pressable onPress={onPress} onPressIn={scaleDown} onPressOut={scaleUp}>
<Animated.View style={[st.createJoin, { backgroundColor: rawColors?.GREEN_360, transform: [{ scale }] }]}>
<Animated.View style={[st.createJoin, { backgroundColor: createJoinBg, transform: [{ scale }] }]}>
{CirclePlusIcon ? (
<CirclePlusIcon size="md" color={rawColors?.WHITE} />
<CirclePlusIcon size="md" color={createJoinIconColor} />
) : (
<Text style={[st.createJoinFallback, { color: rawColors?.WHITE }]}>{"+"}</Text>
<Text style={[st.createJoinFallback, { color: createJoinIconColor }]}>{"+"}</Text>
)}
</Animated.View>
</Pressable>
@ -60,20 +60,14 @@ export default function ServerDrawerSheet({ gestureContext }: { gestureContext:
useProxy(storage);
const pick = React.useCallback((id: string) => {
const haptic = getHaptic();
haptic?.triggerHapticFeedback?.(haptic.HapticFeedbackTypes.SOFT);
const routing = getRouting();
if (routing?.transitionToGuild) {
routing.transitionToGuild(id);
Haptic?.triggerHapticFeedback(Haptic.HapticFeedbackTypes.SOFT);
if (Routing?.transitionToGuild) {
Routing.transitionToGuild(id, null);
} else {
getRootNav()?.getRootNavigationRef?.()?.navigate("guilds", { guildId: id });
RootNav?.getRootNavigationRef()?.navigate("guilds", { guildId: id });
}
}, []);
const Flux = getFlux();
const SortedGuildStore = getSortedGuildStore();
const nodes: GuildNode[] = Flux?.useStateFromStores?.(
[SortedGuildStore],
() => {
@ -91,14 +85,12 @@ 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,4 +1,3 @@
import { logger } from "@vendetta";
import { storage } from "@vendetta/plugin";
import { patchFakeQuestDock } from "./patches/fakeQuestDock";
import { patchQuestDockSlot, patchEmpty } from "./patches/contentPatch";
@ -7,85 +6,31 @@ 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
// (e.g. calling a property off an undefined module) can't take the rest of onLoad's patches
// down with it, same as every other plugin in this repo.
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 - this is exactly why the original plugin needed a server
// tapped before the drawer would show: every one of its lookups was a one-shot find() with no
// retry, so a module that hadn't registered yet at that instant stayed unresolved for the rest of
// the session. Retrying whatever hasn't landed yet on a fast interval until everything's applied
// (or it gives up after ~10s) means the drawer shows up on its own instead of needing a tap to
// nudge Discord into registering the modules this plugin is waiting on.
function applyAll() {
const patchers: Record<string, () => boolean> = {
...(storage.fakeQuestDock ? { fakeQuestDock: () => patchFakeQuestDock(cleanups) } : {}),
expanded: () => patchQuestDockSlot("QuestDockContentExpanded", cleanups),
collapsed: () => patchQuestDockSlot("QuestDockContentCollapsed", cleanups),
enrolledHeader: () => patchEmpty("QuestDockEnrolledHeader", cleanups),
unenrolledHeader: () => patchEmpty("QuestDockUnenrolledHeader", cleanups),
enrolledBody: () => patchEmpty("QuestDockEnrolledBody", cleanups),
unenrolledBody: () => patchEmpty("QuestDockUnenrolledBody", cleanups),
autoCollapseFolders: () => patchAutoCollapseFolders(cleanups),
...(storage.hideGuildsBar ? { hideGuildsBar: () => patchHideGuildsBar(cleanups) } : {}),
};
console.log(TAG, "onLoad");
let patched = 0;
patchCreateElement(cleanups);
patched++;
const pending = new Set(Object.keys(patchers));
const applied = new Set<string>();
if (storage.fakeQuestDock !== false && patchFakeQuestDock(cleanups)) patched++;
if (patchQuestDockSlot("QuestDockContentExpanded", cleanups)) patched++;
if (patchQuestDockSlot("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++;
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);
}
logger.log(`[ServerDrawer] onLoad: applied immediately - ${[...applied].join(", ") || "none"}${pending.size ? `, still waiting on ${[...pending].join(", ")}` : ""}`);
console.log(TAG, `onLoad done - ${patched} patches applied, ${cleanups.length} cleanups`);
}
function unpatchAll() {
if (retryHandle) {
clearInterval(retryHandle);
retryHandle = undefined;
}
for (const fn of cleanups) {
try {
fn();

View file

@ -1,355 +1,54 @@
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>();
const propsIntercepts: PropsIntercept[] = [];
const propsTransforms: PropsTransform[] = [];
let typeDetectors: TypeDetector[] = [];
const detectorKeys = new Set<string>();
let origCreateElement: typeof React.createElement | null = null;
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, 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. Exists because Quest Dock's hooks are called through a same-chunk closure reference, not
// the exported property - patching the export does nothing, but every element creation goes
// through this one shared path regardless of chunk, so there's no equivalent staleness here.
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 - index.ts retries every 200ms for ~10s, and duplicate detectors used
// to pile up, each re-tested against every element created anywhere in the app.
//
// persistent keeps the detector alive for every future match instead of firing once. GuildsBar's
// own type reference changes on some navigation paths (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 the GuildsBar detector is
// persistent and so the list is never empty - an allocation per element on the hottest path in
// the entire app.
if (consumed) {
typeDetectors = typeDetectors.filter((d) => d.persistent || !d.justFired);
}
}
// Marking is done out-of-band so the filter above stays a pure predicate.
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. Previously this guarded against parents
* with multiple children, but the live GuildsBar rail has sibling wrappers (avatar/dm tile) inside
* the same outer container - the rail itself needs to be zeroed out too, and the replaced GuildsBar
* child is the only collapsed one.
*/
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 (what this used to do) doesn't reliably reach real render calls here; after()
// does. Confirmed live: GuildsBar creations were observed firing through this exact patch point,
// but res.type never actually changed - the intercept logic was correct, the patch mechanism wasn't.
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;
intercepts.set(original, { replacement, extraProps });
}
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;
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;
if (isPatched && origCreateElement) {
React.createElement = origCreateElement;
isPatched = false;
}
intercepts.clear();
propsIntercepts.length = 0;
propsTransforms.length = 0;
typeDetectors = [];
detectorKeys.clear();
});
}

View file

@ -1,15 +1,10 @@
import { React } from "@vendetta/metro/common";
import { rawFind } from "../lib/rawFind";
import ServerDrawerSheet from "../components/ServerDrawerSheet";
import { registerIntercept, registerTypeDetector } from "../lib/createElementIntercept";
import { registerIntercept } from "../lib/createElementIntercept";
const TAG = "[ServerDrawer]";
// rawFind, not @vendetta/metro's find - the original plugin's one-shot find() calls are exactly
// why it needed a server tapped before the drawer would show: if the target module hadn't
// registered in Metro yet at the exact moment onLoad ran, find()'s result (undefined) is cached
// forever and never rescanned, so index.tsx's retry loop calling this again was pointless. rawFind
// walks window.modules directly on every call instead, so retrying actually has a chance to work.
let cachedGestureContext: any = null;
function getGestureContext(): any {
if (!cachedGestureContext) {
@ -23,66 +18,46 @@ function isNamed(name: string) {
type?.type?.name === name || type?.type?.displayName === name;
}
// Patches both QuestDockContentExpanded and QuestDockContentCollapsed to render the drawer, not
// just Expanded like the original. Confirmed live (Key Inspector) that restingQuestDockMode - the
// Reanimated value Discord uses to pick which of the two to render - starts as "collapsed" on a
// fresh load, not "expanded". Patching only Expanded left the drawer invisible behind whatever the
// collapsed variant showed (nothing, since that slot went through a separate null-patch) until
// something else happened to flip the mode. Swapping in the same drawer for both names sidesteps
// needing to fight that mode at all - whichever Discord picks, the drawer is what's there.
export function patchQuestDockSlot(name: string, cleanups: (() => void)[]): boolean {
registerTypeDetector(`ServerDrawer.Slot.${name}`, isNamed(name), (real) => {
registerIntercept(real, ServerDrawerSheet, { gestureContext: getGestureContext() });
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 yet (will retry)`);
console.log(TAG, `WARN: ${name} not found (will retry)`);
return false;
}
const orig = mod.type;
registerIntercept(orig, ServerDrawerSheet, { gestureContext: getGestureContext() });
mod.type = function ServerDrawerPatch() {
return <ServerDrawerSheet gestureContext={getGestureContext()} />;
};
cleanups.push(() => { mod.type = orig; });
console.log(TAG, `PATCH: ${name} replaced (module mutation)`);
console.log(TAG, `PATCH: ${name} replaced`);
return true;
}
// Sub-sections inside Discord's own dock content (header/body variants) - kept as null-patches
// like the original. Once patchQuestDockSlot above wins, Discord's real header/body components
// never get created in the first place, so this is mostly moot, but harmless to keep as a backup
// for whichever of the two patches loses its own boot race.
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 yet (will retry)`);
console.log(TAG, `WARN: ${name} not found`);
return false;
}
const orig = mod.type;
function EmptyPatch() {
return null;
}
mod.type = EmptyPatch;
const originalComponent = orig;
try {
Object.defineProperties(mod.type, Object.getOwnPropertyDescriptors(orig));
} catch {
// Some property descriptors can't be copied onto another function - fine, this is a
// best-effort compatibility shim, not something correctness depends on.
mod.type = 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.displayName = name;
cleanups.push(() => { mod.type = orig; });
console.log(TAG, `PATCH: ${name} replaced (module mutation)`);
return true;
}

View file

@ -1,122 +1,43 @@
import React from "react";
import { View } from "react-native";
import { registerPropsTransform, registerTypeDetector, registerIntercept } from "../lib/createElementIntercept";
import { captureFiberRef } from "../lib/fiberCapture";
import { find, findByName } from "@vendetta/metro";
import { registerIntercept } from "../lib/createElementIntercept";
const TAG = "[ServerDrawer]";
const SD_NOTHING_TEST_ID = "ServerDrawerNothing";
// Whether this capture point actually mounts depends entirely on whether the intercept below
// fires (unverified independently of it - see lib/fiberCapture.ts for the primary, confirmed
// capture point inside ServerDrawerSheet.tsx itself, which is guaranteed to mount whenever the
// drawer is visibly showing). Kept here too as a second chance, harmless either way.
function Nothing() {
// Use the same aggressive collapse style as createElementIntercept's COLLAPSE_STYLE so that
// the replaced element itself takes up zero space while still mounting a ref for fiber capture.
// Keeping a mounted element with zero size lets us capture the fiber without leaving a visible
// 1px gap on the left rail.
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",
},
return null;
}
function findGuildsBar(): any {
const byName = findByName("GuildsBar");
if (byName) return { default: byName };
let mod = find((m) => {
try { return m?.default?.type?.name === "GuildsBar"; } catch { return false; }
});
}
if (mod?.default) return mod;
// Confirmed live (Key Inspector's fiber capture): the parent creates GuildsBar via its OUTER
// React.memo wrapper object ($$typeof: Symbol(react.memo)), which has no own .name/.displayName at
// all - those only exist on the memo's inner function, at type.type.name/type.type.displayName.
// Checking only the outer two (as the first version of this file did) never matches anything,
// which is why the intercept never fired despite the underlying mechanism being sound.
function isGuildsBar(type: any): boolean {
return type?.name === "GuildsBar" || type?.displayName === "GuildsBar" ||
type?.type?.name === "GuildsBar" || type?.type?.displayName === "GuildsBar";
}
mod = find((m) => {
try { return m?.default?.displayName === "GuildsBar"; } catch { return false; }
});
if (mod?.default) return mod;
/**
* Reset after live, on-device verification (Key Inspector's fiber capture + Eval) disproved the
* previous approach's own premise. That version assumed GuildsBar had no discoverable runtime name
* and hid it via a literal nativeID prop instead - but a direct fiber-tree walk found the live,
* currently-mounted component's name is genuinely "GuildsBar" (tag 15, SimpleMemoComponent, single
* `enableHome` prop). The real bug: Metro-search-based lookups (rawFindByTypeName("GuildsBar")) DO
* find *something* by that name, but it's a different, unrelated module - confirmed by comparing
* the metro-search result against the live fiber's own `.type` by reference, which do not match.
* That's exactly the kind of silent wrong-match this whole repo has been burned by before.
*
* Also confirmed live: GuildsBar genuinely re-renders on normal usage (switching servers - 2 calls
* counted over a 15s window of real navigation), unlike YouBar's frozen-after-mount leaf. So unlike
* that investigation, there's no structural reason a straightforward patch can't work here - the
* only problem was ever finding the *correct* reference.
*
* registerTypeDetector sidesteps the module-registry name collision entirely: it inspects the
* `type` argument passed directly to createElement/jsx at the moment something actually creates an
* element with it, which is necessarily the real, live reference - there's nothing else it could be.
* Once caught, registerIntercept registers that *exact* reference (matched by identity, not name)
* so every future creation of it - which happens on every re-render of GuildsBar's parent, i.e.
* whenever the rail would normally repaint - renders nothing instead.
*/
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;
return null;
}
export function patchHideGuildsBar(cleanups: (() => void)[]): boolean {
// 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",
},
],
}),
);
const mod = findGuildsBar();
if (!mod?.default) {
console.log(TAG, "WARN: GuildsBar not found");
return false;
}
const orig = mod.default;
// Use a stable string key so duplicate registrations from retry loops are deduped correctly.
registerTypeDetector("ServerDrawer.HideGuildsBar", isGuildsBar, (realGuildsBar) => {
// Ask createElementIntercept to collapse a few ancestor levels as well so wrapper
// containers around the rail don't leave a leftover gap.
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 - the whole detector/intercept state gets cleared when
// createElementIntercept's own patch unwinds, part of the same cleanup pass this plugin
// already runs on unload/restart.
});
console.log(TAG, "PATCH: watching for the real GuildsBar to appear");
registerIntercept(orig, Nothing);
mod.default = function HiddenGuildsBar() {
return null;
};
mod.default.displayName = "GuildsBar";
cleanups.push(() => { mod.default = orig; });
return true;
}

View file

@ -1,28 +1,18 @@
import { lazy } from "../lib/lazy";
import { rawFindByStoreName } from "../lib/rawFind";
import { getFlux, getColorModule } from "../lib/commonModules";
import { findByProps, findByStoreName } from "@vendetta/metro";
// getFlux/getColorModule come from lib/commonModules.ts - see that file for the decoy-module
// writeup. ExpandedGuildFolderStore switches from an eager, module-load-time findByStoreName (the
// caching finder, permanently wrong if it runs before the store module has registered) to lazy +
// rawFindByStoreName for the same timing reason established elsewhere in this plugin.
const getExpandedGuildFolderStore = lazy(() => rawFindByStoreName("ExpandedGuildFolderStore"));
const Flux = findByProps("useStateFromStores");
const colors = findByProps("colors", "unsafe_rawColors")?.colors;
const ExpandedGuildFolderStore = findByStoreName("ExpandedGuildFolderStore");
export function useTheme() {
const colors = getColorModule()?.colors;
return {
// TEXT_NORMAL is confirmed dead via Key Inspector (Discord renamed its whole semanticColors
// scheme) - TEXT_DEFAULT is its current replacement, kept alongside the old name in case an
// older Discord build is still in use.
text: colors?.TEXT_NORMAL ?? colors?.TEXT_DEFAULT ?? "#dbdee1",
text: colors?.TEXT_NORMAL ?? "#dbdee1",
folder: "#5865f2",
hover: colors?.STATE_LAYER_PRESS ?? "rgba(255,255,255,0.06)",
};
}
export function useFolderExpanded(folderId: string | number): boolean {
const Flux = getFlux();
const ExpandedGuildFolderStore = getExpandedGuildFolderStore();
return Flux?.useStateFromStores?.(
[ExpandedGuildFolderStore],
() => {