diff --git a/plugins/server-drawer/src/components/GuildItem.tsx b/plugins/server-drawer/src/components/GuildItem.tsx index c2820ee..9451d6f 100644 --- a/plugins/server-drawer/src/components/GuildItem.tsx +++ b/plugins/server-drawer/src/components/GuildItem.tsx @@ -1,71 +1,32 @@ import React from "react"; import { View, Text, Pressable, Animated, StyleSheet } from "react-native"; -import { clipboard } from "@vendetta/metro/common"; import { storage } from "@vendetta/plugin"; import { useProxy } from "@vendetta/storage"; -import { showToast } from "@vendetta/ui/toasts"; import { lazy } from "../lib/lazy"; -import { rawFindByStoreName } from "../lib/rawFind"; -import { getFlux, getHaptic, getBulkAckMod, getReadStateTypesMod } from "../lib/commonModules"; +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; -// getFlux/getHaptic/getBulkAckMod/getReadStateTypesMod come from lib/commonModules.ts - see that -// file for the decoy-module writeup. -const getBulkAck = getBulkAckMod; -const getReadStateTypes = lazy(() => getReadStateTypesMod()?.ReadStateTypes); - -// These four switch 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 -// reason established earlier this session, not the decoy-module issue above - unverified whether the -// decoy fakes a getName() zero-arg store-name match too, so not claiming that's the cause here. const getGuildReadStateStore = lazy(() => rawFindByStoreName("GuildReadStateStore")); const getGuildStore = lazy(() => rawFindByStoreName("GuildStore")); -const getGuildChannelStore = lazy(() => rawFindByStoreName("GuildChannelStore")); -const getChannelStore = lazy(() => rawFindByStoreName("ChannelStore")); +const getSortedGuildStore = lazy(() => rawFindByStoreName("SortedGuildStore")); // 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 -// function's own .name doesn't survive production minification the way an explicit .displayName -// assignment does (confirmed by cross-checking: many memo-wrapped components' .displayName DID -// show up as literal strings in decompiled source, this function's .name never does). Rather than -// guess at a fragile lookup, this builds a small menu from actions this repo has already confirmed -// work elsewhere (the same bulkAck path MarkAllReadButton uses, scoped to one guild). -function markGuildRead(guildId: string) { - const bulkAck = getBulkAck(); - const ReadStateTypes = getReadStateTypes(); - const GuildChannelStore = getGuildChannelStore(); - const ChannelStore = getChannelStore(); - if (!bulkAck?.bulkAck || ReadStateTypes?.CHANNEL == null || !GuildChannelStore?.getSelectableChannelIds) { - showToast("Couldn't find Discord's read-state action - this may be unavailable on your version.", undefined); - return; - } - - const channelIds = [ - ...(GuildChannelStore.getSelectableChannelIds(guildId) ?? []), - ...(GuildChannelStore.getVocalChannelIds?.(guildId) ?? []), - ]; - const channels = channelIds.map((channelId: string) => ({ - channelId, - readStateType: ReadStateTypes.CHANNEL, - messageId: ChannelStore?.getChannel?.(channelId)?.lastMessageId ?? null, - })); - - if (channels.length === 0) { - showToast("Nothing to mark as read.", undefined); - return; - } - - bulkAck.bulkAck(channels); - showToast("Marked as read", undefined); -} +// 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 +); function Badge({ guildId }: { guildId: string }) { - useProxy(storage); - const Flux = getFlux(); const GuildReadStateStore = getGuildReadStateStore(); @@ -81,8 +42,6 @@ function Badge({ guildId }: { guildId: string }) { [guildId], ) ?? false; - if (!storage.showUnreadBadges) return null; - if (mentionCount > 0) { return ( @@ -111,8 +70,6 @@ function GuildLabel({ guildId }: { guildId: string }) { const Flux = getFlux(); const GuildStore = getGuildStore(); - // Same reasoning as GuildIcon: guild data streams in progressively after connect, so this - // subscribes rather than reading the name once at mount. const name = Flux?.useStateFromStores?.( [GuildStore], () => GuildStore?.getGuild?.(guildId)?.name ?? "", @@ -145,26 +102,26 @@ export default function GuildItem({ node, onPick }: { node: GuildNode; onPick: ( const showMenu = React.useCallback(() => { const guildId = node.id as string; const guild = getGuildStore()?.getGuild?.(guildId); - if (!guild) return; + const menuItemsFn = getMenuItemsFn(); + if (!guild || !menuItemsFn) return; + + const treeVersion = getSortedGuildStore()?.getGuildsTree?.()?.version; + const rawItems = menuItemsFn(guildId, treeVersion); + if (!rawItems?.length) return; const ref = viewRef.current as any; if (!ref?.measure) return; - const items: ContextMenuItem[] = [ - { label: "Mark as Read", action: () => markGuildRead(guildId) }, - { - label: "Copy Server ID", - action: () => { - clipboard.setString(guildId); - showToast("Copied server ID", undefined); - }, - }, - ]; - ref.measure((_fx: number, _fy: number, _w: number, _h: number, pageX: number, pageY: number) => { setMenuState({ visible: true, - items, + items: rawItems.map((item: any) => ({ + label: item.label ?? item.title ?? "Unknown", + action: item.action, + danger: item.variant === "destructive", + iconSource: item.iconSource, + IconComponent: item.IconComponent, + })), title: guild.name, x: pageX, y: pageY, diff --git a/plugins/server-drawer/src/components/MarkAllReadButton.tsx b/plugins/server-drawer/src/components/MarkAllReadButton.tsx deleted file mode 100644 index 44df37a..0000000 --- a/plugins/server-drawer/src/components/MarkAllReadButton.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import React from "react"; -import { Pressable, Image, StyleSheet } from "react-native"; -import { getAssetIDByName } from "@vendetta/ui/assets"; -import { showToast } from "@vendetta/ui/toasts"; -import { lazy } from "../lib/lazy"; -import { rawFindByStoreName } from "../lib/rawFind"; -import { getHaptic, getBulkAckMod, getReadStateTypesMod } from "../lib/commonModules"; - -const ICON = 48; - -const CheckIcon = getAssetIDByName("CheckmarkLargeIcon"); - -// getHaptic/getBulkAckMod/getReadStateTypesMod come from lib/commonModules.ts - see that file for -// the decoy-module writeup. -const getBulkAck = getBulkAckMod; -const getReadStateTypes = lazy(() => getReadStateTypesMod()?.ReadStateTypes); - -// These three switch 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 reason established earlier this session, not the decoy-module issue above - -// unverified whether the decoy fakes a getName() zero-arg store-name match too. -const getSortedGuildStore = lazy(() => rawFindByStoreName("SortedGuildStore")); -const getGuildChannelStore = lazy(() => rawFindByStoreName("GuildChannelStore")); -const getChannelStore = lazy(() => rawFindByStoreName("ChannelStore")); - -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 = getSortedGuildStore()?.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(); - const GuildChannelStore = getGuildChannelStore(); - const ChannelStore = getChannelStore(); - 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 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, - }); - } - } - - if (channels.length === 0) { - showToast("Nothing to mark as read.", undefined); - return; - } - - bulkAck.bulkAck(channels); - showToast(`Marked ${channels.length} channel${channels.length === 1 ? "" : "s"} as read`, undefined); -} - -export default function MarkAllReadButton() { - const onPress = React.useCallback(() => { - const haptic = getHaptic(); - haptic?.triggerHapticFeedback?.(haptic.HapticFeedbackTypes.SOFT); - markAllServersRead(); - }, []); - - return ( - - - - ); -} - -const st = StyleSheet.create({ - outer: { width: ICON, height: ICON, alignItems: "center", justifyContent: "center" }, -}); diff --git a/plugins/server-drawer/src/components/ServerDrawerSheet.tsx b/plugins/server-drawer/src/components/ServerDrawerSheet.tsx index 01a879f..c6c2ac7 100644 --- a/plugins/server-drawer/src/components/ServerDrawerSheet.tsx +++ b/plugins/server-drawer/src/components/ServerDrawerSheet.tsx @@ -5,12 +5,10 @@ import { useProxy } from "@vendetta/storage"; import { lazy } from "../lib/lazy"; import { rawFind, rawFindByFunctionProps } from "../lib/rawFind"; import { getFlux, getHaptic, getColorModule } from "../lib/commonModules"; -import { captureFiberRef } from "../lib/fiberCapture"; import { GuildNode } from "../utils/theme"; import GuildItem from "./GuildItem"; import FolderItem from "./FolderItem"; import DmTile from "./DmTile"; -import MarkAllReadButton from "./MarkAllReadButton"; // 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. @@ -67,12 +65,6 @@ export default function ServerDrawerSheet({ gestureContext }: { gestureContext: const routing = getRouting(); if (routing?.transitionToGuild) { - // Confirmed against decompiled current-build Discord source - // (modules/routing/transitionToGuild.native.tsx): the mobile transitionToGuild takes - // (guildId, options?) and resolves the channel itself - it does NOT take a channelId - // as a second positional argument. Passing one (as this used to) risked being silently - // wrong depending on which of the two same-named "transitionToGuild" modules metro - // happened to resolve. routing.transitionToGuild(id); } else { getRootNav()?.getRootNavigationRef?.()?.navigate("guilds", { guildId: id }); @@ -127,11 +119,6 @@ export default function ServerDrawerSheet({ gestureContext }: { gestureContext: return ( - {/* Invisible - see lib/fiberCapture.ts. This is the reliable capture point: it's - guaranteed to mount whenever the drawer itself is visibly rendering, independent of - whether hideGuildsBar.tsx's own capture point (which depends on that separate - intercept actually firing) works or not. */} - )} - ); diff --git a/plugins/server-drawer/src/index.ts b/plugins/server-drawer/src/index.ts index 7605419..f1014c6 100644 --- a/plugins/server-drawer/src/index.ts +++ b/plugins/server-drawer/src/index.ts @@ -1,7 +1,7 @@ import { logger } from "@vendetta"; import { storage } from "@vendetta/plugin"; import { patchFakeQuestDock } from "./patches/fakeQuestDock"; -import { patchExpanded, patchEmpty } from "./patches/contentPatch"; +import { patchQuestDockSlot, patchEmpty } from "./patches/contentPatch"; import { patchHideGuildsBar } from "./patches/hideGuildsBar"; import { patchCreateElement } from "./lib/createElementIntercept"; import { patchAutoCollapseFolders } from "./patches/autoCollapseFolders"; @@ -24,17 +24,17 @@ 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). +// 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 boolean> = { ...(storage.fakeQuestDock ? { fakeQuestDock: () => patchFakeQuestDock(cleanups) } : {}), - expanded: () => patchExpanded(cleanups), - collapsed: () => patchEmpty("QuestDockContentCollapsed", cleanups), + expanded: () => patchQuestDockSlot("QuestDockContentExpanded", cleanups), + collapsed: () => patchQuestDockSlot("QuestDockContentCollapsed", cleanups), enrolledHeader: () => patchEmpty("QuestDockEnrolledHeader", cleanups), unenrolledHeader: () => patchEmpty("QuestDockUnenrolledHeader", cleanups), enrolledBody: () => patchEmpty("QuestDockEnrolledBody", cleanups), @@ -104,7 +104,7 @@ export function restart() { export default { onLoad: () => { storage.hideGuildsBar ??= true; - storage.showUnreadBadges ??= true; + storage.showGuildNames ??= true; storage.autoCollapseFolders ??= false; storage.hideFolderIcons ??= false; storage.fakeQuestDock ??= true; diff --git a/plugins/server-drawer/src/patches/contentPatch.tsx b/plugins/server-drawer/src/patches/contentPatch.tsx index 8a12a16..1b32bfb 100644 --- a/plugins/server-drawer/src/patches/contentPatch.tsx +++ b/plugins/server-drawer/src/patches/contentPatch.tsx @@ -1,14 +1,15 @@ import { React } from "@vendetta/metro/common"; import { rawFind } from "../lib/rawFind"; import ServerDrawerSheet from "../components/ServerDrawerSheet"; -import { registerIntercept, registerPropsTransform, registerTypeDetector } from "../lib/createElementIntercept"; +import { registerIntercept, registerTypeDetector } from "../lib/createElementIntercept"; 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. +// 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) { @@ -17,103 +18,53 @@ function getGestureContext(): any { return cachedGestureContext; } -// Memo-wrapped components have no own .name/.displayName - only their nested .type does (confirmed -// live for GuildsBar). Checked in addition to the direct check, not instead of it. function isNamed(name: string) { return (type: any) => type?.name === name || type?.displayName === name || type?.type?.name === name || type?.type?.displayName === name; } -const QUEST_DOCK_NAMES = [ - "QuestDock", - "QuestDockBase", - "QuestDockWithQuestContext", - "QuestDockMain", - "QuestDockContent", - "QuestDockExpanded", -]; - -function isQuestDock(type: any): boolean { - return QUEST_DOCK_NAMES.some((name) => isNamed(name)(type)); -} - -const SD_DRAWER_TEST_ID = "ServerDrawer"; - -function hasChildWithTestID(children: any, rest: any[], testID: string): boolean { - const inspect = (child: any) => child != null && typeof child === "object" && child.props?.testID === testID; - if (children != null) { - if (Array.isArray(children)) { if (children.some(inspect)) return true; } - else if (inspect(children)) return true; - } - for (const child of rest) if (inspect(child)) return true; - return false; -} - -function ServerDrawerSheetWrapper() { - return ; -} - -/** - * Two independent mechanisms, both active. Mutating the found module's own `.type` property - * (matched here by isNamed, same nested-memo-aware check GuildsBar's patch uses) makes the drawer - * render immediately if the target module happens to already be registered in Metro by the time - * this runs. But that registration only happens once Discord's own code actually renders Quest - * Dock's expanded content at least once - which itself depends on questDockContext.ts's Context - * value force having already taken effect, and on Discord's own render pass actually happening - * within this plugin's ~10s retry window. That's a real boot-time race, confirmed live: identical - * code gave different results (drawer showing vs not) across genuinely clean reinstalls, pointing - * at timing rather than a deterministic bug. - * - * 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. - */ -export function patchExpanded(cleanups: (() => void)[]): boolean { - const questDockTypes = new Set(); - - // Make the quest dock's wrapper(s) transparent - the ServerDrawerSheet content still renders on - // top, but the panel/drawer background that used to be behind the quest menu is removed. - registerPropsTransform( - (props: any, type: any, rest: any[]) => - questDockTypes.has(type) || - hasChildWithTestID(props?.children, rest, SD_DRAWER_TEST_ID), - (props: any) => ({ ...props, style: [props?.style, { backgroundColor: "transparent" }] }), - ); - - registerTypeDetector("ServerDrawer.QuestDockBg", isQuestDock, (real) => { - questDockTypes.add(real); - console.log(TAG, "PATCH: QuestDock wrapper set transparent", real?.name ?? real?.displayName); +// 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 }); - 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)); + const mod = rawFind((m) => isNamed(name)(m?.type)); if (!mod?.type) { - console.log(TAG, "WARN: QuestDockContentExpanded module not found yet (will retry)"); + console.log(TAG, `WARN: ${name} not found yet (will retry)`); return false; } const orig = mod.type; - registerIntercept(orig, ServerDrawerSheetWrapper, { testID: SD_DRAWER_TEST_ID }); - mod.type = ServerDrawerSheetWrapper; + registerIntercept(orig, ServerDrawerSheet, { gestureContext: getGestureContext() }); + mod.type = function ServerDrawerPatch() { + return ; + }; cleanups.push(() => { mod.type = orig; }); - console.log(TAG, "PATCH: QuestDockContentExpanded replaced (module mutation)"); + console.log(TAG, `PATCH: ${name} replaced (module mutation)`); 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} module not found yet (will retry)`); + console.log(TAG, `WARN: ${name} not found yet (will retry)`); return false; } @@ -126,9 +77,8 @@ export function patchEmpty(name: string, cleanups: (() => void)[]): boolean { try { Object.defineProperties(mod.type, Object.getOwnPropertyDescriptors(orig)); } catch { - // Some property descriptors can't be copied onto another function (e.g. non-configurable - // ones on certain memo/forwardRef wrappers) - fine, this is a best-effort compatibility - // shim, not something correctness depends on. + // 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.displayName = name; diff --git a/plugins/server-drawer/src/ui/Settings.tsx b/plugins/server-drawer/src/ui/Settings.tsx index 2d725bd..84af8a9 100644 --- a/plugins/server-drawer/src/ui/Settings.tsx +++ b/plugins/server-drawer/src/ui/Settings.tsx @@ -15,8 +15,8 @@ export default function Settings() { Server Drawer takes over the Quest Dock (the strip above the tab bar) to show your servers as a grid instead - tap to expand, long-press a server for its usual context - menu, the chat bubble jumps to DMs, the + button creates or joins a server through - Discord's own modal, and the check button marks every channel in every server as read. + menu, the chat bubble jumps to DMs, and the + button creates or joins a server + through Discord's own modal. - { storage.showUnreadBadges = v; }} - />