YouBar+: merge BetterInbox's inbox feature in as a toggle, credit shin, fix reaction-ownership bug

This commit is contained in:
Ally Founder 2026-08-12 13:33:03 -04:00
parent 31157788c5
commit ea1b986090
7 changed files with 457 additions and 3 deletions

View file

@ -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"
}

View file

@ -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<typeof setInterval> | 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
};

View file

@ -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<typeof setTimeout> | 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();
};
}

View file

@ -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;
}

View file

@ -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(() => (
<Navigator
initialRouteName="YouBarInbox"
goBackOnBackPress
screens={{
YouBarInbox: {
title: "Inbox",
headerLeft: modalCloseButton?.(() => Navigation.pop()),
render: () => <NotificationCenter />,
},
}}
/>
));
};
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 && (
<IconButton
variant={originalProps?.variant || "tertiary"}
size={originalProps?.size || "sm"}
icon={BellIcon}
onPress={openInbox}
/>
)}
{storage.showSettingsButton && (
<IconButton
variant={originalProps?.variant || "tertiary"}

View file

@ -0,0 +1,191 @@
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 } 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, memo } = React;
const Router = findByProps("transitionToGuild", "transitionTo");
const NativeTabs = findByDisplayName("Tabs");
const useTabsState = findByProps("useTabsState")?.useTabsState;
const NativeSegmentedControl = findByDisplayName("SegmentedControl");
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", "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 (
<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]}>
{tab.charAt(0).toUpperCase() + tab.slice(1)}
</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 ${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" },
});

View file

@ -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.
</NoteBox>
<TableRowGroup title="Buttons">
<TableSwitchRow
@ -30,6 +32,15 @@ export default function Settings() {
updateYouBar();
}}
/>
<TableSwitchRow
label="Inbox button"
subLabel="Show a categorized mentions/replies/reactions inbox in the YouBar - requires a restart to take effect"
value={!!storage.showInboxButton}
onValueChange={(v: boolean) => {
storage.showInboxButton = v;
updateYouBar();
}}
/>
<TableSwitchRow
label="Settings button"
subLabel="Show the Settings button in the YouBar"