200 lines
9 KiB
TypeScript
200 lines
9 KiB
TypeScript
import { React, ReactNative, NavigationNative } from "@vendetta/metro/common";
|
|
import { findByProps, findByDisplayName } from "@vendetta/metro";
|
|
import { storage } from "@vendetta/plugin";
|
|
import { TableRow, TableRowGroup } from "@shared/ui/table";
|
|
import { getNotifications, subscribeToNotifications } from "../lib/notifications";
|
|
import type { MentionSubCategory, NotificationCategory, NotificationItem } from "../lib/types";
|
|
|
|
// Ported from fshinz/Revenge-Plugins' BetterInbox (credit: shin), merged in here as an optional
|
|
// YouBar+ feature - see lib/notifications.ts for why.
|
|
|
|
const { View, Text, TouchableOpacity, ScrollView, Image, StyleSheet } = ReactNative;
|
|
const { useState, useMemo, useCallback, useEffect, useReducer, memo } = React;
|
|
|
|
const Router = findByProps("transitionToGuild", "transitionTo");
|
|
const NativeTabs = findByDisplayName("Tabs");
|
|
const useTabsState = findByProps("useTabsState")?.useTabsState;
|
|
const NativeSegmentedControl = findByDisplayName("SegmentedControl");
|
|
|
|
function categoryLabel(cat: NotificationCategory): string {
|
|
if (cat === "friend_request") return "Friend Requests";
|
|
if (cat === "thread") return "Threads";
|
|
return cat.charAt(0).toUpperCase() + cat.slice(1);
|
|
}
|
|
|
|
function getAvatarUrl(author: any): string {
|
|
if (!author) return "https://cdn.discordapp.com/embed/avatars/0.png";
|
|
const { id, avatar, discriminator } = author;
|
|
|
|
if (avatar) {
|
|
const ext = typeof avatar === "string" && avatar.startsWith("a_") ? "gif" : "png";
|
|
return `https://cdn.discordapp.com/avatars/${id}/${avatar}.${ext}?size=128`;
|
|
}
|
|
|
|
try {
|
|
const defaultIndex = discriminator && discriminator !== "0"
|
|
? parseInt(discriminator, 10) % 5
|
|
: Number((BigInt(id || "0") >> 22n) % 6n);
|
|
return `https://cdn.discordapp.com/embed/avatars/${defaultIndex}.png`;
|
|
} catch {
|
|
return "https://cdn.discordapp.com/embed/avatars/0.png";
|
|
}
|
|
}
|
|
|
|
const NotificationRow = memo(({ item, onPress }: { item: NotificationItem; onPress: () => void }) => {
|
|
const subLabelText = `${item.guildName} · ${item.channelName}\n${item.content || ""}`.trim();
|
|
|
|
return (
|
|
<TableRow
|
|
label={item.title}
|
|
subLabel={subLabelText}
|
|
trailing={<Text style={styles.timestampText}>{item.timestamp}</Text>}
|
|
icon={<Image source={{ uri: getAvatarUrl(item.author) }} style={styles.avatarImage} />}
|
|
onPress={onPress}
|
|
/>
|
|
);
|
|
});
|
|
|
|
export default function NotificationCenter(): JSX.Element {
|
|
const [activeTabIdx, setActiveTabIdx] = useState(0);
|
|
const [mentionFilterIdx, setMentionFilterIdx] = useState(0);
|
|
|
|
const categories: NotificationCategory[] = ["mentions", "replies", "reactions", "friend_request", "thread", "other"];
|
|
const subFilters: Array<"all" | MentionSubCategory> = ["all", "people", "role", "bot"];
|
|
|
|
const currentCategory = categories[activeTabIdx] ?? "mentions";
|
|
const currentMentionFilter = subFilters[mentionFilterIdx] ?? "all";
|
|
|
|
const tabsState = useTabsState
|
|
? useTabsState({
|
|
items: categories.map((cat) => ({ id: cat, label: categoryLabel(cat) })),
|
|
initialIndex: 0,
|
|
})
|
|
: null;
|
|
|
|
const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
|
|
useEffect(() => subscribeToNotifications(() => forceUpdate()), []);
|
|
|
|
const notifications = getNotifications();
|
|
|
|
const displayedNotifications = useMemo(() => {
|
|
const filtered = notifications.filter((n) => {
|
|
if (currentCategory === "mentions") {
|
|
if (n.category !== "mentions") return false;
|
|
if (currentMentionFilter === "all") return true;
|
|
return n.subCategory === currentMentionFilter;
|
|
}
|
|
return n.category === currentCategory;
|
|
});
|
|
return filtered.slice(0, 30);
|
|
}, [notifications, currentCategory, currentMentionFilter]);
|
|
|
|
const jumpToMessage = useCallback((guildId?: string, channelId?: string, messageId?: string) => {
|
|
if (!channelId || !messageId) return;
|
|
try {
|
|
if (Router?.transitionToGuild) {
|
|
Router.transitionToGuild(guildId || "@me", channelId, messageId);
|
|
} else if (NavigationNative?.navigate) {
|
|
NavigationNative.navigate("Channel", { guildId, channelId, messageId });
|
|
}
|
|
} catch (err) {
|
|
console.error("[YouBar+] Inbox navigation error:", err);
|
|
}
|
|
}, []);
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
{NativeTabs && tabsState ? (
|
|
<NativeTabs
|
|
state={{
|
|
...tabsState,
|
|
activeIndex: activeTabIdx,
|
|
setActiveIndex: (idx: number) => {
|
|
tabsState.setActiveIndex?.(idx);
|
|
setActiveTabIdx(idx);
|
|
},
|
|
}}
|
|
/>
|
|
) : (
|
|
<View style={styles.tabBar}>
|
|
{categories.map((tab, idx) => (
|
|
<TouchableOpacity
|
|
key={tab}
|
|
style={[styles.tabButton, activeTabIdx === idx && styles.activeTabButton]}
|
|
onPress={() => setActiveTabIdx(idx)}
|
|
>
|
|
<Text style={[styles.tabText, activeTabIdx === idx && styles.activeTabText]}>
|
|
{categoryLabel(tab)}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
))}
|
|
</View>
|
|
)}
|
|
|
|
{currentCategory === "mentions" && (
|
|
<View style={styles.subFilterWrapper}>
|
|
{NativeSegmentedControl ? (
|
|
<NativeSegmentedControl
|
|
value={currentMentionFilter}
|
|
options={subFilters.map((sub) => ({ value: sub, label: sub.toUpperCase() }))}
|
|
onChange={(val: string) => {
|
|
const idx = subFilters.indexOf(val as any);
|
|
if (idx !== -1) setMentionFilterIdx(idx);
|
|
}}
|
|
onValueChange={(val: string) => {
|
|
const idx = subFilters.indexOf(val as any);
|
|
if (idx !== -1) setMentionFilterIdx(idx);
|
|
}}
|
|
/>
|
|
) : (
|
|
<View style={styles.subFilterBar}>
|
|
{subFilters.map((sub, idx) => (
|
|
<TouchableOpacity
|
|
key={sub}
|
|
style={[styles.subFilterButton, mentionFilterIdx === idx && styles.activeSubFilter]}
|
|
onPress={() => setMentionFilterIdx(idx)}
|
|
>
|
|
<Text style={styles.subFilterText}>{sub.toUpperCase()}</Text>
|
|
</TouchableOpacity>
|
|
))}
|
|
</View>
|
|
)}
|
|
</View>
|
|
)}
|
|
|
|
<ScrollView style={styles.feed} removeClippedSubviews>
|
|
{displayedNotifications.length === 0 ? (
|
|
<Text style={styles.emptyText}>No notifications found for this category.</Text>
|
|
) : (
|
|
<TableRowGroup title={`RECENT ${categoryLabel(currentCategory).toUpperCase()}`}>
|
|
{displayedNotifications.map((item) => (
|
|
<NotificationRow
|
|
key={item.id || `${item.channelId}-${item.messageId}`}
|
|
item={item}
|
|
onPress={() => jumpToMessage(item.guildId, item.channelId, item.messageId)}
|
|
/>
|
|
))}
|
|
</TableRowGroup>
|
|
)}
|
|
</ScrollView>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: { flex: 1 },
|
|
subFilterWrapper: { paddingHorizontal: 12, paddingVertical: 8 },
|
|
tabBar: { flexDirection: "row", paddingVertical: 4 },
|
|
tabButton: { flex: 1, paddingVertical: 10, alignItems: "center" },
|
|
activeTabButton: { borderBottomWidth: 2, borderBottomColor: "#5865F2" },
|
|
tabText: { color: "#949ba4", fontWeight: "600", fontSize: 13 },
|
|
activeTabText: { color: "#ffffff" },
|
|
subFilterBar: { flexDirection: "row", justifyContent: "center" },
|
|
subFilterButton: { marginHorizontal: 6, paddingHorizontal: 10, paddingVertical: 4, borderRadius: 12 },
|
|
activeSubFilter: { backgroundColor: "#404249" },
|
|
subFilterText: { color: "#dbdee1", fontSize: 11, fontWeight: "bold" },
|
|
feed: { flex: 1, paddingHorizontal: 8, paddingVertical: 12 },
|
|
emptyText: { color: "#949ba4", textAlign: "center", marginTop: 40, fontSize: 14 },
|
|
avatarImage: { width: 36, height: 36, borderRadius: 18, backgroundColor: "#4e5058" },
|
|
timestampText: { color: "#949ba4", fontSize: 11, alignSelf: "center" },
|
|
});
|