diff --git a/plugins/server-drawer/manifest.json b/plugins/server-drawer/manifest.json index c566ee4..340d0bd 100644 --- a/plugins/server-drawer/manifest.json +++ b/plugins/server-drawer/manifest.json @@ -6,7 +6,7 @@ { "name": "kmmiio99o" }, { "name": "fres" } ], - "main": "src/index.ts", + "main": "src/index.tsx", "vendetta": { "icon": "ServerIcon" } diff --git a/plugins/server-drawer/src/components/ContextMenuModal.tsx b/plugins/server-drawer/src/components/ContextMenuModal.tsx index a3153e7..2d19795 100644 --- a/plugins/server-drawer/src/components/ContextMenuModal.tsx +++ b/plugins/server-drawer/src/components/ContextMenuModal.tsx @@ -1,17 +1,17 @@ import React from "react"; import { View, Text, Pressable, Image, Modal, StyleSheet, Dimensions, Animated } from "react-native"; import { semanticColors } from "@vendetta/ui"; -import { 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"; -const getTextStyleSheet = lazy(() => rawFindByValidProps({ - 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; @@ -47,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; @@ -62,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; diff --git a/plugins/server-drawer/src/components/DmTile.tsx b/plugins/server-drawer/src/components/DmTile.tsx deleted file mode 100644 index 8d5f459..0000000 --- a/plugins/server-drawer/src/components/DmTile.tsx +++ /dev/null @@ -1,48 +0,0 @@ -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"; - -const ICON = 48; - -const ChatIcon = getAssetIDByName("ChatIcon"); - -const getChannelActions = lazy(() => rawFindByFunctionProps("selectPrivateChannel")); -const getSelectedChannelStore = lazy(() => rawFindByName("SelectedChannelStore")); -const getNavContext = lazy(() => rawFindByFunctionProps("getGuildId")); - -function openDms() { - const Haptic = getHaptic(); - Haptic?.triggerHapticFeedback?.(Haptic.HapticFeedbackTypes.SOFT); - const ChannelActions = getChannelActions(); - if (ChannelActions?.selectPrivateChannel) { - const lastChannelId = getSelectedChannelStore()?.getLastSelectedChannelId?.(); - ChannelActions.selectPrivateChannel(lastChannelId); - } -} - -export default function DmTile() { - const Flux = getFlux(); - const NavContext = getNavContext(); - const colors = getColorModule()?.colors; - - const selected = Flux?.useStateFromStores?.( - [NavContext], - () => NavContext?.getGuildId?.() == null, - ) ?? false; - - return ( - - - - - - ); -} - -const st = StyleSheet.create({ - outer: { width: ICON, height: ICON }, - icon: { width: ICON, height: ICON, borderRadius: 16, alignItems: "center", justifyContent: "center" }, -}); diff --git a/plugins/server-drawer/src/components/FolderItem.tsx b/plugins/server-drawer/src/components/FolderItem.tsx index 8cbdf08..0076189 100644 --- a/plugins/server-drawer/src/components/FolderItem.tsx +++ b/plugins/server-drawer/src/components/FolderItem.tsx @@ -1,17 +1,14 @@ import React from "react"; import { View, Text, Animated, Pressable, Image, ViewStyle, StyleSheet } from "react-native"; +import { findByProps, findByStoreName } from "@vendetta/metro"; import { getAssetIDByName } from "@vendetta/ui/assets"; -import { storage } from "@vendetta/plugin"; -import { useProxy } from "@vendetta/storage"; import { useFolderExpanded, GuildNode } from "../utils/theme"; -import { lazy } from "../lib/lazy"; -import { rawFindByFunctionProps, rawFindByStoreName } from "../lib/rawFind"; -import { getFlux } from "../lib/commonModules"; import GuildIcon from "./GuildIcon"; import GuildItem from "./GuildItem"; -const getGuildActions = lazy(() => rawFindByFunctionProps("toggleGuildFolderExpand")); -const getGuildReadStateStore = lazy(() => rawFindByStoreName("GuildReadStateStore")); +const GuildActions = findByProps("toggleGuildFolderExpand"); +const Flux = findByProps("useStateFromStores"); +const GuildReadStateStore = findByStoreName("GuildReadStateStore"); const ICON = 48; const MINI = 16; @@ -31,14 +28,7 @@ function folderColor(color?: number | null): string { } function FolderBadge({ node }: { node: GuildNode }) { - const Flux = getFlux(); - const GuildReadStateStore = getGuildReadStateStore(); - - // Guards against an undefined store, not just a falsy return value - confirmed live that - // passing [undefined] into useStateFromStores throws rather than failing safely, which used to - // take the whole drawer down (this only ever runs when there's an actual folder to render, so - // an account with folders showed nothing at all while one without rendered fine). - const total = (Flux?.useStateFromStores && GuildReadStateStore) ? Flux.useStateFromStores( + const total = Flux?.useStateFromStores?.( [GuildReadStateStore], () => { let sum = 0; @@ -48,7 +38,7 @@ function FolderBadge({ node }: { node: GuildNode }) { return sum; }, [node.children], - ) : 0; + ) ?? 0; if (total > 0) { return ( @@ -63,23 +53,15 @@ function FolderBadge({ node }: { node: GuildNode }) { } function FolderCover({ node }: { node: GuildNode }) { - useProxy(storage); const col = folderColor(node.color); - return ( - {storage.hideFolderIcons ? ( - - + {node.children.slice(0, 4).map((ch, i) => ( + + - ) : ( - node.children.slice(0, 4).map((ch, i) => ( - - - - )) - )} + ))} @@ -90,7 +72,6 @@ const fc = StyleSheet.create({ outer: { width: ICON, height: ICON }, icon: { width: ICON, height: ICON, borderRadius: 16, overflow: "hidden" }, cell: { position: "absolute", width: MINI, height: MINI, borderRadius: 8, overflow: "hidden" }, - plainIcon: { width: ICON, height: ICON, alignItems: "center", justifyContent: "center" }, }); function FadeIn({ children }: { children: React.ReactNode }) { @@ -105,7 +86,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) { diff --git a/plugins/server-drawer/src/components/GuildIcon.tsx b/plugins/server-drawer/src/components/GuildIcon.tsx index c203d1e..9aaf6af 100644 --- a/plugins/server-drawer/src/components/GuildIcon.tsx +++ b/plugins/server-drawer/src/components/GuildIcon.tsx @@ -1,19 +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"; -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) { diff --git a/plugins/server-drawer/src/components/GuildItem.tsx b/plugins/server-drawer/src/components/GuildItem.tsx index 38f504a..a00ffb8 100644 --- a/plugins/server-drawer/src/components/GuildItem.tsx +++ b/plugins/server-drawer/src/components/GuildItem.tsx @@ -1,34 +1,19 @@ import React from "react"; import { View, Text, Pressable, Animated, StyleSheet } from "react-native"; -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 { find, findByProps, findByStoreName } from "@vendetta/metro"; +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"). Rendered through ContextMenuModal since there's no accessible way to mount -// Discord's own native menu component standalone outside its usual host, but every item/action in -// it is Discord's real menu, not a custom-built one. -const getMenuItemsFn = lazy(() => - rawFind((m) => m?.default?.name === "getGuildsBarGuildMenuItems")?.default - ?? rawFind((m) => typeof m?.default === "function" && m.default.length <= 2 && /guild/i.test(m.default.name ?? ""))?.default -); +const Flux = findByProps("useStateFromStores"); +const GuildReadStateStore = findByStoreName("GuildReadStateStore"); +const GuildStore = findByStoreName("GuildStore"); +const SortedGuildStore = findByStoreName("SortedGuildStore"); +const Haptic = findByProps("triggerHapticFeedback", "HapticFeedbackTypes"); function Badge({ guildId }: { guildId: string }) { - const Flux = getFlux(); - const GuildReadStateStore = getGuildReadStateStore(); - const mentionCount = Flux?.useStateFromStores?.( [GuildReadStateStore], () => GuildReadStateStore?.getMentionCount?.(guildId) ?? 0, @@ -62,28 +47,6 @@ function Badge({ guildId }: { guildId: string }) { return null; } -function GuildLabel({ guildId }: { guildId: string }) { - useProxy(storage); - const theme = useTheme(); - - const Flux = getFlux(); - const GuildStore = getGuildStore(); - - const name = Flux?.useStateFromStores?.( - [GuildStore], - () => GuildStore?.getGuild?.(guildId)?.name ?? "", - [guildId], - ) ?? ""; - - if (storage.showGuildNames === false || !name) return null; - - return ( - - {name} - - ); -} - export default function GuildItem({ node, onPick }: { node: GuildNode; onPick: (id: string) => void }) { const viewRef = React.useRef(null); const scale = React.useRef(new Animated.Value(1)).current; @@ -100,11 +63,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; @@ -129,7 +96,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]); @@ -145,14 +111,11 @@ export default function GuildItem({ node, onPick }: { node: GuildNode; onPick: ( onLongPress={handleLongPress} delayLongPress={500} > - - - - - - - - + + + + + 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; const PAD = 12; -// Confirmed live: an account with folders showed no drawer at all - not even the DM tile, other -// servers, or the create/join button, which have nothing to do with folders. That points at an -// uncaught throw somewhere in FolderItem's render taking the whole grid down with it, not just the -// one folder. A try/catch around JSX can't catch this - the exception happens during React's own -// render pass, not synchronously in this function's body - only a class component's -// getDerivedStateFromError can. Wrapping each folder individually means one broken folder -// disappears instead of every other server in the drawer. -class FolderErrorBoundary extends React.Component<{ children: React.ReactNode }, { errored: boolean }> { - state = { errored: false }; - static getDerivedStateFromError() { - return { errored: true }; - } - render() { - if (this.state.errored) return null; - return this.props.children; - } -} - function CreateJoinButton() { const scale = React.useRef(new Animated.Value(1)).current; const scaleDown = React.useCallback(() => { @@ -51,21 +36,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 ( - + {CirclePlusIcon ? ( - + ) : ( - {"+"} + {"+"} )} @@ -73,23 +54,15 @@ function CreateJoinButton() { } export default function ServerDrawerSheet({ gestureContext }: { gestureContext: any }) { - 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], () => { @@ -107,14 +80,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; @@ -139,14 +110,9 @@ export default function ServerDrawerSheet({ gestureContext }: { gestureContext: style={[st.grid, { paddingHorizontal: padX, gap: GAP }]} onLayout={onLayout} > - {!storage.hideDmTile && } {nodes.map((node) => node.type === "folder" - ? ( - - - - ) + ? : )} @@ -171,10 +137,12 @@ const st = StyleSheet.create({ width: ICON, height: ICON, borderRadius: 16, + backgroundColor: createJoinBg, alignItems: "center", justifyContent: "center", }, createJoinFallback: { + color: createJoinIconColor, fontSize: 28, fontWeight: "700", lineHeight: 30, diff --git a/plugins/server-drawer/src/index.ts b/plugins/server-drawer/src/index.ts deleted file mode 100644 index f348c01..0000000 --- a/plugins/server-drawer/src/index.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { logger } from "@vendetta"; -import { storage } from "@vendetta/plugin"; -import { patchQuestDockRender, patchQuestDockBase, patchMobileQuestDock, patchGetQuestAsset } from "./patches/fakeQuestDock"; -import { patchExpanded, patchEmpty } from "./patches/contentPatch"; -import { patchHideGuildsBar } from "./patches/hideGuildsBar"; -import { patchCreateElement } from "./lib/createElementIntercept"; -import { patchAutoCollapseFolders } from "./patches/autoCollapseFolders"; -import Settings from "./ui/Settings"; - -let cleanups: (() => void)[] = []; -let retryHandle: ReturnType | undefined; - -// Each of these functions already checks its own lookup and returns false instead of patching -// when it comes up empty - this wrapper adds one more layer so a lookup that throws outright -// can't take the rest of onLoad's patches down with it. -function tryPatch(name: string, fn: () => boolean): boolean { - try { - return fn(); - } catch (e) { - logger.error(`[ServerDrawer] "${name}" patch threw, skipping: ${e}`); - return false; - } -} - -// The Quest Dock (and the internals it depends on) isn't guaranteed to be registered in Metro yet -// at the exact moment onLoad runs. Confirmed live across many reload cycles tonight: without a -// retry loop, each of this plugin's independent module lookups is effectively an independent -// coin-flip on any given boot - not tied to any one account or feature, just whichever modules -// happened to already be registered when onLoad ran. Retrying whatever hasn't landed yet on a fast -// interval until everything's applied (or it gives up after ~10s) is what makes this reliable -// instead of "sometimes works." -function applyAll() { - const patchers: Record boolean> = { - ...(storage.fakeQuestDock !== false ? { - questDockRender: () => patchQuestDockRender(cleanups), - questDockBase: () => patchQuestDockBase(cleanups), - mobileQuestDock: () => patchMobileQuestDock(cleanups), - getQuestAsset: () => patchGetQuestAsset(cleanups), - } : {}), - expanded: () => patchExpanded(cleanups), - collapsed: () => patchEmpty("QuestDockContentCollapsed", cleanups), - enrolledHeader: () => patchEmpty("QuestDockEnrolledHeader", cleanups), - unenrolledHeader: () => patchEmpty("QuestDockUnenrolledHeader", cleanups), - enrolledBody: () => patchEmpty("QuestDockEnrolledBody", cleanups), - unenrolledBody: () => patchEmpty("QuestDockUnenrolledBody", cleanups), - autoCollapseFolders: () => patchAutoCollapseFolders(cleanups), - ...(storage.hideGuildsBar !== false ? { hideGuildsBar: () => patchHideGuildsBar(cleanups) } : {}), - }; - - patchCreateElement(cleanups); - - const pending = new Set(Object.keys(patchers)); - const applied = new Set(); - - 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(", ")}` : ""}`); -} - -function unpatchAll() { - if (retryHandle) { - clearInterval(retryHandle); - retryHandle = undefined; - } - for (const fn of cleanups) { - try { - fn(); - } catch { - // A patch failing to undo shouldn't block the others from unwinding. - } - } - cleanups = []; -} - -export function restart() { - unpatchAll(); - applyAll(); -} - -export default { - onLoad: () => { - storage.hideGuildsBar ??= true; - storage.showGuildNames ??= true; - storage.autoCollapseFolders ??= false; - storage.hideFolderIcons ??= false; - storage.fakeQuestDock ??= true; - - applyAll(); - }, - onUnload: () => unpatchAll(), - settings: Settings, -}; diff --git a/plugins/server-drawer/src/index.tsx b/plugins/server-drawer/src/index.tsx new file mode 100644 index 0000000..d88cde9 --- /dev/null +++ b/plugins/server-drawer/src/index.tsx @@ -0,0 +1,40 @@ +import { patchQuestDockRender } from "./patches/questDockRender"; +import { patchQuestDockBase } from "./patches/questDockBase"; +import { patchMobileQuestDock } from "./patches/mobileQuestDock"; +import { patchGetQuestAsset } from "./patches/getQuestAsset"; +import { patchExpanded, patchEmpty } from "./patches/contentPatch"; +import { patchHideGuildsBar } from "./patches/hideGuildsBar"; +import { patchCreateElement } from "./patches/createElementIntercept"; + +const TAG = "[ServerDrawer]"; +const cleanups: (() => void)[] = []; + +export default { + onLoad() { + console.log(TAG, "onLoad"); + + let patched = 0; + + patchCreateElement(cleanups); + patched++; + + if (patchQuestDockRender(cleanups)) patched++; + if (patchQuestDockBase(cleanups)) patched++; + if (patchMobileQuestDock(cleanups)) patched++; + if (patchGetQuestAsset(cleanups)) patched++; + if (patchExpanded(cleanups)) patched++; + if (patchEmpty("QuestDockContentCollapsed", cleanups)) patched++; + if (patchEmpty("QuestDockEnrolledHeader", cleanups)) patched++; + if (patchEmpty("QuestDockUnenrolledHeader", cleanups)) patched++; + if (patchEmpty("QuestDockEnrolledBody", cleanups)) patched++; + if (patchEmpty("QuestDockUnenrolledBody", cleanups)) patched++; + if (patchHideGuildsBar(cleanups)) patched++; + + console.log(TAG, `onLoad done — ${patched} patches applied, ${cleanups.length} cleanups`); + }, + onUnload() { + console.log(TAG, "onUnload"); + for (const fn of cleanups) fn(); + cleanups.length = 0; + }, +}; diff --git a/plugins/server-drawer/src/lib/color.ts b/plugins/server-drawer/src/lib/color.ts deleted file mode 100644 index 9f0aa73..0000000 --- a/plugins/server-drawer/src/lib/color.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { semanticColors } from "@vendetta/ui"; -import { lazy } from "./lazy"; -import { rawFind, rawFindByStoreName } from "./rawFind"; - -export const HEX_REGEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i; - -// Switched from the caching findByStoreName/find (permanently wrong if either runs before its -// target module registers) to lazy + raw variants for the same timing reason established -// elsewhere in this plugin. -const getThemeStore = lazy(() => rawFindByStoreName("ThemeStore")); - -// @vendetta/ui exposes the semanticColors token map but not a resolver function for it. -const getRawResolveSemanticColor = lazy((): ((theme: any, semanticColor: any) => string | undefined) | undefined => - rawFind((m: any) => m?.default?.internal?.resolveSemanticColor)?.default?.internal?.resolveSemanticColor ?? - rawFind((m: any) => m?.meta?.resolveSemanticColor)?.meta?.resolveSemanticColor); - -/** Resolves a semanticColors[TOKEN_NAME] descriptor to a real color for the current theme. */ -export function resolveSemanticColor(token: any, theme: any = getThemeStore()?.theme): string | undefined { - if (!token) return undefined; - try { - return getRawResolveSemanticColor()?.(theme, token); - } catch { - return undefined; - } -} - -/** - * Same as resolveSemanticColor, but tries a list of token names in order and falls back to a - * hardcoded hex color if every one of them is missing or fails to resolve - Discord renames these - * tokens across versions often enough that a single hardcoded name isn't reliable on its own. - */ -export function resolveSemanticColorSafe(tokenNames: string[], fallbackHex: string, theme: any = getThemeStore()?.theme): string { - for (const name of tokenNames) { - const token = (semanticColors as Record | undefined)?.[name]; - if (!token) continue; - const resolved = resolveSemanticColor(token, theme); - if (resolved) return resolved; - } - return fallbackHex; -} - -export function isValidHex(value: string | undefined | null): value is string { - return !!value && HEX_REGEX.test(value); -} - -/** Expands short form #abc to #aabbcc; leaves 6-digit hex untouched. */ -export function normalizeHex(hex: string): string { - if (hex.length === 4) { - return "#" + hex.slice(1).split("").map((c) => c + c).join(""); - } - return hex; -} - -export function interpolateColor(color1: string, color2: string, percentage: number): string { - const hexToRgb = (hex: string) => (normalizeHex(hex).match(/\w\w/g) ?? ["00", "00", "00"]).map((x) => parseInt(x, 16)); - const rgbToHex = (rgb: number[]) => "#" + rgb.map((x) => x.toString(16).padStart(2, "0")).join(""); - - const rgb1 = hexToRgb(color1); - const rgb2 = hexToRgb(color2); - const interpolated = rgb1.map((c1, i) => Math.round(c1 + (rgb2[i] - c1) * percentage)); - - return rgbToHex(interpolated); -} diff --git a/plugins/server-drawer/src/lib/commonModules.ts b/plugins/server-drawer/src/lib/commonModules.ts deleted file mode 100644 index 9a67a9c..0000000 --- a/plugins/server-drawer/src/lib/commonModules.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { lazy } from "./lazy"; -import { rawFindByFunctionProps, rawFindByValidProps } from "./rawFind"; - -/** - * Confirmed live (Key Inspector's Eval console, full window.modules scan): a single decoy module - * matches dozens of common hook/utility property names at once - useStateFromStores, - * triggerHapticFeedback, colors, transitionToGuild, bulkAck, and more - all with non-function/ - * placeholder values (a type-shape/declaration-style object, not real code). Since window.modules - * iterates in ascending numeric id order and this decoy has a lower id than every real - * implementation it collides with, a plain "does this property exist" check - * (findByProps/rawFindByProps) reliably matches the decoy instead of the real thing - every single - * time, not occasionally. This is what made tapping a server, marking as read, and haptics all - * silently no-op even after this repo's own retry-loop fixes. - * - * rawFindByFunctionProps requires the matched property to actually be a function, which the decoy's - * isn't. Enum/object-shaped properties (which the decoy also fakes) need rawFindByValidProps - * instead, checking for a real value the decoy doesn't have. - * - * Centralized here because every component in this plugin independently needed the same handful of - * lookups - keeping one copy means the decoy-avoidance logic can't drift out of sync across files. - */ -export const getFlux = lazy(() => rawFindByFunctionProps("useStateFromStores")); - -export const getHaptic = lazy(() => rawFindByValidProps({ - triggerHapticFeedback: (v) => typeof v === "function", - HapticFeedbackTypes: (v) => v?.SOFT !== undefined, -})); - -/** Has both `.colors` (semantic tokens like TEXT_NORMAL/BG_ACCENT) and `.unsafe_rawColors` (raw palette like WHITE/GREEN_360) on the same module. */ -export const getColorModule = lazy(() => rawFindByValidProps({ - colors: (v) => v?.BG_ACCENT !== undefined, - unsafe_rawColors: (v) => v?.WHITE !== undefined, -})); - -export const getBulkAckMod = lazy(() => rawFindByFunctionProps("bulkAck", "ackChannel")); - -export const getReadStateTypesMod = lazy(() => rawFindByValidProps({ - ReadStateTypes: (v) => v?.CHANNEL !== undefined, - UnreadSetting: (v) => v?.ALL_MESSAGES !== undefined, -})); diff --git a/plugins/server-drawer/src/lib/createElementIntercept.ts b/plugins/server-drawer/src/lib/createElementIntercept.ts deleted file mode 100644 index e6aaa83..0000000 --- a/plugins/server-drawer/src/lib/createElementIntercept.ts +++ /dev/null @@ -1,346 +0,0 @@ -import { React } from "@vendetta/metro/common"; -import { after } from "@vendetta/patcher"; - -interface Intercept { - replacement: React.ComponentType; - extraProps?: Record; - /** - * 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 | 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, Intercept>(); -const propsIntercepts: PropsIntercept[] = []; -const propsTransforms: PropsTransform[] = []; -let typeDetectors: TypeDetector[] = []; -const detectorKeys = new Set(); -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(); - -/** - * 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, - replacement: React.ComponentType, - extraProps?: Record, - 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 | null) { - propsIntercepts.push({ predicate, replacement }); -} - -// Rewrites an element's props in place, keeping the same type - unlike registerPropsIntercept, -// this doesn't swap in a different component, it just lets the real one render with different -// input. -export function registerPropsTransform(predicate: (props: any) => boolean, transform: (props: any) => any) { - propsTransforms.push({ predicate, transform }); -} - -// Purely observational: the first time an element is created whose type matches predicate, calls -// onDetected(type) with the live reference. Doesn't touch rendering - pair it with -// registerIntercept/registerPropsIntercept in the callback for that. Exists for components that -// aren't findable by searching Metro's registry after the fact (races against first mount). -// -// key dedupes registrations - retrying registration every 200ms used to pile up duplicate -// detectors, each re-tested against every element created anywhere in the app. -// -// persistent keeps the detector alive for every future match instead of firing once. Some type -// references change across navigation (switching servers via this plugin's own drawer), so a -// one-shot detector would only ever catch the first, now-abandoned reference. -export function registerTypeDetector( - key: string, - predicate: (type: any) => boolean, - onDetected: (type: any) => void, - options?: { persistent?: boolean }, -) { - if (detectorKeys.has(key)) return; - detectorKeys.add(key); - typeDetectors.push({ key, predicate, onDetected, persistent: options?.persistent ?? false }); -} - -export function hasTypeDetector(key: string): boolean { - return detectorKeys.has(key); -} - -function runTypeDetectors(type: any) { - if (typeDetectors.length === 0) return; - - let consumed = false; - for (const detector of typeDetectors) { - let matched = false; - try { - matched = detector.predicate(type); - } catch { - // A bad predicate shouldn't block every other detector or every element from rendering. - } - if (!matched) continue; - - try { - detector.onDetected(type); - } catch { - // Same - a detector's own handler throwing shouldn't take anything else down. - } - if (!detector.persistent) { - detector.justFired = true; - consumed = true; - } - } - - // Only rebuild the list when a one-shot detector actually fired. The previous version rebuilt - // it on every single element creation app-wide, forever, because a persistent detector keeps - // the list from ever being empty - an allocation per element on the hottest path in the app. - if (consumed) { - typeDetectors = typeDetectors.filter((d) => d.persistent || !d.justFired); - } -} - -declare module "./createElementIntercept" {} -interface TypeDetector { justFired?: boolean } - -/** - * Returns the collapse depth a parent element should inherit from its children, or 0 for "leave - * this element alone." - * - * Returns the *maximum* collapse budget among real children. - */ -function inspectCollapseChild(child: any): number { - if (child == null || child === false || typeof child !== "object") return 0; - return collapseMarks.get(child) ?? 0; -} - -function inheritedCollapseDepth(props: any, rest: any[] = []): number { - let deepest = 0; - - const children = props?.children; - if (children != null) { - if (Array.isArray(children)) { - for (const child of children) deepest = Math.max(deepest, inspectCollapseChild(child)); - } else { - deepest = Math.max(deepest, inspectCollapseChild(children)); - } - } - - for (const child of rest) deepest = Math.max(deepest, inspectCollapseChild(child)); - return deepest; -} - -/** Returns a replacement type if this element should be intercepted, `null` to render nothing, or `undefined` to pass through unchanged. */ -function resolveReplacement(type: any, props: any, rest: any[]): { type: any; props: any; collapse?: number } | null | undefined { - runTypeDetectors(type); - - let effectiveProps = props; - if (effectiveProps) { - for (const { predicate, transform } of propsTransforms) { - try { - if (predicate(effectiveProps, type, rest)) { - effectiveProps = transform(effectiveProps); - } - } catch { - // A bad transform shouldn't block every other element from rendering. - } - } - } - - if (effectiveProps) { - for (const { predicate, replacement } of propsIntercepts) { - try { - if (predicate(effectiveProps, type, rest)) { - return replacement ? { type: replacement, props: effectiveProps } : null; - } - } catch { - // A bad predicate shouldn't block every other element from rendering. - } - } - } - - const entry = intercepts.get(type); - if (entry) { - return { - type: entry.replacement, - props: entry.extraProps ? { ...effectiveProps, ...entry.extraProps } : effectiveProps, - collapse: entry.collapseAncestors, - }; - } - - // Nothing to swap here, but if this element's only child is one we've already collapsed, this - // element is a wrapper that exists purely to hold it - zero it out too and pass the remaining - // budget further up. - const inherited = inheritedCollapseDepth(effectiveProps, rest); - if (inherited > 0) { - return { - type, - props: { ...effectiveProps, style: [effectiveProps?.style, COLLAPSE_STYLE] }, - collapse: inherited - 1, - }; - } - - if (effectiveProps !== props) { - return { type, props: effectiveProps }; - } - - return undefined; -} - -// Mutates the already-created element in place rather than substituting args before creation - -// after() only sees the return value, not the call itself. A raw property reassignment on the -// jsx-runtime object doesn't reliably reach real render calls; after() does. -function applyResolved(res: any, type: any, props: any, rest: any[]) { - if (!res || typeof res !== "object") return res; - const resolved = resolveReplacement(type, props, rest); - if (resolved === null) { - res.type = () => null; - return res; - } - if (resolved) { - res.type = resolved.type; - res.props = resolved.props ?? null; - if (resolved.collapse && resolved.collapse > 0) { - collapseMarks.set(res, resolved.collapse); - } - } - return res; -} - -export function patchCreateElement(cleanups: (() => void)[]) { - if (isPatched) return; - 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(); - - 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 | undefined = setInterval(() => { - scanAndPatchJsxRuntimes(); - if (++ticks === 50 && timer) { // ~5s at 100ms - clearInterval(timer); - timer = setInterval(() => { - scanAndPatchJsxRuntimes(); - if (++ticks >= 75 && timer) { // + ~25s at 1s - clearInterval(timer); - timer = undefined; - } - }, 1000); - } - }, 100); - - cleanups.push(() => { - if (timer) clearInterval(timer); - timer = undefined; - isPatched = false; - intercepts.clear(); - propsIntercepts.length = 0; - propsTransforms.length = 0; - typeDetectors = []; - detectorKeys.clear(); - }); -} diff --git a/plugins/server-drawer/src/lib/fiberCapture.ts b/plugins/server-drawer/src/lib/fiberCapture.ts deleted file mode 100644 index 3dd8383..0000000 --- a/plugins/server-drawer/src/lib/fiberCapture.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Reads React's internal fiber pointer directly off a native view instance and walks up to the - * root, storing both under window.__serverDrawerFiberRoot/__serverDrawerFiberSelf for Eval scripts - * to search afterward. Same technique Key Inspector's own Settings-screen capture uses, but kept - * under separate globals - Key Inspector's capture only reaches whatever tree its own Settings - * screen renders in (very likely a separate native modal/root from MainTabs' guild tab content), - * so a capture point actually inside this plugin's own rendered output is what's needed to reach - * anything under the Guilds tab (confirmed live: a Settings-screen capture couldn't find - * QuestDockWithQuestContext at all, even navigating to the Guilds tab first). - */ -export function captureFiberRef(instance: any) { - if (!instance) return; - try { - let fiber: any = null; - - if (instance._internalFiberInstanceHandleDEV) { - fiber = instance._internalFiberInstanceHandleDEV; - } else if (instance._internalInstanceHandle) { - fiber = instance._internalInstanceHandle; - } else if (instance.__internalInstanceHandle) { - fiber = instance.__internalInstanceHandle; - } else { - const fiberKey = Object.keys(instance).find( - (k) => k.startsWith("__reactFiber$") || k.startsWith("__reactInternalInstance$") - ); - if (fiberKey) fiber = instance[fiberKey]; - } - if (!fiber) return; - - if (fiber.return === undefined && fiber.child === undefined) { - const nested = fiber.stateNode ?? fiber.fiber ?? fiber._debugOwner; - if (nested && (nested.return !== undefined || nested.child !== undefined)) { - fiber = nested; - } else { - return; - } - } - - let root = fiber; - let guard = 0; - while (root.return && guard < 5000) { - root = root.return; - guard++; - } - - (window as any).__serverDrawerFiberRoot = root; - (window as any).__serverDrawerFiberSelf = fiber; - } catch { - // Best-effort diagnostic capture - a failure here shouldn't affect the caller's own render. - } -} diff --git a/plugins/server-drawer/src/lib/lazy.ts b/plugins/server-drawer/src/lib/lazy.ts deleted file mode 100644 index e0e4889..0000000 --- a/plugins/server-drawer/src/lib/lazy.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Wraps a metro lookup so it isn't run until first actually used, and retries on every call until - * it succeeds once (after which the result is cached forever). A plugin's top-level module code - * runs as soon as its bundle is required, which can be before Discord's own code has required an - * internal module it depends on - a one-shot findByX() at module scope can permanently cache - * undefined even though the real module becomes available moments later. Only meant for values - * used inside event handlers or plain (non-hook) render logic - wrapping something used as a - * React hook (e.g. passed to useContext) in this can violate the Rules of Hooks, since the - * resolved-or-not status is now allowed to change between renders of the same component instance. - */ -export function lazy(resolve: () => T | null | undefined): () => T | undefined { - let cached: T | undefined; - let resolved = false; - - return () => { - if (resolved) return cached; - const value = resolve(); - if (value != null) { - cached = value; - resolved = true; - } - return value ?? undefined; - }; -} diff --git a/plugins/server-drawer/src/lib/rawFind.ts b/plugins/server-drawer/src/lib/rawFind.ts deleted file mode 100644 index 740bfcc..0000000 --- a/plugins/server-drawer/src/lib/rawFind.ts +++ /dev/null @@ -1,181 +0,0 @@ -// Self-contained on purpose - this plugin does not import from @shared/*. Revenge's own -// 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). That makes retry loops 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 to bypass that cache - but critically, it never forces a -// module to initialize (never calls window.__r on something that hasn't run yet). Metro only ever -// runs a module's factory once and caches whatever it produced forever, so force-evaluating one -// before its own dependencies are ready can permanently wedge it into a broken state for the rest -// of the session - no amount of retrying fixes that, since the factory never runs again. This only -// inspects modules Metro's own isInitialized flag says have already been required by something -// else, which is entirely passive and safe to poll repeatedly - it just waits for Discord's own -// code to naturally reach the module we want. -declare const window: any; - -export function rawFind(predicate: (exports: any) => boolean): T | undefined { - const modules = window?.modules; - if (!modules) return undefined; - - for (const id in modules) { - const def = modules[id]; - if (!def?.isInitialized) continue; - - const exports = def.publicModule?.exports; - 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 rawFindAll(predicate: (exports: any) => boolean): T[] { - const modules = window?.modules; - if (!modules) return []; - - const results: T[] = []; - const seen = new Set(); - - for (const id in modules) { - const def = modules[id]; - if (!def?.isInitialized) continue; - - const exports = def.publicModule?.exports; - if (!exports) continue; - - try { - if (predicate(exports) && !seen.has(exports)) { - results.push(exports); - seen.add(exports); - } - const dflt = exports.default; - if (dflt != null && predicate(dflt) && !seen.has(dflt)) { - results.push(dflt); - seen.add(dflt); - } - } catch { - // Ignore predicate errors and continue scanning. - } - } - - return results; -} - -/** - * Async batched scan: does the same as rawFindAll but yields between batches so the UI thread - * isn't blocked for long. Returns a Promise of the matching exports array. - */ -export async function rawFindAllAsync( - predicate: (exports: any) => boolean, - opts?: { batchSize?: number; pauseMs?: number; maxProcessed?: number }, -): Promise { - const modules = window?.modules; - if (!modules) return []; - - const keys = Object.keys(modules); - const batchSize = opts?.batchSize ?? 200; - const pauseMs = opts?.pauseMs ?? 0; // 0 uses setTimeout(...,0) to yield - const maxProcessed = opts?.maxProcessed ?? Infinity; - - const results: T[] = []; - const seen = new Set(); - let processed = 0; - - for (let i = 0; i < keys.length; i += batchSize) { - const end = Math.min(i + batchSize, keys.length); - for (let j = i; j < end; j++) { - if (processed++ >= maxProcessed) return results; - const def = modules[keys[j]]; - if (!def?.isInitialized) continue; - const exports = def.publicModule?.exports; - if (!exports) continue; - try { - if (predicate(exports) && !seen.has(exports)) { - results.push(exports as any); - seen.add(exports); - } - const dflt = exports.default; - if (dflt != null && predicate(dflt) && !seen.has(dflt)) { - results.push(dflt as any); - seen.add(dflt); - } - } catch { - // ignore - } - } - // yield to event loop so UI can update - if (pauseMs === 0) { - await new Promise((r) => setTimeout(r, 0)); - } else if (pauseMs > 0) { - await new Promise((r) => setTimeout(r, pauseMs)); - } - } - - return results; -} - -export function rawFindByTypeName(name: string): T | undefined { - return rawFind((m) => m?.name === name || m?.displayName === name || m?.type?.name === name || m?.type?.displayName === name); -} - -export function rawFindByName(name: string): T | undefined { - return rawFind((m) => m?.name === name || m?.displayName === name); -} - -export function rawFindByProps(...props: string[]): T | undefined { - return rawFind((m) => props.every((p) => m?.[p] !== undefined)); -} - -/** - * Same as rawFindByProps, but additionally requires every named property to be a function - not - * just "defined." Confirmed live (Key Inspector's Eval console, direct window.modules scan) that - * plain rawFindByProps can and does match a completely unrelated module whose properties happen to - * share the same names as the real hook/utility functions being searched for, but hold non-function - * values (e.g. a type-shape/declaration-style object) - and since window.modules iterates in - * ascending numeric id order, a lower-id decoy module reliably wins over the real, higher-id - * implementation *every single time*, not occasionally. This was confirmed to be the actual cause - * of the Quest Dock hijack's unreliability: rawFindByProps("useMobileQuestDock") was silently - * patching an object-shaped decoy at a lower module id instead of the real hook. Use this whenever - * what's being searched for is specifically a function (hooks, utility functions), not just any - * export with that name. - */ -export function rawFindByFunctionProps(...props: string[]): T | undefined { - return rawFind((m) => props.every((p) => typeof m?.[p] === "function")); -} - -/** - * Same idea as rawFindByFunctionProps, but for properties whose real value is an object/enum, not - * a function (e.g. ReadStateTypes, HapticFeedbackTypes) - "is it a function" can't discriminate - * those from the same decoy module rawFindByFunctionProps was built for, since the decoy's version - * is *also* an object. Confirmed live that the decoy's enum-shaped properties don't hold the real - * values (e.g. its ReadStateTypes/UnreadSetting come back null/empty on direct access, despite - * matching a plain "property exists" check) - so this instead requires each named property to pass - * its own validator against a value only the real implementation would actually have, e.g.: - * rawFindByValidProps({ ReadStateTypes: (v) => v?.CHANNEL !== undefined }). - */ -export function rawFindByValidProps(validators: Record boolean>): T | undefined { - const props = Object.keys(validators); - return rawFind((m) => props.every((p) => { - try { - return validators[p](m?.[p]); - } catch { - return false; - } - })); -} - -// Matches Revenge's own byStoreName filter (metro/filters.ts): a zero-arg getName() returning -// the store's name. -export function rawFindByStoreName(name: string): T | undefined { - return rawFind((m) => typeof m?.getName === "function" && m.getName.length === 0 && m.getName() === name); -} diff --git a/plugins/server-drawer/src/lib/waitFor.ts b/plugins/server-drawer/src/lib/waitFor.ts deleted file mode 100644 index 37d78ae..0000000 --- a/plugins/server-drawer/src/lib/waitFor.ts +++ /dev/null @@ -1,45 +0,0 @@ -export interface WaitForHandle { - /** Stops polling. Safe to call even after `find` has already resolved. */ - cancel(): void; -} - -/** - * Polls `find` every `intervalMs` (default 100) until it returns a truthy value, then calls - * `onFound` with it and stops polling. - * - * Needed because a plugin's onLoad can run before every Metro module/component it depends on has - * actually been required - on a cold app start that's a real race, not an edge case. A one-shot - * `findByX(...)` lookup at that exact moment can come back undefined and, without a retry, never - * try again for the rest of the session. `waitFor` checks immediately (so there's no added delay - * once the target is already registered) and keeps retrying otherwise. - * - * Returns a handle whose `cancel()` stops the poll - call it from a plugin's unpatch/onUnload so a - * pending lookup doesn't keep firing (or apply a patch) after the plugin's been turned off. - */ -export function waitFor(find: () => T | null | undefined, onFound: (value: T) => void, intervalMs = 100): WaitForHandle { - let cancelled = false; - let timer: ReturnType | undefined; - - const immediate = find(); - if (immediate) { - onFound(immediate); - return { cancel() {} }; - } - - timer = setInterval(() => { - if (cancelled) return; - const value = find(); - if (value) { - cancelled = true; - clearInterval(timer); - onFound(value); - } - }, intervalMs); - - return { - cancel() { - cancelled = true; - if (timer) clearInterval(timer); - } - }; -} diff --git a/plugins/server-drawer/src/patches/autoCollapseFolders.ts b/plugins/server-drawer/src/patches/autoCollapseFolders.ts deleted file mode 100644 index 17f609c..0000000 --- a/plugins/server-drawer/src/patches/autoCollapseFolders.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { FluxDispatcher } from "@vendetta/metro/common"; -import { storage } from "@vendetta/plugin"; -import { rawFindByStoreName } from "../lib/rawFind"; - -// Adapted from fres621's BetterFolders (autoCollapse.ts) - ported as a ServerDrawer feature -// instead of a standalone plugin because BetterFolders' other half (hideIcons) patches a -// "GuildContainer" component that no longer exists in current Discord builds at all, and its -// icon-collapsing behavior only made sense for the native server rail, which ServerDrawer already -// 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. -export function patchAutoCollapseFolders(cleanups: (() => void)[]): boolean { - const ExpandedGuildFolderStore = rawFindByStoreName("ExpandedGuildFolderStore"); - if (!ExpandedGuildFolderStore?.getExpandedFolders) return false; - - const listener = ({ folderId }: { folderId?: string | number }) => { - if (!storage.autoCollapseFolders || folderId == null) return; - - const expanded = ExpandedGuildFolderStore.getExpandedFolders(); - if (!(expanded instanceof Set) || !expanded.has(folderId)) return; - - for (const id of expanded) { - if (id !== folderId) { - FluxDispatcher.dispatch({ type: "TOGGLE_GUILD_FOLDER_EXPAND", folderId: id }); - } - } - }; - - FluxDispatcher.subscribe("TOGGLE_GUILD_FOLDER_EXPAND", listener); - cleanups.push(() => FluxDispatcher.unsubscribe("TOGGLE_GUILD_FOLDER_EXPAND", listener)); - return true; -} diff --git a/plugins/server-drawer/src/patches/contentPatch.tsx b/plugins/server-drawer/src/patches/contentPatch.tsx index 1bbfb30..735bcad 100644 --- a/plugins/server-drawer/src/patches/contentPatch.tsx +++ b/plugins/server-drawer/src/patches/contentPatch.tsx @@ -1,136 +1,59 @@ +import { find } from "@vendetta/metro"; 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 } from "./createElementIntercept"; const TAG = "[ServerDrawer]"; -// rawFind, not @vendetta/metro's find - Revenge's own find() permanently caches a "not found" -// result per search and never rescans, which makes retrying pointless after the first failed -// attempt. Confirmed live tonight across many reload cycles: without retry, roughly a third of the -// independent lookups this plugin needs fail on any given boot, at random - not specific to any -// one account or feature. let cachedGestureContext: any = null; function getGestureContext(): any { if (!cachedGestureContext) { - cachedGestureContext = rawFind((m) => m?.QuestDockGestureContext)?.QuestDockGestureContext ?? null; + cachedGestureContext = find((m) => m?.QuestDockGestureContext)?.QuestDockGestureContext ?? null; } return cachedGestureContext; } -// Memo-wrapped components have no own .name/.displayName - only their nested .type does. -function isNamed(name: string) { - return (type: any) => type?.name === name || type?.displayName === name || - type?.type?.name === name || type?.type?.displayName === name; -} - -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 makes - * the drawer render immediately if the target module happens to already be registered in Metro by - * the time this runs, but that's a real boot-time race. - * - * registerTypeDetector has no such timing dependency - it inspects the `type` argument passed - * directly to createElement/jsx at the moment something actually creates an element with it, - * whenever that happens, early or late. Combining both means the drawer shows as fast as possible - * when the race is won, and still shows correctly even when it's lost. - * - * Only QuestDockContentExpanded is swapped for the drawer - QuestDockContentCollapsed is patched to - * render nothing (see patchEmpty below), same as every other sub-section. Confirmed live that - * patching both to show the full drawer causes it to render twice: Discord creates both elements - * simultaneously in some cases (very likely a collapse/expand transition state), not just - * whichever one restingQuestDockMode currently selects. - */ -export function patchExpanded(cleanups: (() => void)[]): boolean { - const questDockTypes = new Set(); - - // Make the quest dock's wrapper(s) transparent - the ServerDrawerSheet content still renders on - // top, but the panel/drawer background that used to be behind the quest menu is removed. - registerPropsTransform( - (props: any, type: any, rest: any[]) => - questDockTypes.has(type) || - hasChildWithTestID(props?.children, rest, SD_DRAWER_TEST_ID), - (props: any) => ({ ...props, style: [props?.style, { backgroundColor: "transparent" }] }), - ); - - registerTypeDetector("ServerDrawer.QuestDockBg", isQuestDock, (real) => { - questDockTypes.add(real); - console.log(TAG, "PATCH: QuestDock wrapper set transparent", real?.name ?? real?.displayName); - }, { persistent: true }); - - registerTypeDetector("ServerDrawer.Expanded", isNamed("QuestDockContentExpanded"), (real) => { - registerIntercept(real, ServerDrawerSheetWrapper, { testID: SD_DRAWER_TEST_ID }); - console.log(TAG, "PATCH: QuestDockContentExpanded replaced (type detector)"); - }, { persistent: true }); - - const mod = rawFind((m) => isNamed("QuestDockContentExpanded")(m?.type)); +export function patchExpanded( + cleanups: (() => void)[] +): boolean { + const mod = find((m) => m?.type?.displayName === "QuestDockContentExpanded" || m?.type?.name === "QuestDockContentExpanded"); if (!mod?.type) { - console.log(TAG, "WARN: QuestDockContentExpanded module not found yet (will retry)"); + console.log(TAG, "WARN: QuestDockContentExpanded not found (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: QuestDockContentExpanded replaced"); return true; } -export function patchEmpty(name: string, cleanups: (() => void)[]): boolean { - registerTypeDetector(`ServerDrawer.Empty.${name}`, isNamed(name), (real) => { - registerIntercept(real, function EmptyPatch() { return null; }); - console.log(TAG, `PATCH: ${name} replaced (type detector)`); - }, { persistent: true }); - - const mod = rawFind((m) => isNamed(name)(m?.type)); +export function patchEmpty( + name: string, + cleanups: (() => void)[] +): boolean { + const mod = find((m) => m?.type?.displayName === name || m?.type?.name === name); if (!mod?.type) { - console.log(TAG, `WARN: ${name} module not found yet (will retry)`); + console.log(TAG, `WARN: ${name} not found`); return false; } - const orig = mod.type; - function EmptyPatch() { + const originalComponent = orig; + + mod.type = function EmptyPatch() { return null; - } - mod.type = EmptyPatch; + }; - try { - Object.defineProperties(mod.type, Object.getOwnPropertyDescriptors(orig)); - } catch { - // Best-effort compatibility shim, not something correctness depends on. + if (originalComponent) { + Object.defineProperties(mod.type, Object.getOwnPropertyDescriptors(originalComponent)); + mod.type.displayName = name; } - mod.type.displayName = name; cleanups.push(() => { mod.type = orig; }); - console.log(TAG, `PATCH: ${name} replaced (module mutation)`); return true; } diff --git a/plugins/server-drawer/src/patches/createElementIntercept.ts b/plugins/server-drawer/src/patches/createElementIntercept.ts new file mode 100644 index 0000000..c803c2d --- /dev/null +++ b/plugins/server-drawer/src/patches/createElementIntercept.ts @@ -0,0 +1,56 @@ +import { React } from "@vendetta/metro/common"; + +interface Intercept { + replacement: React.ComponentType; + extraProps?: Record; +} + +const intercepts = new Map, Intercept>(); +let origCreateElement: typeof React.createElement | null = null; +let isPatched = false; + +export function registerIntercept( + original: React.ComponentType, + replacement: React.ComponentType, + extraProps?: Record, +) { + intercepts.set(original, { replacement, extraProps }); +} + +export function patchCreateElement(cleanups: (() => void)[]) { + if (isPatched) return; + + // Guard against React.createElement being null in some environments + 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) { + // Fallback to original createElement if somehow null (should not happen) + return React.createElement(type, props, ...rest); + } + const entry = intercepts.get(type); + if (entry) { + const newProps = entry.extraProps + ? { ...props, ...entry.extraProps } + : props; + return origCreateElement.call(React, entry.replacement, newProps, ...rest); + } + return origCreateElement.call(React, type, props, ...rest); + }; + + Object.assign(patched, origCreateElement); + React.createElement = patched as typeof React.createElement; + isPatched = true; + + cleanups.push(() => { + if (isPatched && origCreateElement) { + React.createElement = origCreateElement; + isPatched = false; + } + intercepts.clear(); + }); +} diff --git a/plugins/server-drawer/src/patches/fakeQuestDock.ts b/plugins/server-drawer/src/patches/fakeQuestDock.ts deleted file mode 100644 index 264103c..0000000 --- a/plugins/server-drawer/src/patches/fakeQuestDock.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { findByProps } from "@vendetta/metro"; -import { rawFindByFunctionProps, rawFindByStoreName } from "../lib/rawFind"; - -// Confirmed live: rawFindByFunctionProps never found any of these four on some accounts, across -// multiple reload and account-switch cycles spanning over a minute - not a timing race, since -// rawFind only scans modules Metro has *already* naturally initialized and never triggers one -// itself. On an account where nothing in the normal boot path happens to touch this specific -// module, passive scanning can retry forever and never find it. Revenge's own findByProps forces -// a module to initialize if it isn't yet, which is exactly what's needed here - confirmed live via -// direct eval that it finds this exact module instantly on an account where rawFind never did. -// Tried first since it's free and carries no caching risk; findByProps is the fallback precisely -// because a failed call there is cached negatively forever, so it's only worth risking once -// rawFind has already had a real chance to succeed on its own. -function findHookModule(...props: string[]): any { - return rawFindByFunctionProps(...props) ?? findByProps(...props); -} - -const TAG = "[ServerDrawer]"; - -// A real, always-available Discord CDN URL - confirmed live that an empty string still resolves -// "successfully" through Discord's buildUrl (concatenates onto the CDN base rather than being -// recognized as absolute) but produces a malformed URL, and a full external https:// URL gets -// double-prefixed the same way ("https://cdn.discordapp.com/https://..."). Neither crashes - -// getQuestAsset below is guarded regardless - so this is cosmetic, not load-bearing. -const FALLBACK_ASSET_URL = "https://cdn.discordapp.com/embed/avatars/0.png"; - -function buildFallbackQuest(userId: string | undefined) { - const now = new Date(); - const nowIso = now.toISOString(); - const farFuture = new Date(now.getTime() + 1000 * 60 * 60 * 24 * 365 * 5).toISOString(); - - return { - id: "server-drawer", - preview: false, - config: { - id: "server-drawer", - configVersion: 2, - startsAt: nowIso, - expiresAt: farFuture, - features: [], - assets: { - hero: FALLBACK_ASSET_URL, heroVideo: "", questBarHero: FALLBACK_ASSET_URL, questBarHeroBlurhash: "", - questBarHeroVideo: "", gameTile: FALLBACK_ASSET_URL, logotype: FALLBACK_ASSET_URL, - gameTileLight: FALLBACK_ASSET_URL, gameTileDark: FALLBACK_ASSET_URL, - logotypeLight: FALLBACK_ASSET_URL, logotypeDark: FALLBACK_ASSET_URL, - }, - colors: { primary: "#5865F2", secondary: "#000000" }, - messages: { - questName: "Server Drawer", - gameTitle: "All quests complete!", - gamePublisher: "ServerDrawer plugin", - }, - taskConfigV2: { - tasks: { - ACHIEVEMENT_IN_ACTIVITY: { - type: "ACHIEVEMENT_IN_ACTIVITY", - target: 1, - eventName: "progress", - messages: { - taskTitle: "Server Drawer is active", - taskDescription: "You've got no active Discord quests right now - enjoy the drawer!", - }, - applications: [{ id: "0" }], - }, - }, - joinOperator: "or", - }, - rewardsConfig: { - assignmentMethod: 1, - rewards: [{ - type: 4, - skuId: "0", - messages: { - redemptionInstructionsByPlatform: {}, - name: "Nothing - just vibes", - nameWithArticle: "Nothing - just vibes", - }, - orbQuantity: 0, - premiumOrbQuantity: 0, - }], - rewardsExpireAt: farFuture, - platforms: [0], - }, - cosponsorMetadata: undefined, - sharePolicy: "shareable_everywhere", - ctaConfig: { android: undefined, ios: undefined, link: "", buttonLabel: "", subtitle: "" }, - }, - userStatus: { - userId: userId ?? null, - questId: "server-drawer", - enrolledAt: nowIso, - completedAt: null, - claimedAt: null, - claimedTier: null, - orbQuantityClaimed: 0, - lastStreamHeartbeatAt: null, - streamProgressSeconds: 0, - dismissedQuestContent: 0, - progress: {}, - }, - targetedContent: [], - trafficMetadataSealed: "", - }; -} - -// Each of these four does its own completely independent rawFind lookup rather than sharing one -// resolved module reference across all of them - confirmed live (GuildsBar's own patch writeup) -// that a metro search can land on a different module copy than another search for a related -// property, even when both "succeed", so patching useMobileQuestDock and -// useIsMobileQuestDockRendered off the same found object was never guaranteed to be the same -// reference the real render path actually reads from. Matches the original plugin's four separate -// files (questDockRender.ts, questDockBase.ts, mobileQuestDock.ts, getQuestAsset.ts) in spirit, -// just retry-safe. -export function patchMobileQuestDock(cleanups: (() => void)[]): boolean { - const mod = findHookModule("useMobileQuestDock"); - if (!mod?.useMobileQuestDock) { - console.log(TAG, "WARN: useMobileQuestDock not found yet (will retry)"); - return false; - } - const orig = mod.useMobileQuestDock; - let cachedUserId: string | undefined; - mod.useMobileQuestDock = function (this: unknown, ...args: any[]) { - const real = orig.apply(this, args); - if (real) return real; - if (cachedUserId === undefined) { - cachedUserId = rawFindByStoreName("UserStore")?.getCurrentUser?.()?.id; - } - return buildFallbackQuest(cachedUserId); - }; - cleanups.push(() => { mod.useMobileQuestDock = orig; }); - return true; -} - -export function patchQuestDockRender(cleanups: (() => void)[]): boolean { - const mod = findHookModule("useIsMobileQuestDockRendered"); - if (!mod?.useIsMobileQuestDockRendered) { - console.log(TAG, "WARN: useIsMobileQuestDockRendered not found yet (will retry)"); - return false; - } - const orig = mod.useIsMobileQuestDockRendered; - mod.useIsMobileQuestDockRendered = function (this: unknown, ...args: any[]) { - orig.apply(this, args); - return true; - }; - cleanups.push(() => { mod.useIsMobileQuestDockRendered = orig; }); - return true; -} - -export function patchQuestDockBase(cleanups: (() => void)[]): boolean { - const mod = findHookModule("useIsMobileQuestDockRenderedBase"); - if (!mod?.useIsMobileQuestDockRenderedBase) { - console.log(TAG, "WARN: useIsMobileQuestDockRenderedBase not found yet (will retry)"); - return false; - } - const orig = mod.useIsMobileQuestDockRenderedBase; - mod.useIsMobileQuestDockRenderedBase = function (this: unknown, ...args: any[]) { - orig.apply(this, args); - return true; - }; - cleanups.push(() => { mod.useIsMobileQuestDockRenderedBase = orig; }); - return true; -} - -export function patchGetQuestAsset(cleanups: (() => void)[]): boolean { - const mod = findHookModule("getQuestAsset"); - if (!mod?.getQuestAsset) { - console.log(TAG, "WARN: getQuestAsset not found yet (will retry)"); - return false; - } - const orig = mod.getQuestAsset; - mod.getQuestAsset = function (this: unknown, ...args: any[]) { - try { - return orig.apply(this, args); - } catch { - return { url: null, isAnimated: false }; - } - }; - cleanups.push(() => { mod.getQuestAsset = orig; }); - return true; -} diff --git a/plugins/server-drawer/src/patches/getQuestAsset.ts b/plugins/server-drawer/src/patches/getQuestAsset.ts new file mode 100644 index 0000000..71b5ac1 --- /dev/null +++ b/plugins/server-drawer/src/patches/getQuestAsset.ts @@ -0,0 +1,21 @@ +import { findByProps } from "@vendetta/metro"; + +const TAG = "[ServerDrawer]"; + +export function patchGetQuestAsset(cleanups: (() => void)[]): boolean { + const mod = findByProps("getQuestAsset"); + if (!mod?.getQuestAsset) { + console.log(TAG, "WARN: getQuestAsset not found"); + return false; + } + const orig = mod.getQuestAsset; + mod.getQuestAsset = function (...args: any[]) { + try { + return orig.apply(this, args); + } catch { + return { url: null, isAnimated: false }; + } + }; + cleanups.push(() => { mod.getQuestAsset = orig; }); + return true; +} diff --git a/plugins/server-drawer/src/patches/hideGuildsBar.tsx b/plugins/server-drawer/src/patches/hideGuildsBar.tsx index efb3926..2b5babc 100644 --- a/plugins/server-drawer/src/patches/hideGuildsBar.tsx +++ b/plugins/server-drawer/src/patches/hideGuildsBar.tsx @@ -1,86 +1,94 @@ import React from "react"; -import { View } from "react-native"; -import { registerPropsTransform, registerTypeDetector, registerIntercept } from "../lib/createElementIntercept"; -import { captureFiberRef } from "../lib/fiberCapture"; +import { Pressable, Image, View } from "react-native"; +import { find, findByName, findByProps } from "@vendetta/metro"; +import { getAssetIDByName } from "@vendetta/ui/assets"; +import { registerIntercept } from "./createElementIntercept"; const TAG = "[ServerDrawer]"; -const SD_NOTHING_TEST_ID = "ServerDrawerNothing"; -function Nothing() { - 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", - }, - }); -} +const ChatIcon = getAssetIDByName("ChatIcon"); +const Haptic = findByProps("triggerHapticFeedback", "HapticFeedbackTypes"); +const ChannelActions = findByProps("selectPrivateChannel"); +const SelectedChannelStore = findByName("SelectedChannelStore"); +const FluxStores = findByProps("useStateFromStores"); +const useStateFromStores = FluxStores?.useStateFromStores; +const GuildStore = findByProps("getGuildId"); +const colors = findByProps("colors", "unsafe_rawColors")?.colors; +const Routes = findByProps("ME"); +const ME = Routes?.ME ?? "/channels/@me"; -// Confirmed live (Key Inspector's fiber capture): the parent creates GuildsBar via its OUTER -// React.memo wrapper object, which has no own .name/.displayName at all - those only exist on the -// memo's inner function, at type.type.name/type.type.displayName. -function isGuildsBar(type: any): boolean { - return type?.name === "GuildsBar" || type?.displayName === "GuildsBar" || - type?.type?.name === "GuildsBar" || type?.type?.displayName === "GuildsBar"; -} - -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; +function openDms() { + Haptic?.triggerHapticFeedback(Haptic.HapticFeedbackTypes.SOFT); + if (ChannelActions?.selectPrivateChannel) { + const lastChannelId = SelectedChannelStore?.getLastSelectedChannelId?.(); + ChannelActions.selectPrivateChannel(lastChannelId); } - for (const child of rest) if (inspect(child)) return true; - return false; +} + +function useIsInDms(): boolean { + try { + return GuildStore ? useStateFromStores([GuildStore], () => { + const guildId = GuildStore?.getGuildId?.(); + return guildId == null || guildId === ME; + }) : false; + } catch { return false; } +} + +function DmsGuildIcon() { + const selected = useIsInDms(); + + const iconColor = selected + ? (colors?.WHITE ?? "#fff") + : (colors?.INTERACTIVE_NORMAL ?? "#80848e"); + + return ( + + {selected ? ( + + ) : 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; + + mod = find((m) => { + try { return m?.default?.displayName === "GuildsBar"; } catch { return false; } + }); + if (mod?.default) return mod; + + 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; - registerTypeDetector("ServerDrawer.HideGuildsBar", isGuildsBar, (realGuildsBar) => { - registerIntercept(realGuildsBar, Nothing, { testID: SD_NOTHING_TEST_ID }, { collapseAncestors: 6 }); - console.log(TAG, "PATCH: found a real GuildsBar reference, now rendering nothing"); - }, { persistent: true }); - cleanups.push(() => { - // No per-call unregister needed - createElementIntercept clears all transforms/intercepts - // together when its own patch unwinds. - }); - console.log(TAG, "PATCH: watching for the real GuildsBar to appear"); + registerIntercept(orig, DmsGuildIcon); + + mod.default = function DmsGuildsBar() { + return React.createElement(DmsGuildIcon); + }; + mod.default.displayName = "GuildsBar"; + cleanups.push(() => { mod.default = orig; }); return true; } diff --git a/plugins/server-drawer/src/patches/mobileQuestDock.ts b/plugins/server-drawer/src/patches/mobileQuestDock.ts new file mode 100644 index 0000000..2fce38b --- /dev/null +++ b/plugins/server-drawer/src/patches/mobileQuestDock.ts @@ -0,0 +1,30 @@ +import { findByProps } from "@vendetta/metro"; + +const TAG = "[ServerDrawer]"; + +export function patchMobileQuestDock(cleanups: (() => void)[]): boolean { + const mod = findByProps("useMobileQuestDock"); + if (!mod.useMobileQuestDock) { + console.log(TAG, "WARN: useMobileQuestDock not found"); + return false; + } + const orig = mod.useMobileQuestDock; + mod.useMobileQuestDock = function (...args: any[]) { + const real = orig.apply(this, args); + if (real) return real; + return { + id: "server-drawer", + config: { + quest_content_type: 0, + assets: { questBarHeroVideo: null, questBarHero: null }, + features: [], + }, + userStatus: { enrolledAt: "2099-01-01", claimedAt: null }, + benefits: { rewards: [] }, + guildId: "0", + tasks: [], + }; + }; + cleanups.push(() => { mod.useMobileQuestDock = orig; }); + return true; +} diff --git a/plugins/server-drawer/src/patches/questDockBase.ts b/plugins/server-drawer/src/patches/questDockBase.ts new file mode 100644 index 0000000..af936b5 --- /dev/null +++ b/plugins/server-drawer/src/patches/questDockBase.ts @@ -0,0 +1,18 @@ +import { findByProps } from "@vendetta/metro"; + +const TAG = "[ServerDrawer]"; + +export function patchQuestDockBase(cleanups: (() => void)[]): boolean { + const mod = findByProps("useIsMobileQuestDockRenderedBase"); + if (!mod?.useIsMobileQuestDockRenderedBase) { + console.log(TAG, "WARN: useIsMobileQuestDockRenderedBase not found"); + return false; + } + const orig = mod.useIsMobileQuestDockRenderedBase; + mod.useIsMobileQuestDockRenderedBase = function (...args: any[]) { + orig.apply(this, args); + return true; + }; + cleanups.push(() => { mod.useIsMobileQuestDockRenderedBase = orig; }); + return true; +} diff --git a/plugins/server-drawer/src/patches/questDockRender.ts b/plugins/server-drawer/src/patches/questDockRender.ts new file mode 100644 index 0000000..9567866 --- /dev/null +++ b/plugins/server-drawer/src/patches/questDockRender.ts @@ -0,0 +1,18 @@ +import { findByProps } from "@vendetta/metro"; + +const TAG = "[ServerDrawer]"; + +export function patchQuestDockRender(cleanups: (() => void)[]): boolean { + const mod = findByProps("useIsMobileQuestDockRendered"); + if (!mod?.useIsMobileQuestDockRendered) { + console.log(TAG, "WARN: useIsMobileQuestDockRendered not found"); + return false; + } + const orig = mod.useIsMobileQuestDockRendered; + mod.useIsMobileQuestDockRendered = function (...args: any[]) { + orig.apply(this, args); + return true; + }; + cleanups.push(() => { mod.useIsMobileQuestDockRendered = orig; }); + return true; +} diff --git a/plugins/server-drawer/src/ui/NoteBox.tsx b/plugins/server-drawer/src/ui/NoteBox.tsx deleted file mode 100644 index d8407bb..0000000 --- a/plugins/server-drawer/src/ui/NoteBox.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { React, ReactNative } from "@vendetta/metro/common"; -import { resolveSemanticColorSafe } from "../lib/color"; - -const { View, Text } = ReactNative; - -/** - * Small bordered callout for explanatory text in a settings screen. Text color used to be unset - * entirely - React Native's Text defaults to black with no explicit color, illegible on Discord's - * dark theme (the default for most users). - */ -export default function NoteBox({ children, style }: { children: any; style?: any }) { - const textColor = resolveSemanticColorSafe(["TEXT_NORMAL", "TEXT_DEFAULT", "TEXT_MUTED"], "#96989d"); - - return ( - - {children} - - ); -} diff --git a/plugins/server-drawer/src/ui/Settings.tsx b/plugins/server-drawer/src/ui/Settings.tsx deleted file mode 100644 index 84af8a9..0000000 --- a/plugins/server-drawer/src/ui/Settings.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { React } from "@vendetta/metro/common"; -import { storage } from "@vendetta/plugin"; -import { useProxy } from "@vendetta/storage"; -import { TableRowGroup, TableSwitchRow } from "@shared/ui/table"; -import SettingsScaffold from "./SettingsScaffold"; -import NoteBox from "./NoteBox"; -import { restart } from "../index"; - - -export default function Settings() { - useProxy(storage); - - return ( - - - 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, and the + button creates or joins a server - through Discord's own modal. - - - { - storage.hideGuildsBar = v; - restart(); - }} - /> - { storage.showGuildNames = v; }} - /> - { storage.hideDmTile = v; }} - /> - { - storage.fakeQuestDock = v; - restart(); - }} - /> - - - { storage.autoCollapseFolders = v; }} - /> - { storage.hideFolderIcons = v; }} - /> - - - The folder options above are ported from fres621's BetterFolders - its other half - (hiding icons in the native left server rail) patched a component that no longer - exists in current Discord builds, so it's been rebuilt here instead, where it - actually applies to what you see. - - - ); -} diff --git a/plugins/server-drawer/src/ui/SettingsScaffold.tsx b/plugins/server-drawer/src/ui/SettingsScaffold.tsx deleted file mode 100644 index 9d67ae8..0000000 --- a/plugins/server-drawer/src/ui/SettingsScaffold.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { React, ReactNative } from "@vendetta/metro/common"; - -const { ScrollView, View } = ReactNative; - -/** Consistent wrapper for the settings screen: scroll view + bottom breathing room. */ -export default function SettingsScaffold({ children }: { children: any }) { - return ( - - {children} - - - ); -} diff --git a/plugins/server-drawer/src/utils/theme.ts b/plugins/server-drawer/src/utils/theme.ts index 5ea43ed..049dfb4 100644 --- a/plugins/server-drawer/src/utils/theme.ts +++ b/plugins/server-drawer/src/utils/theme.ts @@ -1,27 +1,19 @@ -import { lazy } from "../lib/lazy"; -import { rawFindByStoreName } from "../lib/rawFind"; -import { getFlux, getColorModule } from "../lib/commonModules"; +import { findByProps, findByStoreName } from "@vendetta/metro"; -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: 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(); - // Guards against an undefined store, not just a falsy return value - confirmed live that - // passing [undefined] into useStateFromStores throws rather than failing safely, which used to - // take the whole drawer down (this only ever runs when there's an actual folder to render, so - // an account with folders showed nothing at all while one without rendered fine). - if (!Flux?.useStateFromStores || !ExpandedGuildFolderStore) return false; - return Flux.useStateFromStores( + return Flux?.useStateFromStores?.( [ExpandedGuildFolderStore], () => { const folders = ExpandedGuildFolderStore?.getExpandedFolders?.();