mirror of
https://github.com/block/buzz.git
synced 2026-10-28 04:51:40 +00:00
> 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>
207 lines
5.9 KiB
TypeScript
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,
|
|
};
|
|
}
|