Files
buzz/desktop/src/app/AppShell.helpers.test.mjs
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

246 lines
7.4 KiB
JavaScript

import assert from "node:assert/strict";
import test from "node:test";
import {
markAllReadSources,
activateDesktopNotificationTarget,
createDesktopNotificationActivationQueue,
shouldBounceForChannelNotification,
} from "./AppShell.helpers.ts";
test("shouldBounceForChannelNotification_allowsTopLevelChannelMessages", () => {
assert.equal(shouldBounceForChannelNotification([["h", "channel"]]), true);
});
test("shouldBounceForChannelNotification_suppressesThreadReplies", () => {
assert.equal(
shouldBounceForChannelNotification([
["h", "channel"],
["e", "root", "", "reply"],
]),
false,
);
});
test("shouldBounceForChannelNotification_allowsBroadcastReplies", () => {
assert.equal(
shouldBounceForChannelNotification([
["h", "channel"],
["e", "root", "", "reply"],
["broadcast", "1"],
]),
true,
);
});
test("notification activation queue preserves click order", async () => {
const calls = [];
const resolvers = new Map();
const queue = createDesktopNotificationActivationQueue((target) => {
calls.push(`start:${target.channelId}`);
return new Promise((resolve) => {
resolvers.set(target.channelId, () => {
calls.push(`finish:${target.channelId}`);
resolve();
});
});
});
queue.enqueue({ channelId: "first", eventId: null, kind: null });
queue.enqueue({ channelId: "second", eventId: null, kind: null });
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(calls, ["start:first"]);
resolvers.get("first")();
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(calls, ["start:first", "finish:first", "start:second"]);
resolvers.get("second")();
});
test("notification activation queue drops pending targets after cancellation", async () => {
const calls = [];
let resolveFirst;
const queue = createDesktopNotificationActivationQueue((target) => {
calls.push(target.channelId);
if (target.channelId === "first") {
return new Promise((resolve) => {
resolveFirst = resolve;
});
}
return Promise.resolve();
});
queue.enqueue({ channelId: "first", eventId: null, kind: null });
queue.enqueue({ channelId: "second", eventId: null, kind: null });
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(calls, ["first"]);
queue.cancel();
resolveFirst();
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(calls, ["first"]);
});
test("notification activation queue aborts an in-flight activation", async () => {
let observedSignal;
let resolveActivation;
const queue = createDesktopNotificationActivationQueue((_target, signal) => {
observedSignal = signal;
return new Promise((resolve) => {
resolveActivation = resolve;
});
});
queue.enqueue({ channelId: "first", eventId: null, kind: null });
await new Promise((resolve) => setImmediate(resolve));
assert.equal(observedSignal.aborted, false);
queue.cancel();
assert.equal(observedSignal.aborted, true);
resolveActivation();
});
test("notification activation queue reports failures and continues", async () => {
const calls = [];
const errors = [];
const queue = createDesktopNotificationActivationQueue(
async (target) => {
calls.push(target.channelId);
if (target.channelId === "first") {
throw new Error("navigation failed");
}
},
(error) => errors.push(error),
);
queue.enqueue({ channelId: "first", eventId: null, kind: null });
queue.enqueue({ channelId: "second", eventId: null, kind: null });
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(calls, ["first", "second"]);
assert.equal(errors.length, 1);
assert.match(errors[0].message, /navigation failed/);
});
test("notification activation starts routing before a hung reveal", async () => {
const calls = [];
let resolveNavigation;
let navigationSettled = false;
const activation = activateDesktopNotificationTarget(
{
channelId: "channel",
eventId: "event",
kind: 9,
},
{
goChannel: async () => calls.push("channel"),
goHome: async () => calls.push("home"),
revealWindow: () => new Promise(() => {}),
openSearchHit: (_hit, behavior) => {
calls.push(`message:${String(behavior?.force)}`);
return new Promise((resolve) => {
resolveNavigation = () => {
navigationSettled = true;
resolve();
};
});
},
},
);
assert.deepEqual(calls, ["message:true"]);
resolveNavigation();
await activation;
assert.equal(navigationSettled, true);
});
test("notification activation falls back to forced channel navigation", async () => {
const calls = [];
await activateDesktopNotificationTarget(
{ channelId: "channel", eventId: null, kind: null },
{
goChannel: async (channelId, behavior) =>
calls.push(`${channelId}:${String(behavior?.force)}`),
goHome: async () => calls.push("home"),
openSearchHit: async () => calls.push("message"),
revealWindow: async () => calls.push("reveal"),
},
);
assert.deepEqual(calls, ["channel:true", "reveal"]);
});
test("notification activation ignores reveal rejection after routing starts", async () => {
const calls = [];
await activateDesktopNotificationTarget(
{ channelId: "channel", eventId: null, kind: null },
{
goChannel: async () => calls.push("channel"),
goHome: async () => calls.push("home"),
openSearchHit: async () => calls.push("message"),
revealWindow: async () => {
calls.push("reveal");
throw new Error("reveal failed");
},
},
);
assert.deepEqual(calls, ["channel", "reveal"]);
});
test("notification activation without a channel opens home", async () => {
const calls = [];
await activateDesktopNotificationTarget(
{ channelId: null, eventId: "event", kind: 9 },
{
goChannel: async () => calls.push("channel"),
goHome: async () => calls.push("home"),
openSearchHit: async () => calls.push("message"),
revealWindow: async () => calls.push("reveal"),
},
);
assert.deepEqual(calls, ["home", "reveal"]);
});
test("markAllReadSources clears Inbox overrides and active thread activity", () => {
const calls = [];
markAllReadSources({
activeChannelId: "active-channel",
channelActivityItems: [
{ channelId: "another-channel", createdAt: 100 },
{ channelId: "active-channel", createdAt: 200 },
{ channelId: "active-channel", createdAt: 300 },
],
unreadFeedItemIds: new Set(["first-inbox-item", "second-inbox-item"]),
undoUnreadFeedItem: (itemId) => calls.push(`inbox:${itemId}`),
markAllChannelReadMarkers: () => calls.push("channels"),
markActiveChannelRead: (channelId, createdAt) =>
calls.push(`active:${channelId}:${createdAt}`),
});
assert.deepEqual(calls, [
"inbox:first-inbox-item",
"inbox:second-inbox-item",
"channels",
"active:active-channel:300",
]);
});
test("markAllReadSources skips the active marker without projected activity", () => {
const calls = [];
markAllReadSources({
activeChannelId: "active-channel",
channelActivityItems: [],
unreadFeedItemIds: new Set(),
undoUnreadFeedItem: () => calls.push("inbox"),
markAllChannelReadMarkers: () => calls.push("channels"),
markActiveChannelRead: () => calls.push("active"),
});
assert.deepEqual(calls, ["channels"]);
});