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"); const RelationshipStore = findByStoreName("RelationshipStore"); // ActivityTypes.CUSTOM_STATUS - confirmed against the decompiled bundle's getActivityFromCustomStatus, // which builds {name: "Custom Status", type: 4, state: , emoji}. name is always the // literal label "Custom Status", never the user's text - state is what actually holds it. const ACTIVITY_TYPE_CUSTOM_STATUS = 4; // Last-seen custom status per user, so PRESENCE_UPDATES' frequent re-fires don't spam a fresh // notification for the same still-active status. const lastActivitySignature = new Map(); // RelationshipTypes.PENDING_INCOMING - confirmed against the decompiled Discord bundle // (handleRelationshipAdd), stable across builds since it's foundational to the whole friends // system: 0 NONE, 1 FRIEND, 2 BLOCKED, 3 PENDING_INCOMING, 4 PENDING_OUTGOING, 5 IMPLICIT. const RELATIONSHIP_PENDING_INCOMING = 3; let memoryNotifications: NotificationItem[] = []; // The inbox UI has no Flux store to subscribe to, just this module-level array - listeners let it // re-render when a new notification lands instead of only refreshing on its own next render. const listeners = new Set<() => void>(); export function subscribeToNotifications(listener: () => void): () => void { listeners.add(listener); return () => listeners.delete(listener); } function pushNotification(item: NotificationItem) { if (memoryNotifications.some((n) => n.id === item.id)) return; memoryNotifications = [item, ...memoryNotifications].slice(0, 100); storage.notifications = memoryNotifications; listeners.forEach((l) => l()); } 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); } } function handleRelationshipAdd(payload: any) { try { const relationship = payload?.relationship; if (!relationship || relationship.type !== RELATIONSHIP_PENDING_INCOMING) return; const user = relationship.user; if (!user) return; pushNotification({ id: `friend-request-${relationship.id}`, category: "friend_request", title: `${user.globalName || user.username || "Someone"} sent you a friend request`, content: "", guildName: "", channelName: "", timestamp: new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }), author: user, }); } catch (err) { console.error("[YouBar+] Inbox friend request error:", err); } } // Discord's own client already computes this as a distinct event when a relationship transitions // to FRIEND from PENDING_OUTGOING - confirmed against the decompiled bundle, dispatched right // alongside RELATIONSHIP_ADD's own handling rather than something this plugin has to derive itself. function handleFriendRequestAccepted(payload: any) { try { const user = payload?.user; if (!user) return; pushNotification({ id: `friend-accepted-${user.id}-${Date.now()}`, category: "friend_request", title: `${user.globalName || user.username || "Someone"} accepted your friend request`, content: "", guildName: "", channelName: "", timestamp: new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }), author: user, }); } catch (err) { console.error("[YouBar+] Inbox friend accept error:", err); } } function handleThreadMembersUpdate(payload: any) { try { const currentUser = UserStore?.getCurrentUser?.(); if (!currentUser) return; const addedMembers = payload?.addedMembers; if (!Array.isArray(addedMembers)) return; if (!addedMembers.some((m: any) => m?.userId === currentUser.id)) return; const threadId = payload.id; const thread = ChannelStore?.getChannel?.(threadId); if (!thread) return; const guildId = payload.guildId ?? thread.guild_id; const guild = guildId ? GuildStore?.getGuild?.(guildId) : undefined; pushNotification({ id: `thread-added-${threadId}`, category: "thread", title: `You were added to a thread`, content: thread.name ? `#${thread.name}` : "", guildName: guild?.name || "", channelName: thread.name ? `#${thread.name}` : "", guildId, channelId: threadId, timestamp: new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }), }); } catch (err) { console.error("[YouBar+] Inbox thread-added error:", err); } } // Friends only, no bots - a status update from a random server member or a bot's "Playing" state // isn't the kind of thing worth an inbox entry for. function handlePresenceUpdate(update: any) { try { const user = update?.user; const userId = user?.id; if (!userId) return; const currentUser = UserStore?.getCurrentUser?.(); if (!currentUser || userId === currentUser.id) return; const friendIds: string[] = RelationshipStore?.getFriendIDs?.() ?? []; if (!friendIds.includes(userId)) return; if (user.bot) return; const activities = update?.activities; const customStatus = Array.isArray(activities) ? activities.find((a: any) => a?.type === ACTIVITY_TYPE_CUSTOM_STATUS) : undefined; const statusText: string = customStatus?.state || ""; const emojiName: string | undefined = customStatus?.emoji?.name; const signature = statusText || emojiName || ""; if (lastActivitySignature.get(userId) === signature) return; lastActivitySignature.set(userId, signature); if (!signature) return; // cleared their status - nothing to notify about const displayName = user.globalName || user.username || "A friend"; const shownText = statusText || (emojiName ? `:${emojiName}:` : ""); pushNotification({ id: `presence-${userId}-${Date.now()}`, category: "other", title: `${displayName} updated their status`, content: shownText, guildName: "", channelName: "", timestamp: new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }), author: user, }); } catch (err) { console.error("[YouBar+] Inbox presence error:", err); } } // The raw gateway op is singular PRESENCE_UPDATE, but Discord's own socket layer batches them // before they ever reach Flux - confirmed against the decompiled bundle's handlePresenceUpdates, // which reads payload.updates (an array of {guildId, user, status, activities}). Subscribing to // the singular name means this handler simply never fires. function handlePresenceUpdates(payload: any) { const updates = payload?.updates; if (!Array.isArray(updates)) return; for (const update of updates) handlePresenceUpdate(update); } function startTrackingNotifications(): () => void { memoryNotifications = Array.isArray(storage.notifications) ? [...storage.notifications] : []; const unsubMessage = fluxSubscribe("MESSAGE_CREATE", handleIncomingMessage); const unsubReaction = fluxSubscribe("MESSAGE_REACTION_ADD", handleReactionAdd); const unsubRelationship = fluxSubscribe("RELATIONSHIP_ADD", handleRelationshipAdd); const unsubFriendAccepted = fluxSubscribe("FRIEND_REQUEST_ACCEPTED", handleFriendRequestAccepted); const unsubThreadMembers = fluxSubscribe("THREAD_MEMBERS_UPDATE", handleThreadMembersUpdate); const unsubPresence = fluxSubscribe("PRESENCE_UPDATES", handlePresenceUpdates); return () => { unsubMessage(); unsubReaction(); unsubRelationship(); unsubFriendAccepted(); unsubThreadMembers(); unsubPresence(); lastActivitySignature.clear(); }; } let stopTracking: () => void = () => {}; let tracking = false; // Lets the settings toggle flip tracking on/off live instead of requiring a restart to take effect. export function setInboxTracking(enabled: boolean) { if (enabled === tracking) return; tracking = enabled; if (enabled) { stopTracking = startTrackingNotifications(); } else { stopTracking(); stopTracking = () => {}; } }