mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
35
desktop/src/shared/api/tauriFeed.test.mjs
Normal file
35
desktop/src/shared/api/tauriFeed.test.mjs
Normal 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");
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
117
desktop/tests/e2e/dm-double-notification.spec.ts
Normal file
117
desktop/tests/e2e/dm-double-notification.spec.ts
Normal 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");
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user