ServerDrawer: retry the Quest Dock hijack fast instead of once, fix mark-all-read

This commit is contained in:
everestmcarthur 2026-08-01 15:17:34 -04:00
parent 137ccb7e43
commit 543739cc4f
3 changed files with 104 additions and 37 deletions

View file

@ -8,8 +8,9 @@ import { lazy } from "@shared/lib/lazy";
const ICON = 48;
const CheckIcon = getAssetIDByName("CheckmarkLargeIcon");
const GuildStore = findByStoreName("GuildStore");
const SortedGuildStore = findByStoreName("SortedGuildStore");
const GuildChannelStore = findByStoreName("GuildChannelStore");
const ChannelStore = findByStoreName("ChannelStore");
// Lazy because bulkAck lives on an action-creator module that, like routing, may not be required
// by Discord's own code yet at the moment this plugin's bundle loads.
@ -17,34 +18,59 @@ const getHaptic = lazy(() => findByProps("triggerHapticFeedback", "HapticFeedbac
const getBulkAck = lazy(() => findByProps("bulkAck", "ackChannel"));
const getReadStateTypes = lazy(() => findByProps("ReadStateTypes", "UnreadSetting")?.ReadStateTypes);
// Mirrors the payload shape Discord's own bulk-ack call sites use (confirmed against decompiled
// current-build source, e.g. modules/guild/markGuildsAsRead.tsx): an array of
// { channelId, readStateType, messageId } acked in one dispatch. Built here from GuildStore +
// GuildChannelStore directly rather than calling markGuildsAsRead itself, since that module has no
// named export to reliably target with findByProps (it's a bare default export).
function collectGuildIds(): string[] {
// Same tree ServerDrawerSheet already renders from (SortedGuildStore.getGuildsTree()), so this
// reaches every guild the drawer shows, folders included, using data already proven to resolve
// correctly rather than a separately-shaped GuildStore method.
const tree = SortedGuildStore?.getGuildsTree?.();
const roots = (tree?.root?.children ?? []).filter((n: any) => n?.type !== "root");
const ids: string[] = [];
const visit = (nodes: any[]) => {
for (const node of nodes) {
if (node.type === "folder") visit(node.children ?? []);
else if (node.id != null) ids.push(String(node.id));
}
};
visit(roots);
return ids;
}
// Mirrors exactly what Discord's own modules/guild/markGuildsAsRead.tsx does (confirmed against
// decompiled current-build source): flatMap getSelectableChannelIds(guild) + getVocalChannelIds
// (guild), then bulkAck an array of { channelId, readStateType, messageId }. Built here directly
// rather than calling markGuildsAsRead itself, since that module has no named export to reliably
// target with findByProps (it's a bare default export with nothing else on the module to key off).
function markAllServersRead() {
const bulkAck = getBulkAck();
const ReadStateTypes = getReadStateTypes();
if (!bulkAck?.bulkAck || ReadStateTypes?.CHANNEL == null || !GuildStore?.getGuilds || !GuildChannelStore?.getChannels) {
if (
!bulkAck?.bulkAck || ReadStateTypes?.CHANNEL == null ||
!GuildChannelStore?.getSelectableChannelIds || !GuildChannelStore?.getVocalChannelIds
) {
showToast("Couldn't find Discord's read-state action - this may be unavailable on your version.", undefined);
return;
}
const guildIds = collectGuildIds();
if (guildIds.length === 0) {
showToast("Nothing to mark as read.", undefined);
return;
}
const channels: { channelId: string; readStateType: number; messageId: string | null }[] = [];
for (const guildId of Object.keys(GuildStore.getGuilds())) {
const byType = GuildChannelStore.getChannels(guildId) ?? {};
for (const entries of Object.values(byType)) {
if (!Array.isArray(entries)) continue;
for (const entry of entries as any[]) {
const channel = entry?.channel;
if (!channel?.id) continue;
channels.push({
channelId: channel.id,
readStateType: ReadStateTypes.CHANNEL,
messageId: channel.lastMessageId ?? null,
});
}
for (const guildId of guildIds) {
const channelIds = [
...(GuildChannelStore.getSelectableChannelIds(guildId) ?? []),
...(GuildChannelStore.getVocalChannelIds(guildId) ?? []),
];
for (const channelId of channelIds) {
channels.push({
channelId,
readStateType: ReadStateTypes.CHANNEL,
messageId: ChannelStore?.getChannel?.(channelId)?.lastMessageId ?? null,
});
}
}

View file

@ -11,6 +11,7 @@ import { patchAutoCollapseFolders } from "./patches/autoCollapseFolders";
import Settings from "./ui/Settings";
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
@ -25,32 +26,72 @@ function tryPatch(name: string, fn: () => boolean): boolean {
}
}
// The Quest Dock (and the internals it depends on) isn't guaranteed to be registered in Metro yet
// at the exact moment onLoad runs - same class of startup race fixed elsewhere in this repo
// (YouBar+'s button patch, ServerDrawer's own navigation lookups). A single onLoad attempt could
// permanently miss one or more of these, which is what "the drawer sometimes just doesn't show up"
// looked like. Instead of one shot, this retries whatever hasn't landed yet on a fast interval
// until everything's applied (or it gives up after ~10s, which would mean something's genuinely
// missing rather than just not loaded yet).
function applyAll() {
const applied: string[] = [];
const patchers: Record<string, () => boolean> = {
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 ? { hideGuildsBar: () => patchHideGuildsBar(cleanups) } : {}),
};
patchCreateElement(cleanups);
if (tryPatch("questDockRender", () => patchQuestDockRender(cleanups))) applied.push("questDockRender");
if (tryPatch("questDockBase", () => patchQuestDockBase(cleanups))) applied.push("questDockBase");
if (tryPatch("mobileQuestDock", () => patchMobileQuestDock(cleanups))) applied.push("mobileQuestDock");
if (tryPatch("getQuestAsset", () => patchGetQuestAsset(cleanups))) applied.push("getQuestAsset");
if (tryPatch("expanded", () => patchExpanded(cleanups))) applied.push("expanded");
if (tryPatch("collapsed", () => patchEmpty("QuestDockContentCollapsed", cleanups))) applied.push("collapsed");
if (tryPatch("enrolledHeader", () => patchEmpty("QuestDockEnrolledHeader", cleanups))) applied.push("enrolledHeader");
if (tryPatch("unenrolledHeader", () => patchEmpty("QuestDockUnenrolledHeader", cleanups))) applied.push("unenrolledHeader");
if (tryPatch("enrolledBody", () => patchEmpty("QuestDockEnrolledBody", cleanups))) applied.push("enrolledBody");
if (tryPatch("unenrolledBody", () => patchEmpty("QuestDockUnenrolledBody", cleanups))) applied.push("unenrolledBody");
const pending = new Set(Object.keys(patchers));
const applied = new Set<string>();
if (storage.hideGuildsBar) {
if (tryPatch("hideGuildsBar", () => patchHideGuildsBar(cleanups))) applied.push("hideGuildsBar");
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 (tryPatch("autoCollapseFolders", () => patchAutoCollapseFolders(cleanups))) applied.push("autoCollapseFolders");
logger.log(`[ServerDrawer] onLoad done - ${applied.length}/12 patches applied (${applied.join(", ") || "none"})`);
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

@ -243,7 +243,7 @@
"status": "revived",
"accent": "#F97316",
"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.",
"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.",
"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": [
"Tap-to-expand server grid with folders, unread badges, and mention counts",