Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions desktop/src/features/notifications/lib/coalesce.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
62 changes: 62 additions & 0 deletions desktop/src/features/notifications/lib/coalesce.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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}`;
}
Expand Down Expand Up @@ -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<PendingFeedNotification[]>([]);
const flushTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(
null,
);

React.useEffect(() => {
seenItemIdsRef.current = new Set(readStoredSeenFeedIds(normalizedPubkey));
hasInitializedFeedRef.current = false;
hasAutoRequestedRef.current = false;
pendingNotificationsRef.current = [];
}, [normalizedPubkey]);

const autoRequestPermissionIfNeeded = React.useEffectEvent(async () => {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down