From 35bc22df2ec89da02e6b2c9533acbaf46416db75 Mon Sep 17 00:00:00 2001 From: Ally Founder Date: Thu, 20 Aug 2026 11:55:56 -0400 Subject: [PATCH] Server Drawer: restore plugin with server list rewrite --- plugins/server-drawer/manifest.json | 13 + .../src/components/ContextMenuModal.tsx | 213 ++++++++++++ .../server-drawer/src/components/DmTile.tsx | 44 +++ .../src/components/FolderItem.tsx | 154 +++++++++ .../src/components/GuildIcon.tsx | 32 ++ .../src/components/GuildItem.tsx | 209 ++++++++++++ .../src/components/ServerDrawerSheet.tsx | 156 +++++++++ plugins/server-drawer/src/index.tsx | 55 ++++ .../src/patches/contentPatch.tsx | 77 +++++ .../src/patches/createElementIntercept.ts | 304 ++++++++++++++++++ .../src/patches/getQuestAsset.ts | 21 ++ .../src/patches/hideGuildsBar.tsx | 78 +++++ .../src/patches/mobileQuestDock.ts | 30 ++ .../src/patches/questDockBase.ts | 18 ++ .../src/patches/questDockRender.ts | 18 ++ .../src/patches/transparentBackground.ts | 46 +++ plugins/server-drawer/src/ui/NoteBox.tsx | 29 ++ plugins/server-drawer/src/ui/Settings.tsx | 34 ++ plugins/server-drawer/src/utils/theme.ts | 33 ++ 19 files changed, 1564 insertions(+) create mode 100644 plugins/server-drawer/manifest.json create mode 100644 plugins/server-drawer/src/components/ContextMenuModal.tsx create mode 100644 plugins/server-drawer/src/components/DmTile.tsx create mode 100644 plugins/server-drawer/src/components/FolderItem.tsx create mode 100644 plugins/server-drawer/src/components/GuildIcon.tsx create mode 100644 plugins/server-drawer/src/components/GuildItem.tsx create mode 100644 plugins/server-drawer/src/components/ServerDrawerSheet.tsx create mode 100644 plugins/server-drawer/src/index.tsx create mode 100644 plugins/server-drawer/src/patches/contentPatch.tsx create mode 100644 plugins/server-drawer/src/patches/createElementIntercept.ts create mode 100644 plugins/server-drawer/src/patches/getQuestAsset.ts create mode 100644 plugins/server-drawer/src/patches/hideGuildsBar.tsx create mode 100644 plugins/server-drawer/src/patches/mobileQuestDock.ts create mode 100644 plugins/server-drawer/src/patches/questDockBase.ts create mode 100644 plugins/server-drawer/src/patches/questDockRender.ts create mode 100644 plugins/server-drawer/src/patches/transparentBackground.ts create mode 100644 plugins/server-drawer/src/ui/NoteBox.tsx create mode 100644 plugins/server-drawer/src/ui/Settings.tsx create mode 100644 plugins/server-drawer/src/utils/theme.ts diff --git a/plugins/server-drawer/manifest.json b/plugins/server-drawer/manifest.json new file mode 100644 index 0000000..340d0bd --- /dev/null +++ b/plugins/server-drawer/manifest.json @@ -0,0 +1,13 @@ +{ + "name": "ServerDrawer", + "description": "Replaces the Quest Dock with a server grid drawer above the YouBar - tap to expand, long-press a server for its context menu, plus a one-tap create/join button.", + "authors": [ + { "name": "Rosie" }, + { "name": "kmmiio99o" }, + { "name": "fres" } + ], + "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 new file mode 100644 index 0000000..2d19795 --- /dev/null +++ b/plugins/server-drawer/src/components/ContextMenuModal.tsx @@ -0,0 +1,213 @@ +import React from "react"; +import { View, Text, Pressable, Image, Modal, StyleSheet, Dimensions, Animated } from "react-native"; +import { semanticColors } from "@vendetta/ui"; +import { findByProps, findByStoreName } from "@vendetta/metro"; + +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(color: any): string { + const theme = ThemeStore?.theme; + return (color && colorResolver?.resolveSemanticColor(theme, color)) || "#000000"; +} + +const EDGE = 12; +const PAD = 12; +const ITEM_H = 42; +const MIN_W = 220; +const DIVIDER_H = 4; +const RADIUS = 16; +const ICON_SIZE = 20; + +const SPRING_CONFIG = { tension: 200, friction: 20 }; + +export interface ContextMenuItem { + label: string; + action?: () => void; + danger?: boolean; + iconSource?: number; + IconComponent?: React.ComponentType; +} + +export function ContextMenuModal({ + visible, + items, + title, + anchorX, + anchorY, + onClose, +}: { + visible: boolean; + items: ContextMenuItem[]; + title: string; + anchorX: number; + anchorY: number; + onClose: () => void; +}) { + const { width: winW, height: winH } = Dimensions.get("window"); + + const titleH = title ? PAD + 20 + DIVIDER_H : 0; + const menuH = titleH + items.length * ITEM_H; + + let left = anchorX; + let top = anchorY + 48 + 10; + if (left + MIN_W > winW - EDGE) left = winW - MIN_W - EDGE; + if (left < EDGE) left = EDGE; + if (top + menuH > winH - EDGE) top = anchorY - menuH - 10; + if (top < EDGE) top = EDGE; + + 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; + const menuOpacity = React.useRef(new Animated.Value(0)).current; + + const [show, setShow] = React.useState(false); + const closing = React.useRef(false); + + React.useEffect(() => { + if (visible && !closing.current) { + setShow(true); + backdropOpacity.setValue(0); + menuScale.setValue(0.5); + menuOpacity.setValue(0); + Animated.parallel([ + Animated.timing(backdropOpacity, { toValue: 1, duration: 150, useNativeDriver: true }), + Animated.spring(menuScale, { toValue: 1, useNativeDriver: true, ...SPRING_CONFIG }), + Animated.spring(menuOpacity, { toValue: 1, useNativeDriver: true, ...SPRING_CONFIG }), + ]).start(); + } + }, [visible]); + + const handleClose = React.useCallback(() => { + if (closing.current) return; + closing.current = true; + Animated.parallel([ + Animated.timing(backdropOpacity, { toValue: 0, duration: 120, useNativeDriver: true }), + Animated.timing(menuScale, { toValue: 0.8, duration: 120, useNativeDriver: true }), + Animated.timing(menuOpacity, { toValue: 0, duration: 120, useNativeDriver: true }), + ]).start(() => { + closing.current = false; + setShow(false); + onClose(); + }); + }, [onClose]); + + if (!show) return null; + + return ( + + + + + {title ? ( + + + {title} + + + + ) : null} + {items.map((item, i) => { + const isFirst = i === 0 && !title; + const isLast = i === items.length - 1; + const hasIcon = !!(item.iconSource || item.IconComponent); + + return ( + ({ + paddingHorizontal: PAD, + paddingVertical: 10, + minHeight: ITEM_H, + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + gap: 8, + backgroundColor: pressed ? pressedColor : "transparent", + borderTopLeftRadius: isFirst ? RADIUS : 0, + borderTopRightRadius: isFirst ? RADIUS : 0, + borderBottomLeftRadius: isLast ? RADIUS : 0, + borderBottomRightRadius: isLast ? RADIUS : 0, + borderBottomWidth: !isFirst && !isLast ? 1 : 0, + borderBottomColor: borderColor, + })} + onPress={() => { + Haptic?.triggerHapticFeedback?.(Haptic.HapticFeedbackTypes.IMPACT_LIGHT); + handleClose(); + item.action?.(); + }} + > + + {item.label} + + {hasIcon ? ( + + {item.IconComponent ? ( + + ) : item.iconSource ? ( + + ) : null} + + ) : null} + + ); + })} + + + + ); +} + +const localStyles = StyleSheet.create({ + root: { + flex: 1, + }, + backdrop: { + ...StyleSheet.absoluteFill, + backgroundColor: "rgba(0,0,0,0.55)", + }, + container: { + position: "absolute", + borderWidth: 1, + borderRadius: RADIUS, + overflow: "hidden", + elevation: 20, + shadowColor: "#000", + shadowOffset: { width: 0, height: 12 }, + shadowOpacity: 0.24, + shadowRadius: 24, + }, +}); diff --git a/plugins/server-drawer/src/components/DmTile.tsx b/plugins/server-drawer/src/components/DmTile.tsx new file mode 100644 index 0000000..8fd97f4 --- /dev/null +++ b/plugins/server-drawer/src/components/DmTile.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import { View, Pressable, Image, StyleSheet } from "react-native"; +import { getAssetIDByName } from "@vendetta/ui/assets"; +import { findByProps, findByName } from "@vendetta/metro"; + +const ICON = 48; + +const ChatIcon = getAssetIDByName("ChatIcon"); +const Haptic = findByProps("triggerHapticFeedback", "HapticFeedbackTypes"); +const ChannelActions = findByProps("selectPrivateChannel"); +const SelectedChannelStore = findByName("SelectedChannelStore"); +const Flux = findByProps("useStateFromStores"); +const NavContext = findByProps("getGuildId"); +const colors = findByProps("colors", "unsafe_rawColors")?.colors; + +function openDms() { + Haptic?.triggerHapticFeedback?.(Haptic.HapticFeedbackTypes.SOFT); + if (ChannelActions?.selectPrivateChannel) { + const lastChannelId = SelectedChannelStore?.getLastSelectedChannelId?.(); + ChannelActions.selectPrivateChannel(lastChannelId); + } +} + +// Moved into the drawer's own grid as the first tile, since this plugin otherwise hides the +// native rail DMs used to live on. +export default function DmTile() { + 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 new file mode 100644 index 0000000..76088df --- /dev/null +++ b/plugins/server-drawer/src/components/FolderItem.tsx @@ -0,0 +1,154 @@ +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 { useFolderExpanded, GuildNode } from "../utils/theme"; +import GuildIcon from "./GuildIcon"; +import GuildItem from "./GuildItem"; + +const GuildActions = findByProps("toggleGuildFolderExpand"); +const Flux = findByProps("useStateFromStores"); +const GuildReadStateStore = findByStoreName("GuildReadStateStore"); + +const ICON = 48; +const MINI = 16; + +const POS: ViewStyle[] = [ + { top: 6, left: 6 }, + { top: 6, right: 6 }, + { bottom: 6, left: 6 }, + { bottom: 6, right: 6 }, +]; + +const FOLDER_ASSET = getAssetIDByName("FolderIcon"); + +function folderColor(color?: number | null): string { + if (color == null) return "#5865f2"; + return `#${color.toString(16).padStart(6, "0")}`; +} + +function FolderBadge({ node }: { node: GuildNode }) { + const total = Flux?.useStateFromStores?.( + [GuildReadStateStore], + () => { + let sum = 0; + for (const child of node.children) { + sum += GuildReadStateStore?.getMentionCount?.(child.id) ?? 0; + } + return sum; + }, + [node.children], + ) ?? 0; + + if (total > 0) { + return ( + + + {total > 99 ? "99+" : String(total)} + + + ); + } + return null; +} + +function FolderCover({ node }: { node: GuildNode }) { + const col = folderColor(node.color); + return ( + + + {node.children.slice(0, 4).map((ch, i) => ( + + + + ))} + + + + ); +} + +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" }, +}); + +function FadeIn({ children }: { children: React.ReactNode }) { + const opacity = React.useRef(new Animated.Value(0)).current; + React.useEffect(() => { + Animated.timing(opacity, { toValue: 1, duration: 180, useNativeDriver: true }).start(); + }, []); + return {children}; +} + +export default function FolderItem({ node, onPick, showNames }: { node: GuildNode; onPick: (id: string) => void; showNames?: boolean }) { + const open = useFolderExpanded(node.id); + + const toggle = () => { + GuildActions?.toggleGuildFolderExpand?.(node.id); + }; + + if (open) { + return ( + <> + + + + + + {node.children.map((ch) => ( + + + + ))} + + ); + } + + return ( + + + + ); +} + +const fo = StyleSheet.create({ + openIcon: { + width: ICON, + height: ICON, + borderRadius: 16, + alignItems: "center", + justifyContent: "center", + }, + folderImg: { width: 24, height: 24 }, +}); + +const fbd = StyleSheet.create({ + outline: { + position: "absolute", + bottom: -3, + right: -3, + minWidth: 23, + minHeight: 23, + borderRadius: 12, + backgroundColor: "#1a1a2e", + alignItems: "center", + justifyContent: "center", + }, + badge: { + minWidth: 19, + height: 19, + borderRadius: 9, + backgroundColor: "#ed4245", + alignItems: "center", + justifyContent: "center", + paddingHorizontal: 5, + }, + text: { + color: "#fff", + fontSize: 10, + fontWeight: "700", + lineHeight: 19, + }, +}); diff --git a/plugins/server-drawer/src/components/GuildIcon.tsx b/plugins/server-drawer/src/components/GuildIcon.tsx new file mode 100644 index 0000000..9aaf6af --- /dev/null +++ b/plugins/server-drawer/src/components/GuildIcon.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import { View, Text, Image, StyleSheet } from "react-native"; +import { findByStoreName, findByProps } from "@vendetta/metro"; + +const GuildStore = findByStoreName("GuildStore"); +const colors = findByProps("colors", "unsafe_rawColors")?.colors; + +export default function GuildIcon({ id, size = 48 }: { id: string; size?: number }) { + const g = GuildStore?.getGuild(id); + if (!g) return null; + const rad = size >= 40 ? 16 : 8; + if (g.icon) { + return ( + + ); + } + return ( + + + {g.name?.split(" ").map((w: string) => w[0]).join("").slice(0, 2).toUpperCase() || "?"} + + + ); +} + +const st = StyleSheet.create({ + fallback: { alignItems: "center", justifyContent: "center" }, + letter: { color: "#fff", fontWeight: "700" }, +}); diff --git a/plugins/server-drawer/src/components/GuildItem.tsx b/plugins/server-drawer/src/components/GuildItem.tsx new file mode 100644 index 0000000..d3fa502 --- /dev/null +++ b/plugins/server-drawer/src/components/GuildItem.tsx @@ -0,0 +1,209 @@ +import React from "react"; +import { View, Text, Pressable, Animated, StyleSheet } from "react-native"; +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 Flux = findByProps("useStateFromStores"); +const GuildReadStateStore = findByStoreName("GuildReadStateStore"); +const GuildStore = findByStoreName("GuildStore"); +const SortedGuildStore = findByStoreName("SortedGuildStore"); +const Haptic = findByProps("triggerHapticFeedback", "HapticFeedbackTypes"); +const colors = findByProps("colors", "unsafe_rawColors")?.colors; + +function Badge({ guildId }: { guildId: string }) { + const mentionCount = Flux?.useStateFromStores?.( + [GuildReadStateStore], + () => GuildReadStateStore?.getMentionCount?.(guildId) ?? 0, + [guildId], + ) ?? 0; + + const hasUnread = Flux?.useStateFromStores?.( + [GuildReadStateStore], + () => GuildReadStateStore?.hasUnread?.(guildId) ?? false, + [guildId], + ) ?? false; + + if (mentionCount > 0) { + return ( + + + {mentionCount > 99 ? "99+" : String(mentionCount)} + + + ); + } + + if (hasUnread) { + return ( + + + + ); + } + + return null; +} + +export default function GuildItem({ node, onPick, showNames }: { node: GuildNode; onPick: (id: string) => void; showNames?: boolean }) { + const viewRef = React.useRef(null); + const scale = React.useRef(new Animated.Value(1)).current; + const scaleDown = () => Animated.spring(scale, { toValue: 0.85, useNativeDriver: true }).start(); + const scaleUp = () => Animated.spring(scale, { toValue: 1, useNativeDriver: true }).start(); + + const [menuState, setMenuState] = React.useState<{ + visible: boolean; + items: ContextMenuItem[]; + title: string; + x: number; + y: number; + }>({ visible: false, items: [], title: "", x: 0, y: 0 }); + + const showMenu = React.useCallback(() => { + const guildId = node.id as string; + 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; + + if (!guild || !menuItemsFn) { + return; + } + + const treeVersion = SortedGuildStore?.getGuildsTree?.()?.version; + const rawItems = menuItemsFn(guildId, treeVersion); + if (!rawItems?.length) return; + + const ref = viewRef.current as any; + if (!ref?.measure) return; + + ref.measure((_fx: number, _fy: number, _w: number, _h: number, pageX: number, pageY: number) => { + setMenuState({ + visible: true, + 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, + }); + }); + }, [node.id]); + + const handleLongPress = React.useCallback(() => { + Haptic?.triggerHapticFeedback?.(Haptic.HapticFeedbackTypes.IMPACT_MEDIUM); + showMenu(); + }, [showMenu]); + + const guildId = node.id as string; + + const name = Flux?.useStateFromStores?.( + [GuildStore], + () => GuildStore?.getGuild?.(guildId)?.name ?? "", + [guildId], + ) ?? ""; + + return ( + <> + onPick(guildId)} + onLongPress={handleLongPress} + delayLongPress={500} + > + + + + + + + + {showNames && ( + + {name} + + )} + + + setMenuState((s) => ({ ...s, visible: false }))} + /> + + ); +} + +const st = StyleSheet.create({ + outer: { width: ICON, alignItems: "center" }, + iconWrap: { width: ICON, height: ICON }, + icon: { width: ICON, height: ICON, borderRadius: 16, overflow: "hidden" }, + label: { + marginTop: 4, + width: ICON, + fontSize: 10, + lineHeight: 12, + fontWeight: "600", + textAlign: "center", + color: colors?.HEADER_PRIMARY ?? colors?.TEXT_NORMAL ?? "#fff", + textShadowColor: "rgba(0,0,0,0.75)", + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 2, + }, +}); + +const bd = StyleSheet.create({ + outline: { + position: "absolute", + bottom: -3, + right: -3, + minWidth: 23, + minHeight: 23, + borderRadius: 12, + backgroundColor: "#1a1a2e", + alignItems: "center", + justifyContent: "center", + }, + badge: { + minWidth: 19, + height: 19, + borderRadius: 9, + backgroundColor: "#ed4245", + alignItems: "center", + justifyContent: "center", + paddingHorizontal: 5, + }, + text: { + color: "#fff", + fontSize: 10, + fontWeight: "700", + lineHeight: 19, + }, + dotOutline: { + position: "absolute", + bottom: -2, + right: -2, + width: 14, + height: 14, + borderRadius: 7, + backgroundColor: "#1a1a2e", + alignItems: "center", + justifyContent: "center", + }, + dot: { + width: 10, + height: 10, + borderRadius: 5, + backgroundColor: "#ed4245", + }, +}); diff --git a/plugins/server-drawer/src/components/ServerDrawerSheet.tsx b/plugins/server-drawer/src/components/ServerDrawerSheet.tsx new file mode 100644 index 0000000..c3af844 --- /dev/null +++ b/plugins/server-drawer/src/components/ServerDrawerSheet.tsx @@ -0,0 +1,156 @@ +import React from "react"; +import { View, Text, Pressable, Animated, Dimensions, StyleSheet, BackHandler } from "react-native"; +import { find, findByProps, findByStoreName } from "@vendetta/metro"; +import { storage } from "@vendetta/plugin"; +import { useProxy } from "@vendetta/storage"; +import { GuildNode } from "../utils/theme"; +import GuildItem from "./GuildItem"; +import FolderItem from "./FolderItem"; +import DmTile from "./DmTile"; + +const Flux = findByProps("useStateFromStores"); +const SortedGuildStore = findByStoreName("SortedGuildStore"); +const RootNav = findByProps("getRootNavigationRef"); +const Haptic = findByProps("triggerHapticFeedback", "HapticFeedbackTypes"); +const Routing = findByProps("transitionToGuild"); + +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; + +function CreateJoinButton() { + const scale = React.useRef(new Animated.Value(1)).current; + const scaleDown = React.useCallback(() => { + Animated.spring(scale, { toValue: 0.9, useNativeDriver: true }).start(); + }, [scale]); + const scaleUp = React.useCallback(() => { + Animated.spring(scale, { toValue: 1, useNativeDriver: true }).start(); + }, [scale]); + + const onPress = React.useCallback(() => { + Haptic?.triggerHapticFeedback?.(Haptic.HapticFeedbackTypes.SOFT); + CreateJoinGuildMod?.handleCreateJoinGuildPress?.(); + }, []); + + return ( + + + {CirclePlusIcon ? ( + + ) : ( + {"+"} + )} + + + ); +} + +export default function ServerDrawerSheet({ gestureContext }: { gestureContext: any }) { + useProxy(storage); + + const pick = React.useCallback((id: string) => { + Haptic?.triggerHapticFeedback(Haptic.HapticFeedbackTypes.SOFT); + if (Routing?.transitionToGuild) { + Routing.transitionToGuild(id, null); + } else { + RootNav?.getRootNavigationRef()?.navigate("guilds", { guildId: id }); + } + }, []); + + const nodes: GuildNode[] = Flux?.useStateFromStores?.( + [SortedGuildStore], + () => { + const t = SortedGuildStore?.getGuildsTree(); + return (t?.root?.children || []).filter((n: GuildNode) => n.type !== "root"); + }, + ) ?? []; + + const ctx = (gestureContext ? React.useContext(gestureContext) : null) as any; + const minH = ctx?.minExpandedContentHeight; + + const onLayout = React.useCallback((e: any) => { + if (!minH) return; + const h = e.nativeEvent.layout.height; + if (minH.get() !== h) minH.set(h); + }, [minH]); + + const extCtx = ExternalContext ? React.useContext(ExternalContext) as any : null; + const setMode = extCtx?.setRestingQuestDockMode; + + const specs = ctx?.questDockWrapperSpecs; + + React.useEffect(() => { + if (!setMode || !QuestDockMode || !specs) return; + const sub = BackHandler.addEventListener("hardwareBackPress", () => { + const h = specs.get()?.height ?? 56; + if (h > 80) { + setMode(QuestDockMode.COLLAPSED); + return true; + } + return false; + }); + return () => sub.remove(); + }, [setMode, specs]); + + const { width: winW } = Dimensions.get("window"); + + const cols = Math.max(3, Math.floor((winW - PAD * 2 + GAP) / (ICON + GAP))); + const totalW = cols * ICON + (cols - 1) * GAP; + const padX = Math.max(0, (winW - totalW) / 2); + + return ( + + + {!storage.hideDmTile && } + {nodes.map((node) => + node.type === "folder" + ? + : + )} + + + + ); +} + +const st = StyleSheet.create({ + alignTop: { + flex: 1, + justifyContent: "flex-start", + alignItems: "flex-start", + }, + grid: { + flexDirection: "row", + flexWrap: "wrap", + paddingTop: 4, + paddingBottom: 16, + }, + createJoin: { + 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.tsx b/plugins/server-drawer/src/index.tsx new file mode 100644 index 0000000..9e61f49 --- /dev/null +++ b/plugins/server-drawer/src/index.tsx @@ -0,0 +1,55 @@ +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 { patchTransparentBackground } from "./patches/transparentBackground"; +import { patchCreateElement } from "./patches/createElementIntercept"; +import { id, storage } from "@vendetta/plugin"; +import { guardPlugin } from "@shared/lib/guard"; +import Settings from "./ui/Settings"; + +const TAG = "[ServerDrawer]"; +let unpatchAll: () => void = () => {}; + +export default { + onLoad() { + storage.hideDmTile ??= false; + storage.showGuildNames ??= false; + + console.log(TAG, "onLoad"); + + unpatchAll = guardPlugin(id, () => { + const cleanups: (() => void)[] = []; + 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++; + if (patchTransparentBackground(cleanups)) patched++; + + console.log(TAG, `onLoad done — ${patched} patches applied, ${cleanups.length} cleanups`); + return () => { + for (const fn of cleanups) fn(); + cleanups.length = 0; + }; + }); + }, + onUnload() { + console.log(TAG, "onUnload"); + unpatchAll(); + }, + settings: Settings, +}; diff --git a/plugins/server-drawer/src/patches/contentPatch.tsx b/plugins/server-drawer/src/patches/contentPatch.tsx new file mode 100644 index 0000000..13c0ec8 --- /dev/null +++ b/plugins/server-drawer/src/patches/contentPatch.tsx @@ -0,0 +1,77 @@ +import { find } from "@vendetta/metro"; +import { React } from "@vendetta/metro/common"; +import ServerDrawerSheet from "../components/ServerDrawerSheet"; +import { registerIntercept, registerTypeDetector } from "./createElementIntercept"; + +const TAG = "[ServerDrawer]"; + +let cachedGestureContext: any = null; +function getGestureContext(): any { + if (!cachedGestureContext) { + cachedGestureContext = find((m) => m?.QuestDockGestureContext)?.QuestDockGestureContext ?? null; + } + return cachedGestureContext; +} + +function isNamed(name: string) { + return (type: any) => type?.name === name || type?.displayName === name || + type?.type?.name === name || type?.type?.displayName === name; +} + +// find()'s module-registry search can land on a different copy of QuestDockContentExpanded than +// the one actually mounted - registerTypeDetector instead inspects the exact `type` value passed +// to createElement/jsx as it's used, so there's no "which copy" ambiguity. +export function patchExpanded( + cleanups: (() => void)[] +): boolean { + registerTypeDetector("ServerDrawer.Expanded", isNamed("QuestDockContentExpanded"), (real) => { + registerIntercept(real, ServerDrawerSheet, { gestureContext: getGestureContext() }); + console.log(TAG, "PATCH: QuestDockContentExpanded replaced (type detector)"); + }, { persistent: true }); + + const mod = find((m) => m?.type?.displayName === "QuestDockContentExpanded" || m?.type?.name === "QuestDockContentExpanded"); + if (!mod?.type) { + console.log(TAG, "WARN: QuestDockContentExpanded not found (will retry)"); + return true; // type detector is already watching regardless + } + const orig = mod.type; + + registerIntercept(orig, ServerDrawerSheet, { gestureContext: getGestureContext() }); + + mod.type = function ServerDrawerPatch() { + return ; + }; + cleanups.push(() => { mod.type = orig; }); + console.log(TAG, "PATCH: QuestDockContentExpanded replaced (module mutation)"); + 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 = find((m) => m?.type?.displayName === name || m?.type?.name === name); + if (!mod?.type) { + console.log(TAG, `WARN: ${name} not found`); + return true; // type detector is already watching regardless + } + const orig = mod.type; + const originalComponent = orig; + + mod.type = function EmptyPatch() { + return null; + }; + + if (originalComponent) { + Object.defineProperties(mod.type, Object.getOwnPropertyDescriptors(originalComponent)); + mod.type.displayName = name; + } + + cleanups.push(() => { mod.type = orig; }); + 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..6c7b5cd --- /dev/null +++ b/plugins/server-drawer/src/patches/createElementIntercept.ts @@ -0,0 +1,304 @@ +import { React } from "@vendetta/metro/common"; +import { after } from "@vendetta/patcher"; + +interface Intercept { + replacement: React.ComponentType; + extraProps?: Record; + /** How many collapsed-to-zero-size ancestor levels above this element are also allowed to collapse (0 = none). */ + 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; + +// Elements are created child-first, so a collapse mark on a child is already present by the time +// the parent's own call is intercepted - that's what makes walking the collapse upward possible. +const collapseMarks = new WeakMap(); + +// display: none alone doesn't reclaim space on every layout, so this also zeroes width/flex/margin +// and hides overflow. +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 }); +} + +// Matches on a props predicate instead of an exact type reference, for components with no +// reliable name. Pass replacement: null to render nothing. +export function registerPropsIntercept(predicate: (props: any) => boolean, replacement: React.ComponentType | null) { + propsIntercepts.push({ predicate, replacement }); +} + +// Rewrites props in place, keeping the same type. +export function registerPropsTransform(predicate: (props: any) => boolean, transform: (props: any) => any) { + propsTransforms.push({ predicate, transform }); +} + +// Fires onDetected(type) the first time a matching element is created. For components with no +// stable module export to search for after the fact. persistent keeps firing on every match +// instead of once, for references that change across navigation. +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 { + // Ignore + } + if (!matched) continue; + + try { + detector.onDetected(type); + } catch { + // Ignore + } + if (!detector.persistent) { + detector.justFired = true; + consumed = true; + } + } + + if (consumed) { + typeDetectors = typeDetectors.filter((d) => d.persistent || !d.justFired); + } +} + +declare module "./createElementIntercept" {} +interface TypeDetector { justFired?: boolean } + +/** Returns the collapse depth a parent should inherit from its children (max of any real child, 0 if none). */ +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 { + // Ignore + } + } + } + + if (effectiveProps) { + for (const { predicate, replacement } of propsIntercepts) { + try { + if (predicate(effectiveProps, type, rest)) { + return replacement ? { type: replacement, props: effectiveProps } : null; + } + } catch { + // Ignore + } + } + } + + const entry = intercepts.get(type); + if (entry) { + return { + type: entry.replacement, + props: entry.extraProps ? { ...effectiveProps, ...entry.extraProps } : effectiveProps, + collapse: entry.collapseAncestors, + }; + } + + // A wrapper whose only child was already collapsed - zero it out too and pass the remaining budget 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; +} + +// after() only sees the return value, not the call itself, so we mutate the element in place +// rather than substituting args beforehand. +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 compiles JSX through jsx/jsxs/jsxDEV, not React.createElement - scan for those + // runtimes by shape (names are mangled) and keep scanning since Metro registers lazily. + 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 + } + } + + 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); + } + } + + scanAndPatchJsxRuntimes(); + + // Fast while the app boots, then slow, then stop. + 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/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 new file mode 100644 index 0000000..17e57af --- /dev/null +++ b/plugins/server-drawer/src/patches/hideGuildsBar.tsx @@ -0,0 +1,78 @@ +import React from "react"; +import { View } from "react-native"; +import { registerPropsTransform, registerTypeDetector, registerIntercept } from "./createElementIntercept"; + +const TAG = "[ServerDrawer]"; +const SD_NOTHING_TEST_ID = "ServerDrawerNothing"; + +function Nothing() { + return React.createElement(View, { + style: { + display: "none", + width: 0, + minWidth: 0, + maxWidth: 0, + flexGrow: 0, + flexShrink: 0, + flexBasis: 0, + margin: 0, + padding: 0, + borderWidth: 0, + overflow: "hidden", + }, + }); +} + +// 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; + } + for (const child of rest) if (inspect(child)) return true; + return false; +} + +export function patchHideGuildsBar(cleanups: (() => void)[]): boolean { + // Rendering GuildsBar as nothing alone still leaves its space reserved - the immediate parent + // carries an explicit width in its own style, so display: none on the child doesn't reclaim + // it. This zeroes the parent wrapper too once it spots the hidden marker as a child. + 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", + }, + ], + }), + ); + + 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 }); + console.log(TAG, "PATCH: watching for the real GuildsBar to appear"); + 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/patches/transparentBackground.ts b/plugins/server-drawer/src/patches/transparentBackground.ts new file mode 100644 index 0000000..cfda6f9 --- /dev/null +++ b/plugins/server-drawer/src/patches/transparentBackground.ts @@ -0,0 +1,46 @@ +import { registerPropsTransform, registerPropsIntercept } from "./createElementIntercept"; + +const TAG = "[ServerDrawer]"; + +// The quest dock's outer wrapper carries an opaque card background (borderRadius: 24) via +// useAnimatedStyle - no component reference to intercept, so match on the computed style instead. +// The color itself varies in format (rgba vs hex), so borderRadius + "has a background color" is +// the stable signal, not the color string. +function flatten(style: any): Record { + if (!style) return {}; + if (Array.isArray(style)) return Object.assign({}, ...style.map(flatten)); + return style; +} + +function isQuestDockCard(props: any): boolean { + const s = flatten(props?.style); + return s.borderRadius === 24 && typeof s.backgroundColor === "string"; +} + +// Removing the card color reveals a second layer underneath: the quest's promotional hero photo. +// Matched by URL shape, not component, since the same image component renders ordinary guild/ +// folder icons everywhere else too. +function getSourceUri(props: any): string | undefined { + const source = props?.source; + if (!source) return undefined; + if (Array.isArray(source)) return source[0]?.uri; + return source.uri; +} + +function isQuestHeroImage(props: any): boolean { + const uri = getSourceUri(props); + return typeof uri === "string" && uri.includes("/quests/"); +} + +export function patchTransparentBackground(cleanups: (() => void)[]): boolean { + registerPropsTransform( + (props: any) => isQuestDockCard(props), + (props: any) => ({ + ...props, + style: [props?.style, { backgroundColor: "transparent" }], + }), + ); + registerPropsIntercept(isQuestHeroImage, null); + console.log(TAG, "PATCH: watching for the quest dock card background and hero image"); + return true; +} diff --git a/plugins/server-drawer/src/ui/NoteBox.tsx b/plugins/server-drawer/src/ui/NoteBox.tsx new file mode 100644 index 0000000..50a5055 --- /dev/null +++ b/plugins/server-drawer/src/ui/NoteBox.tsx @@ -0,0 +1,29 @@ +import { React, ReactNative } from "@vendetta/metro/common"; +import { findByProps } from "@vendetta/metro"; + +const { View, Text } = ReactNative; + +const colors = findByProps("colors", "unsafe_rawColors")?.colors; + +/** Small bordered callout for explanatory text in a settings screen. */ +export default function NoteBox({ children, style }: { children: any; style?: any }) { + const textColor = colors?.TEXT_MUTED ?? colors?.TEXT_NORMAL ?? "#96989d"; + + return ( + + {children} + + ); +} diff --git a/plugins/server-drawer/src/ui/Settings.tsx b/plugins/server-drawer/src/ui/Settings.tsx new file mode 100644 index 0000000..e21e0b4 --- /dev/null +++ b/plugins/server-drawer/src/ui/Settings.tsx @@ -0,0 +1,34 @@ +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 "@shared/ui/SettingsScaffold"; +import NoteBox from "./NoteBox"; + +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, and the + button creates or joins a server through Discord's own modal. + + + { storage.hideDmTile = v; }} + /> + { storage.showGuildNames = v; }} + /> + + + ); +} diff --git a/plugins/server-drawer/src/utils/theme.ts b/plugins/server-drawer/src/utils/theme.ts new file mode 100644 index 0000000..049dfb4 --- /dev/null +++ b/plugins/server-drawer/src/utils/theme.ts @@ -0,0 +1,33 @@ +import { findByProps, findByStoreName } from "@vendetta/metro"; + +const Flux = findByProps("useStateFromStores"); +const colors = findByProps("colors", "unsafe_rawColors")?.colors; +const ExpandedGuildFolderStore = findByStoreName("ExpandedGuildFolderStore"); + +export function useTheme() { + return { + 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 { + return Flux?.useStateFromStores?.( + [ExpandedGuildFolderStore], + () => { + const folders = ExpandedGuildFolderStore?.getExpandedFolders?.(); + return folders instanceof Set ? folders.has(folderId) : false; + }, + [folderId], + ) ?? false; +} + +export interface GuildNode { + type: string; + id: string | number; + name?: string; + color?: number | null; + expanded?: boolean; + children: GuildNode[]; +}