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>
This commit is contained in:
Wes
2026-08-21 09:17:04 -06:00
committed by GitHub
parent 8740a1fa94
commit 4e3c9e619c
18 changed files with 1215 additions and 133 deletions

View File

@@ -3,6 +3,8 @@ import test from "node:test";
import {
markAllReadSources,
activateDesktopNotificationTarget,
createDesktopNotificationActivationQueue,
shouldBounceForChannelNotification,
} from "./AppShell.helpers.ts";
@@ -31,6 +33,177 @@ test("shouldBounceForChannelNotification_allowsBroadcastReplies", () => {
);
});
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 = [];

View File

@@ -141,6 +141,79 @@ export function toSearchHit(
};
}
export function createDesktopNotificationActivationQueue(
activate: (
target: DesktopNotificationTarget,
signal: AbortSignal,
) => Promise<void>,
onError?: (error: unknown) => void,
): {
cancel: () => void;
enqueue: (target: DesktopNotificationTarget) => void;
} {
const controller = new AbortController();
let pending = Promise.resolve();
return {
cancel: () => {
controller.abort();
},
enqueue: (target) => {
// Preserve native click order when macOS drains multiple queued targets.
// Contain failures so one rejected navigation cannot poison later clicks.
pending = pending
.then(() => {
if (!controller.signal.aborted) {
return activate(target, controller.signal);
}
})
.catch((error) => {
try {
onError?.(error);
} catch {
// Reporting must not poison the activation queue either.
}
});
},
};
}
export async function activateDesktopNotificationTarget(
target: DesktopNotificationTarget,
actions: {
goChannel: (
channelId: string,
options?: { force?: boolean },
) => Promise<unknown>;
goHome: () => Promise<unknown>;
openSearchHit: (
hit: SearchHit,
behavior?: { force?: boolean; signal?: AbortSignal },
) => Promise<unknown>;
revealWindow: () => Promise<void>;
},
signal?: AbortSignal,
): Promise<void> {
if (signal?.aborted) {
return;
}
let navigation: Promise<unknown>;
if (!target.channelId) {
navigation = actions.goHome();
} else {
const anchor = toSearchHit(target);
navigation = anchor
? actions.openSearchHit(anchor, { force: true, signal })
: actions.goChannel(target.channelId, { force: true });
}
// Native activation already foregrounds the app on macOS. Other platforms
// still get a best-effort reveal, but it must never gate click-through.
void actions.revealWindow().catch(() => undefined);
await navigation;
}
export function deriveShellRoute(pathname: string): {
selectedChannelId: string | null;
selectedView: AppView;

View File

@@ -0,0 +1,141 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
activateDesktopNotificationTarget,
createDesktopNotificationActivationQueue,
} from "../AppShell.helpers.ts";
const { clearSearchHitEventCache, getCachedSearchHitEvent } = await import(
"./searchHitEventCache.ts"
);
const { openSearchHitWithNavigation } = await import(
"./searchHitNavigation.ts"
);
const forumComment = {
eventId: "comment",
content: "reply",
kind: 45003,
pubkey: "author",
channelId: "old-community-channel",
channelName: "forum",
createdAt: 1,
score: 0,
threadRootId: null,
};
const plainMessage = {
...forumComment,
eventId: "message",
kind: 9,
threadRootId: "thread-root",
};
test("search-hit navigation preserves forced message routing while active", async () => {
clearSearchHitEventCache();
const calls = [];
const result = await openSearchHitWithNavigation(plainMessage, {
force: true,
goChannel: async (channelId, options) => {
calls.push({ channelId, options });
return true;
},
goForumPost: async () => false,
});
assert.equal(result, true);
assert.deepEqual(calls, [
{
channelId: "old-community-channel",
options: {
force: true,
messageId: "message",
threadRootId: "thread-root",
},
},
]);
assert.equal(getCachedSearchHitEvent("message")?.id, "message");
});
test("cancelled search-hit navigation cannot repopulate cache or route", async () => {
clearSearchHitEventCache();
let resolveLookup;
const destination = new Promise((resolve) => {
resolveLookup = resolve;
});
const calls = [];
const controller = new AbortController();
const navigation = openSearchHitWithNavigation(
forumComment,
{
goChannel: async () => calls.push("channel"),
goForumPost: async () => calls.push("forum"),
signal: controller.signal,
},
() => destination,
);
controller.abort();
clearSearchHitEventCache();
resolveLookup({
kind: "forum-post",
channelId: "old-community-channel",
postId: "old-community-post",
replyId: "comment",
});
await navigation;
assert.deepEqual(calls, []);
assert.equal(getCachedSearchHitEvent("comment"), null);
});
test("queue cancellation fences an in-flight forum-comment activation", async () => {
clearSearchHitEventCache();
let resolveLookup;
const destination = new Promise((resolve) => {
resolveLookup = resolve;
});
const calls = [];
const queue = createDesktopNotificationActivationQueue((target, signal) =>
activateDesktopNotificationTarget(
target,
{
goChannel: async () => calls.push("channel"),
goHome: async () => calls.push("home"),
openSearchHit: (hit, behavior) =>
openSearchHitWithNavigation(
hit,
{
force: behavior?.force,
goChannel: async () => calls.push("channel"),
goForumPost: async () => calls.push("forum"),
signal: behavior?.signal,
},
() => destination,
),
revealWindow: async () => {},
},
signal,
),
);
queue.enqueue({
channelId: "old-community-channel",
eventId: "comment",
kind: 45003,
});
await new Promise((resolve) => setImmediate(resolve));
queue.cancel();
clearSearchHitEventCache();
resolveLookup({
kind: "forum-post",
channelId: "old-community-channel",
postId: "old-community-post",
replyId: "comment",
});
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(calls, []);
assert.equal(getCachedSearchHitEvent("comment"), null);
});

View File

@@ -0,0 +1,60 @@
import { resolveSearchHitDestination } from "@/app/navigation/resolveSearchHitDestination";
import { cacheSearchHitEvent } from "@/app/navigation/searchHitEventCache";
import type { SearchHit } from "@/shared/api/types";
type SearchHitNavigationActions = {
force?: boolean;
goChannel: (
channelId: string,
options?: {
force?: boolean;
messageId?: string;
threadRootId?: string | null;
},
) => Promise<unknown>;
goForumPost: (
channelId: string,
postId: string,
options?: { force?: boolean; replyId?: string },
) => Promise<unknown>;
signal?: AbortSignal;
};
export async function openSearchHitWithNavigation(
hit: SearchHit,
actions: SearchHitNavigationActions,
resolveDestination = resolveSearchHitDestination,
): Promise<unknown> {
if (actions.signal?.aborted) {
return false;
}
const isLifecycleBound = Boolean(actions.signal);
if (!isLifecycleBound) {
cacheSearchHitEvent(hit);
}
const destination = await resolveDestination(hit);
if (!destination || actions.signal?.aborted) {
return false;
}
if (isLifecycleBound) {
// Delay community-scoped writes for notification routing until async
// destination resolution completes and its owner is still current.
cacheSearchHitEvent(hit);
}
if (destination.kind === "forum-post") {
return actions.goForumPost(destination.channelId, destination.postId, {
force: actions.force,
replyId: destination.replyId,
});
}
return actions.goChannel(destination.channelId, {
force: actions.force,
messageId: destination.messageId,
threadRootId: destination.threadRootId,
});
}

View File

@@ -6,8 +6,7 @@ import {
useRouter,
} from "@tanstack/react-router";
import { cacheSearchHitEvent } from "@/app/navigation/searchHitEventCache";
import { resolveSearchHitDestination } from "@/app/navigation/resolveSearchHitDestination";
import { openSearchHitWithNavigation } from "@/app/navigation/searchHitNavigation";
import type { SearchHit } from "@/shared/api/types";
type NavigationBehavior = {
@@ -247,6 +246,10 @@ export function useAppNavigation() {
* firing. Used by the Drafts panel "Send message" confirm flow.
*/
autoSend?: string;
/** Navigate even when the destination matches the current href.
* Used by desktop-notification activation so a click is never
* silently swallowed (block/buzz#3509). */
force?: boolean;
messageId?: string;
replace?: boolean;
/** Open this thread panel directly without waiting for a timeline row. */
@@ -275,6 +278,7 @@ export function useAppNavigation() {
},
},
{
force: options?.force,
replace: options?.replace,
resetScroll: options?.messageId ? true : undefined,
},
@@ -298,6 +302,8 @@ export function useAppNavigation() {
channelId: string,
postId: string,
options?: {
/** Navigate even when the destination matches the current href. */
force?: boolean;
replace?: boolean;
replyId?: string;
},
@@ -312,6 +318,7 @@ export function useAppNavigation() {
search: options?.replyId ? { replyId: options.replyId } : {},
},
{
force: options?.force,
replace: options?.replace,
resetScroll: false,
},
@@ -362,25 +369,23 @@ export function useAppNavigation() {
);
const openSearchHit = React.useCallback(
async (hit: SearchHit) => {
cacheSearchHitEvent(hit);
const destination = await resolveSearchHitDestination(hit);
if (!destination) {
return false;
}
if (destination.kind === "forum-post") {
return goForumPost(destination.channelId, destination.postId, {
replyId: destination.replyId,
});
}
return goChannel(destination.channelId, {
messageId: destination.messageId,
threadRootId: destination.threadRootId,
});
},
async (
hit: SearchHit,
behavior?: {
/** Navigate even when the destination matches the current href.
* Used by desktop-notification activation so a click is never
* silently swallowed (block/buzz#3509). */
force?: boolean;
/** Stop notification-driven routing when its owning lifecycle ends. */
signal?: AbortSignal;
},
) =>
openSearchHitWithNavigation(hit, {
force: behavior?.force,
goChannel,
goForumPost,
signal: behavior?.signal,
}),
[goChannel, goForumPost],
);

View File

@@ -1,10 +1,10 @@
import * as React from "react";
import {
activateDesktopNotificationTarget,
createDesktopNotificationActivationQueue,
shouldBounceForChannelNotification,
toSearchHit,
} from "@/app/AppShell.helpers";
import { getThreadReference } from "@/features/messages/lib/threading";
import { useCommunityJoinAlerts } from "@/features/community-members/useCommunityJoinAlerts";
import { hasMentionForEvent } from "@/features/notifications/lib/shouldNotify";
import type { NotificationSettings } from "@/features/notifications/hooks";
@@ -14,15 +14,14 @@ import {
revealDesktopAppWindow,
sendDesktopNotification,
} from "@/features/notifications/lib/desktop";
import {
formatNotificationTitle,
truncateNotificationBody,
} from "@/features/notifications/lib/notificationFormat";
import { formatMessageNotification } from "@/features/notifications/lib/notificationFormat";
import { buildEventNotificationTarget } from "@/features/notifications/lib/target";
import {
playNotificationSound,
resolveSlotSound,
shouldPlayNotificationSound,
} from "@/features/notifications/lib/sound";
import { useNotificationSenderName } from "@/features/notifications/useNotificationSenderName";
import type { Channel, RelayEvent } from "@/shared/api/types";
export function useAppShellDesktopNotifications({
@@ -37,11 +36,15 @@ export function useAppShellDesktopNotifications({
}: {
channels: Channel[];
enabled: boolean;
goChannel: (channelId: string) => Promise<unknown>;
goChannel: (
channelId: string,
options?: { force?: boolean },
) => Promise<unknown>;
goHome: () => Promise<unknown>;
notificationSettings: NotificationSettings;
openSearchHit: (
hit: import("@/shared/api/types").SearchHit,
behavior?: { force?: boolean },
) => Promise<unknown>;
pubkey?: string;
silentChannelIds?: ReadonlySet<string>;
@@ -53,6 +56,8 @@ export function useAppShellDesktopNotifications({
enabled: enabled && notificationSettings.desktopEnabled,
});
const resolveSenderName = useNotificationSenderName();
const handleChannelNotification = React.useEffectEvent(
(_channelId: string, event: RelayEvent) => {
if (!enabled) return;
@@ -73,22 +78,20 @@ export function useAppShellDesktopNotifications({
}
const channelName = channel.name?.trim() || "Direct message";
const body = truncateNotificationBody(event.content, "New message");
const threadRootId = getThreadReference(event.tags).rootId ?? null;
const { title, body } = formatMessageNotification({
source: "dm",
senderName: resolveSenderName(event.pubkey),
channelName,
content: event.content,
});
void sendDesktopNotification({
title: channelName,
title,
body,
target: {
channelId: channel.id,
channelName,
content: event.content,
createdAt: event.created_at,
eventId: event.id,
kind: event.kind,
pubkey: event.pubkey,
threadRootId,
},
target: buildEventNotificationTarget(event, {
id: channel.id,
name: channelName,
}),
}).then((didSend) => {
if (!didSend) return;
if (shouldPlayNotificationSound(channel.id, silentChannelIds)) {
@@ -118,25 +121,20 @@ export function useAppShellDesktopNotifications({
const resolvedChannel = channels.find((c) => c.id === channelId);
const channelName = resolvedChannel?.name?.trim() ?? null;
// channelLabel is "#name" for the toast title; channelName is the raw
// name stored in the navigation target for click-through routing.
const channelLabel = channelName ? `#${channelName}` : null;
const body = truncateNotificationBody(event.content, "New reply");
const threadRootId = getThreadReference(event.tags).rootId ?? null;
const { title, body } = formatMessageNotification({
source: "thread_reply",
senderName: resolveSenderName(event.pubkey),
channelName,
content: event.content,
});
void sendDesktopNotification({
title: formatNotificationTitle({ prefix: "Reply", channelLabel }),
title,
body,
target: {
channelId,
channelName,
content: event.content,
createdAt: event.created_at,
eventId: event.id,
kind: event.kind,
pubkey: event.pubkey,
threadRootId,
},
target: buildEventNotificationTarget(event, {
id: channelId,
name: channelName,
}),
}).then((didSend) => {
if (!didSend) return;
if (shouldPlayNotificationSound(channelId, silentChannelIds)) {
@@ -152,21 +150,18 @@ export function useAppShellDesktopNotifications({
const handleDesktopNotificationAction = React.useEffectEvent(
async (
target: import("@/features/notifications/lib/desktop").DesktopNotificationTarget,
signal: AbortSignal,
) => {
await revealDesktopAppWindow();
if (!target.channelId) {
void goHome();
return;
}
const anchor = toSearchHit(target);
if (!anchor) {
await goChannel(target.channelId);
return;
}
await openSearchHit(anchor);
await activateDesktopNotificationTarget(
target,
{
goChannel,
goHome,
openSearchHit,
revealWindow: revealDesktopAppWindow,
},
signal,
);
},
);
@@ -174,13 +169,19 @@ export function useAppShellDesktopNotifications({
if (!enabled) return;
let isCancelled = false;
let cleanup = () => {};
const activationQueue = createDesktopNotificationActivationQueue(
(target, signal) => handleDesktopNotificationAction(target, signal),
(error) => {
console.error("Failed to activate desktop notification", error);
},
);
void listenForDesktopNotificationActions((target) => {
if (isCancelled) {
return;
}
void handleDesktopNotificationAction(target);
activationQueue.enqueue(target);
}).then((dispose) => {
if (isCancelled) {
dispose();
@@ -192,6 +193,7 @@ export function useAppShellDesktopNotifications({
return () => {
isCancelled = true;
activationQueue.cancel();
cleanup();
};
}, [enabled]);

View File

@@ -210,6 +210,7 @@ export async function listenForDesktopNotificationActions(
let pluginListener: { unregister: () => Promise<void> } | null = null;
let nativeUnlisten: (() => void) | null = null;
let redrainUnlisten: (() => void) | null = null;
if (isTauri()) {
const usesMacActivationQueue = isMacPlatform();
@@ -279,6 +280,29 @@ export async function listenForDesktopNotificationActions(
);
}
}
if (usesMacActivationQueue) {
// Belt and suspenders for block/buzz#3509: the Rust delegate queues the
// target before emitting, so a lost emit strands the activation with
// nothing re-draining it. macOS always foregrounds the app on a
// notification click, and WebKit delivers the resulting focus and
// visibility transitions independently of the Tauri event channel — use
// them to re-drain so a queued target is never stranded.
const redrain = () => {
void dispatchNativeActivations().catch((error) => {
console.error(
"Failed to drain macOS notification activations on focus",
error,
);
});
};
window.addEventListener("focus", redrain);
document.addEventListener("visibilitychange", redrain);
redrainUnlisten = () => {
window.removeEventListener("focus", redrain);
document.removeEventListener("visibilitychange", redrain);
};
}
}
return () => {
@@ -288,6 +312,7 @@ export async function listenForDesktopNotificationActions(
);
void pluginListener?.unregister();
nativeUnlisten?.();
redrainUnlisten?.();
};
}
@@ -335,6 +360,33 @@ export async function requestDockBounce(): Promise<void> {
}
}
/**
* How long the window-reveal invoke chain may run before callers proceed
* without it. macOS already foregrounds the app when a notification is
* clicked, so a reveal that never settles must not gate click-through
* routing (block/buzz#3509).
*/
const REVEAL_WINDOW_TIMEOUT_MS = 1_500;
function resolveWithinTimeout(
operation: Promise<void>,
timeoutMs: number,
): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(resolve, timeoutMs);
operation.then(
() => {
clearTimeout(timer);
resolve();
},
(error) => {
clearTimeout(timer);
reject(error);
},
);
});
}
export async function revealDesktopAppWindow(): Promise<void> {
if (!isTauri()) {
if (typeof window !== "undefined") {
@@ -345,9 +397,19 @@ export async function revealDesktopAppWindow(): Promise<void> {
try {
const currentWindow = getCurrentWindow();
await currentWindow.unminimize();
await currentWindow.show();
await currentWindow.setFocus();
// The reveal crosses the IPC boundary three times, and the try/catch
// only covers rejections — an invoke that never settles (seen while
// macOS is simultaneously foregrounding the app from a notification
// click) would strand callers that await this helper before navigating.
// Resolve after a timeout so navigation always proceeds.
await resolveWithinTimeout(
(async () => {
await currentWindow.unminimize();
await currentWindow.show();
await currentWindow.setFocus();
})(),
REVEAL_WINDOW_TIMEOUT_MS,
);
} catch {
// Best effort only.
}

View File

@@ -0,0 +1,141 @@
import assert from "node:assert/strict";
import test from "node:test";
// revealDesktopAppWindow and listenForDesktopNotificationActions cross the
// Tauri IPC boundary through window.__TAURI_INTERNALS__ — stub it before the
// module (and @tauri-apps/api) load. block/buzz#3509: a macOS notification
// click must always route, even when a window invoke hangs or the Tauri
// activation emit is lost.
let pendingActivations = [];
let hangWindowInvokes = false;
const tauriInternals = {
invoke(command) {
if (command === "take_pending_activations") {
const drained = pendingActivations;
pendingActivations = [];
return Promise.resolve(drained);
}
if (hangWindowInvokes && command.startsWith("plugin:window|")) {
return new Promise(() => {});
}
if (command === "plugin:event|listen") {
return Promise.resolve(1);
}
return Promise.resolve(undefined);
},
transformCallback() {
return 0;
},
metadata: { currentWindow: { label: "main" } },
};
const testWindow = new EventTarget();
testWindow.__TAURI_INTERNALS__ = tauriInternals;
testWindow.__TAURI_EVENT_PLUGIN_INTERNALS__ = {
unregisterListener() {},
};
// The module under test only checks that a Notification API exists and reads
// its static permission; a plain function stub keeps biome happy.
function StubNotification() {}
StubNotification.permission = "granted";
testWindow.Notification = StubNotification;
globalThis.window = testWindow;
globalThis.document = new EventTarget();
globalThis.isTauri = true;
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: { platform: "MacIntel", userAgent: "buzz-test" },
});
const { listenForDesktopNotificationActions, revealDesktopAppWindow } =
await import("./desktop.ts");
function flushPendingWork() {
return new Promise((resolve) => setImmediate(resolve));
}
test("reveal resolves via timeout when a window invoke hangs", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
hangWindowInvokes = true;
t.after(() => {
hangWindowInvokes = false;
});
let settled = false;
const reveal = revealDesktopAppWindow().then(() => {
settled = true;
});
await Promise.resolve();
await Promise.resolve();
assert.equal(settled, false);
t.mock.timers.tick(1_500);
await reveal;
assert.equal(settled, true);
});
test("reveal resolves without the timer when the invoke chain settles", async (t) => {
// Mocked timers never fire on their own here, so this await only returns
// if the helper resolves through the settled invoke chain.
t.mock.timers.enable({ apis: ["setTimeout"] });
await revealDesktopAppWindow();
});
test("window focus re-drains activations stranded by a lost emit", async () => {
const received = [];
const dispose = await listenForDesktopNotificationActions((target) => {
received.push(target);
});
// The Tauri emit was lost, but the Rust queue still holds the clicked
// target. macOS foregrounds the app anyway; WebKit fires window focus.
pendingActivations = [
{ channelId: "channel-1", eventId: "event-1", kind: 9 },
];
window.dispatchEvent(new Event("focus"));
await flushPendingWork();
assert.deepEqual(received, [
{
channelId: "channel-1",
channelName: null,
content: undefined,
createdAt: null,
eventId: "event-1",
kind: 9,
pubkey: undefined,
threadRootId: null,
},
]);
dispose();
pendingActivations = [
{ channelId: "channel-2", eventId: "event-2", kind: 9 },
];
window.dispatchEvent(new Event("focus"));
await flushPendingWork();
assert.equal(received.length, 1, "disposed listener must not re-drain");
// Leave the queue empty so the next test's mount-time drain starts clean.
pendingActivations = [];
});
test("visibilitychange re-drains activations stranded by a lost emit", async () => {
const received = [];
const dispose = await listenForDesktopNotificationActions((target) => {
received.push(target);
});
pendingActivations = [
{ channelId: "channel-3", eventId: "event-3", kind: 9 },
];
document.dispatchEvent(new Event("visibilitychange"));
await flushPendingWork();
assert.equal(received.length, 1);
assert.equal(received[0].channelId, "channel-3");
dispose();
});

View File

@@ -1,8 +1,5 @@
import type { Channel, FeedItem, HomeFeedResponse } from "@/shared/api/types";
import {
formatNotificationTitle,
truncateNotificationBody,
} from "@/features/notifications/lib/notificationFormat";
import { formatMessageNotification } from "@/features/notifications/lib/notificationFormat";
export type NotificationChannel = Pick<Channel, "id" | "name" | "channelType">;
@@ -31,44 +28,28 @@ export function enrichFeedItemChannel(
};
}
export function notificationTitle(item: FeedItem, senderName?: string) {
const channelLabel =
item.channelType !== "dm" && item.channelName.trim()
? `#${item.channelName.trim()}`
: null;
function feedNotificationSource(item: FeedItem) {
if (item.channelType === "dm") return "dm" as const;
if (item.category === "mention") return "mention" as const;
if (item.kind === 46010) return "approval" as const;
return "needs_action" as const;
}
if (item.channelType === "dm") {
return senderName || "Direct message";
}
if (item.category === "mention") {
return formatNotificationTitle({
prefix: senderName ? `${senderName} mentioned you` : "@Mention",
channelLabel,
});
}
if (item.kind === 46010) {
return formatNotificationTitle({
prefix: senderName
? `${senderName} requested approval`
: "Approval Requested",
channelLabel,
});
}
return formatNotificationTitle({
prefix: senderName ? senderName : "Needs Action",
channelLabel,
export function formatFeedNotification(item: FeedItem, senderName?: string) {
return formatMessageNotification({
source: feedNotificationSource(item),
senderName,
channelName: item.channelType !== "dm" ? item.channelName : null,
content: item.content,
});
}
export function notificationTitle(item: FeedItem, senderName?: string) {
return formatFeedNotification(item, senderName).title;
}
export function notificationBody(item: FeedItem) {
const fallback =
item.kind === 46010
? "A workflow is waiting for your approval."
: "Something in Buzz needs your attention.";
return truncateNotificationBody(item.content, fallback);
return formatFeedNotification(item).body;
}
export function collectHomeAlertItems(feed: HomeFeedResponse) {

View File

@@ -0,0 +1,163 @@
import assert from "node:assert/strict";
import test from "node:test";
import { formatMessageNotification } from "./notificationFormat.ts";
import { senderNameFromSummary } from "./senderName.ts";
test("DM title is the sender name when resolved", () => {
const { title, body } = formatMessageNotification({
source: "dm",
senderName: "Taylor",
channelName: "taylor-wes",
content: "hey there",
});
assert.equal(title, "Taylor");
assert.equal(body, "hey there");
});
test("DM title falls back to the channel name, then generic copy", () => {
assert.equal(
formatMessageNotification({
source: "dm",
senderName: null,
channelName: "taylor-wes",
content: "hi",
}).title,
"taylor-wes",
);
assert.equal(
formatMessageNotification({
source: "dm",
senderName: " ",
channelName: "",
content: "hi",
}).title,
"Direct message",
);
});
test("DM body falls back when the message is blank", () => {
assert.equal(
formatMessageNotification({
source: "dm",
senderName: "Taylor",
channelName: null,
content: " ",
}).body,
"New message",
);
});
test("thread reply leads with the sender when resolved", () => {
assert.equal(
formatMessageNotification({
source: "thread_reply",
senderName: "Taylor",
channelName: "ship-room",
content: "done!",
}).title,
"Taylor replied in #ship-room",
);
});
test("thread reply preserves legacy copy when the sender is unknown", () => {
assert.equal(
formatMessageNotification({
source: "thread_reply",
senderName: null,
channelName: "ship-room",
content: "done!",
}).title,
"Reply in #ship-room",
);
assert.deepEqual(
formatMessageNotification({
source: "thread_reply",
senderName: null,
channelName: null,
content: "",
}),
{ title: "Reply", body: "New reply" },
);
});
test("mention titles match the home-feed conventions", () => {
assert.equal(
formatMessageNotification({
source: "mention",
senderName: "Taylor",
channelName: "ship-room",
content: "@wes look",
}).title,
"Taylor mentioned you in #ship-room",
);
assert.equal(
formatMessageNotification({
source: "mention",
senderName: null,
channelName: "ship-room",
content: "@wes look",
}).title,
"@Mention in #ship-room",
);
});
test("approval and needs-action titles match the home-feed conventions", () => {
assert.deepEqual(
formatMessageNotification({
source: "approval",
senderName: "Taylor",
channelName: "ops",
content: "",
}),
{
title: "Taylor requested approval in #ops",
body: "A workflow is waiting for your approval.",
},
);
assert.deepEqual(
formatMessageNotification({
source: "needs_action",
senderName: null,
channelName: null,
content: "",
}),
{
title: "Needs Action",
body: "Something in Buzz needs your attention.",
},
);
});
test("senderNameFromSummary prefers displayName, then NIP-05, never a pubkey", () => {
assert.equal(
senderNameFromSummary({
displayName: "Taylor",
avatarUrl: null,
nip05Handle: "taylor@buzz.example",
ownerPubkey: null,
}),
"Taylor",
);
assert.equal(
senderNameFromSummary({
displayName: " ",
avatarUrl: null,
nip05Handle: "taylor@buzz.example",
ownerPubkey: null,
}),
"taylor@buzz.example",
);
assert.equal(
senderNameFromSummary({
displayName: null,
avatarUrl: null,
nip05Handle: null,
ownerPubkey: null,
}),
null,
);
assert.equal(senderNameFromSummary(null), null);
assert.equal(senderNameFromSummary(undefined), null);
});

View File

@@ -47,3 +47,66 @@ export function formatNotificationTitle(opts: {
? `${opts.prefix} in ${opts.channelLabel}`
: opts.prefix;
}
export type MessageNotificationSource =
| "mention"
| "approval"
| "needs_action"
| "dm"
| "thread_reply";
const MESSAGE_BODY_FALLBACKS: Record<MessageNotificationSource, string> = {
mention: "Something in Buzz needs your attention.",
approval: "A workflow is waiting for your approval.",
needs_action: "Something in Buzz needs your attention.",
dm: "New message",
thread_reply: "New reply",
};
/**
* Canonical copy for every message-shaped desktop notification (home-feed
* mentions and needs-action items, live DMs, live thread replies). All paths
* format through here so sender attribution and fallbacks stay consistent:
* the sender leads the title whenever their profile has resolved, and each
* source degrades to neutral copy — never a raw pubkey — when it has not.
*
* `senderName` must already be a real human label (see
* `senderNameFromSummary`); `channelName` is the raw channel name without
* a `#` prefix.
*/
export function formatMessageNotification(opts: {
source: MessageNotificationSource;
senderName?: string | null;
channelName?: string | null;
content: string;
}): { title: string; body: string } {
const { source, content } = opts;
const senderName = opts.senderName?.trim() || null;
const channelName = opts.channelName?.trim() || null;
const body = truncateNotificationBody(
content,
MESSAGE_BODY_FALLBACKS[source],
);
if (source === "dm") {
return { title: senderName ?? channelName ?? "Direct message", body };
}
const channelLabel = channelName ? `#${channelName}` : null;
const prefix =
source === "mention"
? senderName
? `${senderName} mentioned you`
: "@Mention"
: source === "approval"
? senderName
? `${senderName} requested approval`
: "Approval Requested"
: source === "thread_reply"
? senderName
? `${senderName} replied`
: "Reply"
: (senderName ?? "Needs Action");
return { title: formatNotificationTitle({ prefix, channelLabel }), body };
}

View File

@@ -0,0 +1,24 @@
import type { UserProfileSummary } from "@/shared/api/types";
/**
* Resolve a sender's human display label from a profile summary, mirroring
* the sidebar's precedence (`resolveUserLabel`: displayName, then NIP-05
* handle) — but returning `null` instead of a truncated pubkey when no real
* name is known. Notification titles fall back to neutral copy ("Reply in
* #channel", "Direct message"), never a hex fragment.
*/
export function senderNameFromSummary(
summary: UserProfileSummary | null | undefined,
): string | null {
const displayName = summary?.displayName?.trim();
if (displayName) {
return displayName;
}
const nip05Handle = summary?.nip05Handle?.trim();
if (nip05Handle) {
return nip05Handle;
}
return null;
}

View File

@@ -0,0 +1,81 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildEventNotificationTarget,
buildFeedItemNotificationTarget,
} from "./target.ts";
test("builds a complete click-through target from a live relay event", () => {
const target = buildEventNotificationTarget(
{
content: "hello",
created_at: 123,
id: "event-id",
kind: 9,
pubkey: "sender",
tags: [
["h", "channel-id"],
["e", "root-id", "", "root"],
["e", "parent-id", "", "reply"],
],
},
{ id: "channel-id", name: "ship-room" },
);
assert.deepEqual(target, {
channelId: "channel-id",
channelName: "ship-room",
content: "hello",
createdAt: 123,
eventId: "event-id",
kind: 9,
pubkey: "sender",
threadRootId: "root-id",
});
});
test("null channel name and top-level events produce null fields", () => {
const target = buildEventNotificationTarget(
{
content: "hello",
created_at: 123,
id: "event-id",
kind: 9,
pubkey: "sender",
tags: [["h", "channel-id"]],
},
{ id: "channel-id", name: " " },
);
assert.equal(target.channelName, null);
assert.equal(target.threadRootId, null);
});
test("builds a complete click-through target from a feed item", () => {
const target = buildFeedItemNotificationTarget({
id: "feed-event",
kind: 9,
pubkey: "sender",
content: "ping",
createdAt: 456,
channelId: "channel-id",
channelName: "ship-room",
tags: [
["e", "root-id", "", "root"],
["e", "parent-id", "", "reply"],
],
category: "mention",
});
assert.deepEqual(target, {
channelId: "channel-id",
channelName: "ship-room",
content: "ping",
createdAt: 456,
eventId: "feed-event",
kind: 9,
pubkey: "sender",
threadRootId: "root-id",
});
});

View File

@@ -0,0 +1,44 @@
import { getThreadReference } from "@/features/messages/lib/threading";
import type { FeedItem, RelayEvent } from "@/shared/api/types";
import type { DesktopNotificationTarget } from "./desktop";
/**
* Build the click-through navigation target for a live relay event (DM or
* thread-reply). Every notification path constructs its target here so the
* payload the OS hands back on activation always carries the full routing
* anchor (eventId + threadRootId), not a hand-rolled subset.
*/
export function buildEventNotificationTarget(
event: Pick<
RelayEvent,
"content" | "created_at" | "id" | "kind" | "pubkey" | "tags"
>,
channel: { id: string; name?: string | null },
): DesktopNotificationTarget {
return {
channelId: channel.id,
channelName: channel.name?.trim() || null,
content: event.content,
createdAt: event.created_at,
eventId: event.id,
kind: event.kind,
pubkey: event.pubkey,
threadRootId: getThreadReference(event.tags).rootId ?? null,
};
}
/** Build the click-through navigation target for a home-feed item. */
export function buildFeedItemNotificationTarget(
item: FeedItem,
): DesktopNotificationTarget {
return {
channelId: item.channelId,
channelName: item.channelName,
content: item.content,
createdAt: item.createdAt,
eventId: item.id,
kind: item.kind,
pubkey: item.pubkey,
threadRootId: getThreadReference(item.tags).rootId ?? null,
};
}

View File

@@ -5,15 +5,14 @@ import {
resolveUserLabel,
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import { getThreadReference } from "@/features/messages/lib/threading";
import type { FeedItem, HomeFeedResponse } from "@/shared/api/types";
import {
collectHomeAlertItems,
eligibleFeedNotificationItems,
formatFeedNotification,
type NotificationChannel,
notificationBody,
notificationTitle,
} from "./lib/feed";
import { buildFeedItemNotificationTarget } from "./lib/target";
import {
getDesktopNotificationPermissionState,
requestDesktopNotificationAccess,
@@ -112,20 +111,11 @@ export function useFeedDesktopNotifications(
const deliverFeedNotification = React.useEffectEvent(
async (item: FeedItem, senderName?: string) => {
const threadRootId = getThreadReference(item.tags).rootId ?? null;
const { title, body } = formatFeedNotification(item, senderName);
const didSend = await sendDesktopNotification({
body: notificationBody(item),
target: {
channelId: item.channelId,
channelName: item.channelName,
content: item.content,
createdAt: item.createdAt,
eventId: item.id,
kind: item.kind,
pubkey: item.pubkey,
threadRootId,
},
title: notificationTitle(item, senderName),
body,
target: buildFeedItemNotificationTarget(item),
title,
});
if (

View File

@@ -0,0 +1,76 @@
import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useCommunities } from "@/features/communities/useCommunities";
import {
usersBatchEntryKey,
type UsersBatchEntry,
} from "@/features/profile/hooks";
import {
readCachedUserLabels,
writeCachedUserLabels,
} from "@/features/profile/lib/userLabelStorage";
import { getUsersBatch } from "@/shared/api/tauriProfiles";
import { senderNameFromSummary } from "./lib/senderName";
/**
* Synchronous sender-name lookup for live desktop notifications (DMs and
* thread replies). Toasts must never wait on the network, so resolution is
* cache-only: the per-pubkey `users-batch-entry` React Query cache first
* (kept warm by the sidebar and home feed), then the persisted label cache
* (survives restart). A cold miss returns `null` — the caller falls back to
* neutral copy — and kicks off a background fetch so the sender's next
* message resolves.
*/
export function useNotificationSenderName(): (
pubkey: string | undefined,
) => string | null {
const queryClient = useQueryClient();
const { activeCommunity } = useCommunities();
const relayUrl = activeCommunity?.relayUrl ?? "";
return React.useCallback(
(pubkey) => {
const normalized = pubkey?.trim().toLowerCase() ?? "";
if (!normalized) {
return null;
}
const entry = queryClient.getQueryData<UsersBatchEntry>(
usersBatchEntryKey(normalized),
);
if (entry) {
// A stale name still beats no name for a toast; relay-confirmed
// misses (summary: null) correctly resolve to the fallback copy.
return senderNameFromSummary(entry.summary);
}
const cached = relayUrl
? readCachedUserLabels(relayUrl, [normalized])
: undefined;
const cachedSummary = cached?.profiles[normalized];
if (cachedSummary) {
return senderNameFromSummary(cachedSummary);
}
void getUsersBatch([normalized])
.then((fresh) => {
queryClient.setQueryData<UsersBatchEntry>(
usersBatchEntryKey(normalized),
{
summary: fresh.profiles[normalized] ?? null,
fetchedAt: Date.now(),
},
);
if (relayUrl) {
writeCachedUserLabels(relayUrl, fresh.profiles, fresh.missing);
}
})
.catch(() => {
// Warm-up is best effort; the toast already shipped with fallback copy.
});
return null;
},
[queryClient, relayUrl],
);
}

View File

@@ -286,12 +286,15 @@ export function useUserProfileQuery(pubkey?: string) {
// `summary: null` records a relay-confirmed miss so unknown pubkeys aren't
// re-requested every page. Entries older than the hook's 60s staleTime are
// treated as unresolved and refetched.
type UsersBatchEntry = {
export type UsersBatchEntry = {
summary: UserProfileSummary | null;
fetchedAt: number;
};
const usersBatchEntryKey = (pubkey: string) => ["users-batch-entry", pubkey];
export const usersBatchEntryKey = (pubkey: string) => [
"users-batch-entry",
pubkey,
];
/**
* Drop the per-pubkey delta-fetch entries so the next `useUsersBatchQuery`

View File

@@ -111,7 +111,7 @@ test("an incoming DM produces exactly one desktop notification", async ({
`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");
// 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");
});