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

269 lines
6.8 KiB
TypeScript

import { isThreadReply } from "@/features/messages/lib/threading";
import type { DesktopNotificationTarget } from "@/features/notifications/lib/desktop";
import type { SearchHit } from "@/shared/api/types";
export type AppView =
| "home"
| "channel"
| "messages"
| "agents"
| "workflows"
| "pulse"
| "projects";
const WINDOW_DRAG_HANDLE_HEIGHT = 44;
const TAURI_DRAG_REGION_ATTR = "data-tauri-drag-region";
const WINDOW_DRAG_INTERACTIVE_SELECTOR =
'button, a, input, textarea, select, label, summary, [role="button"], [role="link"], [role="menuitem"], [role="tab"], [role="checkbox"], [role="radio"], [role="switch"], [role="option"], [contenteditable="true"], [tabindex]:not([tabindex="-1"])';
const CLICKABLE_TAGS = new Set([
"A",
"BUTTON",
"INPUT",
"SELECT",
"TEXTAREA",
"LABEL",
"SUMMARY",
]);
const INTERACTIVE_ROLES = new Set([
"button",
"link",
"menuitem",
"tab",
"checkbox",
"radio",
"switch",
"option",
]);
function isClickableElement(element: HTMLElement) {
return (
CLICKABLE_TAGS.has(element.tagName) ||
(element.hasAttribute("contenteditable") &&
element.getAttribute("contenteditable") !== "false") ||
(element.hasAttribute("tabindex") &&
element.getAttribute("tabindex") !== "-1") ||
INTERACTIVE_ROLES.has(element.getAttribute("role") ?? "")
);
}
function isTauriDragRegionEvent(event: MouseEvent | PointerEvent) {
const path = event.composedPath();
const directTarget = path[0];
for (const item of path) {
if (!(item instanceof HTMLElement)) continue;
const attr = item.getAttribute(TAURI_DRAG_REGION_ATTR);
if (isClickableElement(item) && attr === null) return false;
if (attr === null) continue;
if (attr === "false") return false;
if (attr === "deep") return true;
if (attr === "" || attr === "true") return item === directTarget;
}
return false;
}
export function isWindowDragHandleEvent(event: MouseEvent | PointerEvent) {
if (isTauriDragRegionEvent(event)) {
return true;
}
if (event.clientY > WINDOW_DRAG_HANDLE_HEIGHT) {
return false;
}
const target = event.target;
return !(
target instanceof Element &&
target.closest(WINDOW_DRAG_INTERACTIVE_SELECTOR)
);
}
export function shouldBounceForChannelNotification(tags: string[][]): boolean {
return !isThreadReply(tags);
}
export function markAllReadSources({
activeChannelId,
channelActivityItems,
markAllChannelReadMarkers,
markActiveChannelRead,
undoUnreadFeedItem,
unreadFeedItemIds,
}: {
activeChannelId: string | null;
channelActivityItems: ReadonlyArray<{
channelId: string | null;
createdAt: number;
}>;
markAllChannelReadMarkers: () => void;
markActiveChannelRead: (channelId: string, createdAt: number) => void;
undoUnreadFeedItem: (itemId: string) => void;
unreadFeedItemIds: ReadonlySet<string>;
}) {
for (const itemId of unreadFeedItemIds) {
undoUnreadFeedItem(itemId);
}
markAllChannelReadMarkers();
if (!activeChannelId) return;
let latestActivityAt: number | null = null;
for (const item of channelActivityItems) {
if (item.channelId !== activeChannelId) continue;
latestActivityAt = Math.max(latestActivityAt ?? 0, item.createdAt);
}
if (latestActivityAt !== null) {
markActiveChannelRead(activeChannelId, latestActivityAt);
}
}
export function toSearchHit(
target: DesktopNotificationTarget,
): SearchHit | null {
if (!target.eventId) {
return null;
}
return {
eventId: target.eventId,
content: target.content ?? "",
kind: target.kind ?? 9,
pubkey: target.pubkey ?? "",
channelId: target.channelId,
channelName: target.channelName ?? null,
createdAt: target.createdAt ?? Math.floor(Date.now() / 1_000),
score: 0,
threadRootId: target.threadRootId ?? null,
};
}
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;
} {
if (pathname.startsWith("/channels/")) {
const [, , rawChannelId] = pathname.split("/");
return {
selectedChannelId: rawChannelId ? decodeURIComponent(rawChannelId) : null,
selectedView: "channel",
};
}
if (pathname === "/messages/new") {
return {
selectedChannelId: null,
selectedView: "messages",
};
}
if (pathname === "/agents") {
return {
selectedChannelId: null,
selectedView: "agents",
};
}
if (pathname === "/workflows" || pathname.startsWith("/workflows/")) {
return {
selectedChannelId: null,
selectedView: "workflows",
};
}
if (pathname === "/projects" || pathname.startsWith("/projects/")) {
return {
selectedChannelId: null,
selectedView: "projects",
};
}
if (pathname === "/pulse") {
return {
selectedChannelId: null,
selectedView: "pulse",
};
}
return {
selectedChannelId: null,
selectedView: "home",
};
}