From 1b27bf29706f795b05896d8b1136119260c854e3 Mon Sep 17 00:00:00 2001 From: kmmiio99o Date: Fri, 24 Jul 2026 12:30:34 +0200 Subject: [PATCH] feat: add ServerDrawer Plugin --- README.md | 9 +- index.html | 62 +++++ plugins/ServerDrawer/manifest.json | 14 ++ .../src/components/ContextMenuModal.tsx | 213 ++++++++++++++++++ .../src/components/FolderItem.tsx | 154 +++++++++++++ .../ServerDrawer/src/components/GuildIcon.tsx | 32 +++ .../ServerDrawer/src/components/GuildItem.tsx | 182 +++++++++++++++ .../src/components/ServerDrawerSheet.tsx | 97 ++++++++ plugins/ServerDrawer/src/index.tsx | 40 ++++ .../ServerDrawer/src/patches/contentPatch.tsx | 45 ++++ .../src/patches/createElementIntercept.ts | 45 ++++ .../ServerDrawer/src/patches/getQuestAsset.ts | 21 ++ .../src/patches/hideGuildsBar.tsx | 94 ++++++++ .../src/patches/mobileQuestDock.ts | 29 +++ .../ServerDrawer/src/patches/questDockBase.ts | 18 ++ .../src/patches/questDockRender.ts | 18 ++ plugins/ServerDrawer/src/utils/theme.ts | 33 +++ 17 files changed, 1104 insertions(+), 2 deletions(-) create mode 100644 index.html create mode 100644 plugins/ServerDrawer/manifest.json create mode 100644 plugins/ServerDrawer/src/components/ContextMenuModal.tsx create mode 100644 plugins/ServerDrawer/src/components/FolderItem.tsx create mode 100644 plugins/ServerDrawer/src/components/GuildIcon.tsx create mode 100644 plugins/ServerDrawer/src/components/GuildItem.tsx create mode 100644 plugins/ServerDrawer/src/components/ServerDrawerSheet.tsx create mode 100644 plugins/ServerDrawer/src/index.tsx create mode 100644 plugins/ServerDrawer/src/patches/contentPatch.tsx create mode 100644 plugins/ServerDrawer/src/patches/createElementIntercept.ts create mode 100644 plugins/ServerDrawer/src/patches/getQuestAsset.ts create mode 100644 plugins/ServerDrawer/src/patches/hideGuildsBar.tsx create mode 100644 plugins/ServerDrawer/src/patches/mobileQuestDock.ts create mode 100644 plugins/ServerDrawer/src/patches/questDockBase.ts create mode 100644 plugins/ServerDrawer/src/patches/questDockRender.ts create mode 100644 plugins/ServerDrawer/src/utils/theme.ts diff --git a/README.md b/README.md index f2232fc..2d37a42 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,13 @@ A collection of plugins for [**Kettu**](https://codeberg.org/cocobo1/Kettu), [** | **Multi Scrobbler** | Advanced scrobbling support for multiple music services | | **ChatboxAvatar** | Quickly view your profile from the chatbox. | | **Moyai** | đŸ—ŋ - Plays a sound on message send/react | +| **Server Drawer** | Server Drawer above the YouBar, manage your servers differently! | ## 🚀 Installation 1. Copy plugin link from here -2. Open **Vendetta** and go to the **Plugins** page -3. Click the "Add Plugin" or "Install from URL" button +2. Open **Discord Settings** and go to the **Plugins** page +3. Click the "+" in the corner and paste the plugin link ## 🔗 Plugin Links @@ -40,6 +41,10 @@ https://rksv.kmmiio99o.dev/ChatboxAvatar ``` https://rksv.kmmiio99o.dev/Moyai ``` +- **Server Drawer**: +``` +https://rksv.kmmiio99o.dev/ServerDrawer +``` ## â„šī¸ Notes diff --git a/index.html b/index.html new file mode 100644 index 0000000..6e01ebd --- /dev/null +++ b/index.html @@ -0,0 +1,62 @@ + + + + + + kmmiio99o's Plugins + + + + +
+ + + diff --git a/plugins/ServerDrawer/manifest.json b/plugins/ServerDrawer/manifest.json new file mode 100644 index 0000000..aea7135 --- /dev/null +++ b/plugins/ServerDrawer/manifest.json @@ -0,0 +1,14 @@ +{ + "name": "Server Drawer", + "description": "Server Drawer above the YouBar, manage your servers differently!", + "authors": [ + { + "name": "kmmiio99o", + "id": "879393496627306587" + } + ], + "main": "src/index.tsx", + "vendetta": { + "icon": "ServerIcon" + } +} diff --git a/plugins/ServerDrawer/src/components/ContextMenuModal.tsx b/plugins/ServerDrawer/src/components/ContextMenuModal.tsx new file mode 100644 index 0000000..2d19795 --- /dev/null +++ b/plugins/ServerDrawer/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/ServerDrawer/src/components/FolderItem.tsx b/plugins/ServerDrawer/src/components/FolderItem.tsx new file mode 100644 index 0000000..0076189 --- /dev/null +++ b/plugins/ServerDrawer/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 }: { node: GuildNode; onPick: (id: string) => void }) { + 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/ServerDrawer/src/components/GuildIcon.tsx b/plugins/ServerDrawer/src/components/GuildIcon.tsx new file mode 100644 index 0000000..9aaf6af --- /dev/null +++ b/plugins/ServerDrawer/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/ServerDrawer/src/components/GuildItem.tsx b/plugins/ServerDrawer/src/components/GuildItem.tsx new file mode 100644 index 0000000..a00ffb8 --- /dev/null +++ b/plugins/ServerDrawer/src/components/GuildItem.tsx @@ -0,0 +1,182 @@ +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"); + +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 }: { node: GuildNode; onPick: (id: string) => void }) { + 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; + + return ( + <> + onPick(guildId)} + onLongPress={handleLongPress} + delayLongPress={500} + > + + + + + + + + setMenuState((s) => ({ ...s, visible: false }))} + /> + + ); +} + +const st = StyleSheet.create({ + outer: { width: ICON, height: ICON }, + icon: { width: ICON, height: ICON, borderRadius: 16, overflow: "hidden" }, +}); + +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/ServerDrawer/src/components/ServerDrawerSheet.tsx b/plugins/ServerDrawer/src/components/ServerDrawerSheet.tsx new file mode 100644 index 0000000..9ac0320 --- /dev/null +++ b/plugins/ServerDrawer/src/components/ServerDrawerSheet.tsx @@ -0,0 +1,97 @@ +import React from "react"; +import { View, Dimensions, StyleSheet, BackHandler } from "react-native"; +import { find, findByProps, findByStoreName } from "@vendetta/metro"; +import { GuildNode } from "../utils/theme"; +import GuildItem from "./GuildItem"; +import FolderItem from "./FolderItem"; + +const SortedGuildStore = findByStoreName("SortedGuildStore"); +const RootNav = findByProps("getRootNavigationRef"); +const Haptic = findByProps("triggerHapticFeedback", "HapticFeedbackTypes"); +const Routing = findByProps("transitionToGuild"); + +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; + +export default function ServerDrawerSheet({ gestureContext }: { gestureContext: any }) { + 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[] = React.useMemo(() => { + 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 ( + + + {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, + }, +}); diff --git a/plugins/ServerDrawer/src/index.tsx b/plugins/ServerDrawer/src/index.tsx new file mode 100644 index 0000000..d88cde9 --- /dev/null +++ b/plugins/ServerDrawer/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/ServerDrawer/src/patches/contentPatch.tsx b/plugins/ServerDrawer/src/patches/contentPatch.tsx new file mode 100644 index 0000000..4ddebf7 --- /dev/null +++ b/plugins/ServerDrawer/src/patches/contentPatch.tsx @@ -0,0 +1,45 @@ +import { find } from "@vendetta/metro"; +import { React } from "@vendetta/metro/common"; +import ServerDrawerSheet from "../components/ServerDrawerSheet"; +import { registerIntercept } from "./createElementIntercept"; + +const TAG = "[ServerDrawer]"; + +const QuestDockGestureContext = find((m) => m?.QuestDockGestureContext)?.QuestDockGestureContext; + +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 not found"); + return false; + } + const orig = mod.type; + + registerIntercept(orig, ServerDrawerSheet, { gestureContext: QuestDockGestureContext }); + + mod.type = function ServerDrawerPatch() { + return ; + }; + cleanups.push(() => { mod.type = orig; }); + console.log(TAG, "PATCH: QuestDockContentExpanded replaced"); + return true; +} + +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} not found`); + return false; + } + const orig = mod.type; + mod.type = function EmptyPatch() { + return null; + }; + cleanups.push(() => { mod.type = orig; }); + return true; +} diff --git a/plugins/ServerDrawer/src/patches/createElementIntercept.ts b/plugins/ServerDrawer/src/patches/createElementIntercept.ts new file mode 100644 index 0000000..23a14e9 --- /dev/null +++ b/plugins/ServerDrawer/src/patches/createElementIntercept.ts @@ -0,0 +1,45 @@ +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; + +export function registerIntercept( + original: React.ComponentType, + replacement: React.ComponentType, + extraProps?: Record, +) { + intercepts.set(original, { replacement, extraProps }); +} + +export function patchCreateElement(cleanups: (() => void)[]) { + if (origCreateElement) return; + + origCreateElement = React.createElement; + + const patched = function (type: any, props: any, ...rest: any[]) { + 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; + + cleanups.push(() => { + if (origCreateElement) { + React.createElement = origCreateElement; + origCreateElement = null; + } + intercepts.clear(); + }); +} diff --git a/plugins/ServerDrawer/src/patches/getQuestAsset.ts b/plugins/ServerDrawer/src/patches/getQuestAsset.ts new file mode 100644 index 0000000..71b5ac1 --- /dev/null +++ b/plugins/ServerDrawer/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/ServerDrawer/src/patches/hideGuildsBar.tsx b/plugins/ServerDrawer/src/patches/hideGuildsBar.tsx new file mode 100644 index 0000000..2b5babc --- /dev/null +++ b/plugins/ServerDrawer/src/patches/hideGuildsBar.tsx @@ -0,0 +1,94 @@ +import React from "react"; +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 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"; + +function openDms() { + Haptic?.triggerHapticFeedback(Haptic.HapticFeedbackTypes.SOFT); + if (ChannelActions?.selectPrivateChannel) { + const lastChannelId = SelectedChannelStore?.getLastSelectedChannelId?.(); + ChannelActions.selectPrivateChannel(lastChannelId); + } +} + +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 { + const mod = findGuildsBar(); + if (!mod?.default) { + console.log(TAG, "WARN: GuildsBar not found"); + return false; + } + const orig = mod.default; + + 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/ServerDrawer/src/patches/mobileQuestDock.ts b/plugins/ServerDrawer/src/patches/mobileQuestDock.ts new file mode 100644 index 0000000..4941560 --- /dev/null +++ b/plugins/ServerDrawer/src/patches/mobileQuestDock.ts @@ -0,0 +1,29 @@ +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", + }; + }; + cleanups.push(() => { mod.useMobileQuestDock = orig; }); + return true; +} diff --git a/plugins/ServerDrawer/src/patches/questDockBase.ts b/plugins/ServerDrawer/src/patches/questDockBase.ts new file mode 100644 index 0000000..af936b5 --- /dev/null +++ b/plugins/ServerDrawer/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/ServerDrawer/src/patches/questDockRender.ts b/plugins/ServerDrawer/src/patches/questDockRender.ts new file mode 100644 index 0000000..9567866 --- /dev/null +++ b/plugins/ServerDrawer/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/ServerDrawer/src/utils/theme.ts b/plugins/ServerDrawer/src/utils/theme.ts new file mode 100644 index 0000000..049dfb4 --- /dev/null +++ b/plugins/ServerDrawer/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[]; +}