revenge-plugins/plugins/you-bar-plus/src/lib/notifications.ts

351 lines
14 KiB
TypeScript

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: <the actual text>, 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<string, string>();
// 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 = () => {};
}
}