225 lines
7.4 KiB
TypeScript
225 lines
7.4 KiB
TypeScript
import React from "react";
|
|
import { View, Text, Pressable, Animated, StyleSheet } from "react-native";
|
|
import { findByProps, findByStoreName } from "@vendetta/metro";
|
|
import { clipboard } from "@vendetta/metro/common";
|
|
import { storage } from "@vendetta/plugin";
|
|
import { useProxy } from "@vendetta/storage";
|
|
import { showToast } from "@vendetta/ui/toasts";
|
|
import { lazy } from "@shared/lib/lazy";
|
|
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 GuildChannelStore = findByStoreName("GuildChannelStore");
|
|
const ChannelStore = findByStoreName("ChannelStore");
|
|
const Haptic = findByProps("triggerHapticFeedback", "HapticFeedbackTypes");
|
|
|
|
const getBulkAck = lazy(() => findByProps("bulkAck", "ackChannel"));
|
|
const getReadStateTypes = lazy(() => findByProps("ReadStateTypes", "UnreadSetting")?.ReadStateTypes);
|
|
|
|
// Discord's own per-guild context menu (getGuildsBarGuildMenuItems) is a bare default export with
|
|
// no named export - findByName/`.default.name` heuristics for it are unreliable, since a plain
|
|
// function's own .name doesn't survive production minification the way an explicit .displayName
|
|
// assignment does (confirmed by cross-checking: many memo-wrapped components' .displayName DID
|
|
// show up as literal strings in decompiled source, this function's .name never does). Rather than
|
|
// guess at a fragile lookup, this builds a small menu from actions this repo has already confirmed
|
|
// work elsewhere (the same bulkAck path MarkAllReadButton uses, scoped to one guild).
|
|
function markGuildRead(guildId: string) {
|
|
const bulkAck = getBulkAck();
|
|
const ReadStateTypes = getReadStateTypes();
|
|
if (!bulkAck?.bulkAck || ReadStateTypes?.CHANNEL == null || !GuildChannelStore?.getSelectableChannelIds) {
|
|
showToast("Couldn't find Discord's read-state action - this may be unavailable on your version.", undefined);
|
|
return;
|
|
}
|
|
|
|
const channelIds = [
|
|
...(GuildChannelStore.getSelectableChannelIds(guildId) ?? []),
|
|
...(GuildChannelStore.getVocalChannelIds?.(guildId) ?? []),
|
|
];
|
|
const channels = channelIds.map((channelId: string) => ({
|
|
channelId,
|
|
readStateType: ReadStateTypes.CHANNEL,
|
|
messageId: ChannelStore?.getChannel?.(channelId)?.lastMessageId ?? null,
|
|
}));
|
|
|
|
if (channels.length === 0) {
|
|
showToast("Nothing to mark as read.", undefined);
|
|
return;
|
|
}
|
|
|
|
bulkAck.bulkAck(channels);
|
|
showToast("Marked as read", undefined);
|
|
}
|
|
|
|
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);
|
|
if (!guild) return;
|
|
|
|
const ref = viewRef.current as any;
|
|
if (!ref?.measure) return;
|
|
|
|
const items: ContextMenuItem[] = [
|
|
{ label: "Mark as Read", action: () => markGuildRead(guildId) },
|
|
{
|
|
label: "Copy Server ID",
|
|
action: () => {
|
|
clipboard.setString(guildId);
|
|
showToast("Copied server ID", undefined);
|
|
},
|
|
},
|
|
];
|
|
|
|
ref.measure((_fx: number, _fy: number, _w: number, _h: number, pageX: number, pageY: number) => {
|
|
setMenuState({
|
|
visible: true,
|
|
items,
|
|
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",
|
|
},
|
|
});
|