From ea1b9860903bad5589bd083737d298ed9dba1647 Mon Sep 17 00:00:00 2001 From: Ally Founder Date: Wed, 12 Aug 2026 13:33:03 -0400 Subject: [PATCH] YouBar+: merge BetterInbox's inbox feature in as a toggle, credit shin, fix reaction-ownership bug --- plugins/you-bar-plus/manifest.json | 5 +- plugins/you-bar-plus/src/index.ts | 11 + plugins/you-bar-plus/src/lib/notifications.ts | 178 ++++++++++++++++ plugins/you-bar-plus/src/lib/types.ts | 25 +++ .../src/patches/youBarButtons.tsx | 39 +++- .../src/ui/NotificationCenter.tsx | 191 ++++++++++++++++++ plugins/you-bar-plus/src/ui/Settings.tsx | 11 + 7 files changed, 457 insertions(+), 3 deletions(-) create mode 100644 plugins/you-bar-plus/src/lib/notifications.ts create mode 100644 plugins/you-bar-plus/src/lib/types.ts create mode 100644 plugins/you-bar-plus/src/ui/NotificationCenter.tsx diff --git a/plugins/you-bar-plus/manifest.json b/plugins/you-bar-plus/manifest.json index 95eae66..03a59dd 100644 --- a/plugins/you-bar-plus/manifest.json +++ b/plugins/you-bar-plus/manifest.json @@ -1,9 +1,10 @@ { "name": "YouBar+", - "description": "Adds Settings and DM buttons to the YouBar. (May require a restart after enabling.)", + "description": "Adds Settings, DM, and categorized Inbox buttons to the YouBar. (May require a restart after enabling.)", "authors": [ { "name": "Rosie" }, - { "name": "Purple_Ξye™" } + { "name": "Purple_Ξye™" }, + { "name": "shin" } ], "main": "src/index.ts" } diff --git a/plugins/you-bar-plus/src/index.ts b/plugins/you-bar-plus/src/index.ts index 9336b6c..e5bae9a 100644 --- a/plugins/you-bar-plus/src/index.ts +++ b/plugins/you-bar-plus/src/index.ts @@ -1,9 +1,11 @@ import { logger } from "@vendetta"; import { storage } from "@vendetta/plugin"; import patchYouBarButtons from "./patches/youBarButtons"; +import { startTrackingNotifications } from "./lib/notifications"; import Settings from "./ui/Settings"; let unpatchButtons: () => void = () => {}; +let stopTrackingNotifications: () => void = () => {}; let patched = false; let retryHandle: ReturnType | undefined; @@ -34,6 +36,14 @@ export default { onLoad: () => { storage.showDMButton ??= false; storage.showSettingsButton ??= true; + storage.showInboxButton ??= false; + storage.notifications ??= []; + + // Same "may require a restart" contract as the other buttons - only tracks in the + // background when the feature is actually on, not for every YouBar+ user by default. + if (storage.showInboxButton) { + stopTrackingNotifications = startTrackingNotifications(); + } attempt(); if (!patched) { @@ -47,6 +57,7 @@ export default { onUnload: () => { stopRetrying(); unpatchButtons(); + stopTrackingNotifications(); }, settings: Settings }; diff --git a/plugins/you-bar-plus/src/lib/notifications.ts b/plugins/you-bar-plus/src/lib/notifications.ts new file mode 100644 index 0000000..93d6d52 --- /dev/null +++ b/plugins/you-bar-plus/src/lib/notifications.ts @@ -0,0 +1,178 @@ +import { findByStoreName } from "@vendetta/metro"; +import { storage } from "@vendetta/plugin"; +import { fluxSubscribe } from "@shared/lib/flux"; +import type { MentionSubCategory, NotificationItem } from "./types"; + +// Ported from fshinz/Revenge-Plugins' BetterInbox (credit: shin) - merged in here so only one +// plugin ever patches YouBarNotificationsButton, since two independent instead() patches on the +// same component is what caused the hook-order crash this merge exists to fix. + +const UserStore = findByStoreName("UserStore"); +const ChannelStore = findByStoreName("ChannelStore"); +const GuildStore = findByStoreName("GuildStore"); +const MessageStore = findByStoreName("MessageStore"); +const GuildMemberStore = findByStoreName("GuildMemberStore"); + +let memoryNotifications: NotificationItem[] = []; +let saveTimeout: ReturnType | undefined; + +function syncStorageDebounced() { + if (saveTimeout) clearTimeout(saveTimeout); + saveTimeout = setTimeout(() => { + storage.notifications = memoryNotifications.slice(0, 100); + }, 3000); +} + +function pushNotification(item: NotificationItem) { + if (memoryNotifications.some((n) => n.id === item.id)) return; + memoryNotifications = [item, ...memoryNotifications]; + syncStorageDebounced(); +} + +export function getNotifications(): NotificationItem[] { + return memoryNotifications; +} + +function processMentionMessage(channelId: string, messageId: string, rawMsg?: any) { + try { + const currentUser = UserStore?.getCurrentUser?.(); + if (!currentUser) return; + + const msg = MessageStore?.getMessage?.(channelId, messageId) || rawMsg; + const channel = ChannelStore?.getChannel?.(channelId); + if (!msg || !channel) return; + + const author = msg.author || UserStore?.getUser?.(msg.author?.id); + if (!author || author.id === currentUser.id) return; + + const guild = channel.guild_id ? GuildStore?.getGuild?.(channel.guild_id) : undefined; + const guildName = guild?.name || (channel.isGroupDM?.() ? "Group DM" : "Direct Message"); + const channelName = channel.name ? `#${channel.name}` : "DM"; + const timestamp = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + + const isReply = msg.type === 19 || msg.referenced_message?.author?.id === currentUser.id; + + const category: "mentions" | "replies" = isReply ? "replies" : "mentions"; + let subCategory: MentionSubCategory = "people"; + + if (author.bot) { + subCategory = "bot"; + } else if (msg.mention_roles?.length > 0 || msg.mentionRoles?.length > 0) { + subCategory = "role"; + } + + pushNotification({ + id: msg.id || `${Date.now()}`, + category, + subCategory, + title: isReply + ? `${author.globalName || author.username || "Someone"} replied to you` + : subCategory === "role" + ? `${author.globalName || author.username || "Someone"} mentioned a role you have` + : `${author.globalName || author.username || "Someone"} mentioned you`, + content: msg.content || "", + guildName, + channelName, + guildId: guild?.id, + channelId, + messageId: msg.id, + timestamp, + author, + }); + } catch (err) { + console.error("[YouBar+] Inbox mention process error:", err); + } +} + +function handleIncomingMessage(payload: any) { + try { + const currentUser = UserStore?.getCurrentUser?.(); + if (!currentUser) return; + + const msg = payload?.message || payload; + if (!msg || !msg.channel_id) return; + if (msg.author?.id === currentUser.id) return; + + const isDirectMention = msg.mentions?.some((u: any) => u.id === currentUser.id); + const isReplyToMe = msg.referenced_message?.author?.id === currentUser.id; + + let isRoleMention = false; + const msgRoles = msg.mention_roles || msg.mentionRoles || []; + if (msgRoles.length > 0 && msg.guild_id) { + const myMember = GuildMemberStore?.getMember?.(msg.guild_id, currentUser.id); + const myRoles: string[] = myMember?.roles || []; + isRoleMention = msgRoles.some((roleId: string) => myRoles.includes(roleId)); + } + + if (!isDirectMention && !isReplyToMe && !isRoleMention) return; + + processMentionMessage(msg.channel_id, msg.id, msg); + } catch (err) { + console.error("[YouBar+] Inbox incoming message error:", err); + } +} + +function handleReactionAdd(payload: any) { + try { + const currentUser = UserStore?.getCurrentUser?.(); + if (!currentUser) return; + + const channelId = payload.channel_id || payload.channelId; + const targetMessageId = payload.message_id || payload.messageId; + const reactorId = payload.user_id || payload.userId; + if (reactorId === currentUser.id) return; + + // Must positively confirm the reacted-to message is one of ours - if it isn't cached + // locally (common; reaction events fire for any message you're viewing, not just your + // own), this used to fall through and record the reaction anyway instead of skipping it. + const targetMessage = MessageStore?.getMessage?.(channelId, targetMessageId); + if (!targetMessage || targetMessage.author?.id !== currentUser.id) return; + + const channel = ChannelStore?.getChannel?.(channelId); + const guild = channel?.guild_id ? GuildStore?.getGuild?.(channel.guild_id) : undefined; + const guildName = guild?.name || "Direct Message"; + const channelName = channel?.name ? `#${channel.name}` : "DM"; + const timestamp = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + + const reactorUser = payload.member?.user || payload.user || UserStore?.getUser?.(reactorId); + const finalAuthor = reactorUser || { + id: reactorId, + username: payload.member?.nick || "Someone", + globalName: payload.member?.nick || "Someone", + avatar: null, + }; + + const reactorName = finalAuthor.globalName || finalAuthor.username || "Someone"; + const emojiName = payload.emoji?.name || "an emoji"; + + pushNotification({ + id: `react-${targetMessageId}-${reactorId}`, + category: "reactions", + title: `${reactorName} reacted ${emojiName}`, + content: targetMessage?.content ? `"${targetMessage.content}"` : `Reacted to your message in ${channelName}`, + guildName, + channelName, + guildId: guild?.id, + channelId, + messageId: targetMessageId, + timestamp, + author: finalAuthor, + }); + } catch (err) { + console.error("[YouBar+] Inbox reaction error:", err); + } +} + +export function startTrackingNotifications(): () => void { + memoryNotifications = Array.isArray(storage.notifications) ? [...storage.notifications] : []; + + const unsubMessage = fluxSubscribe("MESSAGE_CREATE", handleIncomingMessage); + const unsubReaction = fluxSubscribe("MESSAGE_REACTION_ADD", handleReactionAdd); + + return () => { + if (saveTimeout) clearTimeout(saveTimeout); + storage.notifications = memoryNotifications.slice(0, 100); + unsubMessage(); + unsubReaction(); + }; +} diff --git a/plugins/you-bar-plus/src/lib/types.ts b/plugins/you-bar-plus/src/lib/types.ts new file mode 100644 index 0000000..5c87ead --- /dev/null +++ b/plugins/you-bar-plus/src/lib/types.ts @@ -0,0 +1,25 @@ +export type NotificationCategory = "mentions" | "replies" | "reactions" | "other"; +export type MentionSubCategory = "people" | "bot" | "role"; + +export interface NotificationAuthor { + id: string; + username: string; + globalName?: string; + avatar?: string | null; + bot?: boolean; +} + +export interface NotificationItem { + id: string; + category: NotificationCategory; + subCategory?: MentionSubCategory; + title: string; + content: string; + guildName: string; + channelName: string; + guildId?: string; + channelId?: string; + messageId?: string; + timestamp: string; + author?: NotificationAuthor; +} diff --git a/plugins/you-bar-plus/src/patches/youBarButtons.tsx b/plugins/you-bar-plus/src/patches/youBarButtons.tsx index 5800082..30b061b 100644 --- a/plugins/you-bar-plus/src/patches/youBarButtons.tsx +++ b/plugins/you-bar-plus/src/patches/youBarButtons.tsx @@ -1,8 +1,9 @@ -import { findByProps, findByTypeName } from "@vendetta/metro"; +import { findByProps, findByTypeName, findByName } from "@vendetta/metro"; import { React } from "@vendetta/metro/common"; import { instead } from "@vendetta/patcher"; import { storage } from "@vendetta/plugin"; import { getAssetIDByName } from "@vendetta/ui/assets"; +import NotificationCenter from "../ui/NotificationCenter"; // Retry logic lives one level up in index.ts, calling this repeatedly until it succeeds - keep // this function itself simple. @@ -14,11 +15,38 @@ export default function patchYouBarButtons(): (() => void) | null { const userSettingsAction = findByProps("openUserSettings"); const transitionModule = findByProps("transitionToGuild"); + const Navigation = findByProps("push", "pushLazy", "pop"); + const Navigator = findByName("Navigator") ?? findByProps("Navigator")?.Navigator; + const modalCloseButton = + findByProps("getRenderCloseButton")?.getRenderCloseButton ?? + findByProps("getHeaderCloseButton")?.getHeaderCloseButton; + const SettingsIcon = getAssetIDByName("SettingsIcon"); const ChatIcon = getAssetIDByName("ChatIcon"); + const BellIcon = getAssetIDByName("BellIcon") ?? getAssetIDByName("NotificationBellIcon"); if (!YouBarNotificationsButton) return null; + const openInbox = () => { + if (!Navigator || !Navigation?.push) { + console.error("[YouBar+] Inbox: Navigator not available"); + return; + } + Navigation.push(() => ( + Navigation.pop()), + render: () => , + }, + }} + /> + )); + }; + return instead("type", YouBarNotificationsButton, (args: any[], OriginalRender: (...a: any[]) => any) => { const [, forceUpdate] = React.useReducer((x: number) => ~x, 0); @@ -44,6 +72,15 @@ export default function patchYouBarButtons(): (() => void) | null { /> )} + {storage.showInboxButton && BellIcon && ( + + )} + {storage.showSettingsButton && ( > 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 ( + {item.timestamp}} + icon={} + onPress={onPress} + /> + ); +}); + +export default function NotificationCenter(): JSX.Element { + const [activeTabIdx, setActiveTabIdx] = useState(0); + const [mentionFilterIdx, setMentionFilterIdx] = useState(0); + + const categories: NotificationCategory[] = ["mentions", "replies", "reactions", "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: cat.charAt(0).toUpperCase() + cat.slice(1) })), + initialIndex: 0, + }) + : null; + + 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 ( + + {NativeTabs && tabsState ? ( + { + tabsState.setActiveIndex?.(idx); + setActiveTabIdx(idx); + }, + }} + /> + ) : ( + + {categories.map((tab, idx) => ( + setActiveTabIdx(idx)} + > + + {tab.charAt(0).toUpperCase() + tab.slice(1)} + + + ))} + + )} + + {currentCategory === "mentions" && ( + + {NativeSegmentedControl ? ( + ({ 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); + }} + /> + ) : ( + + {subFilters.map((sub, idx) => ( + setMentionFilterIdx(idx)} + > + {sub.toUpperCase()} + + ))} + + )} + + )} + + + {displayedNotifications.length === 0 ? ( + No notifications found for this category. + ) : ( + + {displayedNotifications.map((item) => ( + jumpToMessage(item.guildId, item.channelId, item.messageId)} + /> + ))} + + )} + + + ); +} + +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" }, +}); diff --git a/plugins/you-bar-plus/src/ui/Settings.tsx b/plugins/you-bar-plus/src/ui/Settings.tsx index 8f6ffa8..5b41e13 100644 --- a/plugins/you-bar-plus/src/ui/Settings.tsx +++ b/plugins/you-bar-plus/src/ui/Settings.tsx @@ -19,6 +19,8 @@ export default function Settings() { Fork of Purple-EyeZ's YouBar+, ported to Revenge's Vendetta-compat API. Like the original, this patches the YouBar the moment it loads - if you enable it while Discord's already running, you may need to restart once for the buttons to appear. + Inbox categorization is merged in from shin's BetterInbox (fshinz/Revenge-Plugins), + with permission, so both features share one patch instead of two conflicting ones. + { + storage.showInboxButton = v; + updateYouBar(); + }} + />