mirror of
https://github.com/block/buzz.git
synced 2026-10-31 22:41:12 +00:00
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:
@@ -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 = [];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
141
desktop/src/app/navigation/searchHitNavigation.test.mjs
Normal file
141
desktop/src/app/navigation/searchHitNavigation.test.mjs
Normal 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);
|
||||
});
|
||||
60
desktop/src/app/navigation/searchHitNavigation.ts
Normal file
60
desktop/src/app/navigation/searchHitNavigation.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
24
desktop/src/features/notifications/lib/senderName.ts
Normal file
24
desktop/src/features/notifications/lib/senderName.ts
Normal 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;
|
||||
}
|
||||
81
desktop/src/features/notifications/lib/target.test.mjs
Normal file
81
desktop/src/features/notifications/lib/target.test.mjs
Normal 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",
|
||||
});
|
||||
});
|
||||
44
desktop/src/features/notifications/lib/target.ts
Normal file
44
desktop/src/features/notifications/lib/target.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user