Delete plugins/server-drawer directory

This commit is contained in:
Rosie Val 2026-08-27 08:04:42 -04:00 • committed by GitHub
parent 74a3aeac2d
commit 4981833a38
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 0 additions and 1557 deletions

View file

@ -1,13 +0,0 @@
{
"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"
}
}

View file

@ -1,213 +0,0 @@
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<any>;
}
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 (
<Modal transparent visible={show} onRequestClose={handleClose} statusBarTranslucent>
<Pressable style={localStyles.root} onPress={handleClose}>
<Animated.View style={[localStyles.backdrop, { opacity: backdropOpacity }]} />
<Animated.View
style={[
localStyles.container,
{
left,
top,
backgroundColor: bgContainer,
borderColor,
minWidth: MIN_W,
opacity: menuOpacity,
transform: [{ scale: menuScale }],
},
]}
>
{title ? (
<View>
<Text
style={[
TextStyleSheet?.["text-md/bold"],
{ color: textColor, paddingHorizontal: PAD, paddingTop: 13, paddingBottom: 12 },
]}
>
{title}
</Text>
<View style={{ borderBottomWidth: DIVIDER_H, borderBottomColor: borderColor }} />
</View>
) : null}
{items.map((item, i) => {
const isFirst = i === 0 && !title;
const isLast = i === items.length - 1;
const hasIcon = !!(item.iconSource || item.IconComponent);
return (
<Pressable
key={i}
style={({ pressed }) => ({
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?.();
}}
>
<Text
style={[
TextStyleSheet?.["text-md/medium"],
{
flexShrink: 1,
color: textColor,
},
]}
>
{item.label}
</Text>
{hasIcon ? (
<View style={{ width: ICON_SIZE, height: ICON_SIZE, alignItems: "center", justifyContent: "center" }}>
{item.IconComponent ? (
<item.IconComponent size="sm" color={textColor} />
) : item.iconSource ? (
<Image source={item.iconSource} style={{ width: ICON_SIZE, height: ICON_SIZE, tintColor: textColor }} />
) : null}
</View>
) : null}
</Pressable>
);
})}
</Animated.View>
</Pressable>
</Modal>
);
}
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,
},
});

View file

@ -1,44 +0,0 @@
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 (
<Pressable onPress={openDms} style={st.outer}>
<View style={[st.icon, { backgroundColor: selected ? (colors?.BG_ACCENT ?? "#5865f2") : "rgba(128,128,128,0.24)" }]}>
<Image source={ChatIcon} style={{ width: 24, height: 24, tintColor: "#fff" }} />
</View>
</Pressable>
);
}
const st = StyleSheet.create({
outer: { width: ICON, height: ICON },
icon: { width: ICON, height: ICON, borderRadius: 16, alignItems: "center", justifyContent: "center" },
});

View file

@ -1,154 +0,0 @@
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 (
<View style={fbd.outline}>
<View style={fbd.badge}>
<Text style={fbd.text}>{total > 99 ? "99+" : String(total)}</Text>
</View>
</View>
);
}
return null;
}
function FolderCover({ node }: { node: GuildNode }) {
const col = folderColor(node.color);
return (
<View style={fc.outer}>
<View style={[fc.icon, { backgroundColor: col }]}>
{node.children.slice(0, 4).map((ch, i) => (
<View key={ch.id} style={[fc.cell, POS[i]]}>
<GuildIcon id={ch.id as string} size={MINI} />
</View>
))}
</View>
<FolderBadge node={node} />
</View>
);
}
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 <Animated.View style={{ opacity }}>{children}</Animated.View>;
}
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 (
<>
<Pressable onPress={toggle}>
<View style={[fo.openIcon, { backgroundColor: folderColor(node.color) }]}>
<Image source={FOLDER_ASSET} style={fo.folderImg} tintColor="#fff" />
</View>
</Pressable>
{node.children.map((ch) => (
<FadeIn key={ch.id}>
<GuildItem node={ch} onPick={onPick} showNames={showNames} />
</FadeIn>
))}
</>
);
}
return (
<Pressable onPress={toggle}>
<FolderCover node={node} />
</Pressable>
);
}
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,
},
});

View file

@ -1,32 +0,0 @@
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 (
<Image
source={{ uri: `https://cdn.discordapp.com/icons/${g.id}/${g.icon}.png?size=${size * 2}` }}
style={{ width: size, height: size, borderRadius: rad }}
/>
);
}
return (
<View style={[st.fallback, { width: size, height: size, borderRadius: rad, backgroundColor: colors?.BG_ACCENT ?? "#5865f2" }]}>
<Text style={[st.letter, { fontSize: Math.max(10, size * 0.4) }]}>
{g.name?.split(" ").map((w: string) => w[0]).join("").slice(0, 2).toUpperCase() || "?"}
</Text>
</View>
);
}
const st = StyleSheet.create({
fallback: { alignItems: "center", justifyContent: "center" },
letter: { color: "#fff", fontWeight: "700" },
});

View file

@ -1,209 +0,0 @@
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 (
<View style={bd.outline}>
<View style={bd.badge}>
<Text style={bd.text}>{mentionCount > 99 ? "99+" : String(mentionCount)}</Text>
</View>
</View>
);
}
if (hasUnread) {
return (
<View style={bd.dotOutline}>
<View style={bd.dot} />
</View>
);
}
return null;
}
export default function GuildItem({ node, onPick, showNames }: { node: GuildNode; onPick: (id: string) => void; showNames?: boolean }) {
const viewRef = React.useRef<View>(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 (
<>
<Pressable
onPressIn={scaleDown}
onPressOut={scaleUp}
onPress={() => onPick(guildId)}
onLongPress={handleLongPress}
delayLongPress={500}
>
<View style={st.outer}>
<View ref={viewRef} style={st.iconWrap} collapsable={false}>
<Animated.View style={[st.icon, { transform: [{ scale }] }]}>
<GuildIcon id={guildId} />
</Animated.View>
<Badge guildId={guildId} />
</View>
{showNames && (
<Text numberOfLines={2} ellipsizeMode="tail" style={st.label}>
{name}
</Text>
)}
</View>
</Pressable>
<ContextMenuModal
visible={menuState.visible}
items={menuState.items}
title={menuState.title}
anchorX={menuState.x}
anchorY={menuState.y}
onClose={() => 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",
},
});

View file

@ -1,156 +0,0 @@
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 (
<Pressable onPress={onPress} onPressIn={scaleDown} onPressOut={scaleUp}>
<Animated.View style={[st.createJoin, { transform: [{ scale }] }]}>
{CirclePlusIcon ? (
<CirclePlusIcon size="md" color={createJoinIconColor} />
) : (
<Text style={st.createJoinFallback}>{"+"}</Text>
)}
</Animated.View>
</Pressable>
);
}
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 (
<View style={st.alignTop}>
<View
style={[st.grid, { paddingHorizontal: padX, gap: GAP }]}
onLayout={onLayout}
>
{!storage.hideDmTile && <DmTile />}
{nodes.map((node) =>
node.type === "folder"
? <FolderItem key={node.id} node={node} onPick={pick} showNames={!!storage.showGuildNames} />
: <GuildItem key={node.id} node={node} onPick={pick} showNames={!!storage.showGuildNames} />
)}
<CreateJoinButton />
</View>
</View>
);
}
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,
},
});

View file

@ -1,48 +0,0 @@
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 { storage } from "@vendetta/plugin";
import Settings from "./ui/Settings";
const TAG = "[ServerDrawer]";
const cleanups: (() => void)[] = [];
export default {
onLoad() {
storage.hideDmTile ??= false;
storage.showGuildNames ??= false;
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++;
if (patchTransparentBackground(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;
},
settings: Settings,
};

View file

@ -1,77 +0,0 @@
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 <ServerDrawerSheet gestureContext={getGestureContext()} />;
};
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;
}

View file

@ -1,304 +0,0 @@
import { React } from "@vendetta/metro/common";
import { after } from "@vendetta/patcher";
interface Intercept {
replacement: React.ComponentType<any>;
extraProps?: Record<string, any>;
/** 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<any> | 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<React.ComponentType<any>, Intercept>();
const propsIntercepts: PropsIntercept[] = [];
const propsTransforms: PropsTransform[] = [];
let typeDetectors: TypeDetector[] = [];
const detectorKeys = new Set<string>();
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<object, number>();
// 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<any>,
replacement: React.ComponentType<any>,
extraProps?: Record<string, any>,
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<any> | 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<any>();
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<typeof setInterval> | 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();
});
}

View file

@ -1,21 +0,0 @@
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;
}

View file

@ -1,78 +0,0 @@
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;
}

View file

@ -1,30 +0,0 @@
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;
}

View file

@ -1,18 +0,0 @@
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;
}

View file

@ -1,18 +0,0 @@
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;
}

View file

@ -1,46 +0,0 @@
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<string, any> {
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;
}

View file

@ -1,29 +0,0 @@
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 (
<View
style={[
{
marginHorizontal: 16,
marginVertical: 8,
padding: 10,
borderRadius: 8,
borderWidth: 1,
borderColor: "rgba(128,128,128,0.25)"
},
style
]}
>
<Text style={{ fontSize: 12.5, lineHeight: 18, opacity: 0.75, color: textColor }}>{children}</Text>
</View>
);
}

View file

@ -1,34 +0,0 @@
import { React } from "@vendetta/metro/common";
import { storage } from "@vendetta/plugin";
import { useProxy } from "@vendetta/storage";
import { TableRowGroup, TableSwitchRow } from "@shared/ui/table";
import SettingsScaffold from "@shared/ui/SettingsScaffold";
import NoteBox from "./NoteBox";
export default function Settings() {
useProxy(storage);
return (
<SettingsScaffold>
<NoteBox>
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.
</NoteBox>
<TableRowGroup title="Layout">
<TableSwitchRow
label="Hide the DMs tile"
subLabel="Remove the DMs tile from the drawer entirely"
value={!!storage.hideDmTile}
onValueChange={(v: boolean) => { storage.hideDmTile = v; }}
/>
<TableSwitchRow
label="Show server names"
subLabel="Print each server's name under its icon in the drawer"
value={!!storage.showGuildNames}
onValueChange={(v: boolean) => { storage.showGuildNames = v; }}
/>
</TableRowGroup>
</SettingsScaffold>
);
}

View file

@ -1,33 +0,0 @@
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[];
}