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[];
+}