Fix a systemic bug: Revenge's finder cache made every retry loop a no-op

This commit is contained in:
everestmcarthur 2026-08-01 15:55:30 -04:00
parent 886e3e5a6a
commit 98dadf5bca
13 changed files with 119 additions and 39 deletions

View file

@ -6,6 +6,7 @@ import { storage } from "@vendetta/plugin";
import { useProxy } from "@vendetta/storage"; import { useProxy } from "@vendetta/storage";
import { showToast } from "@vendetta/ui/toasts"; import { showToast } from "@vendetta/ui/toasts";
import { lazy } from "@shared/lib/lazy"; import { lazy } from "@shared/lib/lazy";
import { rawFindByProps } from "@shared/lib/rawFind";
import { GuildNode } from "../utils/theme"; import { GuildNode } from "../utils/theme";
import GuildIcon from "./GuildIcon"; import GuildIcon from "./GuildIcon";
import { ContextMenuModal, ContextMenuItem } from "./ContextMenuModal"; import { ContextMenuModal, ContextMenuItem } from "./ContextMenuModal";
@ -19,8 +20,10 @@ const GuildChannelStore = findByStoreName("GuildChannelStore");
const ChannelStore = findByStoreName("ChannelStore"); const ChannelStore = findByStoreName("ChannelStore");
const Haptic = findByProps("triggerHapticFeedback", "HapticFeedbackTypes"); const Haptic = findByProps("triggerHapticFeedback", "HapticFeedbackTypes");
const getBulkAck = lazy(() => findByProps("bulkAck", "ackChannel")); // rawFindByProps, not findByProps, because these are retried via lazy() - see rawFind.ts for why
const getReadStateTypes = lazy(() => findByProps("ReadStateTypes", "UnreadSetting")?.ReadStateTypes); // a retried cached findByProps call is a no-op after its first failure.
const getBulkAck = lazy(() => rawFindByProps("bulkAck", "ackChannel"));
const getReadStateTypes = lazy(() => rawFindByProps("ReadStateTypes", "UnreadSetting")?.ReadStateTypes);
// Discord's own per-guild context menu (getGuildsBarGuildMenuItems) is a bare default export with // Discord's own per-guild context menu (getGuildsBarGuildMenuItems) is a bare default export with
// no named export - findByName/`.default.name` heuristics for it are unreliable, since a plain // no named export - findByName/`.default.name` heuristics for it are unreliable, since a plain

View file

@ -4,6 +4,7 @@ import { findByProps, findByStoreName } from "@vendetta/metro";
import { getAssetIDByName } from "@vendetta/ui/assets"; import { getAssetIDByName } from "@vendetta/ui/assets";
import { showToast } from "@vendetta/ui/toasts"; import { showToast } from "@vendetta/ui/toasts";
import { lazy } from "@shared/lib/lazy"; import { lazy } from "@shared/lib/lazy";
import { rawFindByProps } from "@shared/lib/rawFind";
const ICON = 48; const ICON = 48;
@ -12,11 +13,12 @@ const SortedGuildStore = findByStoreName("SortedGuildStore");
const GuildChannelStore = findByStoreName("GuildChannelStore"); const GuildChannelStore = findByStoreName("GuildChannelStore");
const ChannelStore = findByStoreName("ChannelStore"); const ChannelStore = findByStoreName("ChannelStore");
// Lazy because bulkAck lives on an action-creator module that, like routing, may not be required // Lazy + rawFindByProps (not findByProps) because bulkAck lives on an action-creator module that,
// by Discord's own code yet at the moment this plugin's bundle loads. // like routing, may not be required by Discord's own code yet at the moment this plugin's bundle
const getHaptic = lazy(() => findByProps("triggerHapticFeedback", "HapticFeedbackTypes")); // loads - and a retried findByProps call is a no-op after its first failure (see rawFind.ts).
const getBulkAck = lazy(() => findByProps("bulkAck", "ackChannel")); const getHaptic = lazy(() => rawFindByProps("triggerHapticFeedback", "HapticFeedbackTypes"));
const getReadStateTypes = lazy(() => findByProps("ReadStateTypes", "UnreadSetting")?.ReadStateTypes); const getBulkAck = lazy(() => rawFindByProps("bulkAck", "ackChannel"));
const getReadStateTypes = lazy(() => rawFindByProps("ReadStateTypes", "UnreadSetting")?.ReadStateTypes);
function collectGuildIds(): string[] { function collectGuildIds(): string[] {
// Same tree ServerDrawerSheet already renders from (SortedGuildStore.getGuildsTree()), so this // Same tree ServerDrawerSheet already renders from (SortedGuildStore.getGuildsTree()), so this

View file

@ -2,6 +2,7 @@ import React from "react";
import { View, Text, Pressable, Animated, Dimensions, StyleSheet, BackHandler } from "react-native"; import { View, Text, Pressable, Animated, Dimensions, StyleSheet, BackHandler } from "react-native";
import { find, findByProps, findByStoreName } from "@vendetta/metro"; import { find, findByProps, findByStoreName } from "@vendetta/metro";
import { lazy } from "@shared/lib/lazy"; import { lazy } from "@shared/lib/lazy";
import { rawFind, rawFindByProps } from "@shared/lib/rawFind";
import { GuildNode } from "../utils/theme"; import { GuildNode } from "../utils/theme";
import GuildItem from "./GuildItem"; import GuildItem from "./GuildItem";
import FolderItem from "./FolderItem"; import FolderItem from "./FolderItem";
@ -13,15 +14,18 @@ const SortedGuildStore = findByStoreName("SortedGuildStore");
// These are looked up lazily (retried on first real use, not cached the moment this file is // These are looked up lazily (retried on first real use, not cached the moment this file is
// required) because this whole module gets evaluated as soon as the plugin's bundle loads - which // required) because this whole module gets evaluated as soon as the plugin's bundle loads - which
// can be before Discord's own routing/navigation modules have been required yet. A one-shot // can be before Discord's own routing/navigation modules have been required yet. Crucially, they
// findByProps() here would then permanently cache undefined even though the real module shows up // use rawFind/rawFindByProps (not @vendetta/metro's cached finders) - Revenge's own finders
// moments later, which is exactly what made tapping a server in the drawer silently do nothing. // permanently cache a "not found" result and never rescan, so retrying a cached findByProps would
const getRootNav = lazy(() => findByProps("getRootNavigationRef")); // just hit that poisoned cache every time instead of ever finding the real module once it
const getHaptic = lazy(() => findByProps("triggerHapticFeedback", "HapticFeedbackTypes")); // registers (confirmed by reading Revenge's own metro finder source). This is what actually made
const getRouting = lazy(() => findByProps("transitionToGuild")); // tapping a server in the drawer silently do nothing even after adding retries.
const getCreateJoinGuildMod = lazy(() => find((m: any) => typeof m?.handleCreateJoinGuildPress === "function")); const getRootNav = lazy(() => rawFindByProps("getRootNavigationRef"));
const getCirclePlusIcon = lazy(() => find((m: any) => m?.CirclePlusIcon)?.CirclePlusIcon); const getHaptic = lazy(() => rawFindByProps("triggerHapticFeedback", "HapticFeedbackTypes"));
const getRawColors = lazy(() => findByProps("colors", "unsafe_rawColors")?.unsafe_rawColors); const getRouting = lazy(() => rawFindByProps("transitionToGuild"));
const getCreateJoinGuildMod = lazy(() => rawFind((m: any) => typeof m?.handleCreateJoinGuildPress === "function"));
const getCirclePlusIcon = lazy(() => rawFind((m: any) => m?.CirclePlusIcon)?.CirclePlusIcon);
const getRawColors = lazy(() => rawFindByProps("colors", "unsafe_rawColors")?.unsafe_rawColors);
const ExternalCoordinationMod = find((m: any) => m?.QuestDockExternalCoordinationContext); const ExternalCoordinationMod = find((m: any) => m?.QuestDockExternalCoordinationContext);
const ExternalContext = ExternalCoordinationMod?.QuestDockExternalCoordinationContext; const ExternalContext = ExternalCoordinationMod?.QuestDockExternalCoordinationContext;

View file

@ -1,6 +1,6 @@
import { findByStoreName } from "@vendetta/metro";
import { FluxDispatcher } from "@vendetta/metro/common"; import { FluxDispatcher } from "@vendetta/metro/common";
import { storage } from "@vendetta/plugin"; import { storage } from "@vendetta/plugin";
import { rawFindByStoreName } from "@shared/lib/rawFind";
// Adapted from fres621's BetterFolders (autoCollapse.ts) - ported as a ServerDrawer feature // Adapted from fres621's BetterFolders (autoCollapse.ts) - ported as a ServerDrawer feature
// instead of a standalone plugin because BetterFolders' other half (hideIcons) patches a // instead of a standalone plugin because BetterFolders' other half (hideIcons) patches a
@ -9,7 +9,7 @@ import { storage } from "@vendetta/plugin";
// replaces. This half doesn't touch that component - it only reacts to the same folder-expand // replaces. This half doesn't touch that component - it only reacts to the same folder-expand
// store/event ServerDrawer's own FolderItem already uses, so it works standalone here. // store/event ServerDrawer's own FolderItem already uses, so it works standalone here.
export function patchAutoCollapseFolders(cleanups: (() => void)[]): boolean { export function patchAutoCollapseFolders(cleanups: (() => void)[]): boolean {
const ExpandedGuildFolderStore = findByStoreName("ExpandedGuildFolderStore"); const ExpandedGuildFolderStore = rawFindByStoreName("ExpandedGuildFolderStore");
if (!ExpandedGuildFolderStore?.getExpandedFolders) return false; if (!ExpandedGuildFolderStore?.getExpandedFolders) return false;
const listener = ({ folderId }: { folderId?: string | number }) => { const listener = ({ folderId }: { folderId?: string | number }) => {

View file

@ -1,14 +1,18 @@
import { find } from "@vendetta/metro";
import { React } from "@vendetta/metro/common"; import { React } from "@vendetta/metro/common";
import { rawFind } from "@shared/lib/rawFind";
import ServerDrawerSheet from "../components/ServerDrawerSheet"; import ServerDrawerSheet from "../components/ServerDrawerSheet";
import { registerIntercept } from "./createElementIntercept"; import { registerIntercept } from "./createElementIntercept";
const TAG = "[ServerDrawer]"; const TAG = "[ServerDrawer]";
// rawFind, not @vendetta/metro's find - both patchExpanded and patchEmpty are retried by index.ts's
// fast-retry loop, and Revenge's own find() permanently caches a "not found" result per call site
// and never rescans (see rawFind.ts), which would make the retry pointless after the first
// failed attempt.
let cachedGestureContext: any = null; let cachedGestureContext: any = null;
function getGestureContext(): any { function getGestureContext(): any {
if (!cachedGestureContext) { if (!cachedGestureContext) {
cachedGestureContext = find((m) => m?.QuestDockGestureContext)?.QuestDockGestureContext ?? null; cachedGestureContext = rawFind((m) => m?.QuestDockGestureContext)?.QuestDockGestureContext ?? null;
} }
return cachedGestureContext; return cachedGestureContext;
} }
@ -16,7 +20,7 @@ function getGestureContext(): any {
export function patchExpanded( export function patchExpanded(
cleanups: (() => void)[] cleanups: (() => void)[]
): boolean { ): boolean {
const mod = find((m) => m?.type?.displayName === "QuestDockContentExpanded" || m?.type?.name === "QuestDockContentExpanded"); const mod = rawFind((m) => m?.type?.displayName === "QuestDockContentExpanded" || m?.type?.name === "QuestDockContentExpanded");
if (!mod?.type) { if (!mod?.type) {
console.log(TAG, "WARN: QuestDockContentExpanded not found (will retry)"); console.log(TAG, "WARN: QuestDockContentExpanded not found (will retry)");
return false; return false;
@ -37,7 +41,7 @@ export function patchEmpty(
name: string, name: string,
cleanups: (() => void)[] cleanups: (() => void)[]
): boolean { ): boolean {
const mod = find((m) => m?.type?.displayName === name || m?.type?.name === name); const mod = rawFind((m) => m?.type?.displayName === name || m?.type?.name === name);
if (!mod?.type) { if (!mod?.type) {
console.log(TAG, `WARN: ${name} not found`); console.log(TAG, `WARN: ${name} not found`);
return false; return false;

View file

@ -1,5 +1,5 @@
import { React } from "@vendetta/metro/common"; import { React } from "@vendetta/metro/common";
import { find } from "@vendetta/metro"; import { rawFind } from "@shared/lib/rawFind";
interface Intercept { interface Intercept {
replacement: React.ComponentType<any>; replacement: React.ComponentType<any>;
@ -82,7 +82,7 @@ export function patchCreateElement(cleanups: (() => void)[]) {
// hideGuildsBar's intercept never actually did anything even though it registered correctly. // hideGuildsBar's intercept never actually did anything even though it registered correctly.
// Found by shape (jsx/jsxs/Fragment together), not by name, for the same reason everything // Found by shape (jsx/jsxs/Fragment together), not by name, for the same reason everything
// else in this file avoids name-based lookups. // else in this file avoids name-based lookups.
const jsxRuntime = find((m: any) => typeof m?.jsx === "function" && typeof m?.jsxs === "function" && "Fragment" in m); const jsxRuntime = rawFind((m: any) => typeof m?.jsx === "function" && typeof m?.jsxs === "function" && "Fragment" in m);
const restoreJsx: (() => void)[] = []; const restoreJsx: (() => void)[] = [];
if (jsxRuntime) { if (jsxRuntime) {

View file

@ -1,9 +1,11 @@
import { findByProps } from "@vendetta/metro"; import { rawFindByProps } from "@shared/lib/rawFind";
const TAG = "[ServerDrawer]"; const TAG = "[ServerDrawer]";
// rawFindByProps, not findByProps - see questDockRender.ts for why (retried lookup, and
// Revenge's own findByProps permanently caches a "not found" result and never rescans).
export function patchGetQuestAsset(cleanups: (() => void)[]): boolean { export function patchGetQuestAsset(cleanups: (() => void)[]): boolean {
const mod = findByProps("getQuestAsset"); const mod = rawFindByProps("getQuestAsset");
if (!mod?.getQuestAsset) { if (!mod?.getQuestAsset) {
console.log(TAG, "WARN: getQuestAsset not found"); console.log(TAG, "WARN: getQuestAsset not found");
return false; return false;

View file

@ -1,7 +1,9 @@
import { findByProps } from "@vendetta/metro"; import { rawFindByProps } from "@shared/lib/rawFind";
// rawFindByProps, not findByProps - see questDockRender.ts for why (retried lookup, and
// Revenge's own findByProps permanently caches a "not found" result and never rescans).
export function patchMobileQuestDock(cleanups: (() => void)[]): boolean { export function patchMobileQuestDock(cleanups: (() => void)[]): boolean {
const mod = findByProps("useMobileQuestDock"); const mod = rawFindByProps("useMobileQuestDock");
// The upstream version of this check was `!mod.useMobileQuestDock` (no `?.`) - if the lookup // The upstream version of this check was `!mod.useMobileQuestDock` (no `?.`) - if the lookup
// came back empty, that threw immediately and, since nothing here was wrapped in try/catch, // came back empty, that threw immediately and, since nothing here was wrapped in try/catch,
// took every patch after this one in the onLoad sequence down with it. // took every patch after this one in the onLoad sequence down with it.

View file

@ -1,7 +1,9 @@
import { findByProps } from "@vendetta/metro"; import { rawFindByProps } from "@shared/lib/rawFind";
// rawFindByProps, not findByProps - see questDockRender.ts for why (retried lookup, and
// Revenge's own findByProps permanently caches a "not found" result and never rescans).
export function patchQuestDockBase(cleanups: (() => void)[]): boolean { export function patchQuestDockBase(cleanups: (() => void)[]): boolean {
const mod = findByProps("useIsMobileQuestDockRenderedBase"); const mod = rawFindByProps("useIsMobileQuestDockRenderedBase");
if (!mod?.useIsMobileQuestDockRenderedBase) return false; if (!mod?.useIsMobileQuestDockRenderedBase) return false;
const orig = mod.useIsMobileQuestDockRenderedBase; const orig = mod.useIsMobileQuestDockRenderedBase;

View file

@ -1,7 +1,10 @@
import { findByProps } from "@vendetta/metro"; import { rawFindByProps } from "@shared/lib/rawFind";
// rawFindByProps, not findByProps - this gets retried by index.ts's fast-retry loop, and
// Revenge's own findByProps permanently caches a "not found" result and never rescans (see
// rawFind.ts), which would make the retry pointless after the first failed attempt.
export function patchQuestDockRender(cleanups: (() => void)[]): boolean { export function patchQuestDockRender(cleanups: (() => void)[]): boolean {
const mod = findByProps("useIsMobileQuestDockRendered"); const mod = rawFindByProps("useIsMobileQuestDockRendered");
if (!mod?.useIsMobileQuestDockRendered) return false; if (!mod?.useIsMobileQuestDockRendered) return false;
const orig = mod.useIsMobileQuestDockRendered; const orig = mod.useIsMobileQuestDockRendered;

View file

@ -1,9 +1,10 @@
import { findByProps, findByTypeName } from "@vendetta/metro"; import { findByProps } from "@vendetta/metro";
import { React } from "@vendetta/metro/common"; import { React } from "@vendetta/metro/common";
import { instead } from "@vendetta/patcher"; import { instead } from "@vendetta/patcher";
import { storage } from "@vendetta/plugin"; import { storage } from "@vendetta/plugin";
import { getAssetIDByName } from "@vendetta/ui/assets"; import { getAssetIDByName } from "@vendetta/ui/assets";
import { waitFor } from "@shared/lib/waitFor"; import { waitFor } from "@shared/lib/waitFor";
import { rawFindByTypeName } from "@shared/lib/rawFind";
/** Lets the Settings screen force the patched YouBar to re-render right after a toggle changes. */ /** Lets the Settings screen force the patched YouBar to re-render right after a toggle changes. */
export let updateYouBar = () => {}; export let updateYouBar = () => {};
@ -13,15 +14,19 @@ export let updateYouBar = () => {};
* onLoad runs - on a cold app start plugins can load before every screen component has actually * onLoad runs - on a cold app start plugins can load before every screen component has actually
* been required. The original version of this plugin did a single synchronous * been required. The original version of this plugin did a single synchronous
* `findByTypeName` lookup here and gave up for the whole session if it came back empty, which is * `findByTypeName` lookup here and gave up for the whole session if it came back empty, which is
* the "sometimes the buttons just don't show up, restarting fixes it" bug this port fixes - * the "sometimes the buttons just don't show up, restarting fixes it" bug this port fixes.
* `waitFor` keeps retrying until the component shows up (or this patch gets unloaded first, *
* which cancels the pending wait). * Uses rawFindByTypeName (not @vendetta/metro's findByTypeName) specifically because this call
* gets retried by waitFor - Revenge's own findByTypeName permanently caches a "not found" result
* and never rescans, so retrying the cached version would just hit that poisoned cache every time
* instead of ever actually finding the component once it registers. Confirmed by reading Revenge's
* own metro finder source.
*/ */
export default function patchYouBarButtons(): () => void { export default function patchYouBarButtons(): () => void {
let unpatch: () => void = () => {}; let unpatch: () => void = () => {};
const handle = waitFor( const handle = waitFor(
() => findByTypeName("YouBarNotificationsButton"), () => rawFindByTypeName("YouBarNotificationsButton"),
(YouBarNotificationsButton) => { (YouBarNotificationsButton) => {
unpatch = applyButtonPatch(YouBarNotificationsButton); unpatch = applyButtonPatch(YouBarNotificationsButton);
} }

53
shared/lib/rawFind.ts Normal file
View file

@ -0,0 +1,53 @@
// Revenge's own metro finders (findByProps/findByName/findByTypeName/findByStoreName, and even
// the bare `find`) cache their result per search - including a NEGATIVE result. Once a search
// comes up empty, it's marked "not found" and every later call with the same search never
// rescans window.modules again, even after the real module registers (confirmed by reading
// Revenge's own source: metro/internals/modules.ts's getModules() returns early on a cached
// NOT_FOUND flag, before ever reaching the loop that walks currently-registered modules). That
// makes retry loops (this repo's waitFor/lazy helpers) pointless for any search whose first
// attempt happens to run before the target is registered - every retry after that first miss
// hits the same poisoned cache instead of actually looking again. This walks window.modules
// directly, bypassing that cache entirely, for use specifically inside retry loops.
declare const window: any;
export function rawFind<T = any>(predicate: (exports: any) => boolean): T | undefined {
const modules = window?.modules;
if (!modules) return undefined;
for (const id in modules) {
let exports: any;
try {
exports = window.__r(Number(id));
} catch {
continue;
}
if (!exports) continue;
try {
if (predicate(exports)) return exports;
if (exports.default != null && predicate(exports.default)) return exports.default;
} catch {
// A predicate throwing on one module's shape shouldn't stop the scan.
}
}
return undefined;
}
export function rawFindByTypeName<T = any>(name: string): T | undefined {
return rawFind<T>((m) => m?.name === name || m?.displayName === name || m?.type?.name === name || m?.type?.displayName === name);
}
export function rawFindByName<T = any>(name: string): T | undefined {
return rawFind<T>((m) => m?.name === name || m?.displayName === name);
}
export function rawFindByProps<T = any>(...props: string[]): T | undefined {
return rawFind<T>((m) => props.every((p) => m?.[p] !== undefined));
}
// Matches Revenge's own byStoreName filter (metro/filters.ts): a zero-arg getName() returning
// the store's name.
export function rawFindByStoreName<T = any>(name: string): T | undefined {
return rawFind<T>((m) => typeof m?.getName === "function" && m.getName.length === 0 && m.getName() === name);
}

View file

@ -243,7 +243,7 @@
"status": "revived", "status": "revived",
"accent": "#F97316", "accent": "#F97316",
"tagline": "An expandable server grid with DMs, smarter folders, and mark-all-read built in.", "tagline": "An expandable server grid with DMs, smarter folders, and mark-all-read built in.",
"note": "Fork of kmmiio99o's ServerDrawer. One lookup (useMobileQuestDock) was missing an optional-chaining guard - if that lookup came back empty, the whole onLoad threw and every patch after it in the sequence never got applied, not just that one surface. Fixed, plus every patch call is now wrapped so a single bad lookup only skips its own surface. Folder options (auto-collapse, hide preview icons) are adapted from fres621's BetterFolders - its icon-hiding half patched a \"GuildContainer\" component that no longer exists anywhere in current Discord builds (confirmed against decompiled source), and only applied to the native server rail anyway, which this plugin already replaces - so it's been rebuilt as a feature of the drawer's own folders instead. Two more bugs fixed after real device testing: guild icons that never loaded (GuildStore lookups weren't reactive to guild data streaming in after connect, so most servers just showed a bare unread dot with no icon) and taps in the drawer not navigating anywhere (several lookups - transitionToGuild, the create/join action, the root nav ref - were resolved once at plugin-load time, before Discord had necessarily required those modules yet, same class of bug as YouBar+'s startup race). Both fixed; DMs also moved from a leftover sidebar icon into the drawer itself, and a mark-all-servers-as-read button was added. Two more rounds after that: the Quest Dock hijack (10 separate internal lookups just to force it to render) used to be attempted once at onLoad, so if any single one of them wasn't registered in Metro yet the drawer could just silently not show up for the whole session - it now retries whatever hasn't landed on a fast 200ms interval until everything's applied. And mark-all-read was rebuilt to match Discord's own markGuildsAsRead action exactly (getSelectableChannelIds + getVocalChannelIds per guild, confirmed against decompiled source) instead of a less certain guild-enumeration method. The left server rail hiding was also fixed properly this round: the rail's root component has no discoverable name at runtime at all (confirmed against decompiled source - it's a bare default export, and unlike memo-wrapped components with an explicit displayName, a plain function's own .name doesn't survive production minification), so every name-based lookup for it was guaranteed to fail. Now intercepted by its outermost View's literal nativeID (\"guilds-bar-view\", confirmed unique in the bundle) instead. The long-press context menu on a server icon had the exact same problem (getGuildsBarGuildMenuItems is also a bare default export) - replaced with a small menu of actions built from APIs this repo has already confirmed work (mark this server as read, copy server ID) instead of a lookup that could never succeed.", "note": "Fork of kmmiio99o's ServerDrawer. One lookup (useMobileQuestDock) was missing an optional-chaining guard - if that lookup came back empty, the whole onLoad threw and every patch after it in the sequence never got applied, not just that one surface. Fixed, plus every patch call is now wrapped so a single bad lookup only skips its own surface. Folder options (auto-collapse, hide preview icons) are adapted from fres621's BetterFolders - its icon-hiding half patched a \"GuildContainer\" component that no longer exists anywhere in current Discord builds (confirmed against decompiled source), and only applied to the native server rail anyway, which this plugin already replaces - so it's been rebuilt as a feature of the drawer's own folders instead. Two more bugs fixed after real device testing: guild icons that never loaded (GuildStore lookups weren't reactive to guild data streaming in after connect, so most servers just showed a bare unread dot with no icon) and taps in the drawer not navigating anywhere (several lookups - transitionToGuild, the create/join action, the root nav ref - were resolved once at plugin-load time, before Discord had necessarily required those modules yet, same class of bug as YouBar+'s startup race). Both fixed; DMs also moved from a leftover sidebar icon into the drawer itself, and a mark-all-servers-as-read button was added. Two more rounds after that: the Quest Dock hijack (10 separate internal lookups just to force it to render) used to be attempted once at onLoad, so if any single one of them wasn't registered in Metro yet the drawer could just silently not show up for the whole session - it now retries whatever hasn't landed on a fast 200ms interval until everything's applied. And mark-all-read was rebuilt to match Discord's own markGuildsAsRead action exactly (getSelectableChannelIds + getVocalChannelIds per guild, confirmed against decompiled source) instead of a less certain guild-enumeration method. The left server rail hiding was also fixed properly this round: the rail's root component has no discoverable name at runtime at all (confirmed against decompiled source - it's a bare default export, and unlike memo-wrapped components with an explicit displayName, a plain function's own .name doesn't survive production minification), so every name-based lookup for it was guaranteed to fail. Now intercepted by its outermost View's literal nativeID (\"guilds-bar-view\", confirmed unique in the bundle) instead. The long-press context menu on a server icon had the exact same problem (getGuildsBarGuildMenuItems is also a bare default export) - replaced with a small menu of actions built from APIs this repo has already confirmed work (mark this server as read, copy server ID) instead of a lookup that could never succeed. The same caching bug affected several of ServerDrawer's own retry-based lookups (the Quest Dock hijack's fast-retry loop, and every findByProps call wrapped in the lazy() helper for navigation/action lookups) - all switched to a raw, uncached module scan for the specific calls that get retried, since a retried cached lookup was silently a no-op after its first failure.",
"howItWorks": "Hijacks the Quest Dock - the strip Discord normally uses for daily quests, right above the tab bar - and puts a grid of your servers there instead. Tap it to expand, tap a server to jump to it, long-press for the usual context menu (mark as read, notification settings, leave, etc). A + button opens Discord's own create/join-server modal. Optionally hides the left server rail down to a single DMs icon, since your servers now live in the drawer instead.", "howItWorks": "Hijacks the Quest Dock - the strip Discord normally uses for daily quests, right above the tab bar - and puts a grid of your servers there instead. Tap it to expand, tap a server to jump to it, long-press for the usual context menu (mark as read, notification settings, leave, etc). A + button opens Discord's own create/join-server modal. Optionally hides the left server rail down to a single DMs icon, since your servers now live in the drawer instead.",
"features": [ "features": [
"Tap-to-expand server grid with folders, unread badges, and mention counts", "Tap-to-expand server grid with folders, unread badges, and mention counts",
@ -264,7 +264,7 @@
"status": "revived", "status": "revived",
"accent": "#5865F2", "accent": "#5865F2",
"tagline": "Adds DM and/or Settings buttons to the YouBar.", "tagline": "Adds DM and/or Settings buttons to the YouBar.",
"note": "Fork of Purple-EyeZ's YouBar+. Fixed a startup race condition where the button patch could silently never apply for an entire session - the original did a single one-shot lookup for YouBarNotificationsButton at onLoad and gave up permanently if it wasn't registered in Metro yet, which restarting Discord only fixed by luck. Now retries until the component shows up.", "note": "Fork of Purple-EyeZ's YouBar+. Fixed a startup race condition where the button patch could silently never apply for an entire session - the original did a single one-shot lookup for YouBarNotificationsButton at onLoad and gave up permanently if it wasn't registered in Metro yet, which restarting Discord only fixed by luck. Now retries until the component shows up. A second, deeper bug surfaced after that fix shipped: Revenge's own module finder permanently caches a \"not found\" result per search and never rescans, even after the target registers later - confirmed by reading Revenge's own source. That made the retry loop a no-op whenever the very first attempt happened too early, which is exactly the scenario it existed to handle. Now uses a raw, uncached module scan specifically for the retried lookup.",
"howItWorks": "The YouBar is the row near your avatar/status at the top of the app. This plugin patches its notification button area to also render a DM button and/or a Settings button, each independently toggleable. DM jumps straight to your DMs; Settings opens user settings.", "howItWorks": "The YouBar is the row near your avatar/status at the top of the app. This plugin patches its notification button area to also render a DM button and/or a Settings button, each independently toggleable. DM jumps straight to your DMs; Settings opens user settings.",
"features": [ "features": [
"Independent toggles for the DM and Settings buttons", "Independent toggles for the DM and Settings buttons",