Files
buzz/desktop/src/app/useAppShellDesktopNotifications.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

207 lines
5.9 KiB
TypeScript

import * as React from "react";
import {
activateDesktopNotificationTarget,
createDesktopNotificationActivationQueue,
shouldBounceForChannelNotification,
} from "@/app/AppShell.helpers";
import { useCommunityJoinAlerts } from "@/features/community-members/useCommunityJoinAlerts";
import { hasMentionForEvent } from "@/features/notifications/lib/shouldNotify";
import type { NotificationSettings } from "@/features/notifications/hooks";
import {
listenForDesktopNotificationActions,
requestDockBounce,
revealDesktopAppWindow,
sendDesktopNotification,
} from "@/features/notifications/lib/desktop";
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({
channels,
enabled,
goChannel,
goHome,
notificationSettings,
openSearchHit,
pubkey,
silentChannelIds,
}: {
channels: Channel[];
enabled: boolean;
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>;
}) {
// Roster alerts are owner/admin-only and self-gating; mounted here because
// it shares this hook's "desktop notifications are on" precondition and
// AppShell sits at the file-size ratchet ceiling.
useCommunityJoinAlerts({
enabled: enabled && notificationSettings.desktopEnabled,
});
const resolveSenderName = useNotificationSenderName();
const handleChannelNotification = React.useEffectEvent(
(_channelId: string, event: RelayEvent) => {
if (!enabled) return;
if (!shouldBounceForChannelNotification(event.tags)) return;
if (!notificationSettings.desktopEnabled) return;
void requestDockBounce();
},
);
const handleDmNotification = React.useEffectEvent(
(event: RelayEvent, channel: Channel) => {
if (!enabled) return;
if (
!notificationSettings.desktopEnabled ||
!notificationSettings.slotAlertsEnabled.dm
) {
return;
}
const channelName = channel.name?.trim() || "Direct message";
const { title, body } = formatMessageNotification({
source: "dm",
senderName: resolveSenderName(event.pubkey),
channelName,
content: event.content,
});
void sendDesktopNotification({
title,
body,
target: buildEventNotificationTarget(event, {
id: channel.id,
name: channelName,
}),
}).then((didSend) => {
if (!didSend) return;
if (shouldPlayNotificationSound(channel.id, silentChannelIds)) {
playNotificationSound(resolveSlotSound(notificationSettings, "dm"));
}
void requestDockBounce();
});
},
);
const handleThreadReplyDesktopNotification = React.useEffectEvent(
(channelId: string, event: RelayEvent) => {
if (!enabled) return;
if (
!notificationSettings.desktopEnabled ||
!notificationSettings.slotAlertsEnabled.thread_reply
) {
return;
}
// Replies that @-mention the user are owned by the home-feed mention
// path — skip them here so they don't notify (and sound) twice.
const normalizedPubkey = pubkey?.trim().toLowerCase() ?? "";
if (hasMentionForEvent(event, normalizedPubkey)) {
return;
}
const resolvedChannel = channels.find((c) => c.id === channelId);
const channelName = resolvedChannel?.name?.trim() ?? null;
const { title, body } = formatMessageNotification({
source: "thread_reply",
senderName: resolveSenderName(event.pubkey),
channelName,
content: event.content,
});
void sendDesktopNotification({
title,
body,
target: buildEventNotificationTarget(event, {
id: channelId,
name: channelName,
}),
}).then((didSend) => {
if (!didSend) return;
if (shouldPlayNotificationSound(channelId, silentChannelIds)) {
playNotificationSound(
resolveSlotSound(notificationSettings, "thread_reply"),
);
}
void requestDockBounce();
});
},
);
const handleDesktopNotificationAction = React.useEffectEvent(
async (
target: import("@/features/notifications/lib/desktop").DesktopNotificationTarget,
signal: AbortSignal,
) => {
await activateDesktopNotificationTarget(
target,
{
goChannel,
goHome,
openSearchHit,
revealWindow: revealDesktopAppWindow,
},
signal,
);
},
);
React.useEffect(() => {
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;
}
activationQueue.enqueue(target);
}).then((dispose) => {
if (isCancelled) {
dispose();
return;
}
cleanup = dispose;
});
return () => {
isCancelled = true;
activationQueue.cancel();
cleanup();
};
}, [enabled]);
return {
handleChannelNotification,
handleDmNotification,
handleThreadReplyDesktopNotification,
};
}