Files
buzz/desktop/tests/e2e/dm-double-notification.spec.ts
Wes 4e3c9e619c fix(desktop): sender names in notifications + macOS click-through routing (#6427)
> Opened by the agents Brain and Pinky on behalf of @wesbillman.

Fixes two desktop notification issues (requested in Buzz channel
`desktop-notification-improvements`):

## 1. Notifications now show who sent the message

Live DM and thread-reply notifications showed only "Direct message" /
"Reply in #channel", while home-feed mention toasts already carried the
sender's name. All message-notification copy is now centralized:

- **`formatMessageNotification`**
(`notifications/lib/notificationFormat.ts`) — canonical title/body for
all five sources (mention, approval, needs-action, DM, thread reply).
Sender-first titles with neutral fallbacks, never a raw pubkey:
  - DM: `Taylor` instead of `Direct message`
- Thread reply: `Taylor replied in #ship-room` instead of `Reply in
#ship-room`
- **`useNotificationSenderName`** — synchronous cache-only lookup
(react-query users-batch entry cache → persisted label cache); a cold
miss ships the fallback title immediately and warms the cache in the
background. No toast delay, no new network machinery.
- **`buildEventNotificationTarget` / `buildFeedItemNotificationTarget`**
(`notifications/lib/target.ts`) — the click-through target payload is
built in one place instead of three hand-rolled copies.

## 2. macOS notification clicks route to the target message (#3509)

On packaged builds, clicking a notification focused the app but never
navigated. Three independent gaps lined up behind one symptom:

- **Reveal hang**: notification navigation now starts before the
best-effort `unminimize → show → setFocus` chain, so a hung native
invoke cannot gate click-through; the existing 1.5s reveal timeout
remains as a secondary guard.
- **Lost emit**: the Rust delegate queues the activation target *before*
emitting `native-notification-activated`; a lost emit stranded the
target with nothing re-draining the queue. The macOS listener now also
drains on window `focus` / `visibilitychange` — delivered by WebKit
independently of the Tauri event channel, and always produced by the
click's own foregrounding.
- **Silent no-op**: `commitNavigation` skips same-href destinations;
`goChannel` / `goForumPost` / `openSearchHit` now accept `force`, and
the notification activation handler passes it so a click always routes.
Multiple queued activations are serialized FIFO with rejection
containment so an older click cannot finish after and overwrite a newer
one. Queue teardown now aborts already-running activations as well as
pending ones; async forum-comment destination resolution rechecks
ownership before community-scoped cache writes or routing.

Diagnostic evidence from the macOS unified log (packaged v0.5.17):
delegate confirmed live in release builds (`willPresent` honored —
`(["list"])` presentations); a real click response at 09:25:06 reached
`usernoted`, the app was fronted by LaunchServices, and no navigation
followed.

## Verification

- Fixed the stale relay-backed DM dedupe expectation: exactly one toast
remains required, with sender-first title `alice` instead of channel
title `alice-tyler`
- Rebased onto `origin/main` and verified 15 focused activation/click
tests, desktop typecheck/check, file-size gate, Biome on changed files,
and `git diff --check` at `21a7e0cf5`
- Notification + navigation + AppShell.helpers unit tests: 89/89 pass
(includes new `notificationFormat.test.mjs`, `target.test.mjs`,
`desktopActivations.test.mjs`)
- Full desktop JS suite run by Pinky at f03979ea: 5133/5133 pass;
pre-push hooks (desktop-test, desktop-typecheck, desktop-tauri-checks,
rust-tests, file-size ratchet) all green on this branch
- `tsc --noEmit` and changed-file Biome checks clean; re-verified after
rebasing current `origin/main`
- Click-through on a packaged build still needs human verification —
@wesbillman, next release build is the real test.

---------

Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
Signed-off-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
Co-authored-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
2026-08-21 09:17:04 -06:00

118 lines
3.8 KiB
TypeScript

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
// sender name), not the home-feed mention duplicate ("… mentioned you in …").
expect(notifications[0].title).toBe("alice");
});