From 9811a7ca74912d9e3303151a1c3b1f07f58aa969 Mon Sep 17 00:00:00 2001 From: Michael Feth Date: Tue, 25 Aug 2026 04:23:46 -0400 Subject: [PATCH] feat(desktop): coalesce burst feed notifications into one toast The feed notification hook delivered one OS notification and one sound per eligible item, so a burst of ten items produced ten toasts. Buffer eligible items for a bounded 2s window and flush once: two or more items collapse into a single count-only summary, while a lone item keeps its existing single-item format so the common case is unchanged. The summary is derived from counts and the relay's existing needs_action partition only - no item content, channel name, or pubkey reaches the toast. Scoped to the feed producer; the WebSocket producer is deliberately untouched. Signed-off-by: Michael Feth --- .../notifications/lib/coalesce.test.mjs | 113 ++++++++++++++++++ .../features/notifications/lib/coalesce.ts | 62 ++++++++++ .../use-feed-desktop-notifications.ts | 78 +++++++++++- 3 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 desktop/src/features/notifications/lib/coalesce.test.mjs create mode 100644 desktop/src/features/notifications/lib/coalesce.ts diff --git a/desktop/src/features/notifications/lib/coalesce.test.mjs b/desktop/src/features/notifications/lib/coalesce.test.mjs new file mode 100644 index 00000000000..dfa24d552b2 --- /dev/null +++ b/desktop/src/features/notifications/lib/coalesce.test.mjs @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { FEED_COALESCE_WINDOW_MS, coalesceFeedItems } from "./coalesce.ts"; + +function feedItem(overrides = {}) { + return { + category: "mention", + channelId: "channel-1", + channelName: "ship-room", + content: "hello", + createdAt: 1, + id: "item-1", + kind: 9, + pubkey: "agent-a", + tags: [], + ...overrides, + }; +} + +test("returns null for zero items so nothing is delivered", () => { + assert.equal(coalesceFeedItems([]), null); +}); + +test("returns null for a single item so the existing format is preserved", () => { + assert.equal(coalesceFeedItems([feedItem()]), null); +}); + +test("folds a burst into one summary counting distinct agents", () => { + const items = [ + feedItem({ id: "a", pubkey: "agent-a" }), + feedItem({ id: "b", pubkey: "agent-b" }), + feedItem({ id: "c", pubkey: "agent-c" }), + feedItem({ id: "d", pubkey: "agent-a" }), + ]; + + const summary = coalesceFeedItems(items); + + assert.equal(summary.count, 4); + assert.equal(summary.agentCount, 3); + assert.equal(summary.title, "3 agents: 4 events"); +}); + +test("surfaces the relay's needs_action partition in the body", () => { + const items = [ + feedItem({ category: "mention", id: "a", pubkey: "agent-a" }), + feedItem({ category: "needs_action", id: "b", pubkey: "agent-b" }), + feedItem({ category: "needs_action", id: "c", pubkey: "agent-c" }), + ]; + + const summary = coalesceFeedItems(items); + + assert.equal(summary.needsActionCount, 2); + assert.equal(summary.body, "2 items need you"); +}); + +test("uses singular copy for exactly one needs-action item", () => { + const summary = coalesceFeedItems([ + feedItem({ category: "mention", id: "a", pubkey: "agent-a" }), + feedItem({ category: "needs_action", id: "b", pubkey: "agent-b" }), + ]); + + assert.equal(summary.body, "1 item needs you"); +}); + +test("falls back to an update count when nothing needs action", () => { + const summary = coalesceFeedItems([ + feedItem({ id: "a", pubkey: "agent-a" }), + feedItem({ id: "b", pubkey: "agent-a" }), + ]); + + assert.equal(summary.needsActionCount, 0); + assert.equal(summary.agentCount, 1); + assert.equal(summary.title, "1 agent: 2 events"); + assert.equal(summary.body, "2 new updates in Buzz"); +}); + +test("summary carries no item content, channel name, or pubkey", () => { + const summary = coalesceFeedItems([ + feedItem({ + content: "secret token abc123", + id: "a", + pubkey: "npub-sensitive-a", + }), + feedItem({ + channelName: "private-room", + content: "another secret", + id: "b", + pubkey: "npub-sensitive-b", + }), + ]); + + const rendered = `${summary.title} ${summary.body}`; + for (const leak of [ + "secret", + "abc123", + "npub-sensitive-a", + "npub-sensitive-b", + "private-room", + "ship-room", + ]) { + assert.equal( + rendered.includes(leak), + false, + `coalesced summary leaked untrusted text: ${leak}`, + ); + } +}); + +test("window constant is a positive finite duration", () => { + assert.equal(Number.isFinite(FEED_COALESCE_WINDOW_MS), true); + assert.equal(FEED_COALESCE_WINDOW_MS > 0, true); +}); diff --git a/desktop/src/features/notifications/lib/coalesce.ts b/desktop/src/features/notifications/lib/coalesce.ts new file mode 100644 index 00000000000..aaf9a96dd1e --- /dev/null +++ b/desktop/src/features/notifications/lib/coalesce.ts @@ -0,0 +1,62 @@ +import type { FeedItem } from "@/shared/api/types"; + +/** + * Length of the coalescing window, in milliseconds. + * + * Long enough to batch a feed-page burst, short enough that a single item still + * feels immediate. Fixed for this slice — there is deliberately no setting. + */ +export const FEED_COALESCE_WINDOW_MS = 2000; + +export type CoalescedFeedSummary = { + /** Total number of items folded into the single notification. */ + count: number; + /** Number of distinct senders across those items. */ + agentCount: number; + /** Items the relay already partitioned as needing the user. */ + needsActionCount: number; + title: string; + body: string; +}; + +function pluralize(count: number, singular: string) { + return count === 1 ? singular : `${singular}s`; +} + +/** + * Summarize a burst of feed items into ONE notification's title and body. + * + * Pure and synchronous: the time window itself is owned by the caller (the feed + * hook buffers for {@link FEED_COALESCE_WINDOW_MS} and then calls this with + * whatever accumulated), which keeps this function trivially unit-testable and + * free of timers. + * + * Returns `null` for fewer than two items, which is the signal for the caller to + * keep using the existing single-item format — coalescing must not regress the + * common case of one isolated notification. + * + * SECURITY: the summary is derived from COUNTS ONLY. No item content, channel + * name, or sender label reaches the toast, so this path introduces no new + * untrusted-text surface at all. + */ +export function coalesceFeedItems( + items: readonly FeedItem[], +): CoalescedFeedSummary | null { + if (items.length < 2) { + return null; + } + + const agentCount = new Set(items.map((item) => item.pubkey)).size; + const needsActionCount = items.filter( + (item) => item.category === "needs_action", + ).length; + const count = items.length; + + const title = `${agentCount} ${pluralize(agentCount, "agent")}: ${count} ${pluralize(count, "event")}`; + const body = + needsActionCount > 0 + ? `${needsActionCount} ${pluralize(needsActionCount, "item")} ${needsActionCount === 1 ? "needs" : "need"} you` + : `${count} new ${pluralize(count, "update")} in Buzz`; + + return { agentCount, body, count, needsActionCount, title }; +} diff --git a/desktop/src/features/notifications/use-feed-desktop-notifications.ts b/desktop/src/features/notifications/use-feed-desktop-notifications.ts index 4a0865437ed..8f438a86c01 100644 --- a/desktop/src/features/notifications/use-feed-desktop-notifications.ts +++ b/desktop/src/features/notifications/use-feed-desktop-notifications.ts @@ -13,6 +13,7 @@ import { type NotificationChannel, } from "./lib/feed"; import { buildFeedItemNotificationTarget } from "./lib/target"; +import { FEED_COALESCE_WINDOW_MS, coalesceFeedItems } from "./lib/coalesce"; import { getDesktopNotificationPermissionState, requestDesktopNotificationAccess, @@ -29,6 +30,8 @@ import type { NotificationSettings } from "./hooks"; const HOME_FEED_SEEN_STORAGE_KEY = "buzz-home-feed-seen.v1"; const HOME_FEED_SEEN_MAX_ITEMS = 500; +type PendingFeedNotification = { item: FeedItem; senderName?: string }; + function homeFeedSeenStorageKey(pubkey: string) { return `${HOME_FEED_SEEN_STORAGE_KEY}:${pubkey}`; } @@ -85,11 +88,18 @@ export function useFeedDesktopNotifications( ); const hasInitializedFeedRef = React.useRef(false); const hasAutoRequestedRef = React.useRef(false); + // Items awaiting the end of the coalescing window, plus the timer that owns + // the window. Refs (not state) so buffering never re-renders the caller. + const pendingNotificationsRef = React.useRef([]); + const flushTimerRef = React.useRef | null>( + null, + ); React.useEffect(() => { seenItemIdsRef.current = new Set(readStoredSeenFeedIds(normalizedPubkey)); hasInitializedFeedRef.current = false; hasAutoRequestedRef.current = false; + pendingNotificationsRef.current = []; }, [normalizedPubkey]); const autoRequestPermissionIfNeeded = React.useEffectEvent(async () => { @@ -128,6 +138,63 @@ export function useFeedDesktopNotifications( }, ); + const deliverCoalescedNotification = React.useEffectEvent( + async (items: readonly FeedItem[], title: string, body: string) => { + // Anchor click routing and sound on the first item of the burst, so the + // coalesced toast behaves exactly like the notification that item would + // have produced on its own. + const anchor = items[0]; + const didSend = await sendDesktopNotification({ + body, + target: buildFeedItemNotificationTarget(anchor), + title, + }); + + if ( + didSend && + shouldPlayNotificationSound(anchor.channelId, silentChannelIds) + ) { + const slot = slotForFeedKind(anchor.kind, anchor.category); + playNotificationSound(resolveSlotSound(settings, slot)); + } + }, + ); + + const flushPendingNotifications = React.useEffectEvent(async () => { + flushTimerRef.current = null; + const pending = pendingNotificationsRef.current; + pendingNotificationsRef.current = []; + + if (pending.length === 0) { + return; + } + + const summary = coalesceFeedItems(pending.map((entry) => entry.item)); + if (!summary) { + // Fewer than two items in the window: keep the existing single-item + // format so the common case is unchanged. + const [only] = pending; + await deliverFeedNotification(only.item, only.senderName); + return; + } + + await deliverCoalescedNotification( + pending.map((entry) => entry.item), + summary.title, + summary.body, + ); + }); + + // Drop a scheduled flush when the hook unmounts so no timer outlives it. + React.useEffect(() => { + return () => { + if (flushTimerRef.current !== null) { + clearTimeout(flushTimerRef.current); + flushTimerRef.current = null; + } + }; + }, []); + React.useEffect(() => { if (!enabled || !feed) { return; @@ -207,7 +274,16 @@ export function useFeedDesktopNotifications( resolvedLabel && resolvedLabel !== truncatePubkey(item.pubkey) ? resolvedLabel : undefined; - void deliverFeedNotification(item, senderName); + pendingNotificationsRef.current.push({ item, senderName }); + } + + // One bounded window per burst: the first eligible item opens it, every + // later item inside it joins the same flush. Zero eligible items schedule + // nothing at all, preserving today's no-items-no-toast behavior. + if (newItems.length > 0 && flushTimerRef.current === null) { + flushTimerRef.current = setTimeout(() => { + void flushPendingNotifications(); + }, FEED_COALESCE_WINDOW_MS); } }, [ enabled,