fix(desktop): stop DMs from firing duplicate desktop notifications (#2105)

Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
Tyler
2026-07-18 23:16:00 -04:00
committed by GitHub
parent b1b4e7d55c
commit da2e2592a3
8 changed files with 263 additions and 18 deletions

View File

@@ -124,6 +124,7 @@ export default defineConfig({
"**/onboarding.spec.ts", "**/onboarding.spec.ts",
"**/stream.spec.ts", "**/stream.spec.ts",
"**/integration.spec.ts", "**/integration.spec.ts",
"**/dm-double-notification.spec.ts",
"**/profile.spec.ts", "**/profile.spec.ts",
"**/sidebar.spec.ts", "**/sidebar.spec.ts",
"**/sidebar-relay-card.spec.ts", "**/sidebar-relay-card.spec.ts",

View File

@@ -1,7 +1,11 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { enrichFeedItemChannel, notificationTitle } from "./feed.ts"; import {
eligibleFeedNotificationItems,
enrichFeedItemChannel,
notificationTitle,
} from "./feed.ts";
const feedItem = (overrides = {}) => ({ const feedItem = (overrides = {}) => ({
id: "event-id", id: "event-id",
@@ -56,3 +60,63 @@ test("does not replace direct-message notification titles", () => {
assert.equal(notificationTitle(item, "Taylor"), "Taylor"); assert.equal(notificationTitle(item, "Taylor"), "Taylor");
}); });
const feedResponse = (mentions, needsAction = []) => ({
feed: { mentions, needsAction },
});
const allSlots = { mentions: true, needsAction: true };
test("excludes a DM mention whose feed item is missing channel metadata but whose channel is loaded", () => {
// The backend feed emits channel_type: None and channel_name: "" for DMs;
// the DM-exclusion guard must resolve the type from the channel list
// BEFORE filtering, or every DM double-notifies alongside the WS path.
const dmItem = feedItem({
category: "mention",
channelId: "dm-channel",
channelName: "",
channelType: undefined,
});
const items = eligibleFeedNotificationItems(
feedResponse([dmItem]),
allSlots,
[{ id: "dm-channel", name: "alice-tyler", channelType: "dm" }],
);
assert.equal(items.length, 0);
});
test("keeps a mention eligible when its channel is not in the loaded channel list", () => {
// Unknown DM channels get no WS notification (handleDmEvent bails on
// unknown channels), so the feed path must remain the failover.
const unknownItem = feedItem({
category: "mention",
channelId: "brand-new-dm",
channelName: "",
channelType: undefined,
});
const items = eligibleFeedNotificationItems(
feedResponse([unknownItem]),
allSlots,
[{ id: "some-other-channel", name: "general", channelType: "stream" }],
);
assert.equal(items.length, 1);
assert.equal(items[0].id, unknownItem.id);
});
test("resolves and excludes a DM whose feed item has a name but no type", () => {
const namedItem = feedItem({
category: "mention",
channelId: "dm-channel",
channelName: "alice-tyler",
channelType: undefined,
});
const items = eligibleFeedNotificationItems(
feedResponse([namedItem]),
allSlots,
[{ id: "dm-channel", name: "alice-tyler", channelType: "dm" }],
);
assert.equal(items.length, 0);
});

View File

@@ -10,7 +10,9 @@ export function enrichFeedItemChannel(
item: FeedItem, item: FeedItem,
channels: readonly NotificationChannel[], channels: readonly NotificationChannel[],
): FeedItem { ): FeedItem {
if (!item.channelId || item.channelName.trim()) { const needsName = !item.channelName.trim();
const needsType = item.channelType === undefined;
if (!item.channelId || (!needsName && !needsType)) {
return item; return item;
} }
@@ -19,10 +21,13 @@ export function enrichFeedItemChannel(
return item; return item;
} }
// Fill each missing field independently: the backend feed may supply a
// channel name but no type (or vice versa), and the DM-exclusion filter in
// eligibleFeedNotificationItems depends on channelType being resolved.
return { return {
...item, ...item,
channelName: channel.name, channelName: needsName ? channel.name : item.channelName,
channelType: item.channelType ?? channel.channelType, channelType: needsType ? channel.channelType : item.channelType,
}; };
} }
@@ -73,19 +78,28 @@ export function collectHomeAlertItems(feed: HomeFeedResponse) {
export function eligibleFeedNotificationItems( export function eligibleFeedNotificationItems(
feed: HomeFeedResponse, feed: HomeFeedResponse,
options: { mentions: boolean; needsAction: boolean }, options: { mentions: boolean; needsAction: boolean },
channels: readonly NotificationChannel[] = [],
) { ) {
const items: FeedItem[] = []; const items: FeedItem[] = [];
// DM notifications are handled by the real-time WebSocket hook, so we // DM notifications are handled by the real-time WebSocket hook, so we
// exclude DM items here to avoid duplicate toasts. // exclude DM items here to avoid duplicate toasts. The backend feed emits
// no channelType, so resolve it from the loaded channel list BEFORE
// filtering — otherwise every DM sails through as `undefined !== "dm"`.
if (options.mentions) { if (options.mentions) {
items.push( items.push(
...feed.feed.mentions.filter((item) => item.channelType !== "dm"), ...feed.feed.mentions
.map((item) => enrichFeedItemChannel(item, channels))
.filter((item) => item.channelType !== "dm"),
); );
} }
if (options.needsAction) { if (options.needsAction) {
items.push(...feed.feed.needsAction); items.push(
...feed.feed.needsAction.map((item) =>
enrichFeedItemChannel(item, channels),
),
);
} }
return items.sort((left, right) => left.createdAt - right.createdAt); return items.sort((left, right) => left.createdAt - right.createdAt);

View File

@@ -10,7 +10,6 @@ import type { FeedItem, HomeFeedResponse } from "@/shared/api/types";
import { import {
collectHomeAlertItems, collectHomeAlertItems,
eligibleFeedNotificationItems, eligibleFeedNotificationItems,
enrichFeedItemChannel,
type NotificationChannel, type NotificationChannel,
notificationBody, notificationBody,
notificationTitle, notificationTitle,
@@ -160,10 +159,14 @@ export function useFeedDesktopNotifications(
const nextSeenItemIds = new Set(seenItemIdsRef.current); const nextSeenItemIds = new Set(seenItemIdsRef.current);
const newItems = settings.desktopEnabled const newItems = settings.desktopEnabled
? eligibleFeedNotificationItems(feed, { ? eligibleFeedNotificationItems(
mentions: settings.slotAlertsEnabled.mention, feed,
needsAction: settings.slotAlertsEnabled.needs_action, {
}) mentions: settings.slotAlertsEnabled.mention,
needsAction: settings.slotAlertsEnabled.needs_action,
},
channels,
)
.filter((item) => !nextSeenItemIds.has(item.id)) .filter((item) => !nextSeenItemIds.has(item.id))
.filter( .filter(
(item) => (item) =>
@@ -195,8 +198,7 @@ export function useFeedDesktopNotifications(
void autoRequestPermissionIfNeeded(); void autoRequestPermissionIfNeeded();
} }
for (const rawItem of newItems) { for (const item of newItems) {
const item = enrichFeedItemChannel(rawItem, channels);
const resolvedLabel = profiles const resolvedLabel = profiles
? resolveUserLabel({ ? resolveUserLabel({
pubkey: item.pubkey, pubkey: item.pubkey,

View File

@@ -54,7 +54,9 @@ type RawFeedItem = {
created_at: number; created_at: number;
channel_id: string | null; channel_id: string | null;
channel_name: string; channel_name: string;
channel_type: string; // Native FeedItemInfo.channel_type is Option<String>: serde emits `null`,
// never omits the key.
channel_type: string | null;
tags: string[][]; tags: string[][];
category: "mention" | "needs_action" | "activity" | "agent_activity"; category: "mention" | "needs_action" | "activity" | "agent_activity";
}; };
@@ -275,7 +277,7 @@ export async function invokeTauri<T>(
} }
} }
function fromRawFeedItem(item: RawFeedItem) { export function fromRawFeedItem(item: RawFeedItem) {
return { return {
id: item.id, id: item.id,
kind: item.kind, kind: item.kind,
@@ -284,7 +286,10 @@ function fromRawFeedItem(item: RawFeedItem) {
createdAt: item.created_at, createdAt: item.created_at,
channelId: item.channel_id, channelId: item.channel_id,
channelName: item.channel_name, channelName: item.channel_name,
channelType: item.channel_type, // Canonicalize the wire `null` to undefined so FeedItem's optional
// channelType contract holds at runtime (enrichment and the DM
// notification filter both key off `=== undefined`).
channelType: item.channel_type ?? undefined,
tags: item.tags, tags: item.tags,
category: item.category, category: item.category,
}; };

View File

@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import test from "node:test";
import { fromRawFeedItem } from "./tauri.ts";
const rawFeedItem = (overrides = {}) => ({
id: "event-id",
kind: 9,
pubkey: "author",
content: "hello",
created_at: 1,
channel_id: "channel-id",
channel_name: "",
channel_type: null,
tags: [["h", "channel-id"]],
category: "mention",
...overrides,
});
test("canonicalizes the native null channel_type to undefined", () => {
// Native get_feed serializes FeedItemInfo.channel_type (Option<String>)
// as `null`, never omitting the key. FeedItem declares channelType as
// optional, and the DM notification filter distinguishes "unresolved"
// via `=== undefined` — so null must not survive the boundary.
const item = fromRawFeedItem(rawFeedItem());
assert.equal(item.channelType, undefined);
assert.ok("channelType" in item);
});
test("passes a present channel_type through unchanged", () => {
const item = fromRawFeedItem(rawFeedItem({ channel_type: "dm" }));
assert.equal(item.channelType, "dm");
});

View File

@@ -484,7 +484,9 @@ type RawFeedItem = {
created_at: number; created_at: number;
channel_id: string | null; channel_id: string | null;
channel_name: string; channel_name: string;
channel_type?: string; // Mirrors native FeedItemInfo.channel_type (Option<String>): the Tauri
// backend always emits the key, as `null` when unknown.
channel_type?: string | null;
tags: string[][]; tags: string[][];
category: "mention" | "needs_action" | "activity" | "agent_activity"; category: "mention" | "needs_action" | "activity" | "agent_activity";
}; };
@@ -6565,6 +6567,11 @@ async function handleGetFeed(
tags: (ev.tags ?? []) as string[][], tags: (ev.tags ?? []) as string[][],
channel_id: chId, channel_id: chId,
channel_name: chId ? (channelNameMap.get(chId) ?? "") : "", channel_name: chId ? (channelNameMap.get(chId) ?? "") : "",
// Native-shaped: get_feed emits channel_type: null (Option<String>),
// never omits the key. Keeping the bridge faithful here is what lets
// the DM dedupe e2e catch null-vs-undefined regressions at the API
// conversion seam.
channel_type: null,
category: "mention" as const, category: "mention" as const,
}; };
}); });

View File

@@ -0,0 +1,117 @@
import { expect, test } from "@playwright/test";
import { finalizeEvent } from "nostr-tools/pure";
import { hexToBytes } from "@noble/hashes/utils.js";
import { installRelayBridge, TEST_IDENTITIES } from "../helpers/bridge";
import { assertRelaySeeded } from "../helpers/seed";
const isCi = Boolean(process.env.CI);
const relaySeedHookTimeoutMs = isCi ? 90_000 : 30_000;
const RELAY_HTTP_URL =
process.env.BUZZ_E2E_RELAY_URL ?? "http://localhost:3000";
// setup-desktop-test-data.sh: uuid5(NAMESPACE_DNS, "buzz.channel.dm.alice-tyler")
const ALICE_TYLER_DM_CHANNEL_ID = "5a9c064e-0411-5242-ae6b-0363ba99b8e6";
async function getLoggedNotifications(page: import("@playwright/test").Page) {
return page.evaluate(() => {
const win = window as Window & {
__BUZZ_E2E_NOTIFICATIONS__?: Array<{
body: string | null;
title: string;
}>;
};
return win.__BUZZ_E2E_NOTIFICATIONS__ ?? [];
});
}
/**
* Publishes a REAL signed DM message from alice through the relay ingest
* path. Like every DM send in the product, it carries a recipient `p` tag
* (see messageMentionPubkeys) — which is exactly what makes the event match
* both the live DM subscription and the home-feed mention query.
*/
async function publishAliceDm(content: string) {
const event = finalizeEvent(
{
kind: 9,
content,
tags: [
["h", ALICE_TYLER_DM_CHANNEL_ID],
["p", TEST_IDENTITIES.tyler.pubkey],
],
created_at: Math.floor(Date.now() / 1000),
},
hexToBytes(TEST_IDENTITIES.alice.privateKey),
);
const response = await fetch(`${RELAY_HTTP_URL}/events`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Pubkey": event.pubkey,
},
body: JSON.stringify(event),
});
if (!response.ok) {
throw new Error(
`POST /events failed (${response.status}): ${await response.text()}`,
);
}
}
test.beforeAll(async () => {
test.setTimeout(relaySeedHookTimeoutMs);
await assertRelaySeeded();
});
test("an incoming DM produces exactly one desktop notification", async ({
page,
}) => {
await installRelayBridge(page, "tyler");
// Deterministically wait for the home feed's initial mention query
// (kinds 9/... + #p filter) to complete before publishing: the feed
// dedupe-seen set must be initialized, otherwise the duplicate is
// accidentally swallowed as "initial backlog" and the repro goes flaky.
const feedInitialized = page.waitForResponse(
(response) =>
response.url().includes("/query") &&
(response.request().postData() ?? "").includes('"#p"'),
{ timeout: 15_000 },
);
await page.goto("/");
// Wait until the DM channel is loaded — live subscriptions (channel + home
// feed mention) are established once the channel list resolves.
await expect(page.getByTestId("dm-list")).toContainText("alice-tyler");
await feedInitialized;
// Small buffer for the feed effect (seen-set initialization) to run.
await page.waitForTimeout(1_000);
const message = `dm dedupe probe ${Date.now()}`;
await publishAliceDm(message);
// Wait for the DM toast to arrive.
await expect
.poll(async () => (await getLoggedNotifications(page)).length, {
timeout: 15_000,
})
.toBeGreaterThan(0);
// Give the duplicate (home-feed mention path) time to fire — it arrives via
// the onLiveMention → feed refetch round trip, which lags the WS toast.
await page.waitForTimeout(5_000);
const notifications = await getLoggedNotifications(page);
expect(
notifications,
`expected exactly one notification for a single DM, got: ${JSON.stringify(notifications)}`,
).toHaveLength(1);
// The survivor must be the live WebSocket DM toast (titled with the DM
// channel name), not the home-feed mention duplicate ("… mentioned you in …").
expect(notifications[0].title).toBe("alice-tyler");
});