351 lines
14 KiB
TypeScript
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 = () => {};
|
|
}
|
|
}
|