Add ServerDrawer: fork of kmmiio99o's ServerDrawer with a crash-isolation fix
This commit is contained in:
parent
12d8d9021d
commit
ef8b28d07e
18 changed files with 1437 additions and 217 deletions
|
|
@ -26,6 +26,7 @@ To install: open Discord with Revenge installed, go to **Settings → Plugins**,
|
||||||
| FakeProfileThemesAndEffects | Revived | Profile theming and profile effects without Nitro. Fork of [shipwr3ckd's FPTE](https://github.com/shipwr3ckd/FPTE) - fixed a crash on every profile-editor open, confirmed via on-device diagnostics. | `https://rp.jarviscli.dev/fake-profile-themes-and-effects/install/` |
|
| FakeProfileThemesAndEffects | Revived | Profile theming and profile effects without Nitro. Fork of [shipwr3ckd's FPTE](https://github.com/shipwr3ckd/FPTE) - fixed a crash on every profile-editor open, confirmed via on-device diagnostics. | `https://rp.jarviscli.dev/fake-profile-themes-and-effects/install/` |
|
||||||
| BetterTimestamps | Revived | Calendar-relative, fully relative, ISO 8601, or a custom moment.js timestamp format, for both messages and day dividers. | `https://rp.jarviscli.dev/better-timestamps/install/` |
|
| BetterTimestamps | Revived | Calendar-relative, fully relative, ISO 8601, or a custom moment.js timestamp format, for both messages and day dividers. | `https://rp.jarviscli.dev/better-timestamps/install/` |
|
||||||
| CopyRoleColor | Revived | Long-press a role pill to copy its color as hex. | `https://rp.jarviscli.dev/copy-role-color/install/` |
|
| CopyRoleColor | Revived | Long-press a role pill to copy its color as hex. | `https://rp.jarviscli.dev/copy-role-color/install/` |
|
||||||
|
| ServerDrawer | Revived | Replaces the Quest Dock with an expandable server grid, hides the left server rail down to a single DMs icon, long-press a server for its context menu, one-tap create/join. Fork of [kmmiio99o's ServerDrawer](https://github.com/kmmiio99o/vd-plugins/tree/main/plugins/ServerDrawer) - fixed an unguarded lookup that could silently skip every patch after it. | `https://rp.jarviscli.dev/server-drawer/install/` |
|
||||||
|
|
||||||
### Productivity
|
### Productivity
|
||||||
|
|
||||||
|
|
|
||||||
12
plugins/server-drawer/manifest.json
Normal file
12
plugins/server-drawer/manifest.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"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": "everestmcarthur" },
|
||||||
|
{ "name": "kmmiio99o" }
|
||||||
|
],
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"vendetta": {
|
||||||
|
"icon": "ServerIcon"
|
||||||
|
}
|
||||||
|
}
|
||||||
210
plugins/server-drawer/src/components/ContextMenuModal.tsx
Normal file
210
plugins/server-drawer/src/components/ContextMenuModal.tsx
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
import React from "react";
|
||||||
|
import { View, Text, Pressable, Image, Modal, StyleSheet, Dimensions, Animated } from "react-native";
|
||||||
|
import { semanticColors } from "@vendetta/ui";
|
||||||
|
import { findByProps } from "@vendetta/metro";
|
||||||
|
import { resolveSemanticColor } from "@shared/lib/color";
|
||||||
|
|
||||||
|
const TextStyleSheet = findByProps("TextStyleSheet")?.TextStyleSheet;
|
||||||
|
const Haptic = findByProps("triggerHapticFeedback", "HapticFeedbackTypes");
|
||||||
|
|
||||||
|
function resolve(token: any, fallback: string): string {
|
||||||
|
return resolveSemanticColor(token) ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
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, "#1e1f22");
|
||||||
|
const borderColor = resolve(semanticColors?.BORDER_SUBTLE, "#3f4147");
|
||||||
|
const textColor = resolve(semanticColors?.TEXT_STRONG, "#f2f3f5");
|
||||||
|
const pressedColor = resolve(semanticColors?.BACKGROUND_MOD_SUBTLE, "rgba(255,255,255,0.06)");
|
||||||
|
|
||||||
|
const 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
154
plugins/server-drawer/src/components/FolderItem.tsx
Normal file
154
plugins/server-drawer/src/components/FolderItem.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<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 }: { node: GuildNode; onPick: (id: string) => void }) {
|
||||||
|
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} />
|
||||||
|
</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,
|
||||||
|
},
|
||||||
|
});
|
||||||
32
plugins/server-drawer/src/components/GuildIcon.tsx
Normal file
32
plugins/server-drawer/src/components/GuildIcon.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<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" },
|
||||||
|
});
|
||||||
188
plugins/server-drawer/src/components/GuildItem.tsx
Normal file
188
plugins/server-drawer/src/components/GuildItem.tsx
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
import React from "react";
|
||||||
|
import { View, Text, Pressable, Animated, StyleSheet } 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 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 }) {
|
||||||
|
useProxy(storage);
|
||||||
|
|
||||||
|
const mentionCount = Flux?.useStateFromStores?.(
|
||||||
|
[GuildReadStateStore],
|
||||||
|
() => GuildReadStateStore?.getMentionCount?.(guildId) ?? 0,
|
||||||
|
[guildId],
|
||||||
|
) ?? 0;
|
||||||
|
|
||||||
|
const hasUnread = Flux?.useStateFromStores?.(
|
||||||
|
[GuildReadStateStore],
|
||||||
|
() => GuildReadStateStore?.hasUnread?.(guildId) ?? false,
|
||||||
|
[guildId],
|
||||||
|
) ?? false;
|
||||||
|
|
||||||
|
if (!storage.showUnreadBadges) return null;
|
||||||
|
|
||||||
|
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 }: { node: GuildNode; onPick: (id: string) => void }) {
|
||||||
|
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;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Pressable
|
||||||
|
onPressIn={scaleDown}
|
||||||
|
onPressOut={scaleUp}
|
||||||
|
onPress={() => onPick(guildId)}
|
||||||
|
onLongPress={handleLongPress}
|
||||||
|
delayLongPress={500}
|
||||||
|
>
|
||||||
|
<View ref={viewRef} style={st.outer} collapsable={false}>
|
||||||
|
<Animated.View style={[st.icon, { transform: [{ scale }] }]}>
|
||||||
|
<GuildIcon id={guildId} />
|
||||||
|
</Animated.View>
|
||||||
|
<Badge guildId={guildId} />
|
||||||
|
</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, 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
150
plugins/server-drawer/src/components/ServerDrawerSheet.tsx
Normal file
150
plugins/server-drawer/src/components/ServerDrawerSheet.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
import React from "react";
|
||||||
|
import { View, Text, Pressable, Animated, 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 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 }) {
|
||||||
|
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}
|
||||||
|
>
|
||||||
|
{nodes.map((node) =>
|
||||||
|
node.type === "folder"
|
||||||
|
? <FolderItem key={node.id} node={node} onPick={pick} />
|
||||||
|
: <GuildItem key={node.id} node={node} onPick={pick} />
|
||||||
|
)}
|
||||||
|
<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,
|
||||||
|
},
|
||||||
|
});
|
||||||
75
plugins/server-drawer/src/index.ts
Normal file
75
plugins/server-drawer/src/index.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { logger } from "@vendetta";
|
||||||
|
import { storage } from "@vendetta/plugin";
|
||||||
|
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";
|
||||||
|
import Settings from "./ui/Settings";
|
||||||
|
|
||||||
|
let cleanups: (() => void)[] = [];
|
||||||
|
|
||||||
|
// Each of these functions already checks its own lookup and returns false instead of patching
|
||||||
|
// when it comes up empty - this wrapper adds one more layer so a lookup that throws outright
|
||||||
|
// (e.g. calling a property off an undefined module) can't take the rest of onLoad's patches
|
||||||
|
// down with it, same as every other plugin in this repo.
|
||||||
|
function tryPatch(name: string, fn: () => boolean): boolean {
|
||||||
|
try {
|
||||||
|
return fn();
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(`[ServerDrawer] "${name}" patch threw, skipping: ${e}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAll() {
|
||||||
|
const applied: string[] = [];
|
||||||
|
|
||||||
|
patchCreateElement(cleanups);
|
||||||
|
|
||||||
|
if (tryPatch("questDockRender", () => patchQuestDockRender(cleanups))) applied.push("questDockRender");
|
||||||
|
if (tryPatch("questDockBase", () => patchQuestDockBase(cleanups))) applied.push("questDockBase");
|
||||||
|
if (tryPatch("mobileQuestDock", () => patchMobileQuestDock(cleanups))) applied.push("mobileQuestDock");
|
||||||
|
if (tryPatch("getQuestAsset", () => patchGetQuestAsset(cleanups))) applied.push("getQuestAsset");
|
||||||
|
if (tryPatch("expanded", () => patchExpanded(cleanups))) applied.push("expanded");
|
||||||
|
if (tryPatch("collapsed", () => patchEmpty("QuestDockContentCollapsed", cleanups))) applied.push("collapsed");
|
||||||
|
if (tryPatch("enrolledHeader", () => patchEmpty("QuestDockEnrolledHeader", cleanups))) applied.push("enrolledHeader");
|
||||||
|
if (tryPatch("unenrolledHeader", () => patchEmpty("QuestDockUnenrolledHeader", cleanups))) applied.push("unenrolledHeader");
|
||||||
|
if (tryPatch("enrolledBody", () => patchEmpty("QuestDockEnrolledBody", cleanups))) applied.push("enrolledBody");
|
||||||
|
if (tryPatch("unenrolledBody", () => patchEmpty("QuestDockUnenrolledBody", cleanups))) applied.push("unenrolledBody");
|
||||||
|
|
||||||
|
if (storage.hideGuildsBar) {
|
||||||
|
if (tryPatch("hideGuildsBar", () => patchHideGuildsBar(cleanups))) applied.push("hideGuildsBar");
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log(`[ServerDrawer] onLoad done - ${applied.length}/11 patches applied (${applied.join(", ") || "none"})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unpatchAll() {
|
||||||
|
for (const fn of cleanups) {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
} catch {
|
||||||
|
// A patch failing to undo shouldn't block the others from unwinding.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cleanups = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restart() {
|
||||||
|
unpatchAll();
|
||||||
|
applyAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
onLoad: () => {
|
||||||
|
storage.hideGuildsBar ??= true;
|
||||||
|
storage.showUnreadBadges ??= true;
|
||||||
|
|
||||||
|
applyAll();
|
||||||
|
},
|
||||||
|
onUnload: () => unpatchAll(),
|
||||||
|
settings: Settings,
|
||||||
|
};
|
||||||
59
plugins/server-drawer/src/patches/contentPatch.tsx
Normal file
59
plugins/server-drawer/src/patches/contentPatch.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { find } from "@vendetta/metro";
|
||||||
|
import { React } from "@vendetta/metro/common";
|
||||||
|
import ServerDrawerSheet from "../components/ServerDrawerSheet";
|
||||||
|
import { registerIntercept } from "./createElementIntercept";
|
||||||
|
|
||||||
|
const TAG = "[ServerDrawer]";
|
||||||
|
|
||||||
|
let cachedGestureContext: any = null;
|
||||||
|
function getGestureContext(): any {
|
||||||
|
if (!cachedGestureContext) {
|
||||||
|
cachedGestureContext = find((m) => m?.QuestDockGestureContext)?.QuestDockGestureContext ?? null;
|
||||||
|
}
|
||||||
|
return cachedGestureContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (will retry)");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
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");
|
||||||
|
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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
56
plugins/server-drawer/src/patches/createElementIntercept.ts
Normal file
56
plugins/server-drawer/src/patches/createElementIntercept.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { React } from "@vendetta/metro/common";
|
||||||
|
|
||||||
|
interface Intercept {
|
||||||
|
replacement: React.ComponentType<any>;
|
||||||
|
extraProps?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const intercepts = new Map<React.ComponentType<any>, Intercept>();
|
||||||
|
let origCreateElement: typeof React.createElement | null = null;
|
||||||
|
let isPatched = false;
|
||||||
|
|
||||||
|
export function registerIntercept(
|
||||||
|
original: React.ComponentType<any>,
|
||||||
|
replacement: React.ComponentType<any>,
|
||||||
|
extraProps?: Record<string, any>,
|
||||||
|
) {
|
||||||
|
intercepts.set(original, { replacement, extraProps });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function patchCreateElement(cleanups: (() => void)[]) {
|
||||||
|
if (isPatched) return;
|
||||||
|
|
||||||
|
// Guard against React.createElement being null in some environments
|
||||||
|
origCreateElement = React.createElement;
|
||||||
|
if (!origCreateElement) {
|
||||||
|
console.warn("[ServerDrawer] React.createElement is null, skipping patch");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const patched = function (type: any, props: any, ...rest: any[]) {
|
||||||
|
if (!origCreateElement) {
|
||||||
|
// Fallback to original createElement if somehow null (should not happen)
|
||||||
|
return React.createElement(type, props, ...rest);
|
||||||
|
}
|
||||||
|
const entry = intercepts.get(type);
|
||||||
|
if (entry) {
|
||||||
|
const newProps = entry.extraProps
|
||||||
|
? { ...props, ...entry.extraProps }
|
||||||
|
: props;
|
||||||
|
return origCreateElement.call(React, entry.replacement, newProps, ...rest);
|
||||||
|
}
|
||||||
|
return origCreateElement.call(React, type, props, ...rest);
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.assign(patched, origCreateElement);
|
||||||
|
React.createElement = patched as typeof React.createElement;
|
||||||
|
isPatched = true;
|
||||||
|
|
||||||
|
cleanups.push(() => {
|
||||||
|
if (isPatched && origCreateElement) {
|
||||||
|
React.createElement = origCreateElement;
|
||||||
|
isPatched = false;
|
||||||
|
}
|
||||||
|
intercepts.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
21
plugins/server-drawer/src/patches/getQuestAsset.ts
Normal file
21
plugins/server-drawer/src/patches/getQuestAsset.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
94
plugins/server-drawer/src/patches/hideGuildsBar.tsx
Normal file
94
plugins/server-drawer/src/patches/hideGuildsBar.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<Pressable onPress={openDms} style={{ flex: 1, alignItems: "center", justifyContent: "center" }}>
|
||||||
|
{selected ? (
|
||||||
|
<View style={{
|
||||||
|
position: "absolute", left: 0, top: "25%",
|
||||||
|
width: 4, height: "50%", borderRadius: 2,
|
||||||
|
backgroundColor: colors?.WHITE ?? "#fff",
|
||||||
|
}} />
|
||||||
|
) : null}
|
||||||
|
<Image
|
||||||
|
source={ChatIcon}
|
||||||
|
style={{ width: 28, height: 28, tintColor: iconColor }}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
29
plugins/server-drawer/src/patches/mobileQuestDock.ts
Normal file
29
plugins/server-drawer/src/patches/mobileQuestDock.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { findByProps } from "@vendetta/metro";
|
||||||
|
|
||||||
|
export function patchMobileQuestDock(cleanups: (() => void)[]): boolean {
|
||||||
|
const mod = findByProps("useMobileQuestDock");
|
||||||
|
// The upstream version of this check was `!mod.useMobileQuestDock` (no `?.`) - if the lookup
|
||||||
|
// came back empty, that threw immediately and, since nothing here was wrapped in try/catch,
|
||||||
|
// took every patch after this one in the onLoad sequence down with it.
|
||||||
|
if (!mod?.useMobileQuestDock) 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;
|
||||||
|
}
|
||||||
14
plugins/server-drawer/src/patches/questDockBase.ts
Normal file
14
plugins/server-drawer/src/patches/questDockBase.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { findByProps } from "@vendetta/metro";
|
||||||
|
|
||||||
|
export function patchQuestDockBase(cleanups: (() => void)[]): boolean {
|
||||||
|
const mod = findByProps("useIsMobileQuestDockRenderedBase");
|
||||||
|
if (!mod?.useIsMobileQuestDockRenderedBase) return false;
|
||||||
|
|
||||||
|
const orig = mod.useIsMobileQuestDockRenderedBase;
|
||||||
|
mod.useIsMobileQuestDockRenderedBase = function (...args: any[]) {
|
||||||
|
orig.apply(this, args);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
cleanups.push(() => { mod.useIsMobileQuestDockRenderedBase = orig; });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
14
plugins/server-drawer/src/patches/questDockRender.ts
Normal file
14
plugins/server-drawer/src/patches/questDockRender.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { findByProps } from "@vendetta/metro";
|
||||||
|
|
||||||
|
export function patchQuestDockRender(cleanups: (() => void)[]): boolean {
|
||||||
|
const mod = findByProps("useIsMobileQuestDockRendered");
|
||||||
|
if (!mod?.useIsMobileQuestDockRendered) return false;
|
||||||
|
|
||||||
|
const orig = mod.useIsMobileQuestDockRendered;
|
||||||
|
mod.useIsMobileQuestDockRendered = function (...args: any[]) {
|
||||||
|
orig.apply(this, args);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
cleanups.push(() => { mod.useIsMobileQuestDockRendered = orig; });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
40
plugins/server-drawer/src/ui/Settings.tsx
Normal file
40
plugins/server-drawer/src/ui/Settings.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { React } from "@vendetta/metro/common";
|
||||||
|
import { storage } from "@vendetta/plugin";
|
||||||
|
import { useProxy } from "@vendetta/storage";
|
||||||
|
import { Forms } from "@vendetta/ui/components";
|
||||||
|
import SettingsScaffold from "@shared/ui/SettingsScaffold";
|
||||||
|
import NoteBox from "@shared/ui/NoteBox";
|
||||||
|
import { restart } from "../index";
|
||||||
|
|
||||||
|
const { FormSection, FormSwitchRow } = Forms;
|
||||||
|
|
||||||
|
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 use the + button to create or join a server through Discord's own modal.
|
||||||
|
</NoteBox>
|
||||||
|
<FormSection title="Layout">
|
||||||
|
<FormSwitchRow
|
||||||
|
label="Hide the left server rail"
|
||||||
|
subLabel="Replaces it with a single DMs icon since your servers now live in the drawer"
|
||||||
|
value={storage.hideGuildsBar !== false}
|
||||||
|
onValueChange={(v: boolean) => {
|
||||||
|
storage.hideGuildsBar = v;
|
||||||
|
restart();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FormSwitchRow
|
||||||
|
label="Unread badges"
|
||||||
|
subLabel="Show mention counts and unread dots on server icons in the drawer"
|
||||||
|
value={storage.showUnreadBadges !== false}
|
||||||
|
onValueChange={(v: boolean) => { storage.showUnreadBadges = v; }}
|
||||||
|
/>
|
||||||
|
</FormSection>
|
||||||
|
</SettingsScaffold>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
plugins/server-drawer/src/utils/theme.ts
Normal file
33
plugins/server-drawer/src/utils/theme.ts
Normal file
|
|
@ -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[];
|
||||||
|
}
|
||||||
|
|
@ -64,10 +64,22 @@
|
||||||
"No character limit beyond Discord's own message limit"
|
"No character limit beyond Discord's own message limit"
|
||||||
],
|
],
|
||||||
"commands": [
|
"commands": [
|
||||||
{ "cmd": "/snippet <name>", "desc": "Sends the saved snippet as a message in the current channel" },
|
{
|
||||||
{ "cmd": "/snippet-save <name> <text>", "desc": "Saves or overwrites a snippet" },
|
"cmd": "/snippet <name>",
|
||||||
{ "cmd": "/snippet-delete <name>", "desc": "Deletes a saved snippet" },
|
"desc": "Sends the saved snippet as a message in the current channel"
|
||||||
{ "cmd": "/snippet-list", "desc": "Shows your saved snippet names as a toast (not posted publicly)" }
|
},
|
||||||
|
{
|
||||||
|
"cmd": "/snippet-save <name> <text>",
|
||||||
|
"desc": "Saves or overwrites a snippet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cmd": "/snippet-delete <name>",
|
||||||
|
"desc": "Deletes a saved snippet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cmd": "/snippet-list",
|
||||||
|
"desc": "Shows your saved snippet names as a toast (not posted publicly)"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"limitations": ""
|
"limitations": ""
|
||||||
},
|
},
|
||||||
|
|
@ -84,7 +96,10 @@
|
||||||
"Persists across a plugin reload"
|
"Persists across a plugin reload"
|
||||||
],
|
],
|
||||||
"commands": [
|
"commands": [
|
||||||
{ "cmd": "/remind <in> <text>", "desc": "e.g. /remind 20m Walk the dog - fires an in-app alert when it's due" }
|
{
|
||||||
|
"cmd": "/remind <in> <text>",
|
||||||
|
"desc": "e.g. /remind 20m Walk the dog - fires an in-app alert when it's due"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"limitations": "Can't wake Discord up from fully closed - that needs a native OS-level scheduled notification, which isn't something a JS-only plugin has access to. If you force-close the app, pending reminders resume checking once you reopen it, but won't have fired while it was closed."
|
"limitations": "Can't wake Discord up from fully closed - that needs a native OS-level scheduled notification, which isn't something a JS-only plugin has access to. If you force-close the app, pending reminders resume checking once you reopen it, but won't have fired while it was closed."
|
||||||
},
|
},
|
||||||
|
|
@ -163,7 +178,10 @@
|
||||||
"Truncates long entries so they stay readable in chat"
|
"Truncates long entries so they stay readable in chat"
|
||||||
],
|
],
|
||||||
"commands": [
|
"commands": [
|
||||||
{ "cmd": "/urban <term>", "desc": "Looks up a term and posts the top Urban Dictionary result" }
|
{
|
||||||
|
"cmd": "/urban <term>",
|
||||||
|
"desc": "Looks up a term and posts the top Urban Dictionary result"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"limitations": "Urban Dictionary's community submissions are what they are - results aren't moderated by this plugin."
|
"limitations": "Urban Dictionary's community submissions are what they are - results aren't moderated by this plugin."
|
||||||
},
|
},
|
||||||
|
|
@ -179,7 +197,10 @@
|
||||||
"Defaults to Google Lens if no engine is specified"
|
"Defaults to Google Lens if no engine is specified"
|
||||||
],
|
],
|
||||||
"commands": [
|
"commands": [
|
||||||
{ "cmd": "/reverse-image-search <image-url> [engine]", "desc": "Opens the image in your browser's reverse image search" }
|
{
|
||||||
|
"cmd": "/reverse-image-search <image-url> [engine]",
|
||||||
|
"desc": "Opens the image in your browser's reverse image search"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"limitations": "Needs a direct image URL, not a Discord attachment reference - use \"Copy Media Link\" first."
|
"limitations": "Needs a direct image URL, not a Discord attachment reference - use \"Copy Media Link\" first."
|
||||||
},
|
},
|
||||||
|
|
@ -215,5 +236,22 @@
|
||||||
],
|
],
|
||||||
"commands": [],
|
"commands": [],
|
||||||
"limitations": "The Material-Design component reskin from the original isn't included (needs react-native-reanimated). Each module was fixed and defensively guarded, but only the specific reported crash was verified against live decompiled source - the rest are guarded, not individually confirmed working on-device."
|
"limitations": "The Material-Design component reskin from the original isn't included (needs react-native-reanimated). Each module was fixed and defensively guarded, but only the specific reported crash was verified against live decompiled source - the rest are guarded, not individually confirmed working on-device."
|
||||||
|
},
|
||||||
|
"server-drawer": {
|
||||||
|
"category": "Moderation & Appearance",
|
||||||
|
"status": "revived",
|
||||||
|
"accent": "#F97316",
|
||||||
|
"tagline": "Turns the Quest Dock into an expandable server grid, above the YouBar.",
|
||||||
|
"note": "Fork of kmmiio99o's ServerDrawer. One lookup (useMobileQuestDock) was missing an optional-chaining guard - if that lookup came back empty, the whole onLoad threw and every patch after it in the sequence never got applied, not just that one surface. Fixed, plus every patch call is now wrapped so a single bad lookup only skips its own surface.",
|
||||||
|
"howItWorks": "Hijacks the Quest Dock - the strip Discord normally uses for daily quests, right above the tab bar - and puts a grid of your servers there instead. Tap it to expand, tap a server to jump to it, long-press for the usual context menu (mark as read, notification settings, leave, etc). A + button opens Discord's own create/join-server modal. Optionally hides the left server rail down to a single DMs icon, since your servers now live in the drawer instead.",
|
||||||
|
"features": [
|
||||||
|
"Tap-to-expand server grid with folders, unread badges, and mention counts",
|
||||||
|
"Long-press a server for its real context menu, not a rebuilt one",
|
||||||
|
"One-tap create/join through Discord's native modal",
|
||||||
|
"Optional: collapse the left server rail to a single DMs icon",
|
||||||
|
"Toggle unread badges independently"
|
||||||
|
],
|
||||||
|
"commands": [],
|
||||||
|
"limitations": "Depends on several internal Quest Dock lookups (11 in total) - each is individually guarded, so a future Discord rename degrades one surface at a time instead of breaking the whole plugin. Drawer position (left/right/top) is planned but not in this build yet - it currently only replaces the Quest Dock's native bottom position."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue