From 3d5ee18a423bf4afb78f7efd0c19486d6385a4a0 Mon Sep 17 00:00:00 2001 From: Fatima Nur Date: Mon, 24 Aug 2026 18:50:28 +0500 Subject: [PATCH 1/7] fix(desktop): enable native Windows toast notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The show_native_notification command only handled Linux and macOS. On Windows it returned an error, forcing the frontend to fall through to window.Notification (WebKit API). WebView2's Notification.permission reports 'denied' even when the WinRT toast API is available, so the app never appeared in Windows Settings > System > Notifications and the settings toggle was stuck showing 'Desktop notifications are blocked.' - Add tauri-winrt-notification as a Windows-specific dependency - Add a windows module in notifications.rs that posts WinRT toasts using the app's Tauri identifier as AppUserModelID (this is what registers the app with Windows notification settings) - Handle click actions through WinRT Activated handler, forwarding to the same native-notification-activated event that Linux uses - Add isWindowsPlatform() helper to platform.ts - Skip WebView2 Notification.permission check on Windows in getDesktopNotificationPermissionState() — use the Tauri plugin's isPermissionGranted() which queries native WinRT status - Route Windows through the native show_native_notification path in sendDesktopNotification() - Skip the Tauri plugin's onAction listener on Windows (click actions come through the native WinRT event instead) Fixes #6377 Signed-off-by: Fatima Nur --- desktop/src-tauri/Cargo.lock | 1 + desktop/src-tauri/Cargo.toml | 3 + .../src-tauri/src/commands/notifications.rs | 58 +++++++++++- .../src/features/notifications/lib/desktop.ts | 26 +++++- desktop/src/shared/lib/platform.test.mjs | 92 +++++++++++++++++++ desktop/src/shared/lib/platform.ts | 9 ++ 6 files changed, 183 insertions(+), 6 deletions(-) create mode 100644 desktop/src/shared/lib/platform.test.mjs diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 6af27ee2438..60ddcd3af90 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1159,6 +1159,7 @@ dependencies = [ "tauri-plugin-updater", "tauri-plugin-window-state", "tauri-utils", + "tauri-winrt-notification", "tempfile", "tokio", "tokio-tungstenite 0.29.0", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index a7ae5229d1a..758568ddebf 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -66,6 +66,9 @@ plist = "1" windows-sys = { version = "0.61", features = ["Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_Threading", "Win32_Foundation"] } keyring = { version = "3.6.3", default-features = false, features = ["windows-native", "vendored"], optional = true } user-idle = { version = "0.6", default-features = false } +# Native Windows toast notifications so the app registers with Windows Settings +# > System > Notifications and click actions work through WinRT. +tauri-winrt-notification = "0.7" [dependencies] atomic-write-file = "0.3" diff --git a/desktop/src-tauri/src/commands/notifications.rs b/desktop/src-tauri/src/commands/notifications.rs index 79aa15f969a..1c16ad9d39e 100644 --- a/desktop/src-tauri/src/commands/notifications.rs +++ b/desktop/src-tauri/src/commands/notifications.rs @@ -39,10 +39,16 @@ pub async fn show_native_notification( crate::macos_notifications::show(title, body, target).await } - #[cfg(not(any(target_os = "linux", target_os = "macos")))] + #[cfg(target_os = "windows")] + { + windows::show(app, title, body, target); + Ok(()) + } + + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] { let _ = (&app, &title, &body, &target); - Err("show_native_notification is only supported on Linux and macOS".to_string()) + Err("show_native_notification is not supported on this platform".to_string()) } } @@ -106,3 +112,51 @@ mod linux { }); } } + +// ── Windows ──────────────────────────────────────────────────────────────── +// +// Uses `tauri-winrt-notification` to post Windows toast notifications. This +// registers the app with Windows Settings > System > Notifications (so the +// user can control per-app notification preferences) and surfaces click +// actions through the WinRT `Activated` handler, which we forward to the +// frontend via the same `native-notification-activated` event that Linux uses. + +#[cfg(target_os = "windows")] +mod windows { + use super::NATIVE_NOTIFICATION_ACTIVATED_EVENT; + use tauri::Emitter; + use tauri_winrt_notification::{Duration, Toast}; + + pub fn show( + app: tauri::AppHandle, + title: String, + body: Option, + target: Option, + ) { + // The Tauri identifier (e.g. "xyz.block.buzz.app") is the + // AppUserModelID that Windows uses to group notifications and + // surface the app in Settings > Notifications. + let app_id = app.config().identifier.clone(); + + std::thread::spawn(move || { + let app_clone = app.clone(); + let result = Toast::new(&app_id) + .title(&title) + .text1(body.as_deref().unwrap_or("")) + .sound(None) + .duration(Duration::Short) + .on_activated(move |_action| { + // _action is None for the default (body) click and + // Some(arg) for button clicks. We only use the default + // click, matching the Linux behaviour. + let _ = app_clone.emit(NATIVE_NOTIFICATION_ACTIVATED_EVENT, &target); + Ok(()) + }) + .show(); + + if let Err(error) = result { + eprintln!("buzz-desktop: failed to post Windows notification: {error}"); + } + }); + } +} diff --git a/desktop/src/features/notifications/lib/desktop.ts b/desktop/src/features/notifications/lib/desktop.ts index 0844c24de51..b05344a197d 100644 --- a/desktop/src/features/notifications/lib/desktop.ts +++ b/desktop/src/features/notifications/lib/desktop.ts @@ -6,7 +6,7 @@ import { onAction, requestPermission, } from "@tauri-apps/plugin-notification"; -import { isLinuxPlatform, isMacPlatform } from "@/shared/lib/platform"; +import { isLinuxPlatform, isMacPlatform, isWindowsPlatform } from "@/shared/lib/platform"; // Backend event emitted when a native Linux notification is clicked or a // queued macOS activation becomes available. See src-tauri notification code. @@ -146,6 +146,17 @@ export async function getDesktopNotificationPermissionState(): Promise { pendingPermissionRequest = null; }); @@ -215,7 +229,7 @@ export async function listenForDesktopNotificationActions( if (isTauri()) { const usesMacActivationQueue = isMacPlatform(); - if (!isLinuxPlatform() && !usesMacActivationQueue) { + if (!isLinuxPlatform() && !isWindowsPlatform() && !usesMacActivationQueue) { try { pluginListener = await onAction((notification) => { const target = parseNotificationTarget( @@ -424,8 +438,12 @@ export async function sendDesktopNotification( // Linux needs a retained D-Bus connection. macOS needs a native notification // center delegate because the Tauri plugin does not deliver desktop clicks. + // Windows needs WinRT toast notifications so the app registers with + // Settings > System > Notifications and click actions work. + // Do NOT use the Tauri notification plugin's sendNotification() on Windows — + // the native WinRT path handles delivery and click actions exclusively. // See src-tauri/src/commands/notifications.rs. - if (isTauri() && (isLinuxPlatform() || isMacPlatform())) { + if (isTauri() && (isLinuxPlatform() || isMacPlatform() || isWindowsPlatform())) { try { await invoke("show_native_notification", { title: payload.title, diff --git a/desktop/src/shared/lib/platform.test.mjs b/desktop/src/shared/lib/platform.test.mjs new file mode 100644 index 00000000000..d61bdd00913 --- /dev/null +++ b/desktop/src/shared/lib/platform.test.mjs @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +// Save the original navigator so we can restore it after each test. +const originalNavigator = globalThis.navigator; + +function withNavigator(platform, userAgent) { + Object.defineProperty(globalThis, "navigator", { + value: { platform, userAgent }, + configurable: true, + }); +} + +function restoreNavigator() { + Object.defineProperty(globalThis, "navigator", { + value: originalNavigator, + configurable: true, + }); +} + +const { isMacPlatform, isLinuxPlatform, isWindowsPlatform } = await import( + "./platform.ts" +); + +// ── isWindowsPlatform ────────────────────────────────────────────────────── + +test("isWindowsPlatform returns true for Win32", () => { + withNavigator("Win32", "Mozilla/5.0"); + assert.equal(isWindowsPlatform(), true); + restoreNavigator(); +}); + +test("isWindowsPlatform returns true for Win64", () => { + withNavigator("Win64", "Mozilla/5.0"); + assert.equal(isWindowsPlatform(), true); + restoreNavigator(); +}); + +test("isWindowsPlatform returns false for macOS", () => { + withNavigator("MacIntel", "Mozilla/5.0"); + assert.equal(isWindowsPlatform(), false); + restoreNavigator(); +}); + +test("isWindowsPlatform returns false for Linux", () => { + withNavigator("Linux x86_64", "Mozilla/5.0"); + assert.equal(isWindowsPlatform(), false); + restoreNavigator(); +}); + +test("isWindowsPlatform returns false when navigator is undefined", () => { + Object.defineProperty(globalThis, "navigator", { + value: undefined, + configurable: true, + }); + assert.equal(isWindowsPlatform(), false); + restoreNavigator(); +}); + +// ── isMacPlatform ────────────────────────────────────────────────────────── + +test("isMacPlatform returns true for MacIntel", () => { + withNavigator("MacIntel", "Mozilla/5.0"); + assert.equal(isMacPlatform(), true); + restoreNavigator(); +}); + +test("isMacPlatform returns false for Win32", () => { + withNavigator("Win32", "Mozilla/5.0"); + assert.equal(isMacPlatform(), false); + restoreNavigator(); +}); + +// ── isLinuxPlatform ──────────────────────────────────────────────────────── + +test("isLinuxPlatform returns true for Linux", () => { + withNavigator("Linux x86_64", "Mozilla/5.0"); + assert.equal(isLinuxPlatform(), true); + restoreNavigator(); +}); + +test("isLinuxPlatform returns false for Android", () => { + withNavigator("Linux armv81", "Mozilla/5.0 (Linux; Android 14)"); + assert.equal(isLinuxPlatform(), false); + restoreNavigator(); +}); + +test("isLinuxPlatform returns false for Win32", () => { + withNavigator("Win32", "Mozilla/5.0"); + assert.equal(isLinuxPlatform(), false); + restoreNavigator(); +}); diff --git a/desktop/src/shared/lib/platform.ts b/desktop/src/shared/lib/platform.ts index 42e7f5b944f..e4b3a9cc377 100644 --- a/desktop/src/shared/lib/platform.ts +++ b/desktop/src/shared/lib/platform.ts @@ -23,6 +23,15 @@ export function isLinuxPlatform(): boolean { ); } +/** Returns true on Windows desktops. */ +export function isWindowsPlatform(): boolean { + if (typeof navigator === "undefined") { + return false; + } + + return /win/i.test(navigator.platform); +} + /** * The platform's normal application-shortcut modifier: * - macOS: Command (Meta) From fe8b3fc794882f9ecad38ae83b8169226d5ae40b Mon Sep 17 00:00:00 2001 From: Bernhard Kaindl Date: Sat, 12 Sep 2026 23:46:28 +0200 Subject: [PATCH 2/7] fix(desktop): make Windows notifications reliable Problem ------- Windows notification delivery and activation had three related gaps: - permission repair could still be in flight when the first notification was sent, - clicking a normal message notification could open an empty reply branch, - broad live channel subscriptions updated only the projected message cache, so an active channel could miss the message until reload. Solution -------- - Await and confirm desktop notification permission before feed, DM, and dedicated thread-reply delivery. - Carry explicit timeline-versus-thread intent in notification targets and channel routes. - Merge visible live channel messages into the authoritative channel window store before projection. - Add focused regression coverage for permission sequencing, activation routing, notify-while-viewing, and live window projection. Signed-off-by: Bernhard Kaindl --- desktop/src/app/AppShell.helpers.test.mjs | 42 ++++++++-- desktop/src/app/AppShell.helpers.ts | 12 ++- .../src/app/navigation/useAppNavigation.ts | 3 + desktop/src/app/routes/ChannelRouteScreen.tsx | 3 + .../src/app/routes/channels.$channelId.tsx | 3 + .../app/useAppShellDesktopNotifications.ts | 79 +++++++++++-------- .../features/channels/ui/ChannelScreen.tsx | 3 +- .../channels/ui/ChannelScreen.types.ts | 2 + .../features/channels/ui/channelSearchKeys.ts | 1 + .../channels/ui/useChannelRouteTarget.ts | 7 ++ .../channels/useLiveChannelUpdates.test.mjs | 38 ++++++++- .../channels/useLiveChannelUpdates.ts | 23 ++++++ .../notifications/lib/desktop.test.mjs | 39 ++++++++- .../src/features/notifications/lib/desktop.ts | 28 ++++++- .../notifications/lib/target.test.mjs | 28 ++++++- .../src/features/notifications/lib/target.ts | 7 +- .../use-feed-desktop-notifications.test.mjs | 46 +++++++++++ .../use-feed-desktop-notifications.ts | 53 ++++++++----- 18 files changed, 350 insertions(+), 67 deletions(-) create mode 100644 desktop/src/features/notifications/use-feed-desktop-notifications.test.mjs diff --git a/desktop/src/app/AppShell.helpers.test.mjs b/desktop/src/app/AppShell.helpers.test.mjs index fc4a14f2cd7..e51284d95d2 100644 --- a/desktop/src/app/AppShell.helpers.test.mjs +++ b/desktop/src/app/AppShell.helpers.test.mjs @@ -134,11 +134,8 @@ test("notification activation starts routing before a hung reveal", async () => kind: 9, }, { - goChannel: async () => calls.push("channel"), - goHome: async () => calls.push("home"), - revealWindow: () => new Promise(() => {}), - openSearchHit: (_hit, behavior) => { - calls.push(`message:${String(behavior?.force)}`); + goChannel: (_channelId, behavior) => { + calls.push({ kind: "channel", behavior }); return new Promise((resolve) => { resolveNavigation = () => { navigationSettled = true; @@ -146,15 +143,48 @@ test("notification activation starts routing before a hung reveal", async () => }; }); }, + goHome: async () => calls.push("home"), + revealWindow: () => new Promise(() => {}), + openSearchHit: async () => calls.push("thread"), }, ); - assert.deepEqual(calls, ["message:true"]); + assert.deepEqual(calls, [ + { + kind: "channel", + behavior: { + force: true, + messageId: "event", + messageView: "timeline", + }, + }, + ]); resolveNavigation(); await activation; assert.equal(navigationSettled, true); }); +test("notification activation retains thread routing for branch replies", async () => { + const calls = []; + await activateDesktopNotificationTarget( + { + channelId: "channel", + eventId: "reply", + kind: 9, + openInThread: true, + threadRootId: "root", + }, + { + goChannel: async () => calls.push("channel"), + goHome: async () => calls.push("home"), + openSearchHit: async (hit) => calls.push(hit.threadRootId), + revealWindow: async () => {}, + }, + ); + + assert.deepEqual(calls, ["root"]); +}); + test("notification activation falls back to forced channel navigation", async () => { const calls = []; await activateDesktopNotificationTarget( diff --git a/desktop/src/app/AppShell.helpers.ts b/desktop/src/app/AppShell.helpers.ts index 9fc14736c7c..754f6d1c4e5 100644 --- a/desktop/src/app/AppShell.helpers.ts +++ b/desktop/src/app/AppShell.helpers.ts @@ -183,7 +183,11 @@ export async function activateDesktopNotificationTarget( actions: { goChannel: ( channelId: string, - options?: { force?: boolean }, + options?: { + force?: boolean; + messageId?: string; + messageView?: "timeline"; + }, ) => Promise; goHome: () => Promise; openSearchHit: ( @@ -201,6 +205,12 @@ export async function activateDesktopNotificationTarget( let navigation: Promise; if (!target.channelId) { navigation = actions.goHome(); + } else if (target.eventId && !target.openInThread) { + navigation = actions.goChannel(target.channelId, { + force: true, + messageId: target.eventId, + messageView: "timeline", + }); } else { const anchor = toSearchHit(target); navigation = anchor diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index ade8c9332c0..5aa42f6a0e5 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -277,6 +277,8 @@ export function useAppNavigation() { * silently swallowed (block/buzz#3509). */ force?: boolean; messageId?: string; + /** Focus the message in the main timeline without opening its replies. */ + messageView?: "timeline"; /** Preserve an active search highlight; ordinary navigation clears it. */ preserveSearchHighlight?: boolean; searchHighlight?: SearchHighlightNavigation; @@ -296,6 +298,7 @@ export function useAppNavigation() { ...(options?.messageId ? { messageId: options.messageId, + messageView: options.messageView, threadRootId: options.threadRootId ?? undefined, } : {}), diff --git a/desktop/src/app/routes/ChannelRouteScreen.tsx b/desktop/src/app/routes/ChannelRouteScreen.tsx index 50371bc369f..da875934e16 100644 --- a/desktop/src/app/routes/ChannelRouteScreen.tsx +++ b/desktop/src/app/routes/ChannelRouteScreen.tsx @@ -36,6 +36,7 @@ type ChannelRouteScreenProps = { searchHighlight: SearchHighlightNavigation | null | undefined; selectedPostId: string | null; targetMessageId: string | null; + targetMessageView?: "timeline" | null; targetReplyId: string | null; targetThreadRootId: string | null; }; @@ -117,6 +118,7 @@ export function ChannelRouteScreen({ searchHighlight, selectedPostId, targetMessageId, + targetMessageView = null, targetReplyId, targetThreadRootId, }: ChannelRouteScreenProps) { @@ -324,6 +326,7 @@ export function ChannelRouteScreen({ targetForumReplyId={targetReplyId} targetMessageEvents={targetMessageEvents} targetMessageId={targetMessageId} + targetMessageView={targetMessageView} targetSearchMessageId={activeSearchHighlight?.messageId} targetSearchQuery={activeSearchHighlight?.query} /> diff --git a/desktop/src/app/routes/channels.$channelId.tsx b/desktop/src/app/routes/channels.$channelId.tsx index 7c9d1cc47cc..2b1d7fdd268 100644 --- a/desktop/src/app/routes/channels.$channelId.tsx +++ b/desktop/src/app/routes/channels.$channelId.tsx @@ -22,6 +22,7 @@ type ChannelRouteSearch = { */ autoSend?: string; messageId?: string; + messageView?: "timeline"; profile?: string; profileTab?: ProfilePanelTab; profileView?: ProfilePanelView; @@ -40,6 +41,7 @@ function validateChannelSearch( agentSession: nonEmptyString(search.agentSession), autoSend: nonEmptyString(search.autoSend), messageId: nonEmptyString(search.messageId), + messageView: search.messageView === "timeline" ? "timeline" : undefined, profile: nonEmptyString(search.profile), profileTab: parseProfilePanelTab(search.profileTab) ?? undefined, profileView: parseProfilePanelView(search.profileView) ?? undefined, @@ -82,6 +84,7 @@ function ChannelRouteComponent() { searchHighlight={searchHighlight} selectedPostId={null} targetMessageId={search.messageId ?? null} + targetMessageView={search.messageView ?? null} targetReplyId={null} targetThreadRootId={search.threadRootId ?? search.thread ?? null} /> diff --git a/desktop/src/app/useAppShellDesktopNotifications.ts b/desktop/src/app/useAppShellDesktopNotifications.ts index b86b95363cd..cf1204f2f9c 100644 --- a/desktop/src/app/useAppShellDesktopNotifications.ts +++ b/desktop/src/app/useAppShellDesktopNotifications.ts @@ -9,6 +9,7 @@ import { useCommunityJoinAlerts } from "@/features/community-members/useCommunit import { hasMentionForEvent } from "@/features/notifications/lib/shouldNotify"; import type { NotificationSettings } from "@/features/notifications/hooks"; import { + ensureDesktopNotificationPermissionGranted, listenForDesktopNotificationActions, requestDockBounce, revealDesktopAppWindow, @@ -38,7 +39,11 @@ export function useAppShellDesktopNotifications({ enabled: boolean; goChannel: ( channelId: string, - options?: { force?: boolean }, + options?: { + force?: boolean; + messageId?: string; + messageView?: "timeline"; + }, ) => Promise; goHome: () => Promise; notificationSettings: NotificationSettings; @@ -85,20 +90,24 @@ export function useAppShellDesktopNotifications({ 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(); - }); + void ensureDesktopNotificationPermissionGranted().then( + async (permissionGranted) => { + if (!permissionGranted) return; + const didSend = await sendDesktopNotification({ + title, + body, + target: buildEventNotificationTarget(event, { + id: channel.id, + name: channelName, + }), + }); + if (!didSend) return; + if (shouldPlayNotificationSound(channel.id, silentChannelIds)) { + playNotificationSound(resolveSlotSound(notificationSettings, "dm")); + } + void requestDockBounce(); + }, + ); }, ); @@ -128,22 +137,30 @@ export function useAppShellDesktopNotifications({ 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(); - }); + void ensureDesktopNotificationPermissionGranted().then( + async (permissionGranted) => { + if (!permissionGranted) return; + const didSend = await sendDesktopNotification({ + title, + body, + target: buildEventNotificationTarget( + event, + { + id: channelId, + name: channelName, + }, + { openInThread: true }, + ), + }); + if (!didSend) return; + if (shouldPlayNotificationSound(channelId, silentChannelIds)) { + playNotificationSound( + resolveSlotSound(notificationSettings, "thread_reply"), + ); + } + void requestDockBounce(); + }, + ); }, ); diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index c9330bc74c0..9164e914c18 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -98,7 +98,7 @@ export function ChannelScreen({ onAddFiles, onCloseIdleAuxiliaryPanel, onCloseForumPost, onSelectForumPost, selectedForumPostId, targetForumReplyId, - targetMessageEvents, targetMessageId, + targetMessageEvents, targetMessageId, targetMessageView, ...searchTarget }: ChannelScreenProps) { const queryClient = useQueryClient(); @@ -657,6 +657,7 @@ export function ChannelScreen({ setThreadReplyTargetId, setThreadScrollTargetId, targetMessageId, + targetMessageView, timelineMessages, }); useThreadTargetSync({ diff --git a/desktop/src/features/channels/ui/ChannelScreen.types.ts b/desktop/src/features/channels/ui/ChannelScreen.types.ts index 0a465331399..886370272e9 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.types.ts +++ b/desktop/src/features/channels/ui/ChannelScreen.types.ts @@ -32,6 +32,8 @@ export type ChannelScreenProps = { targetForumReplyId: string | null; targetMessageEvents: RelayEvent[]; targetMessageId: string | null; + /** Keep a notification target in the main timeline instead of opening replies. */ + targetMessageView?: "timeline" | null; /** Exact clicked result id, retained after route target cleanup. */ targetSearchMessageId?: string; /** Search text to highlight within the opened result message. */ diff --git a/desktop/src/features/channels/ui/channelSearchKeys.ts b/desktop/src/features/channels/ui/channelSearchKeys.ts index 0828d9fec6d..f4f3b5c664c 100644 --- a/desktop/src/features/channels/ui/channelSearchKeys.ts +++ b/desktop/src/features/channels/ui/channelSearchKeys.ts @@ -11,6 +11,7 @@ export const CHANNEL_SEARCH_KEYS = [ "autoSend", "channelManagement", "messageId", + "messageView", "profile", "profileTab", "profileView", diff --git a/desktop/src/features/channels/ui/useChannelRouteTarget.ts b/desktop/src/features/channels/ui/useChannelRouteTarget.ts index 39e8a6688d5..c0582dec3e0 100644 --- a/desktop/src/features/channels/ui/useChannelRouteTarget.ts +++ b/desktop/src/features/channels/ui/useChannelRouteTarget.ts @@ -64,6 +64,7 @@ export function useChannelRouteTarget({ setThreadReplyTargetId, setThreadScrollTargetId, targetMessageId, + targetMessageView, timelineMessages, }: { activeChannel: Channel | null; @@ -77,6 +78,7 @@ export function useChannelRouteTarget({ setThreadReplyTargetId: React.Dispatch>; setThreadScrollTargetId: React.Dispatch>; targetMessageId: string | null; + targetMessageView?: "timeline" | null; timelineMessages: TimelineMessage[]; }) { const timelineMessageById = React.useMemo( @@ -117,6 +119,10 @@ export function useChannelRouteTarget({ } if (!targetMessage.parentId) { + if (targetMessageView === "timeline") { + handledThreadRouteTargetRef.current = targetKey; + return; + } if (!requireThreadEditResolution()) { return; } @@ -170,6 +176,7 @@ export function useChannelRouteTarget({ setThreadReplyTargetId, setThreadScrollTargetId, targetMessageId, + targetMessageView, timelineMessageById, ]); diff --git a/desktop/src/features/channels/useLiveChannelUpdates.test.mjs b/desktop/src/features/channels/useLiveChannelUpdates.test.mjs index 5b80bd91f6d..51957e89d89 100644 --- a/desktop/src/features/channels/useLiveChannelUpdates.test.mjs +++ b/desktop/src/features/channels/useLiveChannelUpdates.test.mjs @@ -42,7 +42,12 @@ function message(id, overrides = {}) { }; } -async function mount(initialChannels, options = {}, subscribeImpl) { +async function mount( + initialChannels, + options = {}, + subscribeImpl, + activeChannelId = null, +) { const { act, cleanup, renderHook } = await import("@testing-library/react"); const React = await import("react"); const { QueryClient, QueryClientProvider } = await import( @@ -50,7 +55,7 @@ async function mount(initialChannels, options = {}, subscribeImpl) { ); const { relayClient } = await import("@/shared/api/relayClient"); const { useLiveChannelUpdates } = await import("./useLiveChannelUpdates.ts"); - const { channelMessagesKey } = await import( + const { channelMessagesKey, channelWindowKey } = await import( "@/features/messages/lib/messageQueryKeys" ); const originalLive = relayClient.subscribeLive; @@ -79,7 +84,8 @@ async function mount(initialChannels, options = {}, subscribeImpl) { const wrapper = ({ children }) => React.createElement(QueryClientProvider, { client: queryClient }, children); const hook = renderHook( - ({ members, opts }) => useLiveChannelUpdates(members, null, opts), + ({ members, opts }) => + useLiveChannelUpdates(members, activeChannelId, opts), { wrapper, initialProps: { @@ -101,6 +107,7 @@ async function mount(initialChannels, options = {}, subscribeImpl) { mentionSubscriptions, queryClient, channelMessagesKey, + channelWindowKey, rerender(members, opts = options) { hook.rerender({ members, opts: { currentPubkey: VIEWER, ...opts } }); }, @@ -175,6 +182,31 @@ test("live channel stream drives mention, unread and DM callbacks once across re assert.deepEqual(mentions, ["mention"]); assert.deepEqual(unreads, [["channel-0", "mention"]]); assert.deepEqual(dms, [["channel-0", "mention"]]); + assert.deepEqual( + h.queryClient + .getQueryData(h.channelWindowKey("channel-0")) + .liveOverlay.map((item) => item.id), + ["mention"], + ); + } finally { + h.restore(); + } +}); + +test("notify while viewing permits DM notifications for the active channel", async () => { + const dms = []; + const h = await mount( + channels(1), + { + notifyForActiveChannel: true, + onDmMessage: (event, channel) => dms.push([channel.id, event.id]), + }, + undefined, + "channel-0", + ); + try { + await h.deliver(h.subscriptions[0], message("active-dm")); + assert.deepEqual(dms, [["channel-0", "active-dm"]]); } finally { h.restore(); } diff --git a/desktop/src/features/channels/useLiveChannelUpdates.ts b/desktop/src/features/channels/useLiveChannelUpdates.ts index b25df3acf92..3985b50e60e 100644 --- a/desktop/src/features/channels/useLiveChannelUpdates.ts +++ b/desktop/src/features/channels/useLiveChannelUpdates.ts @@ -7,8 +7,16 @@ import { mergeTimelineCacheMessages } from "@/features/messages/hooks"; import { channelMessagesKey } from "@/features/messages/lib/messageQueryKeys"; import { getChannelIdFromTags, + isBroadcastReply, isThreadReply, } from "@/features/messages/lib/threading"; +import { projectChannelWindowMessages } from "@/features/messages/lib/projectChannelWindow"; +import { channelWindowKey } from "@/features/messages/lib/messageQueryKeys"; +import { + emptyChannelWindowStore, + mergeLiveChannelWindowEvent, + type ChannelWindowStore, +} from "@/features/messages/lib/channelWindowStore"; import { hasMentionForEvent, shouldNotifyForEvent, @@ -349,6 +357,21 @@ export function useLiveChannelUpdates( return mergeTimelineCacheMessages(current, event); }, ); + + if ( + isUnreadTriggerKind && + (!isThreadedReply || isBroadcastReply(event.tags)) + ) { + const windowKey = channelWindowKey(channelId); + const currentWindow = + queryClient.getQueryData(windowKey) ?? + emptyChannelWindowStore(); + const nextWindow = mergeLiveChannelWindowEvent(currentWindow, event); + if (nextWindow !== currentWindow) { + queryClient.setQueryData(windowKey, nextWindow); + projectChannelWindowMessages(queryClient, channelId); + } + } }); React.useEffect(() => { diff --git a/desktop/src/features/notifications/lib/desktop.test.mjs b/desktop/src/features/notifications/lib/desktop.test.mjs index 7fb8830683a..ec175581b56 100644 --- a/desktop/src/features/notifications/lib/desktop.test.mjs +++ b/desktop/src/features/notifications/lib/desktop.test.mjs @@ -23,7 +23,44 @@ class ThrowingNotification { globalThis.window = { Notification: ThrowingNotification }; -const { sendDesktopNotification } = await import("./desktop.ts"); +const { ensureDesktopNotificationPermissionGranted, sendDesktopNotification } = + await import("./desktop.ts"); + +test("permission gate awaits a default-state request before allowing delivery", async () => { + let releaseRequest; + const request = new Promise((resolve) => { + releaseRequest = resolve; + }); + let settled = false; + + const permission = ensureDesktopNotificationPermissionGranted( + async () => "default", + async () => request, + ).then((granted) => { + settled = true; + return granted; + }); + + await Promise.resolve(); + assert.equal(settled, false); + + releaseRequest("granted"); + assert.equal(await permission, true); +}); + +test("permission gate rejects denied state without requesting access", async () => { + let requested = false; + const granted = await ensureDesktopNotificationPermissionGranted( + async () => "denied", + async () => { + requested = true; + return "granted"; + }, + ); + + assert.equal(granted, false); + assert.equal(requested, false); +}); test("constructor failure is a delivery miss and does not prevent a later notification", async (t) => { const warnings = []; diff --git a/desktop/src/features/notifications/lib/desktop.ts b/desktop/src/features/notifications/lib/desktop.ts index b05344a197d..e8b7edba188 100644 --- a/desktop/src/features/notifications/lib/desktop.ts +++ b/desktop/src/features/notifications/lib/desktop.ts @@ -6,7 +6,11 @@ import { onAction, requestPermission, } from "@tauri-apps/plugin-notification"; -import { isLinuxPlatform, isMacPlatform, isWindowsPlatform } from "@/shared/lib/platform"; +import { + isLinuxPlatform, + isMacPlatform, + isWindowsPlatform, +} from "@/shared/lib/platform"; // Backend event emitted when a native Linux notification is clicked or a // queued macOS activation becomes available. See src-tauri notification code. @@ -32,6 +36,7 @@ export type DesktopNotificationTarget = { eventId: string | null; kind: number | null; pubkey?: string; + openInThread?: boolean; threadRootId?: string | null; }; @@ -89,6 +94,7 @@ function parseNotificationTarget( const kind = typeof candidate.kind === "number" ? candidate.kind : null; const pubkey = typeof candidate.pubkey === "string" ? candidate.pubkey : undefined; + const openInThread = candidate.openInThread === true; const threadRootId = typeof candidate.threadRootId === "string" ? candidate.threadRootId : null; @@ -104,6 +110,7 @@ function parseNotificationTarget( eventId, kind, pubkey, + openInThread, threadRootId, }; } @@ -205,6 +212,20 @@ export async function requestDesktopNotificationAccess(): Promise { + const currentPermission = await getPermissionState(); + if (currentPermission === "granted") { + return true; + } + if (currentPermission !== "default") { + return false; + } + return (await requestAccess()) === "granted"; +} + export async function listenForDesktopNotificationActions( onTarget: (target: DesktopNotificationTarget) => void, ): Promise<() => void> { @@ -443,7 +464,10 @@ export async function sendDesktopNotification( // Do NOT use the Tauri notification plugin's sendNotification() on Windows — // the native WinRT path handles delivery and click actions exclusively. // See src-tauri/src/commands/notifications.rs. - if (isTauri() && (isLinuxPlatform() || isMacPlatform() || isWindowsPlatform())) { + if ( + isTauri() && + (isLinuxPlatform() || isMacPlatform() || isWindowsPlatform()) + ) { try { await invoke("show_native_notification", { title: payload.title, diff --git a/desktop/src/features/notifications/lib/target.test.mjs b/desktop/src/features/notifications/lib/target.test.mjs index b9966381bdb..6e1607298a5 100644 --- a/desktop/src/features/notifications/lib/target.test.mjs +++ b/desktop/src/features/notifications/lib/target.test.mjs @@ -6,7 +6,7 @@ import { buildFeedItemNotificationTarget, } from "./target.ts"; -test("builds a complete click-through target from a live relay event", () => { +test("DM notification targets open replies in the channel timeline", () => { const target = buildEventNotificationTarget( { content: "hello", @@ -31,10 +31,33 @@ test("builds a complete click-through target from a live relay event", () => { eventId: "event-id", kind: 9, pubkey: "sender", - threadRootId: "root-id", + openInThread: false, + threadRootId: null, }); }); +test("thread-reply notification targets open the containing branch", () => { + 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" }, + { openInThread: true }, + ); + + assert.equal(target.openInThread, true); + assert.equal(target.threadRootId, "root-id"); +}); + test("null channel name and top-level events produce null fields", () => { const target = buildEventNotificationTarget( { @@ -76,6 +99,7 @@ test("builds a complete click-through target from a feed item", () => { eventId: "feed-event", kind: 9, pubkey: "sender", + openInThread: true, threadRootId: "root-id", }); }); diff --git a/desktop/src/features/notifications/lib/target.ts b/desktop/src/features/notifications/lib/target.ts index be4459b9c18..5c30d4908e5 100644 --- a/desktop/src/features/notifications/lib/target.ts +++ b/desktop/src/features/notifications/lib/target.ts @@ -14,6 +14,7 @@ export function buildEventNotificationTarget( "content" | "created_at" | "id" | "kind" | "pubkey" | "tags" >, channel: { id: string; name?: string | null }, + options: { openInThread?: boolean } = {}, ): DesktopNotificationTarget { return { channelId: channel.id, @@ -23,7 +24,10 @@ export function buildEventNotificationTarget( eventId: event.id, kind: event.kind, pubkey: event.pubkey, - threadRootId: getThreadReference(event.tags).rootId ?? null, + openInThread: options.openInThread === true, + threadRootId: options.openInThread + ? (getThreadReference(event.tags).rootId ?? null) + : null, }; } @@ -39,6 +43,7 @@ export function buildFeedItemNotificationTarget( eventId: item.id, kind: item.kind, pubkey: item.pubkey, + openInThread: getThreadReference(item.tags).rootId !== null, threadRootId: getThreadReference(item.tags).rootId ?? null, }; } diff --git a/desktop/src/features/notifications/use-feed-desktop-notifications.test.mjs b/desktop/src/features/notifications/use-feed-desktop-notifications.test.mjs new file mode 100644 index 00000000000..7e400308ae1 --- /dev/null +++ b/desktop/src/features/notifications/use-feed-desktop-notifications.test.mjs @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const { deliverFeedNotificationBatch } = await import( + "./use-feed-desktop-notifications.ts" +); + +test("an enabled restart waits for permission repair before delivering the feed batch", async () => { + let releaseRepair; + const repairPermission = new Promise((resolve) => { + releaseRepair = resolve; + }); + const delivered = []; + + const delivery = deliverFeedNotificationBatch( + [{ id: "restart-alert" }], + async () => { + await repairPermission; + return true; + }, + async (item) => { + delivered.push(item.id); + }, + ); + + await Promise.resolve(); + assert.deepEqual(delivered, []); + + releaseRepair(); + await delivery; + assert.deepEqual(delivered, ["restart-alert"]); +}); + +test("a permission repair that is not granted suppresses the feed batch", async () => { + const delivered = []; + + await deliverFeedNotificationBatch( + [{ id: "blocked-alert" }], + async () => false, + async (item) => { + delivered.push(item.id); + }, + ); + + assert.deepEqual(delivered, []); +}); diff --git a/desktop/src/features/notifications/use-feed-desktop-notifications.ts b/desktop/src/features/notifications/use-feed-desktop-notifications.ts index e4594547eed..2175b2e07dc 100644 --- a/desktop/src/features/notifications/use-feed-desktop-notifications.ts +++ b/desktop/src/features/notifications/use-feed-desktop-notifications.ts @@ -68,6 +68,17 @@ export function writeStoredSeenFeedIds(pubkey: string, ids: string[]) { ); } +export async function deliverFeedNotificationBatch( + items: readonly FeedItem[], + ensurePermissionGranted: () => Promise, + deliver: (item: FeedItem) => Promise, +): Promise { + if (!(await ensurePermissionGranted())) { + return; + } + await Promise.all(items.map((item) => deliver(item))); +} + export function useFeedDesktopNotifications( feed: HomeFeedResponse | undefined, pubkey: string | undefined, @@ -94,19 +105,21 @@ export function useFeedDesktopNotifications( const autoRequestPermissionIfNeeded = React.useEffectEvent(async () => { if (hasAutoRequestedRef.current) { - return; + return (await getDesktopNotificationPermissionState()) === "granted"; } const currentPermission = await getDesktopNotificationPermissionState(); if (currentPermission !== "default") { - return; + return currentPermission === "granted"; } hasAutoRequestedRef.current = true; const result = await requestDesktopNotificationAccess(); if (result !== "granted") { void setDesktopEnabled(false); + return false; } + return true; }); const deliverFeedNotification = React.useEffectEvent( @@ -191,23 +204,25 @@ export function useFeedDesktopNotifications( writeStoredSeenFeedIds(normalizedPubkey, [...nextSeenItemIds]); if (newItems.length > 0) { - void autoRequestPermissionIfNeeded(); - } - - for (const item of newItems) { - const resolvedLabel = profiles - ? resolveUserLabel({ - pubkey: item.pubkey, - profiles, - preferResolvedSelfLabel: true, - }) - : undefined; - // Only use real display names, not truncated pubkey fallbacks. - const senderName = - resolvedLabel && resolvedLabel !== truncateNpub(item.pubkey) - ? resolvedLabel - : undefined; - void deliverFeedNotification(item, senderName); + void deliverFeedNotificationBatch( + newItems, + autoRequestPermissionIfNeeded, + async (item) => { + const resolvedLabel = profiles + ? resolveUserLabel({ + pubkey: item.pubkey, + profiles, + preferResolvedSelfLabel: true, + }) + : undefined; + // Only use real display names, not truncated pubkey fallbacks. + const senderName = + resolvedLabel && resolvedLabel !== truncateNpub(item.pubkey) + ? resolvedLabel + : undefined; + await deliverFeedNotification(item, senderName); + }, + ); } }, [ enabled, From d81497661d40ec333aa7064c6a9447ba9f56a5fc Mon Sep 17 00:00:00 2001 From: Bernhard Kaindl Date: Sun, 13 Sep 2026 15:24:07 +0200 Subject: [PATCH 3/7] fix(desktop): address notification review findings Signed-off-by: Bernhard Kaindl --- .../src-tauri/src/commands/notifications.rs | 32 ++++++----- desktop/src/app/AppShell.helpers.test.mjs | 22 +++++++ desktop/src/app/AppShell.helpers.ts | 8 ++- .../channels/ui/channelSearchKeys.test.mjs | 12 ++++ .../features/channels/ui/channelSearchKeys.ts | 7 +++ .../ui/useChannelPanelHistoryState.ts | 3 +- .../notifications/lib/desktop.test.mjs | 24 ++++++++ .../src/features/notifications/lib/desktop.ts | 24 +++++--- .../lib/desktopActivations.test.mjs | 38 ++++++++++++- .../use-feed-desktop-notifications.test.mjs | 43 +++++++++++++- .../use-feed-desktop-notifications.ts | 57 ++++++++++++------- 11 files changed, 224 insertions(+), 46 deletions(-) create mode 100644 desktop/src/features/channels/ui/channelSearchKeys.test.mjs diff --git a/desktop/src-tauri/src/commands/notifications.rs b/desktop/src-tauri/src/commands/notifications.rs index 1c16ad9d39e..835982829fd 100644 --- a/desktop/src-tauri/src/commands/notifications.rs +++ b/desktop/src-tauri/src/commands/notifications.rs @@ -41,8 +41,7 @@ pub async fn show_native_notification( #[cfg(target_os = "windows")] { - windows::show(app, title, body, target); - Ok(()) + windows::show(app, title, body, target).await } #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] @@ -124,22 +123,26 @@ mod linux { #[cfg(target_os = "windows")] mod windows { use super::NATIVE_NOTIFICATION_ACTIVATED_EVENT; - use tauri::Emitter; + use tauri::{Emitter, Manager}; use tauri_winrt_notification::{Duration, Toast}; - pub fn show( + pub async fn show( app: tauri::AppHandle, title: String, body: Option, target: Option, - ) { + ) -> Result<(), String> { // The Tauri identifier (e.g. "xyz.block.buzz.app") is the // AppUserModelID that Windows uses to group notifications and // surface the app in Settings > Notifications. let app_id = app.config().identifier.clone(); + let (sender, receiver) = tokio::sync::oneshot::channel(); + let notification_app = app.clone(); - std::thread::spawn(move || { - let app_clone = app.clone(); + // Tauri's main thread owns an initialized Windows apartment. Construct + // and post WinRT notifications there, then report the real result. + app.run_on_main_thread(move || { + let activation_app = notification_app.clone(); let result = Toast::new(&app_id) .title(&title) .text1(body.as_deref().unwrap_or("")) @@ -149,14 +152,17 @@ mod windows { // _action is None for the default (body) click and // Some(arg) for button clicks. We only use the default // click, matching the Linux behaviour. - let _ = app_clone.emit(NATIVE_NOTIFICATION_ACTIVATED_EVENT, &target); + let _ = activation_app.emit(NATIVE_NOTIFICATION_ACTIVATED_EVENT, &target); Ok(()) }) - .show(); + .show() + .map_err(|error| format!("failed to post Windows notification: {error}")); + let _ = sender.send(result); + }) + .map_err(|error| format!("failed to schedule Windows notification: {error}"))?; - if let Err(error) = result { - eprintln!("buzz-desktop: failed to post Windows notification: {error}"); - } - }); + receiver + .await + .map_err(|_| "Windows notification task ended before posting".to_string())? } } diff --git a/desktop/src/app/AppShell.helpers.test.mjs b/desktop/src/app/AppShell.helpers.test.mjs index e51284d95d2..55dade20a5a 100644 --- a/desktop/src/app/AppShell.helpers.test.mjs +++ b/desktop/src/app/AppShell.helpers.test.mjs @@ -185,6 +185,28 @@ test("notification activation retains thread routing for branch replies", async assert.deepEqual(calls, ["root"]); }); +for (const kind of [45001, 45003]) { + test(`notification activation retains kind-aware routing for forum kind ${kind}`, async () => { + const calls = []; + await activateDesktopNotificationTarget( + { + channelId: "forum-channel", + eventId: "forum-event", + kind, + openInThread: false, + }, + { + goChannel: async () => calls.push("channel"), + goHome: async () => calls.push("home"), + openSearchHit: async (hit) => calls.push(hit.kind), + revealWindow: async () => {}, + }, + ); + + assert.deepEqual(calls, [kind]); + }); +} + test("notification activation falls back to forced channel navigation", async () => { const calls = []; await activateDesktopNotificationTarget( diff --git a/desktop/src/app/AppShell.helpers.ts b/desktop/src/app/AppShell.helpers.ts index 754f6d1c4e5..a65eed82501 100644 --- a/desktop/src/app/AppShell.helpers.ts +++ b/desktop/src/app/AppShell.helpers.ts @@ -1,6 +1,7 @@ import { isThreadReply } from "@/features/messages/lib/threading"; import type { DesktopNotificationTarget } from "@/features/notifications/lib/desktop"; import type { SearchHit } from "@/shared/api/types"; +import { KIND_FORUM_COMMENT, KIND_FORUM_POST } from "@/shared/constants/kinds"; export type AppView = | "home" @@ -205,7 +206,12 @@ export async function activateDesktopNotificationTarget( let navigation: Promise; if (!target.channelId) { navigation = actions.goHome(); - } else if (target.eventId && !target.openInThread) { + } else if ( + target.eventId && + !target.openInThread && + target.kind !== KIND_FORUM_POST && + target.kind !== KIND_FORUM_COMMENT + ) { navigation = actions.goChannel(target.channelId, { force: true, messageId: target.eventId, diff --git a/desktop/src/features/channels/ui/channelSearchKeys.test.mjs b/desktop/src/features/channels/ui/channelSearchKeys.test.mjs new file mode 100644 index 00000000000..66dc9162574 --- /dev/null +++ b/desktop/src/features/channels/ui/channelSearchKeys.test.mjs @@ -0,0 +1,12 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildMessageRouteTargetClearPatch } from "./channelSearchKeys.ts"; + +test("clearing a message route removes its view mode and thread anchor", () => { + assert.deepEqual(buildMessageRouteTargetClearPatch(), { + messageId: null, + messageView: null, + threadRootId: null, + }); +}); diff --git a/desktop/src/features/channels/ui/channelSearchKeys.ts b/desktop/src/features/channels/ui/channelSearchKeys.ts index f4f3b5c664c..9eb97eb121d 100644 --- a/desktop/src/features/channels/ui/channelSearchKeys.ts +++ b/desktop/src/features/channels/ui/channelSearchKeys.ts @@ -36,3 +36,10 @@ export function buildAutoSendClearPatch(): Partial< > { return { autoSend: null }; } + +/** Clear every URL field that belongs to a targeted message navigation. */ +export function buildMessageRouteTargetClearPatch(): Partial< + Record +> { + return { messageId: null, messageView: null, threadRootId: null }; +} diff --git a/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts b/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts index 60e3a291b21..730d3fa7615 100644 --- a/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts +++ b/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts @@ -13,6 +13,7 @@ import { } from "@/shared/hooks/useHistorySearchState"; import { buildAutoSendClearPatch, + buildMessageRouteTargetClearPatch, CHANNEL_SEARCH_KEYS, } from "./channelSearchKeys"; export type { ChannelSearchKey } from "./channelSearchKeys"; @@ -107,7 +108,7 @@ export function useChannelPanelHistoryState() { const clearMessageRouteTarget = React.useCallback( (options?: PanelSetterOptions) => - applyPatch({ messageId: null, threadRootId: null }, options), + applyPatch(buildMessageRouteTargetClearPatch(), options), [applyPatch], ); diff --git a/desktop/src/features/notifications/lib/desktop.test.mjs b/desktop/src/features/notifications/lib/desktop.test.mjs index ec175581b56..e02d0d0a0e3 100644 --- a/desktop/src/features/notifications/lib/desktop.test.mjs +++ b/desktop/src/features/notifications/lib/desktop.test.mjs @@ -62,6 +62,30 @@ test("permission gate rejects denied state without requesting access", async () assert.equal(requested, false); }); +test("permission gate returns false when checking state fails", async (t) => { + t.mock.method(console, "warn", () => {}); + const granted = await ensureDesktopNotificationPermissionGranted( + async () => { + throw new Error("state unavailable"); + }, + async () => "granted", + ); + + assert.equal(granted, false); +}); + +test("permission gate returns false when requesting access fails", async (t) => { + t.mock.method(console, "warn", () => {}); + const granted = await ensureDesktopNotificationPermissionGranted( + async () => "default", + async () => { + throw new Error("request unavailable"); + }, + ); + + assert.equal(granted, false); +}); + test("constructor failure is a delivery miss and does not prevent a later notification", async (t) => { const warnings = []; t.mock.method(console, "warn", (...args) => warnings.push(args)); diff --git a/desktop/src/features/notifications/lib/desktop.ts b/desktop/src/features/notifications/lib/desktop.ts index e8b7edba188..f0472ca5868 100644 --- a/desktop/src/features/notifications/lib/desktop.ts +++ b/desktop/src/features/notifications/lib/desktop.ts @@ -94,9 +94,14 @@ function parseNotificationTarget( const kind = typeof candidate.kind === "number" ? candidate.kind : null; const pubkey = typeof candidate.pubkey === "string" ? candidate.pubkey : undefined; - const openInThread = candidate.openInThread === true; const threadRootId = typeof candidate.threadRootId === "string" ? candidate.threadRootId : null; + // Notifications can outlive an app upgrade. Legacy payloads did not carry + // openInThread, so only those infer branch navigation from their root id. + const openInThread = + typeof candidate.openInThread === "boolean" + ? candidate.openInThread + : threadRootId !== null; if (!channelId && !eventId) { return null; @@ -216,14 +221,19 @@ export async function ensureDesktopNotificationPermissionGranted( getPermissionState = getDesktopNotificationPermissionState, requestAccess = requestDesktopNotificationAccess, ): Promise { - const currentPermission = await getPermissionState(); - if (currentPermission === "granted") { - return true; - } - if (currentPermission !== "default") { + try { + const currentPermission = await getPermissionState(); + if (currentPermission === "granted") { + return true; + } + if (currentPermission !== "default") { + return false; + } + return (await requestAccess()) === "granted"; + } catch (error) { + console.warn("Failed to determine desktop notification permission", error); return false; } - return (await requestAccess()) === "granted"; } export async function listenForDesktopNotificationActions( diff --git a/desktop/src/features/notifications/lib/desktopActivations.test.mjs b/desktop/src/features/notifications/lib/desktopActivations.test.mjs index 880018c415d..b9976cd38e7 100644 --- a/desktop/src/features/notifications/lib/desktopActivations.test.mjs +++ b/desktop/src/features/notifications/lib/desktopActivations.test.mjs @@ -95,6 +95,19 @@ test("window focus re-drains activations stranded by a lost emit", async () => { // target. macOS foregrounds the app anyway; WebKit fires window focus. pendingActivations = [ { channelId: "channel-1", eventId: "event-1", kind: 9 }, + { + channelId: "channel-1", + eventId: "legacy-reply", + kind: 9, + threadRootId: "legacy-root", + }, + { + channelId: "channel-1", + eventId: "timeline-reply", + kind: 9, + openInThread: false, + threadRootId: "legacy-root", + }, ]; window.dispatchEvent(new Event("focus")); await flushPendingWork(); @@ -108,8 +121,31 @@ test("window focus re-drains activations stranded by a lost emit", async () => { eventId: "event-1", kind: 9, pubkey: undefined, + openInThread: false, threadRootId: null, }, + { + channelId: "channel-1", + channelName: null, + content: undefined, + createdAt: null, + eventId: "legacy-reply", + kind: 9, + pubkey: undefined, + openInThread: true, + threadRootId: "legacy-root", + }, + { + channelId: "channel-1", + channelName: null, + content: undefined, + createdAt: null, + eventId: "timeline-reply", + kind: 9, + pubkey: undefined, + openInThread: false, + threadRootId: "legacy-root", + }, ]); dispose(); @@ -118,7 +154,7 @@ test("window focus re-drains activations stranded by a lost emit", async () => { ]; window.dispatchEvent(new Event("focus")); await flushPendingWork(); - assert.equal(received.length, 1, "disposed listener must not re-drain"); + assert.equal(received.length, 3, "disposed listener must not re-drain"); // Leave the queue empty so the next test's mount-time drain starts clean. pendingActivations = []; }); diff --git a/desktop/src/features/notifications/use-feed-desktop-notifications.test.mjs b/desktop/src/features/notifications/use-feed-desktop-notifications.test.mjs index 7e400308ae1..148f0c5623c 100644 --- a/desktop/src/features/notifications/use-feed-desktop-notifications.test.mjs +++ b/desktop/src/features/notifications/use-feed-desktop-notifications.test.mjs @@ -1,9 +1,8 @@ import assert from "node:assert/strict"; import test from "node:test"; -const { deliverFeedNotificationBatch } = await import( - "./use-feed-desktop-notifications.ts" -); +const { deliverFeedNotificationBatch, ensureFeedNotificationPermission } = + await import("./use-feed-desktop-notifications.ts"); test("an enabled restart waits for permission repair before delivering the feed batch", async () => { let releaseRepair; @@ -44,3 +43,41 @@ test("a permission repair that is not granted suppresses the feed batch", async assert.deepEqual(delivered, []); }); + +test("a concurrent feed batch joins the pending permission request", async () => { + const attempt = { hasRequested: false }; + let permissionStateChecks = 0; + let requestCalls = 0; + let releaseRequest; + const request = new Promise((resolve) => { + releaseRequest = resolve; + }); + const getPermissionState = async () => { + permissionStateChecks++; + return "default"; + }; + const requestAccess = () => { + requestCalls++; + return request; + }; + const setDesktopEnabled = async () => true; + + const first = ensureFeedNotificationPermission( + attempt, + setDesktopEnabled, + getPermissionState, + requestAccess, + ); + await Promise.resolve(); + const second = ensureFeedNotificationPermission( + attempt, + setDesktopEnabled, + getPermissionState, + requestAccess, + ); + + assert.equal(permissionStateChecks, 1); + assert.equal(requestCalls, 2); + releaseRequest("granted"); + assert.deepEqual(await Promise.all([first, second]), [true, true]); +}); diff --git a/desktop/src/features/notifications/use-feed-desktop-notifications.ts b/desktop/src/features/notifications/use-feed-desktop-notifications.ts index 2175b2e07dc..6e04e215a10 100644 --- a/desktop/src/features/notifications/use-feed-desktop-notifications.ts +++ b/desktop/src/features/notifications/use-feed-desktop-notifications.ts @@ -79,6 +79,35 @@ export async function deliverFeedNotificationBatch( await Promise.all(items.map((item) => deliver(item))); } +export async function ensureFeedNotificationPermission( + attempt: { hasRequested: boolean }, + setDesktopEnabled: (enabled: boolean) => Promise, + getPermissionState = getDesktopNotificationPermissionState, + requestAccess = requestDesktopNotificationAccess, +): Promise { + try { + if (!attempt.hasRequested) { + const currentPermission = await getPermissionState(); + if (currentPermission !== "default") { + return currentPermission === "granted"; + } + attempt.hasRequested = true; + } + + // requestDesktopNotificationAccess owns app-wide single-flight state; + // repeated calls join an in-progress OS permission prompt. + const result = await requestAccess(); + if (result !== "granted") { + void setDesktopEnabled(false); + return false; + } + return true; + } catch (error) { + console.warn("Failed to request desktop notification permission", error); + return false; + } +} + export function useFeedDesktopNotifications( feed: HomeFeedResponse | undefined, pubkey: string | undefined, @@ -95,32 +124,20 @@ export function useFeedDesktopNotifications( new Set(readStoredSeenFeedIds(normalizedPubkey)), ); const hasInitializedFeedRef = React.useRef(false); - const hasAutoRequestedRef = React.useRef(false); + const permissionAttemptRef = React.useRef({ hasRequested: false }); React.useEffect(() => { seenItemIdsRef.current = new Set(readStoredSeenFeedIds(normalizedPubkey)); hasInitializedFeedRef.current = false; - hasAutoRequestedRef.current = false; + permissionAttemptRef.current.hasRequested = false; }, [normalizedPubkey]); - const autoRequestPermissionIfNeeded = React.useEffectEvent(async () => { - if (hasAutoRequestedRef.current) { - return (await getDesktopNotificationPermissionState()) === "granted"; - } - - const currentPermission = await getDesktopNotificationPermissionState(); - if (currentPermission !== "default") { - return currentPermission === "granted"; - } - - hasAutoRequestedRef.current = true; - const result = await requestDesktopNotificationAccess(); - if (result !== "granted") { - void setDesktopEnabled(false); - return false; - } - return true; - }); + const autoRequestPermissionIfNeeded = React.useEffectEvent(() => + ensureFeedNotificationPermission( + permissionAttemptRef.current, + setDesktopEnabled, + ), + ); const deliverFeedNotification = React.useEffectEvent( async (item: FeedItem, senderName?: string) => { From 0f891143100fbdde5330672dff8d1307771c59da Mon Sep 17 00:00:00 2001 From: Bernhard Kaindl Date: Sun, 13 Sep 2026 16:54:14 +0200 Subject: [PATCH 4/7] fix(desktop): address follow-up notification review Signed-off-by: Bernhard Kaindl --- desktop/src-tauri/Cargo.lock | 1 + desktop/src-tauri/Cargo.toml | 1 + .../src-tauri/src/commands/notifications.rs | 64 +++++++- desktop/src-tauri/src/lib.rs | 2 + desktop/src/app/routes/ChannelRouteScreen.tsx | 1 + .../ui/useChannelRouteTarget.test.mjs | 100 ++++++++++++ .../channels/ui/useChannelRouteTarget.ts | 13 +- .../channels/useLiveChannelUpdates.test.mjs | 82 +++++++++- .../channels/useLiveChannelUpdates.ts | 19 ++- desktop/src/features/notifications/hooks.ts | 17 ++- .../src/features/notifications/lib/desktop.ts | 33 ++-- .../lib/desktopWindowsPermission.test.mjs | 54 +++++++ .../notifications/lib/target.test.mjs | 22 +++ .../src/features/notifications/lib/target.ts | 14 +- .../use-feed-desktop-notifications.test.mjs | 142 +++++++++++++++++- .../use-feed-desktop-notifications.ts | 108 +++++++++++-- .../projects/ui/ProjectChannelHome.tsx | 3 + desktop/tests/e2e/project-cold-start.spec.ts | 46 ++++++ 18 files changed, 669 insertions(+), 53 deletions(-) create mode 100644 desktop/src/features/channels/ui/useChannelRouteTarget.test.mjs create mode 100644 desktop/src/features/notifications/lib/desktopWindowsPermission.test.mjs diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 60ddcd3af90..dc3ef859fc6 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1171,6 +1171,7 @@ dependencies = [ "uuid", "webkit2gtk", "window-vibrancy", + "windows 0.61.3", "windows-sys 0.61.2", "zeroize", "zip 8.6.0", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 758568ddebf..4e7bd9169e4 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -69,6 +69,7 @@ user-idle = { version = "0.6", default-features = false } # Native Windows toast notifications so the app registers with Windows Settings # > System > Notifications and click actions work through WinRT. tauri-winrt-notification = "0.7" +windows = { version = "0.61", features = ["UI_Notifications"] } [dependencies] atomic-write-file = "0.3" diff --git a/desktop/src-tauri/src/commands/notifications.rs b/desktop/src-tauri/src/commands/notifications.rs index 835982829fd..8b2b8cb905c 100644 --- a/desktop/src-tauri/src/commands/notifications.rs +++ b/desktop/src-tauri/src/commands/notifications.rs @@ -51,6 +51,14 @@ pub async fn show_native_notification( } } +#[cfg(target_os = "windows")] +#[tauri::command] +pub async fn windows_notification_permission_state( + app: tauri::AppHandle, +) -> Result { + windows::permission_state(app).await.map(str::to_string) +} + #[cfg(target_os = "linux")] mod linux { use super::NATIVE_NOTIFICATION_ACTIVATED_EVENT; @@ -123,8 +131,41 @@ mod linux { #[cfg(target_os = "windows")] mod windows { use super::NATIVE_NOTIFICATION_ACTIVATED_EVENT; - use tauri::{Emitter, Manager}; + use tauri::Emitter; use tauri_winrt_notification::{Duration, Toast}; + use windows::{ + core::HSTRING, + UI::Notifications::{NotificationSetting, ToastNotificationManager}, + }; + + fn permission_state_label(setting: NotificationSetting) -> &'static str { + if setting == NotificationSetting::Enabled { + "granted" + } else { + "denied" + } + } + + pub async fn permission_state(app: tauri::AppHandle) -> Result<&'static str, String> { + let app_id = app.config().identifier.clone(); + let (sender, receiver) = tokio::sync::oneshot::channel(); + + app.run_on_main_thread(move || { + let result = + ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from(app_id)) + .and_then(|notifier| notifier.Setting()) + .map(permission_state_label) + .map_err(|error| { + format!("failed to query Windows notification setting: {error}") + }); + let _ = sender.send(result); + }) + .map_err(|error| format!("failed to schedule Windows notification query: {error}"))?; + + receiver + .await + .map_err(|_| "Windows notification query ended before completing".to_string())? + } pub async fn show( app: tauri::AppHandle, @@ -165,4 +206,25 @@ mod windows { .await .map_err(|_| "Windows notification task ended before posting".to_string())? } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn only_enabled_notification_setting_is_granted() { + assert_eq!( + permission_state_label(NotificationSetting::Enabled), + "granted" + ); + for setting in [ + NotificationSetting::DisabledForApplication, + NotificationSetting::DisabledForUser, + NotificationSetting::DisabledByGroupPolicy, + NotificationSetting::DisabledByManifest, + ] { + assert_eq!(permission_state_label(setting), "denied"); + } + } + } } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 43d7b038577..1bdbed42c2a 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -661,6 +661,8 @@ pub fn run() { get_event, get_events, show_native_notification, + #[cfg(target_os = "windows")] + windows_notification_permission_state, #[cfg(target_os = "macos")] macos_notifications::take_pending_activations, #[cfg(target_os = "macos")] diff --git a/desktop/src/app/routes/ChannelRouteScreen.tsx b/desktop/src/app/routes/ChannelRouteScreen.tsx index da875934e16..34d2ce9feff 100644 --- a/desktop/src/app/routes/ChannelRouteScreen.tsx +++ b/desktop/src/app/routes/ChannelRouteScreen.tsx @@ -306,6 +306,7 @@ export function ChannelRouteScreen({ projects={projectsQuery.data ?? [projectHome]} targetMessageEvents={targetMessageEvents} targetMessageId={targetMessageId} + targetMessageView={targetMessageView} /> ); } diff --git a/desktop/src/features/channels/ui/useChannelRouteTarget.test.mjs b/desktop/src/features/channels/ui/useChannelRouteTarget.test.mjs new file mode 100644 index 00000000000..52bb5584126 --- /dev/null +++ b/desktop/src/features/channels/ui/useChannelRouteTarget.test.mjs @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); +}); +after(() => dom.window.close()); + +test("timeline intent keeps an ordinary reply out of the thread panel", async () => { + const { cleanup, renderHook } = await import("@testing-library/react"); + const { useChannelRouteTarget } = await import("./useChannelRouteTarget.ts"); + const calls = []; + const root = { + id: "root", + parentId: null, + rootId: null, + tags: [], + }; + const reply = { + id: "reply", + parentId: "root", + rootId: "root", + tags: [ + ["e", "root", "", "root"], + ["e", "root", "", "reply"], + ], + }; + + const hook = renderHook(() => + useChannelRouteTarget({ + activeChannel: { id: "channel", channelType: "stream" }, + activeChannelId: "channel", + closeAgentSession: () => calls.push("close-agent"), + requireThreadEditResolution: () => { + calls.push("resolve-edit"); + return true; + }, + setEditTargetId: () => calls.push("edit"), + setExpandedThreadReplyIds: () => calls.push("expand"), + setOpenThreadHeadId: () => calls.push("open-thread"), + setProfilePanelPubkey: () => calls.push("profile"), + setThreadReplyTargetId: () => calls.push("reply-target"), + setThreadScrollTargetId: () => calls.push("scroll"), + targetMessageId: "reply", + targetMessageView: "timeline", + timelineMessages: [root, reply], + }), + ); + + assert.equal(hook.result.current, "root"); + assert.deepEqual(calls, []); + hook.unmount(); + cleanup(); +}); + +test("timeline intent preserves the exact id of a broadcast reply row", async () => { + const { cleanup, renderHook } = await import("@testing-library/react"); + const { useChannelRouteTarget } = await import("./useChannelRouteTarget.ts"); + const broadcast = { + id: "broadcast-reply", + parentId: "root", + rootId: "root", + tags: [ + ["e", "root", "", "root"], + ["e", "root", "", "reply"], + ["broadcast", "1"], + ], + }; + + const hook = renderHook(() => + useChannelRouteTarget({ + activeChannel: { id: "channel", channelType: "stream" }, + activeChannelId: "channel", + closeAgentSession: () => {}, + requireThreadEditResolution: () => true, + setEditTargetId: () => {}, + setExpandedThreadReplyIds: () => {}, + setOpenThreadHeadId: () => {}, + setProfilePanelPubkey: () => {}, + setThreadReplyTargetId: () => {}, + setThreadScrollTargetId: () => {}, + targetMessageId: "broadcast-reply", + targetMessageView: "timeline", + timelineMessages: [broadcast], + }), + ); + + assert.equal(hook.result.current, "broadcast-reply"); + hook.unmount(); + cleanup(); +}); diff --git a/desktop/src/features/channels/ui/useChannelRouteTarget.ts b/desktop/src/features/channels/ui/useChannelRouteTarget.ts index c0582dec3e0..93a0b838f99 100644 --- a/desktop/src/features/channels/ui/useChannelRouteTarget.ts +++ b/desktop/src/features/channels/ui/useChannelRouteTarget.ts @@ -49,6 +49,9 @@ function getRouteMainTimelineTargetId( return targetMessageId; } + // Ordinary replies are intentionally absent from the main timeline. Keep + // timeline navigation on the containing root; only broadcast replies have + // a rendered row whose exact id can be targeted there. return targetMessage.rootId ?? targetMessage.parentId; } @@ -118,11 +121,13 @@ export function useChannelRouteTarget({ return; } + // Explicit timeline intent outranks ancestry-based thread navigation. + if (targetMessageView === "timeline") { + handledThreadRouteTargetRef.current = targetKey; + return; + } + if (!targetMessage.parentId) { - if (targetMessageView === "timeline") { - handledThreadRouteTargetRef.current = targetKey; - return; - } if (!requireThreadEditResolution()) { return; } diff --git a/desktop/src/features/channels/useLiveChannelUpdates.test.mjs b/desktop/src/features/channels/useLiveChannelUpdates.test.mjs index 51957e89d89..77b979d666b 100644 --- a/desktop/src/features/channels/useLiveChannelUpdates.test.mjs +++ b/desktop/src/features/channels/useLiveChannelUpdates.test.mjs @@ -1,7 +1,13 @@ import assert from "node:assert/strict"; import { after, before, test } from "node:test"; import { JSDOM } from "jsdom"; -import { HOME_MENTION_EVENT_KINDS } from "@/shared/constants/kinds"; +import { + HOME_MENTION_EVENT_KINDS, + KIND_HUDDLE_STARTED, + KIND_REACTION, + KIND_STREAM_MESSAGE_DIFF, + KIND_SYSTEM_MESSAGE, +} from "@/shared/constants/kinds"; const dom = new JSDOM("", { url: "http://localhost", @@ -356,6 +362,80 @@ test("untagged auxiliary event keeps its single-channel context in the timeline } }); +test("live rows and auxiliary events survive authoritative window projection", async () => { + const h = await mount(channels(1)); + try { + const channelId = "channel-0"; + const parent = message("parent", { + created_at: Math.floor(Date.now() / 1000) - 10, + }); + h.queryClient.setQueryData(h.channelWindowKey(channelId), { + pages: [ + { + startCursor: null, + rows: [{ event: parent, thread: null }], + aux: [], + nextCursor: null, + hasMore: false, + }, + ], + liveOverlay: [], + liveAux: [], + liveSummaries: {}, + }); + h.queryClient.setQueryData(h.channelMessagesKey(channelId), [parent]); + + const sub = h.subscriptions[0]; + for (const [id, kind] of [ + ["diff", KIND_STREAM_MESSAGE_DIFF], + ["system", KIND_SYSTEM_MESSAGE], + ["huddle", KIND_HUDDLE_STARTED], + ["reaction", KIND_REACTION], + ]) { + await h.deliver(sub, message(id, { kind })); + } + await h.deliver( + sub, + message("thread-only", { + tags: [ + ["h", channelId], + ["e", "parent", "", "root"], + ["e", "parent", "", "reply"], + ], + }), + ); + await h.deliver(sub, message("next-row")); + + const window = h.queryClient.getQueryData(h.channelWindowKey(channelId)); + assert.deepEqual( + new Set(window.liveOverlay.map((event) => event.id)), + new Set(["diff", "system", "huddle", "next-row"]), + ); + assert.deepEqual( + window.liveAux.map((event) => event.id), + ["reaction"], + ); + assert.deepEqual( + new Set( + h.queryClient + .getQueryData(h.channelMessagesKey(channelId)) + .map((event) => event.id), + ), + new Set([ + "parent", + "diff", + "system", + "huddle", + "reaction", + "thread-only", + "next-row", + ]), + ); + } finally { + h.restore(); + } +}); + test("one failed setup does not abort other channel streams and retries only that channel", async () => { const originalSetTimeout = window.setTimeout; const originalClearTimeout = window.clearTimeout; diff --git a/desktop/src/features/channels/useLiveChannelUpdates.ts b/desktop/src/features/channels/useLiveChannelUpdates.ts index 3985b50e60e..65c8d9bc412 100644 --- a/desktop/src/features/channels/useLiveChannelUpdates.ts +++ b/desktop/src/features/channels/useLiveChannelUpdates.ts @@ -23,8 +23,10 @@ import { } from "@/features/notifications/lib/shouldNotify"; import { relayClient } from "@/shared/api/relayClient"; import { + CHANNEL_AUX_EVENT_KINDS, CHANNEL_EVENT_KINDS, CHANNEL_MESSAGE_EVENT_KINDS, + CHANNEL_TIMELINE_CONTENT_KINDS, HOME_MENTION_EVENT_KINDS, } from "@/shared/constants/kinds"; import type { Channel, RelayEvent } from "@/shared/api/types"; @@ -92,6 +94,8 @@ const CHANNELS_INVALIDATE_DEBOUNCE_MS = 500; // Only "new content" kinds should bump unread state. Shared with the // catch-up query in useUnreadChannels so the two paths stay in lockstep. const UNREAD_TRIGGER_KINDS = new Set(CHANNEL_MESSAGE_EVENT_KINDS); +const CHANNEL_TIMELINE_KINDS = new Set(CHANNEL_TIMELINE_CONTENT_KINDS); +const CHANNEL_AUX_KINDS = new Set(CHANNEL_AUX_EVENT_KINDS); export const EMPTY_SET: ReadonlySet = new Set(); @@ -358,15 +362,24 @@ export function useLiveChannelUpdates( }, ); + const isTimelineRow = CHANNEL_TIMELINE_KINDS.has(event.kind); + const isAuxEvent = CHANNEL_AUX_KINDS.has(event.kind); + // channelMessagesKey is derived from this store. Admission here follows + // render semantics, not the deliberately narrower unread-kind policy, so + // a later projection cannot discard a live row or structural overlay. if ( - isUnreadTriggerKind && - (!isThreadedReply || isBroadcastReply(event.tags)) + (isTimelineRow && (!isThreadedReply || isBroadcastReply(event.tags))) || + isAuxEvent ) { const windowKey = channelWindowKey(channelId); const currentWindow = queryClient.getQueryData(windowKey) ?? emptyChannelWindowStore(); - const nextWindow = mergeLiveChannelWindowEvent(currentWindow, event); + const nextWindow = mergeLiveChannelWindowEvent( + currentWindow, + event, + isTimelineRow, + ); if (nextWindow !== currentWindow) { queryClient.setQueryData(windowKey, nextWindow); projectChannelWindowMessages(queryClient, channelId); diff --git a/desktop/src/features/notifications/hooks.ts b/desktop/src/features/notifications/hooks.ts index 1a2cb4a9a53..59df17d0aa7 100644 --- a/desktop/src/features/notifications/hooks.ts +++ b/desktop/src/features/notifications/hooks.ts @@ -205,9 +205,18 @@ export function useNotificationSettings(pubkey?: string) { return nextPermission; }); + const refreshPermissionSafely = React.useEffectEvent(async () => { + try { + return await refreshPermission(); + } catch (error) { + console.warn("Failed to refresh desktop notification permission", error); + return null; + } + }); + React.useEffect(() => { void normalizedPubkey; - void refreshPermission(); + void refreshPermissionSafely(); }, [normalizedPubkey]); React.useEffect(() => { @@ -221,7 +230,9 @@ export function useNotificationSettings(pubkey?: string) { if (cancelPendingRefresh) return; cancelPendingRefresh = scheduleAfterForegroundReady(() => { cancelPendingRefresh = null; - if (document.visibilityState === "visible") void refreshPermission(); + if (document.visibilityState === "visible") { + void refreshPermissionSafely(); + } }); }; document.addEventListener("visibilitychange", refreshWhenVisible); @@ -249,7 +260,7 @@ export function useNotificationSettings(pubkey?: string) { ...current, desktopEnabled: false, })); - void refreshPermission(); + void refreshPermissionSafely(); return true; } diff --git a/desktop/src/features/notifications/lib/desktop.ts b/desktop/src/features/notifications/lib/desktop.ts index f0472ca5868..ef6a0e5e53e 100644 --- a/desktop/src/features/notifications/lib/desktop.ts +++ b/desktop/src/features/notifications/lib/desktop.ts @@ -18,6 +18,8 @@ const NATIVE_NOTIFICATION_ACTIVATED_EVENT = "native-notification-activated"; const TAKE_PENDING_MACOS_NOTIFICATION_ACTIVATIONS = "take_pending_activations"; const MACOS_NOTIFICATION_PERMISSION_STATE = "notification_permission_state"; const REQUEST_MACOS_NOTIFICATION_ACCESS = "request_notification_access"; +const WINDOWS_NOTIFICATION_PERMISSION_STATE = + "windows_notification_permission_state"; export type DesktopNotificationPermissionState = | NotificationPermission @@ -158,15 +160,13 @@ export async function getDesktopNotificationPermissionState(): Promise( + WINDOWS_NOTIFICATION_PERMISSION_STATE, + ); } if (window.Notification.permission !== "default") { @@ -197,19 +197,18 @@ export async function requestDesktopNotificationAccess(): Promise(REQUEST_MACOS_NOTIFICATION_ACCESS).catch( - (error) => { + isTauri() && isWindowsPlatform() + ? getDesktopNotificationPermissionState() + : isTauri() && isMacPlatform() + ? invoke( + REQUEST_MACOS_NOTIFICATION_ACCESS, + ).catch((error) => { if (shouldUseMacDevelopmentFallback(error)) { return requestPermission(); } throw error; - }, - ) - : // On Windows, always use the Tauri plugin's requestPermission() which - // triggers the WinRT notification permission prompt. The browser-level - // Notification.requestPermission() is unreliable in WebView2. - requestPermission(); + }) + : requestPermission(); pendingPermissionRequest = request.finally(() => { pendingPermissionRequest = null; }); diff --git a/desktop/src/features/notifications/lib/desktopWindowsPermission.test.mjs b/desktop/src/features/notifications/lib/desktopWindowsPermission.test.mjs new file mode 100644 index 00000000000..54d8e15d8c9 --- /dev/null +++ b/desktop/src/features/notifications/lib/desktopWindowsPermission.test.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +let nativePermission = "denied"; +let nativeQueries = 0; +const testWindow = { + Notification: Object.assign(function StubNotification() {}, { + permission: "denied", + }), + __TAURI_INTERNALS__: { + invoke(command) { + if (command === "windows_notification_permission_state") { + nativeQueries++; + return Promise.resolve(nativePermission); + } + return Promise.reject(new Error(`unexpected command: ${command}`)); + }, + }, +}; +globalThis.window = testWindow; +globalThis.isTauri = true; +Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { platform: "Win32", userAgent: "buzz-test" }, +}); + +const { + ensureDesktopNotificationPermissionGranted, + getDesktopNotificationPermissionState, + requestDesktopNotificationAccess, +} = await import("./desktop.ts"); + +test("Windows reports a terminal native denial without requesting again", async () => { + let requested = false; + assert.equal(await getDesktopNotificationPermissionState(), "denied"); + assert.equal( + await ensureDesktopNotificationPermissionGranted( + getDesktopNotificationPermissionState, + async () => { + requested = true; + return "granted"; + }, + ), + false, + ); + assert.equal(requested, false); +}); + +test("Windows access requests re-query the native setting", async () => { + nativePermission = "granted"; + const before = nativeQueries; + assert.equal(await requestDesktopNotificationAccess(), "granted"); + assert.equal(nativeQueries, before + 1); +}); diff --git a/desktop/src/features/notifications/lib/target.test.mjs b/desktop/src/features/notifications/lib/target.test.mjs index 6e1607298a5..e2e8a7b2bcf 100644 --- a/desktop/src/features/notifications/lib/target.test.mjs +++ b/desktop/src/features/notifications/lib/target.test.mjs @@ -103,3 +103,25 @@ test("builds a complete click-through target from a feed item", () => { threadRootId: "root-id", }); }); + +test("broadcast reply feed targets stay on their exact timeline row", () => { + const target = buildFeedItemNotificationTarget({ + id: "broadcast-event", + kind: 9, + pubkey: "sender", + content: "announcement reply", + createdAt: 456, + channelId: "channel-id", + channelName: "ship-room", + tags: [ + ["e", "root-id", "", "root"], + ["e", "parent-id", "", "reply"], + ["broadcast", "1"], + ], + category: "mention", + }); + + assert.equal(target.eventId, "broadcast-event"); + assert.equal(target.openInThread, false); + assert.equal(target.threadRootId, null); +}); diff --git a/desktop/src/features/notifications/lib/target.ts b/desktop/src/features/notifications/lib/target.ts index 5c30d4908e5..432a10982f4 100644 --- a/desktop/src/features/notifications/lib/target.ts +++ b/desktop/src/features/notifications/lib/target.ts @@ -1,4 +1,7 @@ -import { getThreadReference } from "@/features/messages/lib/threading"; +import { + getThreadReference, + isBroadcastReply, +} from "@/features/messages/lib/threading"; import type { FeedItem, RelayEvent } from "@/shared/api/types"; import type { DesktopNotificationTarget } from "./desktop"; @@ -35,6 +38,11 @@ export function buildEventNotificationTarget( export function buildFeedItemNotificationTarget( item: FeedItem, ): DesktopNotificationTarget { + const threadRootId = getThreadReference(item.tags).rootId; + // Broadcast replies retain ancestry tags for context but render as their + // own channel-timeline rows, so activation must not open a thread panel. + const openInThread = threadRootId !== null && !isBroadcastReply(item.tags); + return { channelId: item.channelId, channelName: item.channelName, @@ -43,7 +51,7 @@ export function buildFeedItemNotificationTarget( eventId: item.id, kind: item.kind, pubkey: item.pubkey, - openInThread: getThreadReference(item.tags).rootId !== null, - threadRootId: getThreadReference(item.tags).rootId ?? null, + openInThread, + threadRootId: openInThread ? threadRootId : null, }; } diff --git a/desktop/src/features/notifications/use-feed-desktop-notifications.test.mjs b/desktop/src/features/notifications/use-feed-desktop-notifications.test.mjs index 148f0c5623c..93d2f4ad0af 100644 --- a/desktop/src/features/notifications/use-feed-desktop-notifications.test.mjs +++ b/desktop/src/features/notifications/use-feed-desktop-notifications.test.mjs @@ -1,5 +1,20 @@ import assert from "node:assert/strict"; -import test from "node:test"; +import { after, before, test } from "node:test"; +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + localStorage: dom.window.localStorage, + }); +}); +after(() => dom.window.close()); const { deliverFeedNotificationBatch, ensureFeedNotificationPermission } = await import("./use-feed-desktop-notifications.ts"); @@ -15,10 +30,11 @@ test("an enabled restart waits for permission repair before delivering the feed [{ id: "restart-alert" }], async () => { await repairPermission; - return true; + return "granted"; }, async (item) => { delivered.push(item.id); + return true; }, ); @@ -26,22 +42,48 @@ test("an enabled restart waits for permission repair before delivering the feed assert.deepEqual(delivered, []); releaseRepair(); - await delivery; + assert.deepEqual(await delivery, { + handledIds: ["restart-alert"], + retryableIds: [], + }); assert.deepEqual(delivered, ["restart-alert"]); }); test("a permission repair that is not granted suppresses the feed batch", async () => { const delivered = []; - await deliverFeedNotificationBatch( + const result = await deliverFeedNotificationBatch( [{ id: "blocked-alert" }], - async () => false, + async () => "denied", async (item) => { delivered.push(item.id); + return true; }, ); assert.deepEqual(delivered, []); + assert.deepEqual(result, { + handledIds: ["blocked-alert"], + retryableIds: [], + }); +}); + +test("an operational permission failure keeps the feed batch retryable", async () => { + let delivered = false; + const result = await deliverFeedNotificationBatch( + [{ id: "retry-permission-alert" }], + async () => "error", + async () => { + delivered = true; + return true; + }, + ); + + assert.equal(delivered, false); + assert.deepEqual(result, { + handledIds: [], + retryableIds: ["retry-permission-alert"], + }); }); test("a concurrent feed batch joins the pending permission request", async () => { @@ -79,5 +121,93 @@ test("a concurrent feed batch joins the pending permission request", async () => assert.equal(permissionStateChecks, 1); assert.equal(requestCalls, 2); releaseRequest("granted"); - assert.deepEqual(await Promise.all([first, second]), [true, true]); + assert.deepEqual(await Promise.all([first, second]), ["granted", "granted"]); +}); + +test("a delivery failure remains unseen and retries on the next feed result", async (t) => { + t.mock.method(console, "warn", () => {}); + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { useFeedDesktopNotifications } = await import( + "./use-feed-desktop-notifications.ts" + ); + let shouldFail = true; + const delivered = []; + class TestNotification { + static permission = "granted"; + + constructor(_title, options) { + if (shouldFail) { + throw new Error("notification backend unavailable"); + } + delivered.push(options.extra.buzzNotificationTarget.eventId); + } + + close() {} + } + Object.defineProperty(window, "Notification", { + configurable: true, + value: TestNotification, + }); + const emptyFeed = { feed: { mentions: [], needsAction: [] } }; + const item = { + id: "retry-alert", + kind: 9, + pubkey: "sender", + content: "retry me", + createdAt: 123, + channelId: "channel-id", + channelName: "ship-room", + channelType: "stream", + tags: [], + category: "mention", + }; + const settings = { + desktopEnabled: true, + slotAlertsEnabled: { mention: true, needs_action: true }, + }; + const setDesktopEnabled = async () => true; + const profiles = new Map(); + const channels = [ + { id: "channel-id", name: "ship-room", channelType: "stream" }, + ]; + const silentChannelIds = new Set(["channel-id"]); + const render = (feed) => + useFeedDesktopNotifications( + feed, + "viewer", + settings, + setDesktopEnabled, + true, + profiles, + undefined, + channels, + silentChannelIds, + ); + const hook = renderHook(({ feed }) => render(feed), { + initialProps: { feed: emptyFeed }, + }); + const settle = () => + act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + await settle(); + + hook.rerender({ feed: { feed: { mentions: [item], needsAction: [] } } }); + await settle(); + assert.deepEqual( + JSON.parse(localStorage.getItem("buzz-home-feed-seen.v1:viewer")), + [], + ); + + shouldFail = false; + hook.rerender({ feed: { feed: { mentions: [item], needsAction: [] } } }); + await settle(); + assert.deepEqual(delivered, ["retry-alert"]); + assert.deepEqual( + JSON.parse(localStorage.getItem("buzz-home-feed-seen.v1:viewer")), + ["retry-alert"], + ); + + hook.unmount(); + cleanup(); }); diff --git a/desktop/src/features/notifications/use-feed-desktop-notifications.ts b/desktop/src/features/notifications/use-feed-desktop-notifications.ts index 6e04e215a10..dfa6be1c46b 100644 --- a/desktop/src/features/notifications/use-feed-desktop-notifications.ts +++ b/desktop/src/features/notifications/use-feed-desktop-notifications.ts @@ -68,15 +68,44 @@ export function writeStoredSeenFeedIds(pubkey: string, ids: string[]) { ); } +export type FeedNotificationPermissionOutcome = "granted" | "denied" | "error"; + +export type FeedNotificationBatchResult = { + handledIds: string[]; + retryableIds: string[]; +}; + export async function deliverFeedNotificationBatch( items: readonly FeedItem[], - ensurePermissionGranted: () => Promise, - deliver: (item: FeedItem) => Promise, -): Promise { - if (!(await ensurePermissionGranted())) { - return; + ensurePermission: () => Promise, + deliver: (item: FeedItem) => Promise, +): Promise { + const permission = await ensurePermission(); + if (permission !== "granted") { + const ids = items.map((item) => item.id); + return permission === "denied" + ? { handledIds: ids, retryableIds: [] } + : { handledIds: [], retryableIds: ids }; } - await Promise.all(items.map((item) => deliver(item))); + + const outcomes = await Promise.all( + items.map(async (item) => { + try { + return { id: item.id, delivered: await deliver(item) }; + } catch (error) { + console.warn("Failed to deliver feed notification", item.id, error); + return { id: item.id, delivered: false }; + } + }), + ); + return { + handledIds: outcomes + .filter((outcome) => outcome.delivered) + .map((outcome) => outcome.id), + retryableIds: outcomes + .filter((outcome) => !outcome.delivered) + .map((outcome) => outcome.id), + }; } export async function ensureFeedNotificationPermission( @@ -84,12 +113,18 @@ export async function ensureFeedNotificationPermission( setDesktopEnabled: (enabled: boolean) => Promise, getPermissionState = getDesktopNotificationPermissionState, requestAccess = requestDesktopNotificationAccess, -): Promise { +): Promise { try { if (!attempt.hasRequested) { const currentPermission = await getPermissionState(); if (currentPermission !== "default") { - return currentPermission === "granted"; + if (currentPermission === "granted") { + return "granted"; + } + void setDesktopEnabled(false).catch((error) => { + console.warn("Failed to disable desktop notifications", error); + }); + return "denied"; } attempt.hasRequested = true; } @@ -98,13 +133,15 @@ export async function ensureFeedNotificationPermission( // repeated calls join an in-progress OS permission prompt. const result = await requestAccess(); if (result !== "granted") { - void setDesktopEnabled(false); - return false; + void setDesktopEnabled(false).catch((error) => { + console.warn("Failed to disable desktop notifications", error); + }); + return "denied"; } - return true; + return "granted"; } catch (error) { console.warn("Failed to request desktop notification permission", error); - return false; + return "error"; } } @@ -125,11 +162,22 @@ export function useFeedDesktopNotifications( ); const hasInitializedFeedRef = React.useRef(false); const permissionAttemptRef = React.useRef({ hasRequested: false }); + const inFlightItemIdsRef = React.useRef(new Set()); + const notificationGenerationRef = React.useRef(0); React.useEffect(() => { seenItemIdsRef.current = new Set(readStoredSeenFeedIds(normalizedPubkey)); hasInitializedFeedRef.current = false; permissionAttemptRef.current.hasRequested = false; + inFlightItemIdsRef.current.clear(); + const generation = notificationGenerationRef.current + 1; + notificationGenerationRef.current = generation; + return () => { + if (notificationGenerationRef.current === generation) { + notificationGenerationRef.current += 1; + } + inFlightItemIdsRef.current.clear(); + }; }, [normalizedPubkey]); const autoRequestPermissionIfNeeded = React.useEffectEvent(() => @@ -155,6 +203,7 @@ export function useFeedDesktopNotifications( const slot = slotForFeedKind(item.kind, item.category); playNotificationSound(resolveSlotSound(settings, slot)); } + return didSend; }, ); @@ -194,6 +243,7 @@ export function useFeedDesktopNotifications( channels, ) .filter((item) => !nextSeenItemIds.has(item.id)) + .filter((item) => !inFlightItemIdsRef.current.has(item.id)) .filter( (item) => !item.channelId || @@ -202,8 +252,14 @@ export function useFeedDesktopNotifications( ) : []; + const pendingItemIds = new Set(newItems.map((item) => item.id)); for (const item of currentFeedItems) { - nextSeenItemIds.add(item.id); + if ( + !pendingItemIds.has(item.id) && + !inFlightItemIdsRef.current.has(item.id) + ) { + nextSeenItemIds.add(item.id); + } } // Prevent unbounded growth — keep only the most recent entries. @@ -221,6 +277,10 @@ export function useFeedDesktopNotifications( writeStoredSeenFeedIds(normalizedPubkey, [...nextSeenItemIds]); if (newItems.length > 0) { + for (const item of newItems) { + inFlightItemIdsRef.current.add(item.id); + } + const generation = notificationGenerationRef.current; void deliverFeedNotificationBatch( newItems, autoRequestPermissionIfNeeded, @@ -237,9 +297,27 @@ export function useFeedDesktopNotifications( resolvedLabel && resolvedLabel !== truncateNpub(item.pubkey) ? resolvedLabel : undefined; - await deliverFeedNotification(item, senderName); + return deliverFeedNotification(item, senderName); }, - ); + ).then((result) => { + if (generation !== notificationGenerationRef.current) { + return; + } + for (const item of newItems) { + inFlightItemIdsRef.current.delete(item.id); + } + if (result.handledIds.length === 0) { + return; + } + + const handled = new Set(seenItemIdsRef.current); + for (const id of result.handledIds) { + handled.add(id); + } + const handledIds = [...handled].slice(-HOME_FEED_SEEN_MAX_ITEMS); + seenItemIdsRef.current = new Set(handledIds); + writeStoredSeenFeedIds(normalizedPubkey, handledIds); + }); } }, [ enabled, diff --git a/desktop/src/features/projects/ui/ProjectChannelHome.tsx b/desktop/src/features/projects/ui/ProjectChannelHome.tsx index dc526b1f0ef..fa185fc8dea 100644 --- a/desktop/src/features/projects/ui/ProjectChannelHome.tsx +++ b/desktop/src/features/projects/ui/ProjectChannelHome.tsx @@ -91,6 +91,7 @@ export function ProjectChannelHome({ projects, targetMessageEvents = EMPTY_TARGET_MESSAGE_EVENTS, targetMessageId, + targetMessageView, }: { allowRepositoryHealing: boolean; autoSendDraftKey?: string | null; @@ -98,6 +99,7 @@ export function ProjectChannelHome({ projects: Project[]; targetMessageEvents?: RelayEvent[]; targetMessageId?: string | null; + targetMessageView?: "timeline" | null; }) { const { goChannel, goProject, goProjects } = useAppNavigation(); const sidebar = useOptionalSidebar(); @@ -383,6 +385,7 @@ export function ProjectChannelHome({ ? (search.messageId ?? null) : targetMessageId } + targetMessageView={targetMessageView} /> ) : ( diff --git a/desktop/tests/e2e/project-cold-start.spec.ts b/desktop/tests/e2e/project-cold-start.spec.ts index 8fcfb0eafc0..e91d90dd373 100644 --- a/desktop/tests/e2e/project-cold-start.spec.ts +++ b/desktop/tests/e2e/project-cold-start.spec.ts @@ -73,6 +73,21 @@ async function waitForProjectEnumeration( .toBe(true); } +async function waitForProjectChannelSubscription( + page: import("@playwright/test").Page, +): Promise { + await expect + .poll(() => + page.evaluate( + () => + window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: "buzz", + }) ?? false, + ), + ) + .toBe(true); +} + test("snapshot project home cannot publish repository healing", async ({ page, }) => { @@ -169,3 +184,34 @@ test("equal live project data enables healing after snapshot reconciliation", as "true", ); }); + +test("project-home timeline targets do not open the thread panel", async ({ + page, +}) => { + await enableProjectsFeature(page); + await installMockBridge(page); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.getByTestId("channel-buzz").click(); + await expect(page.getByTestId("project-home-context-panel")).toBeVisible(); + await waitForProjectChannelSubscription(page); + + const messageId = "project-home-timeline-target"; + await page.evaluate((id) => { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "buzz", + content: "Project timeline target", + id, + }); + }, messageId); + await expect(page.getByText("Project timeline target")).toBeVisible(); + + await page.evaluate( + ({ channelId, id }) => { + window.location.hash = `/channels/${channelId}?messageId=${id}&messageView=timeline`; + }, + { channelId: PROJECT_HOME_CHANNEL_ID, id: messageId }, + ); + + await expect(page).not.toHaveURL(/messageId=/); + await expect(page.getByTestId("message-thread-panel")).toHaveCount(0); +}); From 91c380971d4ea492fcfe82acac3688c1089f6b0b Mon Sep 17 00:00:00 2001 From: Bernhard Kaindl Date: Sun, 13 Sep 2026 18:13:01 +0200 Subject: [PATCH 5/7] fix(desktop): complete reliable native notifications Signed-off-by: Bernhard Kaindl --- AGENTS.md | 8 + desktop/src-tauri/Cargo.toml | 4 +- .../src-tauri/src/commands/notifications.rs | 319 ++++++++++++++++++ desktop/src-tauri/src/lib.rs | 3 + .../ui/useChannelRouteTarget.test.mjs | 40 +++ .../channels/ui/useChannelRouteTarget.ts | 2 +- .../messages/lib/channelWindowStore.ts | 12 +- .../lib/projectChannelWindow.test.mjs | 15 + .../messages/lib/projectChannelWindow.ts | 14 +- .../src/features/notifications/lib/desktop.ts | 31 +- .../lib/desktopWindowsPermission.test.mjs | 14 + .../use-feed-desktop-notifications.test.mjs | 67 +++- .../use-feed-desktop-notifications.ts | 80 ++++- 13 files changed, 587 insertions(+), 22 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 26794990124..df43b0c1552 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -142,6 +142,14 @@ lane subprocesses resolve the pinned flutter/dart/lefthook even when an unactivated shell has Homebrew first. Activating Hermit remains recommended for non-hook commands. +**Windows tool resolution:** the committed files under `bin\` (for example +`bin\cargo`, `bin\rustc`, `bin\pnpm`, and `bin\node`) are Hermit package +entries, not native Windows executables. Do not invoke those paths directly, +and do not assume a bare command is valid when `Get-Command` resolves it into +the repository's `bin\` directory. For Windows validation, run the actual +installed `.exe`/`.cmd` tools, or use their full paths, and verify the resolved +`Source` is not under this repository before running a build or test. + **Commit with `git commit -s`.** The required **DCO Check** fails any PR with a commit missing a `Signed-off-by` trailer, and `just hooks` installs a `commit-msg` hook that adds it to commits you create locally (`git rebase` and `git cherry-pick` still need `--signoff`) — if you build commit commands programmatically, include `-s` every time. To repair a branch that already has unsigned commits: `git rebase --signoff main`, then force-push. Additional rules: diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 4e7bd9169e4..78978f02ee4 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -63,13 +63,13 @@ user-idle = { version = "0.6", default-features = false } plist = "1" [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_Threading", "Win32_Foundation"] } +windows-sys = { version = "0.61", features = ["Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_Threading", "Win32_Foundation", "Win32_UI_Shell"] } keyring = { version = "3.6.3", default-features = false, features = ["windows-native", "vendored"], optional = true } user-idle = { version = "0.6", default-features = false } # Native Windows toast notifications so the app registers with Windows Settings # > System > Notifications and click actions work through WinRT. tauri-winrt-notification = "0.7" -windows = { version = "0.61", features = ["UI_Notifications"] } +windows = { version = "0.61", features = ["UI_Notifications", "Win32_Foundation", "Win32_Storage_EnhancedStorage", "Win32_System_Com", "Win32_System_Com_StructuredStorage", "Win32_UI_Shell", "Win32_UI_Shell_PropertiesSystem"] } [dependencies] atomic-write-file = "0.3" diff --git a/desktop/src-tauri/src/commands/notifications.rs b/desktop/src-tauri/src/commands/notifications.rs index 8b2b8cb905c..a60a8db40a3 100644 --- a/desktop/src-tauri/src/commands/notifications.rs +++ b/desktop/src-tauri/src/commands/notifications.rs @@ -51,6 +51,14 @@ pub async fn show_native_notification( } } +#[cfg(target_os = "windows")] +pub(crate) fn ensure_startup_registration(app: &tauri::AppHandle) { + windows::ensure_startup_registration(app); +} + +#[cfg(not(target_os = "windows"))] +pub(crate) fn ensure_startup_registration(_app: &tauri::AppHandle) {} + #[cfg(target_os = "windows")] #[tauri::command] pub async fn windows_notification_permission_state( @@ -59,6 +67,12 @@ pub async fn windows_notification_permission_state( windows::permission_state(app).await.map(str::to_string) } +#[cfg(target_os = "windows")] +#[tauri::command] +pub fn take_pending_windows_activations() -> Result, String> { + windows::take_pending_windows_activations() +} + #[cfg(target_os = "linux")] mod linux { use super::NATIVE_NOTIFICATION_ACTIVATED_EVENT; @@ -131,6 +145,8 @@ mod linux { #[cfg(target_os = "windows")] mod windows { use super::NATIVE_NOTIFICATION_ACTIVATED_EVENT; + use std::collections::VecDeque; + use std::sync::{Mutex, Once, OnceLock}; use tauri::Emitter; use tauri_winrt_notification::{Duration, Toast}; use windows::{ @@ -138,6 +154,296 @@ mod windows { UI::Notifications::{NotificationSetting, ToastNotificationManager}, }; + const MAX_PENDING_ACTIVATIONS: usize = 64; + static STARTUP_REGISTRATION: Once = Once::new(); + static PENDING_ACTIVATIONS: OnceLock>> = OnceLock::new(); + + pub fn ensure_startup_registration(app: &tauri::AppHandle) { + STARTUP_REGISTRATION.call_once(|| { + let app = app.clone(); + let (ready_sender, ready_receiver) = std::sync::mpsc::sync_channel(1); + std::thread::spawn(move || { + let app_id = app.config().identifier.clone(); + set_process_aumid(&app_id); + if let Err(error) = write_aumid_registry_entry(&app, &app_id) { + eprintln!("buzz-desktop: failed to register Windows AUMID: {error}"); + } + if let Err(error) = write_notification_settings_entry(&app_id) { + eprintln!( + "buzz-desktop: failed to register Windows notification settings: {error}" + ); + } + if let Err(error) = ensure_start_menu_shortcut(&app, &app_id) { + eprintln!("buzz-desktop: failed to repair Windows shortcut AUMID: {error}"); + } + let _ = ready_sender.send(()); + }); + let _ = ready_receiver.recv(); + }); + } + + fn to_wide(value: &str) -> Vec { + value.encode_utf16().chain(std::iter::once(0)).collect() + } + + fn set_process_aumid(app_id: &str) { + use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID; + + let app_id = to_wide(app_id); + let result = unsafe { SetCurrentProcessExplicitAppUserModelID(app_id.as_ptr()) }; + if result < 0 { + eprintln!("buzz-desktop: failed to set Windows process AUMID: 0x{result:08X}"); + } + } + + fn write_notification_settings_entry(app_id: &str) -> Result<(), String> { + use windows_sys::Win32::System::Registry::{ + RegCloseKey, RegCreateKeyExW, RegOpenKeyExW, RegSetValueExW, HKEY, HKEY_CURRENT_USER, + KEY_READ, KEY_WRITE, REG_DWORD, REG_OPTION_NON_VOLATILE, + }; + + let subkey = to_wide(&format!( + "Software\\Microsoft\\Windows\\CurrentVersion\\Notifications\\Settings\\{app_id}" + )); + unsafe { + let mut existing: HKEY = std::ptr::null_mut(); + if RegOpenKeyExW( + HKEY_CURRENT_USER, + subkey.as_ptr(), + 0, + KEY_READ, + &mut existing, + ) == 0 + { + RegCloseKey(existing); + return Ok(()); + } + + let mut key: HKEY = std::ptr::null_mut(); + let status = RegCreateKeyExW( + HKEY_CURRENT_USER, + subkey.as_ptr(), + 0, + std::ptr::null(), + REG_OPTION_NON_VOLATILE, + KEY_WRITE, + std::ptr::null(), + &mut key, + std::ptr::null_mut(), + ); + if status != 0 { + return Err(format!("RegCreateKeyExW failed with status {status}")); + } + + let show_name = to_wide("ShowInActionCenter"); + let enabled_name = to_wide("Enabled"); + let value: u32 = 1; + let show_status = RegSetValueExW( + key, + show_name.as_ptr(), + 0, + REG_DWORD, + (&value as *const u32).cast(), + std::mem::size_of::() as u32, + ); + let enabled_status = RegSetValueExW( + key, + enabled_name.as_ptr(), + 0, + REG_DWORD, + (&value as *const u32).cast(), + std::mem::size_of::() as u32, + ); + RegCloseKey(key); + if show_status != 0 { + return Err(format!( + "RegSetValueExW(ShowInActionCenter) failed: {show_status}" + )); + } + if enabled_status != 0 { + return Err(format!("RegSetValueExW(Enabled) failed: {enabled_status}")); + } + } + Ok(()) + } + + fn write_aumid_registry_entry(app: &tauri::AppHandle, app_id: &str) -> Result<(), String> { + use windows_sys::Win32::System::Registry::{ + RegCloseKey, RegCreateKeyExW, RegSetValueExW, HKEY, HKEY_CURRENT_USER, KEY_WRITE, + REG_OPTION_NON_VOLATILE, REG_SZ, + }; + + let display_name = app + .config() + .product_name + .clone() + .unwrap_or_else(|| "Buzz".to_string()); + let icon_path = std::env::current_exe() + .map_err(|error| format!("could not resolve current executable: {error}"))? + .to_string_lossy() + .into_owned(); + let subkey = to_wide(&format!("Software\\Classes\\AppUserModelId\\{app_id}")); + let display_name_key = to_wide("DisplayName"); + let display_name_value = to_wide(&display_name); + let icon_key = to_wide("IconUri"); + let icon_value = to_wide(&icon_path); + + unsafe { + let mut key: HKEY = std::ptr::null_mut(); + let status = RegCreateKeyExW( + HKEY_CURRENT_USER, + subkey.as_ptr(), + 0, + std::ptr::null(), + REG_OPTION_NON_VOLATILE, + KEY_WRITE, + std::ptr::null(), + &mut key, + std::ptr::null_mut(), + ); + if status != 0 { + return Err(format!("RegCreateKeyExW failed with status {status}")); + } + let display_status = RegSetValueExW( + key, + display_name_key.as_ptr(), + 0, + REG_SZ, + display_name_value.as_ptr().cast(), + (display_name_value.len() * 2) as u32, + ); + let icon_status = RegSetValueExW( + key, + icon_key.as_ptr(), + 0, + REG_SZ, + icon_value.as_ptr().cast(), + (icon_value.len() * 2) as u32, + ); + RegCloseKey(key); + if display_status != 0 { + return Err(format!( + "RegSetValueExW(DisplayName) failed: {display_status}" + )); + } + if icon_status != 0 { + return Err(format!("RegSetValueExW(IconUri) failed: {icon_status}")); + } + } + Ok(()) + } + + fn ensure_start_menu_shortcut(app: &tauri::AppHandle, app_id: &str) -> Result<(), String> { + use windows::core::{Interface, PCWSTR}; + use windows::Win32::Storage::EnhancedStorage::PKEY_AppUserModel_ID; + use windows::Win32::System::Com::StructuredStorage::PROPVARIANT; + use windows::Win32::System::Com::{ + CoCreateInstance, CoInitializeEx, CoTaskMemFree, CoUninitialize, IPersistFile, + CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED, STGM_READWRITE, + }; + use windows::Win32::UI::Shell::{ + FOLDERID_Programs, IShellLinkW, PropertiesSystem::IPropertyStore, SHGetKnownFolderPath, + ShellLink, KF_FLAG_CREATE, + }; + + let product_name = app + .config() + .product_name + .clone() + .unwrap_or_else(|| "Buzz".to_string()); + let executable = std::env::current_exe() + .map_err(|error| format!("could not resolve current executable: {error}"))?; + let initialized = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) }; + if initialized.is_err() { + return Err(format!("CoInitializeEx failed: {initialized:?}")); + } + + let result = (|| -> Result<(), String> { + let programs_path_ptr = unsafe { + SHGetKnownFolderPath(&FOLDERID_Programs, KF_FLAG_CREATE, None) + .map_err(|error| format!("SHGetKnownFolderPath failed: {error}"))? + }; + let programs_path_result = unsafe { programs_path_ptr.to_string() }; + unsafe { + CoTaskMemFree(Some(programs_path_ptr.0.cast())); + } + let programs_path = programs_path_result + .map_err(|error| format!("invalid Start Menu path: {error}"))?; + + let subfolder = format!("{programs_path}\\{product_name}"); + let nested_path = format!("{subfolder}\\{product_name}.lnk"); + let flat_path = format!("{programs_path}\\{product_name}.lnk"); + let shortcut_path = if std::path::Path::new(&nested_path).exists() { + nested_path + } else if std::path::Path::new(&flat_path).exists() { + flat_path + } else { + std::fs::create_dir_all(&subfolder) + .map_err(|error| format!("could not create Start Menu folder: {error}"))?; + nested_path + }; + let shortcut_path = to_wide(&shortcut_path); + let executable = to_wide(&executable.to_string_lossy()); + let link: IShellLinkW = unsafe { + CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER) + .map_err(|error| format!("CoCreateInstance(ShellLink) failed: {error}"))? + }; + let persist: IPersistFile = link + .cast() + .map_err(|error| format!("IShellLinkW -> IPersistFile failed: {error}"))?; + if unsafe { persist.Load(PCWSTR(shortcut_path.as_ptr()), STGM_READWRITE) }.is_err() { + unsafe { + link.SetPath(PCWSTR(executable.as_ptr())) + .map_err(|error| format!("IShellLinkW::SetPath failed: {error}"))?; + link.SetIconLocation(PCWSTR(executable.as_ptr()), 0) + .map_err(|error| format!("IShellLinkW::SetIconLocation failed: {error}"))?; + } + } + let properties: IPropertyStore = link + .cast() + .map_err(|error| format!("IShellLinkW -> IPropertyStore failed: {error}"))?; + let value = PROPVARIANT::from(app_id); + unsafe { + properties + .SetValue(&PKEY_AppUserModel_ID, &value) + .map_err(|error| format!("IPropertyStore::SetValue failed: {error}"))?; + properties + .Commit() + .map_err(|error| format!("IPropertyStore::Commit failed: {error}"))?; + persist + .Save(PCWSTR(shortcut_path.as_ptr()), true) + .map_err(|error| format!("IPersistFile::Save failed: {error}"))?; + } + Ok(()) + })(); + + unsafe { CoUninitialize() }; + result + } + + fn queue_activation(target: Option) { + let Some(target) = target else { + return; + }; + let queue = PENDING_ACTIVATIONS.get_or_init(Default::default); + let Ok(mut queue) = queue.lock() else { + eprintln!("buzz-desktop: Windows activation queue is unavailable"); + return; + }; + if queue.len() == MAX_PENDING_ACTIVATIONS { + queue.pop_front(); + } + queue.push_back(target); + } + + pub fn take_pending_windows_activations() -> Result, String> { + let queue = PENDING_ACTIVATIONS.get_or_init(Default::default); + let mut queue = queue + .lock() + .map_err(|_| "Windows activation queue is unavailable".to_string())?; + Ok(queue.drain(..).collect()) + } + fn permission_state_label(setting: NotificationSetting) -> &'static str { if setting == NotificationSetting::Enabled { "granted" @@ -193,6 +499,7 @@ mod windows { // _action is None for the default (body) click and // Some(arg) for button clicks. We only use the default // click, matching the Linux behaviour. + queue_activation(target.clone()); let _ = activation_app.emit(NATIVE_NOTIFICATION_ACTIVATED_EVENT, &target); Ok(()) }) @@ -226,5 +533,17 @@ mod windows { assert_eq!(permission_state_label(setting), "denied"); } } + + #[test] + fn activation_queue_is_bounded() { + let _ = take_pending_windows_activations(); + for index in 0..=MAX_PENDING_ACTIVATIONS { + queue_activation(Some(serde_json::json!({ "index": index }))); + } + + let activations = take_pending_windows_activations().expect("activation queue"); + assert_eq!(activations.len(), MAX_PENDING_ACTIVATIONS); + assert_eq!(activations[0]["index"], 1); + } } } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 1bdbed42c2a..ec4e92efd53 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -236,6 +236,7 @@ pub fn run() { .manage(channel_head_cache::ChannelHeadCacheStore::default()) .setup(move |app| { let app_handle = app.handle().clone(); + commands::ensure_startup_registration(&app_handle); #[cfg(target_os = "macos")] { tray_menu::init(&app_handle)?; @@ -663,6 +664,8 @@ pub fn run() { show_native_notification, #[cfg(target_os = "windows")] windows_notification_permission_state, + #[cfg(target_os = "windows")] + take_pending_windows_activations, #[cfg(target_os = "macos")] macos_notifications::take_pending_activations, #[cfg(target_os = "macos")] diff --git a/desktop/src/features/channels/ui/useChannelRouteTarget.test.mjs b/desktop/src/features/channels/ui/useChannelRouteTarget.test.mjs index 52bb5584126..61d8fed7134 100644 --- a/desktop/src/features/channels/ui/useChannelRouteTarget.test.mjs +++ b/desktop/src/features/channels/ui/useChannelRouteTarget.test.mjs @@ -98,3 +98,43 @@ test("timeline intent preserves the exact id of a broadcast reply row", async () hook.unmount(); cleanup(); }); + +test("different view intents are handled independently for the same target", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { useChannelRouteTarget } = await import("./useChannelRouteTarget.ts"); + const calls = []; + const root = { + id: "root", + parentId: null, + rootId: null, + tags: [], + }; + + const hook = renderHook( + ({ targetMessageView }) => + useChannelRouteTarget({ + activeChannel: { id: "channel", channelType: "stream" }, + activeChannelId: "channel", + closeAgentSession: () => {}, + requireThreadEditResolution: () => true, + setEditTargetId: () => {}, + setExpandedThreadReplyIds: () => {}, + setOpenThreadHeadId: () => calls.push("open-thread"), + setProfilePanelPubkey: () => {}, + setThreadReplyTargetId: () => {}, + setThreadScrollTargetId: () => {}, + targetMessageId: "root", + targetMessageView, + timelineMessages: [root], + }), + { initialProps: { targetMessageView: undefined } }, + ); + + assert.deepEqual(calls, ["open-thread"]); + await act(async () => { + hook.rerender({ targetMessageView: "timeline" }); + }); + assert.deepEqual(calls, ["open-thread"]); + hook.unmount(); + cleanup(); +}); diff --git a/desktop/src/features/channels/ui/useChannelRouteTarget.ts b/desktop/src/features/channels/ui/useChannelRouteTarget.ts index 93a0b838f99..2095d6a705e 100644 --- a/desktop/src/features/channels/ui/useChannelRouteTarget.ts +++ b/desktop/src/features/channels/ui/useChannelRouteTarget.ts @@ -103,7 +103,7 @@ export function useChannelRouteTarget({ return; } - const targetKey = `${activeChannelId ?? "none"}:${targetMessageId}`; + const targetKey = `${activeChannelId ?? "none"}:${targetMessageId}:${targetMessageView ?? "default"}`; if (handledThreadRouteTargetRef.current !== targetKey) { handledThreadRouteTargetRef.current = null; } diff --git a/desktop/src/features/messages/lib/channelWindowStore.ts b/desktop/src/features/messages/lib/channelWindowStore.ts index 1bd921aabf4..823c93ddedb 100644 --- a/desktop/src/features/messages/lib/channelWindowStore.ts +++ b/desktop/src/features/messages/lib/channelWindowStore.ts @@ -36,6 +36,8 @@ export type ChannelWindowStore = { liveSummaries: Record; }; +export const MAX_LIVE_WINDOW_EVENTS = 500; + export const emptyChannelWindowStore = (): ChannelWindowStore => ({ pages: [], liveOverlay: [], @@ -194,7 +196,12 @@ export function mergeLiveChannelWindowEvent( ) { return current; } - return { ...current, liveAux: [...current.liveAux, event] }; + return { + ...current, + liveAux: [...current.liveAux, event] + .sort(compareRelayOrder) + .slice(0, MAX_LIVE_WINDOW_EVENTS), + }; } if ( current.pages.some((page) => @@ -217,7 +224,8 @@ export function mergeLiveChannelWindowEvent( liveOverlay: current.liveOverlay .filter((candidate) => candidate.id !== event.id) .concat(event) - .sort(compareRelayOrder), + .sort(compareRelayOrder) + .slice(0, MAX_LIVE_WINDOW_EVENTS), }; } diff --git a/desktop/src/features/messages/lib/projectChannelWindow.test.mjs b/desktop/src/features/messages/lib/projectChannelWindow.test.mjs index 9594b937879..4846acc9711 100644 --- a/desktop/src/features/messages/lib/projectChannelWindow.test.mjs +++ b/desktop/src/features/messages/lib/projectChannelWindow.test.mjs @@ -129,6 +129,21 @@ test("test_live_event_during_fetch_survives_refetch_projection", async () => { assert.deepEqual(contents(harness), ["initial", "during-fetch"]); }); +test("projection does not create a fresh message cache for a background channel", () => { + const client = new QueryClient(); + const channelId = "background-channel"; + const eventToProject = event("background-live", 110); + const window = mergeLiveChannelWindowEvent( + emptyChannelWindowStore(), + eventToProject, + ); + client.setQueryData(channelWindowKey(channelId), window); + + projectChannelWindowMessages(client, channelId); + + assert.equal(client.getQueryData(channelMessagesKey(channelId)), undefined); +}); + test("test_live_event_after_query_resolution_survives_refetch_projection", async () => { const harness = createHarness(); const live = event("post-resolve", 110); diff --git a/desktop/src/features/messages/lib/projectChannelWindow.ts b/desktop/src/features/messages/lib/projectChannelWindow.ts index 8531c867f2b..477972878dd 100644 --- a/desktop/src/features/messages/lib/projectChannelWindow.ts +++ b/desktop/src/features/messages/lib/projectChannelWindow.ts @@ -14,12 +14,20 @@ export function projectChannelWindowMessages( queryClient: QueryClient, channelId: string, ) { + const queryKey = channelMessagesKey(channelId); + const query = queryClient.getQueryCache().find({ + queryKey, + exact: true, + }); + if (!query || query.state.data === undefined) { + return; + } + const window = queryClient.getQueryData(channelWindowKey(channelId)) ?? emptyChannelWindowStore(); - queryClient.setQueryData( - channelMessagesKey(channelId), - (messages = []) => reconcileChannelWindowMessages(window, messages), + queryClient.setQueryData(queryKey, (messages = []) => + reconcileChannelWindowMessages(window, messages), ); } diff --git a/desktop/src/features/notifications/lib/desktop.ts b/desktop/src/features/notifications/lib/desktop.ts index ef6a0e5e53e..0a7eb405233 100644 --- a/desktop/src/features/notifications/lib/desktop.ts +++ b/desktop/src/features/notifications/lib/desktop.ts @@ -16,6 +16,8 @@ import { // queued macOS activation becomes available. See src-tauri notification code. const NATIVE_NOTIFICATION_ACTIVATED_EVENT = "native-notification-activated"; const TAKE_PENDING_MACOS_NOTIFICATION_ACTIVATIONS = "take_pending_activations"; +const TAKE_PENDING_WINDOWS_NOTIFICATION_ACTIVATIONS = + "take_pending_windows_activations"; const MACOS_NOTIFICATION_PERMISSION_STATE = "notification_permission_state"; const REQUEST_MACOS_NOTIFICATION_ACCESS = "request_notification_access"; const WINDOWS_NOTIFICATION_PERMISSION_STATE = @@ -258,8 +260,13 @@ export async function listenForDesktopNotificationActions( if (isTauri()) { const usesMacActivationQueue = isMacPlatform(); + const usesWindowsActivationQueue = isWindowsPlatform(); + const usesActivationQueue = + usesMacActivationQueue || usesWindowsActivationQueue; - if (!isLinuxPlatform() && !isWindowsPlatform() && !usesMacActivationQueue) { + // Keep the plugin action path for other Tauri targets, such as Android; + // Linux, macOS, and Windows use the native event/activation paths above. + if (!isLinuxPlatform() && !usesActivationQueue) { try { pluginListener = await onAction((notification) => { const target = parseNotificationTarget( @@ -279,9 +286,11 @@ export async function listenForDesktopNotificationActions( // Linux forwards the target as the event payload. macOS queues targets in // Rust first so cold-start clicks survive until this listener is mounted. const dispatchNativeActivations = async (payload?: unknown) => { - if (usesMacActivationQueue) { + if (usesActivationQueue) { const targets = await invoke( - TAKE_PENDING_MACOS_NOTIFICATION_ACTIVATIONS, + usesMacActivationQueue + ? TAKE_PENDING_MACOS_NOTIFICATION_ACTIVATIONS + : TAKE_PENDING_WINDOWS_NOTIFICATION_ACTIVATIONS, ); for (const pendingTarget of targets) { const target = parseNotificationTarget(pendingTarget); @@ -314,7 +323,7 @@ export async function listenForDesktopNotificationActions( nativeUnlisten = null; } - if (nativeUnlisten && usesMacActivationQueue) { + if (nativeUnlisten && usesActivationQueue) { try { await dispatchNativeActivations(); } catch (error) { @@ -325,7 +334,7 @@ export async function listenForDesktopNotificationActions( } } - if (usesMacActivationQueue) { + if (usesActivationQueue) { // 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 @@ -335,7 +344,7 @@ export async function listenForDesktopNotificationActions( const redrain = () => { void dispatchNativeActivations().catch((error) => { console.error( - "Failed to drain macOS notification activations on focus", + "Failed to drain pending notification activations on focus", error, ); }); @@ -462,7 +471,15 @@ export async function revealDesktopAppWindow(): Promise { export async function sendDesktopNotification( payload: DesktopNotificationPayload, ): Promise { - if ((await getDesktopNotificationPermissionState()) !== "granted") { + let permission: DesktopNotificationPermissionState; + try { + permission = await getDesktopNotificationPermissionState(); + } catch (error) { + console.warn("Failed to determine desktop notification permission", error); + return false; + } + + if (permission !== "granted") { return false; } diff --git a/desktop/src/features/notifications/lib/desktopWindowsPermission.test.mjs b/desktop/src/features/notifications/lib/desktopWindowsPermission.test.mjs index 54d8e15d8c9..4d30bf4faf3 100644 --- a/desktop/src/features/notifications/lib/desktopWindowsPermission.test.mjs +++ b/desktop/src/features/notifications/lib/desktopWindowsPermission.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; let nativePermission = "denied"; let nativeQueries = 0; +let nativePermissionError = false; const testWindow = { Notification: Object.assign(function StubNotification() {}, { permission: "denied", @@ -11,6 +12,9 @@ const testWindow = { invoke(command) { if (command === "windows_notification_permission_state") { nativeQueries++; + if (nativePermissionError) { + return Promise.reject(new Error("Windows permission query failed")); + } return Promise.resolve(nativePermission); } return Promise.reject(new Error(`unexpected command: ${command}`)); @@ -52,3 +56,13 @@ test("Windows access requests re-query the native setting", async () => { assert.equal(await requestDesktopNotificationAccess(), "granted"); assert.equal(nativeQueries, before + 1); }); + +test("Windows delivery returns false when the native permission query fails", async (t) => { + const { sendDesktopNotification } = await import("./desktop.ts"); + t.mock.method(console, "warn", () => {}); + nativePermissionError = true; + + assert.equal(await sendDesktopNotification({ title: "Unavailable" }), false); + + nativePermissionError = false; +}); diff --git a/desktop/src/features/notifications/use-feed-desktop-notifications.test.mjs b/desktop/src/features/notifications/use-feed-desktop-notifications.test.mjs index 93d2f4ad0af..2f7ef3f651d 100644 --- a/desktop/src/features/notifications/use-feed-desktop-notifications.test.mjs +++ b/desktop/src/features/notifications/use-feed-desktop-notifications.test.mjs @@ -124,7 +124,61 @@ test("a concurrent feed batch joins the pending permission request", async () => assert.deepEqual(await Promise.all([first, second]), ["granted", "granted"]); }); -test("a delivery failure remains unseen and retries on the next feed result", async (t) => { +test("the production permission request is single-flight across feed batches", async () => { + const { requestDesktopNotificationAccess } = await import("./lib/desktop.ts"); + const previousInternals = window.__TAURI_INTERNALS__; + const previousIsTauri = globalThis.isTauri; + const previousNotification = window.Notification; + const previousPlatform = navigator.platform; + let releaseRequest; + let requestCalls = 0; + const request = new Promise((resolve) => { + releaseRequest = resolve; + }); + + Object.defineProperty(window, "Notification", { + configurable: true, + value: { permission: "default" }, + }); + globalThis.isTauri = true; + Object.defineProperty(navigator, "platform", { + configurable: true, + value: "Win32", + }); + window.__TAURI_INTERNALS__ = { + invoke(command) { + assert.equal(command, "windows_notification_permission_state"); + requestCalls += 1; + return request; + }, + }; + + try { + const first = requestDesktopNotificationAccess(); + await Promise.resolve(); + const second = requestDesktopNotificationAccess(); + + assert.equal(requestCalls, 1); + releaseRequest("granted"); + assert.deepEqual(await Promise.all([first, second]), [ + "granted", + "granted", + ]); + } finally { + window.__TAURI_INTERNALS__ = previousInternals; + globalThis.isTauri = previousIsTauri; + Object.defineProperty(window, "Notification", { + configurable: true, + value: previousNotification, + }); + Object.defineProperty(navigator, "platform", { + configurable: true, + value: previousPlatform, + }); + } +}); + +test("a delivery failure remains retryable across a hook remount", async (t) => { t.mock.method(console, "warn", () => {}); const { act, cleanup, renderHook } = await import("@testing-library/react"); const { useFeedDesktopNotifications } = await import( @@ -199,8 +253,15 @@ test("a delivery failure remains unseen and retries on the next feed result", as [], ); + hook.unmount(); + cleanup(); + shouldFail = false; - hook.rerender({ feed: { feed: { mentions: [item], needsAction: [] } } }); + const remountedHook = renderHook(({ feed }) => render(feed), { + initialProps: { + feed: { feed: { mentions: [item], needsAction: [] } }, + }, + }); await settle(); assert.deepEqual(delivered, ["retry-alert"]); assert.deepEqual( @@ -208,6 +269,6 @@ test("a delivery failure remains unseen and retries on the next feed result", as ["retry-alert"], ); - hook.unmount(); + remountedHook.unmount(); cleanup(); }); diff --git a/desktop/src/features/notifications/use-feed-desktop-notifications.ts b/desktop/src/features/notifications/use-feed-desktop-notifications.ts index dfa6be1c46b..c79035bd208 100644 --- a/desktop/src/features/notifications/use-feed-desktop-notifications.ts +++ b/desktop/src/features/notifications/use-feed-desktop-notifications.ts @@ -27,12 +27,17 @@ import { import type { NotificationSettings } from "./hooks"; const HOME_FEED_SEEN_STORAGE_KEY = "buzz-home-feed-seen.v1"; +const HOME_FEED_RETRY_STORAGE_KEY = "buzz-home-feed-retry.v1"; const HOME_FEED_SEEN_MAX_ITEMS = 500; function homeFeedSeenStorageKey(pubkey: string) { return `${HOME_FEED_SEEN_STORAGE_KEY}:${pubkey}`; } +function homeFeedRetryStorageKey(pubkey: string) { + return `${HOME_FEED_RETRY_STORAGE_KEY}:${pubkey}`; +} + export function readStoredSeenFeedIds(pubkey: string): string[] { if (typeof window === "undefined" || pubkey.length === 0) { return []; @@ -68,6 +73,41 @@ export function writeStoredSeenFeedIds(pubkey: string, ids: string[]) { ); } +export function readStoredRetryFeedIds(pubkey: string): string[] { + if (typeof window === "undefined" || pubkey.length === 0) { + return []; + } + + const rawValue = window.localStorage.getItem(homeFeedRetryStorageKey(pubkey)); + if (!rawValue) { + return []; + } + + try { + const parsed = JSON.parse(rawValue); + if (!Array.isArray(parsed)) { + return []; + } + + return parsed + .filter((value): value is string => typeof value === "string") + .slice(-HOME_FEED_SEEN_MAX_ITEMS); + } catch { + return []; + } +} + +export function writeStoredRetryFeedIds(pubkey: string, ids: string[]) { + if (typeof window === "undefined" || pubkey.length === 0) { + return; + } + + window.localStorage.setItem( + homeFeedRetryStorageKey(pubkey), + JSON.stringify(ids.slice(-HOME_FEED_SEEN_MAX_ITEMS)), + ); +} + export type FeedNotificationPermissionOutcome = "granted" | "denied" | "error"; export type FeedNotificationBatchResult = { @@ -160,6 +200,9 @@ export function useFeedDesktopNotifications( const seenItemIdsRef = React.useRef>( new Set(readStoredSeenFeedIds(normalizedPubkey)), ); + const retryItemIdsRef = React.useRef>( + new Set(readStoredRetryFeedIds(normalizedPubkey)), + ); const hasInitializedFeedRef = React.useRef(false); const permissionAttemptRef = React.useRef({ hasRequested: false }); const inFlightItemIdsRef = React.useRef(new Set()); @@ -167,6 +210,7 @@ export function useFeedDesktopNotifications( React.useEffect(() => { seenItemIdsRef.current = new Set(readStoredSeenFeedIds(normalizedPubkey)); + retryItemIdsRef.current = new Set(readStoredRetryFeedIds(normalizedPubkey)); hasInitializedFeedRef.current = false; permissionAttemptRef.current.hasRequested = false; inFlightItemIdsRef.current.clear(); @@ -180,6 +224,15 @@ export function useFeedDesktopNotifications( }; }, [normalizedPubkey]); + React.useEffect(() => { + if (enabled) { + return; + } + + notificationGenerationRef.current += 1; + inFlightItemIdsRef.current.clear(); + }, [enabled]); + const autoRequestPermissionIfNeeded = React.useEffectEvent(() => ensureFeedNotificationPermission( permissionAttemptRef.current, @@ -225,11 +278,12 @@ export function useFeedDesktopNotifications( hasInitializedFeedRef.current = true; if (currentFeedItems.length > 0) { seenItemIdsRef.current = new Set( - currentFeedItems.map((item) => item.id), + currentFeedItems + .filter((item) => !retryItemIdsRef.current.has(item.id)) + .map((item) => item.id), ); writeStoredSeenFeedIds(normalizedPubkey, [...seenItemIdsRef.current]); } - return; } const nextSeenItemIds = new Set(seenItemIdsRef.current); @@ -256,7 +310,8 @@ export function useFeedDesktopNotifications( for (const item of currentFeedItems) { if ( !pendingItemIds.has(item.id) && - !inFlightItemIdsRef.current.has(item.id) + !inFlightItemIdsRef.current.has(item.id) && + !retryItemIdsRef.current.has(item.id) ) { nextSeenItemIds.add(item.id); } @@ -279,12 +334,22 @@ export function useFeedDesktopNotifications( if (newItems.length > 0) { for (const item of newItems) { inFlightItemIdsRef.current.add(item.id); + retryItemIdsRef.current.add(item.id); } + writeStoredRetryFeedIds(normalizedPubkey, [...retryItemIdsRef.current]); const generation = notificationGenerationRef.current; void deliverFeedNotificationBatch( newItems, - autoRequestPermissionIfNeeded, + async () => { + if (!enabled || generation !== notificationGenerationRef.current) { + return "error"; + } + return autoRequestPermissionIfNeeded(); + }, async (item) => { + if (!enabled || generation !== notificationGenerationRef.current) { + return false; + } const resolvedLabel = profiles ? resolveUserLabel({ pubkey: item.pubkey, @@ -306,6 +371,13 @@ export function useFeedDesktopNotifications( for (const item of newItems) { inFlightItemIdsRef.current.delete(item.id); } + for (const id of result.handledIds) { + retryItemIdsRef.current.delete(id); + } + for (const id of result.retryableIds) { + retryItemIdsRef.current.add(id); + } + writeStoredRetryFeedIds(normalizedPubkey, [...retryItemIdsRef.current]); if (result.handledIds.length === 0) { return; } From e5201439f1ba1b2e30dd11cb6db31b3ea1084549 Mon Sep 17 00:00:00 2001 From: Bernhard Kaindl Date: Sun, 13 Sep 2026 18:26:57 +0200 Subject: [PATCH 6/7] fix(desktop): gate Windows toast registration at startup Signed-off-by: Bernhard Kaindl --- .../src-tauri/src/commands/notifications.rs | 49 ++++++++----------- desktop/src-tauri/src/lib.rs | 2 +- 2 files changed, 22 insertions(+), 29 deletions(-) diff --git a/desktop/src-tauri/src/commands/notifications.rs b/desktop/src-tauri/src/commands/notifications.rs index a60a8db40a3..fb3fa526942 100644 --- a/desktop/src-tauri/src/commands/notifications.rs +++ b/desktop/src-tauri/src/commands/notifications.rs @@ -52,12 +52,14 @@ pub async fn show_native_notification( } #[cfg(target_os = "windows")] -pub(crate) fn ensure_startup_registration(app: &tauri::AppHandle) { - windows::ensure_startup_registration(app); +pub(crate) fn ensure_startup_registration(app: &tauri::AppHandle) -> Result<(), String> { + windows::ensure_startup_registration(app) } #[cfg(not(target_os = "windows"))] -pub(crate) fn ensure_startup_registration(_app: &tauri::AppHandle) {} +pub(crate) fn ensure_startup_registration(_app: &tauri::AppHandle) -> Result<(), String> { + Ok(()) +} #[cfg(target_os = "windows")] #[tauri::command] @@ -146,7 +148,7 @@ mod linux { mod windows { use super::NATIVE_NOTIFICATION_ACTIVATED_EVENT; use std::collections::VecDeque; - use std::sync::{Mutex, Once, OnceLock}; + use std::sync::{Mutex, OnceLock}; use tauri::Emitter; use tauri_winrt_notification::{Duration, Toast}; use windows::{ @@ -155,45 +157,36 @@ mod windows { }; const MAX_PENDING_ACTIVATIONS: usize = 64; - static STARTUP_REGISTRATION: Once = Once::new(); + static STARTUP_REGISTRATION: OnceLock> = OnceLock::new(); static PENDING_ACTIVATIONS: OnceLock>> = OnceLock::new(); - pub fn ensure_startup_registration(app: &tauri::AppHandle) { - STARTUP_REGISTRATION.call_once(|| { - let app = app.clone(); - let (ready_sender, ready_receiver) = std::sync::mpsc::sync_channel(1); - std::thread::spawn(move || { + pub fn ensure_startup_registration(app: &tauri::AppHandle) -> Result<(), String> { + STARTUP_REGISTRATION + .get_or_init(|| { let app_id = app.config().identifier.clone(); - set_process_aumid(&app_id); - if let Err(error) = write_aumid_registry_entry(&app, &app_id) { - eprintln!("buzz-desktop: failed to register Windows AUMID: {error}"); - } - if let Err(error) = write_notification_settings_entry(&app_id) { - eprintln!( - "buzz-desktop: failed to register Windows notification settings: {error}" - ); - } - if let Err(error) = ensure_start_menu_shortcut(&app, &app_id) { - eprintln!("buzz-desktop: failed to repair Windows shortcut AUMID: {error}"); - } - let _ = ready_sender.send(()); - }); - let _ = ready_receiver.recv(); - }); + set_process_aumid(&app_id)?; + write_aumid_registry_entry(app, &app_id)?; + write_notification_settings_entry(&app_id)?; + ensure_start_menu_shortcut(app, &app_id) + }) + .clone() } fn to_wide(value: &str) -> Vec { value.encode_utf16().chain(std::iter::once(0)).collect() } - fn set_process_aumid(app_id: &str) { + fn set_process_aumid(app_id: &str) -> Result<(), String> { use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID; let app_id = to_wide(app_id); let result = unsafe { SetCurrentProcessExplicitAppUserModelID(app_id.as_ptr()) }; if result < 0 { - eprintln!("buzz-desktop: failed to set Windows process AUMID: 0x{result:08X}"); + return Err(format!( + "failed to set Windows process AUMID: 0x{result:08X}" + )); } + Ok(()) } fn write_notification_settings_entry(app_id: &str) -> Result<(), String> { diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index ec4e92efd53..be0bb28a49b 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -236,7 +236,7 @@ pub fn run() { .manage(channel_head_cache::ChannelHeadCacheStore::default()) .setup(move |app| { let app_handle = app.handle().clone(); - commands::ensure_startup_registration(&app_handle); + commands::ensure_startup_registration(&app_handle).map_err(std::io::Error::other)?; #[cfg(target_os = "macos")] { tray_menu::init(&app_handle)?; From 35892267adecfe00c091baaac0c388a20c2b9ffa Mon Sep 17 00:00:00 2001 From: Bernhard Kaindl Date: Sun, 13 Sep 2026 19:52:45 +0200 Subject: [PATCH 7/7] fix(desktop): use safe Windows registration and durable notification delivery Replace raw notification registration FFI with WinSafe and forbid unsafe code in the notification module. Preserve existing notification opt-outs and shortcut arguments, with native regression tests. Fence feed and live deliveries on current settings, persist bounded DM/thread retry records, and test Windows activation drains even when listener setup fails. Revert the AGENTS.md addition from 91c38097; local Windows guidance stays excluded. Signed-off-by: Bernhard Kaindl --- AGENTS.md | 8 - desktop/src-tauri/Cargo.lock | 9 +- desktop/src-tauri/Cargo.toml | 5 +- .../src-tauri/src/commands/notifications.rs | 449 ++++++++++-------- .../app/useAppShellDesktopNotifications.ts | 86 ++-- .../src/features/notifications/lib/desktop.ts | 13 +- .../lib/desktopActivations.test.mjs | 73 ++- .../use-feed-desktop-notifications.test.mjs | 98 ++++ .../use-feed-desktop-notifications.ts | 34 +- .../useLiveNotificationDelivery.test.mjs | 194 ++++++++ .../useLiveNotificationDelivery.ts | 202 ++++++++ 11 files changed, 890 insertions(+), 281 deletions(-) create mode 100644 desktop/src/features/notifications/useLiveNotificationDelivery.test.mjs create mode 100644 desktop/src/features/notifications/useLiveNotificationDelivery.ts diff --git a/AGENTS.md b/AGENTS.md index df43b0c1552..26794990124 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -142,14 +142,6 @@ lane subprocesses resolve the pinned flutter/dart/lefthook even when an unactivated shell has Homebrew first. Activating Hermit remains recommended for non-hook commands. -**Windows tool resolution:** the committed files under `bin\` (for example -`bin\cargo`, `bin\rustc`, `bin\pnpm`, and `bin\node`) are Hermit package -entries, not native Windows executables. Do not invoke those paths directly, -and do not assume a bare command is valid when `Get-Command` resolves it into -the repository's `bin\` directory. For Windows validation, run the actual -installed `.exe`/`.cmd` tools, or use their full paths, and verify the resolved -`Source` is not under this repository before running a build or test. - **Commit with `git commit -s`.** The required **DCO Check** fails any PR with a commit missing a `Signed-off-by` trailer, and `just hooks` installs a `commit-msg` hook that adds it to commits you create locally (`git rebase` and `git cherry-pick` still need `--signoff`) — if you build commit commands programmatically, include `-s` every time. To repair a branch that already has unsigned commits: `git rebase --signoff main`, then force-push. Additional rules: diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index dc3ef859fc6..e18123f6c5f 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1173,6 +1173,7 @@ dependencies = [ "window-vibrancy", "windows 0.61.3", "windows-sys 0.61.2", + "winsafe", "zeroize", "zip 8.6.0", ] @@ -6552,7 +6553,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate 2.0.2", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.118", @@ -13192,6 +13193,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "winsafe" +version = "0.0.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef0ffc427f045c0cc9ebffd6f4f91153dcd2a1547b5c3c29ee3f541e16e95c6" + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 78978f02ee4..fea04667f2f 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -63,13 +63,14 @@ user-idle = { version = "0.6", default-features = false } plist = "1" [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_Threading", "Win32_Foundation", "Win32_UI_Shell"] } +windows-sys = { version = "0.61", features = ["Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_Threading", "Win32_Foundation"] } keyring = { version = "3.6.3", default-features = false, features = ["windows-native", "vendored"], optional = true } user-idle = { version = "0.6", default-features = false } # Native Windows toast notifications so the app registers with Windows Settings # > System > Notifications and click actions work through WinRT. tauri-winrt-notification = "0.7" -windows = { version = "0.61", features = ["UI_Notifications", "Win32_Foundation", "Win32_Storage_EnhancedStorage", "Win32_System_Com", "Win32_System_Com_StructuredStorage", "Win32_UI_Shell", "Win32_UI_Shell_PropertiesSystem"] } +winsafe = { version = "0.0.29", features = ["advapi", "shell"] } +windows = { version = "0.61", features = ["UI_Notifications"] } [dependencies] atomic-write-file = "0.3" diff --git a/desktop/src-tauri/src/commands/notifications.rs b/desktop/src-tauri/src/commands/notifications.rs index fb3fa526942..d778e438d0b 100644 --- a/desktop/src-tauri/src/commands/notifications.rs +++ b/desktop/src-tauri/src/commands/notifications.rs @@ -13,6 +13,8 @@ //! action, which we forward to the frontend so it can focus the window and //! route to the notification target. +#![forbid(unsafe_code)] + pub(crate) const NATIVE_NOTIFICATION_ACTIVATED_EVENT: &str = "native-notification-activated"; /// Show a desktop notification natively. @@ -155,6 +157,7 @@ mod windows { core::HSTRING, UI::Notifications::{NotificationSetting, ToastNotificationManager}, }; + use winsafe::{co, prelude::*, IPersistFile, IPropertyStore, IShellLink, RegistryValue, HKEY}; const MAX_PENDING_ACTIVATIONS: usize = 64; static STARTUP_REGISTRATION: OnceLock> = OnceLock::new(); @@ -172,173 +175,76 @@ mod windows { .clone() } - fn to_wide(value: &str) -> Vec { - value.encode_utf16().chain(std::iter::once(0)).collect() - } - fn set_process_aumid(app_id: &str) -> Result<(), String> { - use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID; - - let app_id = to_wide(app_id); - let result = unsafe { SetCurrentProcessExplicitAppUserModelID(app_id.as_ptr()) }; - if result < 0 { - return Err(format!( - "failed to set Windows process AUMID: 0x{result:08X}" - )); - } - Ok(()) + winsafe::SetCurrentProcessExplicitAppUserModelID(app_id) + .map_err(|error| format!("failed to set Windows process AUMID: {error}")) } fn write_notification_settings_entry(app_id: &str) -> Result<(), String> { - use windows_sys::Win32::System::Registry::{ - RegCloseKey, RegCreateKeyExW, RegOpenKeyExW, RegSetValueExW, HKEY, HKEY_CURRENT_USER, - KEY_READ, KEY_WRITE, REG_DWORD, REG_OPTION_NON_VOLATILE, - }; - - let subkey = to_wide(&format!( + initialize_notification_settings(&format!( "Software\\Microsoft\\Windows\\CurrentVersion\\Notifications\\Settings\\{app_id}" - )); - unsafe { - let mut existing: HKEY = std::ptr::null_mut(); - if RegOpenKeyExW( - HKEY_CURRENT_USER, - subkey.as_ptr(), - 0, - KEY_READ, - &mut existing, - ) == 0 - { - RegCloseKey(existing); - return Ok(()); - } - - let mut key: HKEY = std::ptr::null_mut(); - let status = RegCreateKeyExW( - HKEY_CURRENT_USER, - subkey.as_ptr(), - 0, - std::ptr::null(), - REG_OPTION_NON_VOLATILE, - KEY_WRITE, - std::ptr::null(), - &mut key, - std::ptr::null_mut(), - ); - if status != 0 { - return Err(format!("RegCreateKeyExW failed with status {status}")); - } + )) + } - let show_name = to_wide("ShowInActionCenter"); - let enabled_name = to_wide("Enabled"); - let value: u32 = 1; - let show_status = RegSetValueExW( - key, - show_name.as_ptr(), - 0, - REG_DWORD, - (&value as *const u32).cast(), - std::mem::size_of::() as u32, - ); - let enabled_status = RegSetValueExW( - key, - enabled_name.as_ptr(), - 0, - REG_DWORD, - (&value as *const u32).cast(), - std::mem::size_of::() as u32, - ); - RegCloseKey(key); - if show_status != 0 { - return Err(format!( - "RegSetValueExW(ShowInActionCenter) failed: {show_status}" - )); - } - if enabled_status != 0 { - return Err(format!("RegSetValueExW(Enabled) failed: {enabled_status}")); - } + fn initialize_notification_settings(subkey: &str) -> Result<(), String> { + let (key, disposition) = HKEY::CURRENT_USER + .RegCreateKeyEx( + subkey, + None, + co::REG_OPTION::NON_VOLATILE, + co::KEY::WRITE, + None, + ) + .map_err(|error| format!("could not open Windows notification settings: {error}"))?; + if disposition == co::REG_DISPOSITION::OPENED_EXISTING_KEY { + return Ok(()); + } + for name in ["ShowInActionCenter", "Enabled"] { + key.RegSetValueEx(Some(name), RegistryValue::Dword(1)) + .map_err(|error| { + format!("could not initialize notification setting {name}: {error}") + })?; } Ok(()) } fn write_aumid_registry_entry(app: &tauri::AppHandle, app_id: &str) -> Result<(), String> { - use windows_sys::Win32::System::Registry::{ - RegCloseKey, RegCreateKeyExW, RegSetValueExW, HKEY, HKEY_CURRENT_USER, KEY_WRITE, - REG_OPTION_NON_VOLATILE, REG_SZ, - }; - let display_name = app .config() .product_name .clone() .unwrap_or_else(|| "Buzz".to_string()); let icon_path = std::env::current_exe() - .map_err(|error| format!("could not resolve current executable: {error}"))? - .to_string_lossy() - .into_owned(); - let subkey = to_wide(&format!("Software\\Classes\\AppUserModelId\\{app_id}")); - let display_name_key = to_wide("DisplayName"); - let display_name_value = to_wide(&display_name); - let icon_key = to_wide("IconUri"); - let icon_value = to_wide(&icon_path); - - unsafe { - let mut key: HKEY = std::ptr::null_mut(); - let status = RegCreateKeyExW( - HKEY_CURRENT_USER, - subkey.as_ptr(), - 0, - std::ptr::null(), - REG_OPTION_NON_VOLATILE, - KEY_WRITE, - std::ptr::null(), - &mut key, - std::ptr::null_mut(), - ); - if status != 0 { - return Err(format!("RegCreateKeyExW failed with status {status}")); - } - let display_status = RegSetValueExW( - key, - display_name_key.as_ptr(), - 0, - REG_SZ, - display_name_value.as_ptr().cast(), - (display_name_value.len() * 2) as u32, - ); - let icon_status = RegSetValueExW( - key, - icon_key.as_ptr(), - 0, - REG_SZ, - icon_value.as_ptr().cast(), - (icon_value.len() * 2) as u32, - ); - RegCloseKey(key); - if display_status != 0 { - return Err(format!( - "RegSetValueExW(DisplayName) failed: {display_status}" - )); - } - if icon_status != 0 { - return Err(format!("RegSetValueExW(IconUri) failed: {icon_status}")); - } + .map_err(|error| format!("could not resolve current executable: {error}"))?; + write_aumid_metadata( + &format!("Software\\Classes\\AppUserModelId\\{app_id}"), + &display_name, + &icon_path.to_string_lossy(), + ) + } + + fn write_aumid_metadata( + subkey: &str, + display_name: &str, + icon_path: &str, + ) -> Result<(), String> { + let (key, _) = HKEY::CURRENT_USER + .RegCreateKeyEx( + subkey, + None, + co::REG_OPTION::NON_VOLATILE, + co::KEY::WRITE, + None, + ) + .map_err(|error| format!("could not open Windows AUMID metadata: {error}"))?; + for (name, value) in [("DisplayName", display_name), ("IconUri", icon_path)] { + key.RegSetValueEx(Some(name), RegistryValue::Sz(value.to_string())) + .map_err(|error| format!("could not write AUMID {name}: {error}"))?; } Ok(()) } fn ensure_start_menu_shortcut(app: &tauri::AppHandle, app_id: &str) -> Result<(), String> { - use windows::core::{Interface, PCWSTR}; - use windows::Win32::Storage::EnhancedStorage::PKEY_AppUserModel_ID; - use windows::Win32::System::Com::StructuredStorage::PROPVARIANT; - use windows::Win32::System::Com::{ - CoCreateInstance, CoInitializeEx, CoTaskMemFree, CoUninitialize, IPersistFile, - CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED, STGM_READWRITE, - }; - use windows::Win32::UI::Shell::{ - FOLDERID_Programs, IShellLinkW, PropertiesSystem::IPropertyStore, SHGetKnownFolderPath, - ShellLink, KF_FLAG_CREATE, - }; - let product_name = app .config() .product_name @@ -346,72 +252,71 @@ mod windows { .unwrap_or_else(|| "Buzz".to_string()); let executable = std::env::current_exe() .map_err(|error| format!("could not resolve current executable: {error}"))?; - let initialized = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) }; - if initialized.is_err() { - return Err(format!("CoInitializeEx failed: {initialized:?}")); - } - - let result = (|| -> Result<(), String> { - let programs_path_ptr = unsafe { - SHGetKnownFolderPath(&FOLDERID_Programs, KF_FLAG_CREATE, None) - .map_err(|error| format!("SHGetKnownFolderPath failed: {error}"))? - }; - let programs_path_result = unsafe { programs_path_ptr.to_string() }; - unsafe { - CoTaskMemFree(Some(programs_path_ptr.0.cast())); - } - let programs_path = programs_path_result - .map_err(|error| format!("invalid Start Menu path: {error}"))?; - - let subfolder = format!("{programs_path}\\{product_name}"); - let nested_path = format!("{subfolder}\\{product_name}.lnk"); - let flat_path = format!("{programs_path}\\{product_name}.lnk"); - let shortcut_path = if std::path::Path::new(&nested_path).exists() { - nested_path - } else if std::path::Path::new(&flat_path).exists() { - flat_path - } else { - std::fs::create_dir_all(&subfolder) - .map_err(|error| format!("could not create Start Menu folder: {error}"))?; - nested_path - }; - let shortcut_path = to_wide(&shortcut_path); - let executable = to_wide(&executable.to_string_lossy()); - let link: IShellLinkW = unsafe { - CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER) - .map_err(|error| format!("CoCreateInstance(ShellLink) failed: {error}"))? - }; - let persist: IPersistFile = link - .cast() - .map_err(|error| format!("IShellLinkW -> IPersistFile failed: {error}"))?; - if unsafe { persist.Load(PCWSTR(shortcut_path.as_ptr()), STGM_READWRITE) }.is_err() { - unsafe { - link.SetPath(PCWSTR(executable.as_ptr())) - .map_err(|error| format!("IShellLinkW::SetPath failed: {error}"))?; - link.SetIconLocation(PCWSTR(executable.as_ptr()), 0) - .map_err(|error| format!("IShellLinkW::SetIconLocation failed: {error}"))?; - } - } - let properties: IPropertyStore = link - .cast() - .map_err(|error| format!("IShellLinkW -> IPropertyStore failed: {error}"))?; - let value = PROPVARIANT::from(app_id); - unsafe { - properties - .SetValue(&PKEY_AppUserModel_ID, &value) - .map_err(|error| format!("IPropertyStore::SetValue failed: {error}"))?; - properties - .Commit() - .map_err(|error| format!("IPropertyStore::Commit failed: {error}"))?; - persist - .Save(PCWSTR(shortcut_path.as_ptr()), true) - .map_err(|error| format!("IPersistFile::Save failed: {error}"))?; - } - Ok(()) - })(); + let programs = + winsafe::SHGetKnownFolderPath(&co::KNOWNFOLDERID::Programs, co::KF::CREATE, None) + .map_err(|error| format!("could not find Start Menu programs: {error}"))?; + ensure_shortcut_in( + std::path::Path::new(&programs), + &product_name, + &executable, + app_id, + ) + } - unsafe { CoUninitialize() }; - result + fn ensure_shortcut_in( + programs: &std::path::Path, + product_name: &str, + executable: &std::path::Path, + app_id: &str, + ) -> Result<(), String> { + let _apartment = winsafe::CoInitializeEx(co::COINIT::APARTMENTTHREADED) + .map_err(|error| format!("could not initialize shortcut COM apartment: {error}"))?; + let subfolder = programs.join(product_name); + let nested_path = subfolder.join(format!("{product_name}.lnk")); + let flat_path = programs.join(format!("{product_name}.lnk")); + let shortcut_path = if nested_path.exists() { + nested_path + } else if flat_path.exists() { + flat_path + } else { + std::fs::create_dir_all(&subfolder) + .map_err(|error| format!("could not create Start Menu folder: {error}"))?; + nested_path + }; + let link = winsafe::CoCreateInstance::( + &co::CLSID::ShellLink, + None::<&winsafe::IUnknown>, + co::CLSCTX::INPROC_SERVER, + ) + .map_err(|error| format!("could not create ShellLink: {error}"))?; + let persist: IPersistFile = link + .QueryInterface() + .map_err(|error| format!("could not query shortcut persistence: {error}"))?; + if shortcut_path.exists() { + persist + .Load(&shortcut_path.to_string_lossy(), co::STGM::READWRITE) + .map_err(|error| format!("could not load Start Menu shortcut: {error}"))?; + } else { + link.SetPath(&executable.to_string_lossy()) + .map_err(|error| format!("could not set shortcut path: {error}"))?; + link.SetIconLocation(&executable.to_string_lossy(), 0) + .map_err(|error| format!("could not set shortcut icon: {error}"))?; + } + let properties: IPropertyStore = link + .QueryInterface() + .map_err(|error| format!("could not query shortcut property store: {error}"))?; + properties + .SetValue( + &co::PKEY::AppUserModel_ID, + &winsafe::PropVariant::from_str(app_id), + ) + .map_err(|error| format!("could not set shortcut AUMID: {error}"))?; + properties + .Commit() + .map_err(|error| format!("could not commit shortcut properties: {error}"))?; + persist + .Save(Some(&shortcut_path.to_string_lossy()), true) + .map_err(|error| format!("could not save Start Menu shortcut: {error}")) } fn queue_activation(target: Option) { @@ -511,6 +416,130 @@ mod windows { mod tests { use super::*; + struct TestRegistryKey(String); + + impl TestRegistryKey { + fn new() -> Self { + Self(format!( + "Software\\BuzzNotificationTest-{}", + uuid::Uuid::new_v4() + )) + } + + fn open(&self) -> winsafe::guard::RegCloseKeyGuard { + HKEY::CURRENT_USER + .RegOpenKeyEx( + Some(&self.0), + co::REG_OPTION::default(), + co::KEY::READ | co::KEY::WRITE, + ) + .expect("open isolated test key") + } + } + + impl Drop for TestRegistryKey { + fn drop(&mut self) { + HKEY::CURRENT_USER + .RegDeleteTree(Some(&self.0)) + .expect("remove isolated test key"); + } + } + + #[test] + fn notification_settings_preserve_existing_opt_out() { + let test_key = TestRegistryKey::new(); + initialize_notification_settings(&test_key.0).expect("initialize settings"); + let key = test_key.open(); + for name in ["Enabled", "ShowInActionCenter"] { + assert!(matches!( + key.RegQueryValueEx(Some(name)), + Ok(RegistryValue::Dword(1)) + )); + key.RegSetValueEx(Some(name), RegistryValue::Dword(0)) + .expect("disable setting"); + } + initialize_notification_settings(&test_key.0).expect("repair settings"); + for name in ["Enabled", "ShowInActionCenter"] { + assert!(matches!( + key.RegQueryValueEx(Some(name)), + Ok(RegistryValue::Dword(0)) + )); + } + } + + #[test] + fn aumid_metadata_is_repaired() { + let test_key = TestRegistryKey::new(); + write_aumid_metadata(&test_key.0, "Old Buzz", "old.exe").expect("initial metadata"); + write_aumid_metadata(&test_key.0, "Buzz", "new.exe").expect("repair metadata"); + let key = test_key.open(); + assert!( + matches!(key.RegQueryValueEx(Some("DisplayName")), Ok(RegistryValue::Sz(value)) if value == "Buzz") + ); + assert!( + matches!(key.RegQueryValueEx(Some("IconUri")), Ok(RegistryValue::Sz(value)) if value == "new.exe") + ); + } + + #[test] + fn shortcut_aumid_is_repaired_without_changing_arguments() { + let directory = tempfile::tempdir().expect("temporary programs folder"); + let executable = std::env::current_exe().expect("test executable"); + ensure_shortcut_in(directory.path(), "Buzz", &executable, "buzz.test.old") + .expect("create shortcut"); + let path = directory.path().join("Buzz").join("Buzz.lnk"); + let _apartment = + winsafe::CoInitializeEx(co::COINIT::APARTMENTTHREADED).expect("COM apartment"); + let load = || { + let link = winsafe::CoCreateInstance::( + &co::CLSID::ShellLink, + None::<&winsafe::IUnknown>, + co::CLSCTX::INPROC_SERVER, + ) + .expect("ShellLink"); + link.QueryInterface::() + .expect("persist") + .Load(&path.to_string_lossy(), co::STGM::READWRITE) + .expect("load shortcut"); + link + }; + { + let link = load(); + link.SetArguments("--preserve-this").expect("arguments"); + link.QueryInterface::() + .expect("persist") + .Save(Some(&path.to_string_lossy()), true) + .expect("save arguments"); + } + ensure_shortcut_in(directory.path(), "Buzz", &executable, "buzz.test.new") + .expect("repair shortcut"); + let link = load(); + assert_eq!( + link.GetArguments().expect("read arguments"), + "--preserve-this" + ); + let value = link + .QueryInterface::() + .expect("properties") + .GetValue(&co::PKEY::AppUserModel_ID) + .expect("read AUMID"); + assert!(matches!(value, winsafe::PropVariant::Bstr(value) if value == "buzz.test.new")); + } + + #[test] + fn shortcut_failure_is_propagated() { + let directory = tempfile::tempdir().expect("temporary programs folder"); + std::fs::write(directory.path().join("Buzz"), "blocks directory creation") + .expect("block shortcut folder"); + assert!(ensure_shortcut_in( + directory.path(), + "Buzz", + std::path::Path::new("buzz.exe"), + "buzz.test" + ) + .is_err()); + } + #[test] fn only_enabled_notification_setting_is_granted() { assert_eq!( diff --git a/desktop/src/app/useAppShellDesktopNotifications.ts b/desktop/src/app/useAppShellDesktopNotifications.ts index cf1204f2f9c..401ee7d9967 100644 --- a/desktop/src/app/useAppShellDesktopNotifications.ts +++ b/desktop/src/app/useAppShellDesktopNotifications.ts @@ -6,14 +6,13 @@ import { shouldBounceForChannelNotification, } from "@/app/AppShell.helpers"; import { useCommunityJoinAlerts } from "@/features/community-members/useCommunityJoinAlerts"; +import { useCommunities } from "@/features/communities/useCommunities"; import { hasMentionForEvent } from "@/features/notifications/lib/shouldNotify"; import type { NotificationSettings } from "@/features/notifications/hooks"; import { - ensureDesktopNotificationPermissionGranted, listenForDesktopNotificationActions, requestDockBounce, revealDesktopAppWindow, - sendDesktopNotification, } from "@/features/notifications/lib/desktop"; import { formatMessageNotification } from "@/features/notifications/lib/notificationFormat"; import { buildEventNotificationTarget } from "@/features/notifications/lib/target"; @@ -23,6 +22,7 @@ import { shouldPlayNotificationSound, } from "@/features/notifications/lib/sound"; import { useNotificationSenderName } from "@/features/notifications/useNotificationSenderName"; +import { useLiveNotificationDelivery } from "@/features/notifications/useLiveNotificationDelivery"; import type { Channel, RelayEvent } from "@/shared/api/types"; export function useAppShellDesktopNotifications({ @@ -62,6 +62,25 @@ export function useAppShellDesktopNotifications({ }); const resolveSenderName = useNotificationSenderName(); + const { activeCommunity } = useCommunities(); + const normalizedPubkey = pubkey?.trim().toLowerCase() ?? ""; + const enqueueLiveNotification = useLiveNotificationDelivery({ + scope: + activeCommunity && normalizedPubkey + ? JSON.stringify([activeCommunity.relayUrl, normalizedPubkey]) + : null, + enabled: enabled && notificationSettings.desktopEnabled, + dmEnabled: notificationSettings.slotAlertsEnabled.dm, + threadReplyEnabled: notificationSettings.slotAlertsEnabled.thread_reply, + onDelivered: ({ payload, slot }) => { + if ( + shouldPlayNotificationSound(payload.target?.channelId, silentChannelIds) + ) { + playNotificationSound(resolveSlotSound(notificationSettings, slot)); + } + void requestDockBounce(); + }, + }); const handleChannelNotification = React.useEffectEvent( (_channelId: string, event: RelayEvent) => { @@ -90,24 +109,18 @@ export function useAppShellDesktopNotifications({ content: event.content, }); - void ensureDesktopNotificationPermissionGranted().then( - async (permissionGranted) => { - if (!permissionGranted) return; - const didSend = await sendDesktopNotification({ - title, - body, - target: buildEventNotificationTarget(event, { - id: channel.id, - name: channelName, - }), - }); - if (!didSend) return; - if (shouldPlayNotificationSound(channel.id, silentChannelIds)) { - playNotificationSound(resolveSlotSound(notificationSettings, "dm")); - } - void requestDockBounce(); + enqueueLiveNotification({ + id: event.id, + slot: "dm", + payload: { + title, + body, + target: buildEventNotificationTarget(event, { + id: channel.id, + name: channelName, + }), }, - ); + }); }, ); @@ -137,30 +150,19 @@ export function useAppShellDesktopNotifications({ content: event.content, }); - void ensureDesktopNotificationPermissionGranted().then( - async (permissionGranted) => { - if (!permissionGranted) return; - const didSend = await sendDesktopNotification({ - title, - body, - target: buildEventNotificationTarget( - event, - { - id: channelId, - name: channelName, - }, - { openInThread: true }, - ), - }); - if (!didSend) return; - if (shouldPlayNotificationSound(channelId, silentChannelIds)) { - playNotificationSound( - resolveSlotSound(notificationSettings, "thread_reply"), - ); - } - void requestDockBounce(); + enqueueLiveNotification({ + id: event.id, + slot: "thread_reply", + payload: { + title, + body, + target: buildEventNotificationTarget( + event, + { id: channelId, name: channelName }, + { openInThread: true }, + ), }, - ); + }); }, ); diff --git a/desktop/src/features/notifications/lib/desktop.ts b/desktop/src/features/notifications/lib/desktop.ts index 0a7eb405233..5ee2cd7f3ce 100644 --- a/desktop/src/features/notifications/lib/desktop.ts +++ b/desktop/src/features/notifications/lib/desktop.ts @@ -44,7 +44,7 @@ export type DesktopNotificationTarget = { threadRootId?: string | null; }; -type DesktopNotificationPayload = { +export type DesktopNotificationPayload = { body?: string; target?: DesktopNotificationTarget; title: string; @@ -283,8 +283,8 @@ export async function listenForDesktopNotificationActions( } } - // Linux forwards the target as the event payload. macOS queues targets in - // Rust first so cold-start clicks survive until this listener is mounted. + // Linux forwards the target as the event payload. macOS and Windows queue + // targets in Rust before emitting so clicks survive a missing listener. const dispatchNativeActivations = async (payload?: unknown) => { if (usesActivationQueue) { const targets = await invoke( @@ -323,12 +323,12 @@ export async function listenForDesktopNotificationActions( nativeUnlisten = null; } - if (nativeUnlisten && usesActivationQueue) { + if (usesActivationQueue) { try { await dispatchNativeActivations(); } catch (error) { console.error( - "Failed to drain pending macOS notification activations", + "Failed to drain pending notification activations", error, ); } @@ -470,6 +470,7 @@ export async function revealDesktopAppWindow(): Promise { export async function sendDesktopNotification( payload: DesktopNotificationPayload, + canDeliver: () => boolean = () => true, ): Promise { let permission: DesktopNotificationPermissionState; try { @@ -479,7 +480,7 @@ export async function sendDesktopNotification( return false; } - if (permission !== "granted") { + if (permission !== "granted" || !canDeliver()) { return false; } diff --git a/desktop/src/features/notifications/lib/desktopActivations.test.mjs b/desktop/src/features/notifications/lib/desktopActivations.test.mjs index b9976cd38e7..598ad438ad4 100644 --- a/desktop/src/features/notifications/lib/desktopActivations.test.mjs +++ b/desktop/src/features/notifications/lib/desktopActivations.test.mjs @@ -9,10 +9,18 @@ import test from "node:test"; let pendingActivations = []; let hangWindowInvokes = false; +let rejectListener = false; +let rejectDrain = false; +const drainCommands = []; const tauriInternals = { invoke(command) { - if (command === "take_pending_activations") { + if ( + command === "take_pending_activations" || + command === "take_pending_windows_activations" + ) { + drainCommands.push(command); + if (rejectDrain) return Promise.reject(new Error("drain unavailable")); const drained = pendingActivations; pendingActivations = []; return Promise.resolve(drained); @@ -21,6 +29,8 @@ const tauriInternals = { return new Promise(() => {}); } if (command === "plugin:event|listen") { + if (rejectListener) + return Promise.reject(new Error("listener unavailable")); return Promise.resolve(1); } return Promise.resolve(undefined); @@ -175,3 +185,64 @@ test("visibilitychange re-drains activations stranded by a lost emit", async () assert.equal(received[0].channelId, "channel-3"); dispose(); }); + +for (const [platform, command] of [ + ["MacIntel", "take_pending_activations"], + ["Win32", "take_pending_windows_activations"], +]) { + for (const listenerFails of [false, true]) { + test(`${platform} drains a queued target on mount (listener failure: ${listenerFails})`, async (t) => { + navigator.platform = platform; + rejectListener = listenerFails; + drainCommands.length = 0; + pendingActivations = [ + { channelId: "cold-channel", eventId: "cold-event", kind: 9 }, + ]; + t.after(() => { + navigator.platform = "MacIntel"; + rejectListener = false; + pendingActivations = []; + }); + const received = []; + const dispose = await listenForDesktopNotificationActions((target) => + received.push(target), + ); + t.after(dispose); + assert.deepEqual(drainCommands, [command]); + assert.equal(received.length, 1); + assert.equal(received[0].eventId, "cold-event"); + window.dispatchEvent(new Event("focus")); + await flushPendingWork(); + assert.equal( + received.length, + 1, + "drained target must not be delivered twice", + ); + pendingActivations = [ + { channelId: "next-channel", eventId: "next-event", kind: 9 }, + ]; + document.dispatchEvent(new Event("visibilitychange")); + await flushPendingWork(); + assert.equal(received[1].eventId, "next-event"); + assert.deepEqual(drainCommands, [command, command, command]); + }); + } +} + +test("Windows initial drain failures use platform-neutral diagnostics", async (t) => { + navigator.platform = "Win32"; + rejectDrain = true; + t.after(() => { + navigator.platform = "MacIntel"; + rejectDrain = false; + }); + const errors = []; + t.mock.method(console, "error", (...args) => errors.push(args)); + const dispose = await listenForDesktopNotificationActions(() => {}); + t.after(dispose); + assert.equal(errors.length, 1); + assert.equal( + errors[0][0], + "Failed to drain pending notification activations", + ); +}); diff --git a/desktop/src/features/notifications/use-feed-desktop-notifications.test.mjs b/desktop/src/features/notifications/use-feed-desktop-notifications.test.mjs index 2f7ef3f651d..cfea7f66578 100644 --- a/desktop/src/features/notifications/use-feed-desktop-notifications.test.mjs +++ b/desktop/src/features/notifications/use-feed-desktop-notifications.test.mjs @@ -272,3 +272,101 @@ test("a delivery failure remains retryable across a hook remount", async (t) => remountedHook.unmount(); cleanup(); }); + +test("feed retry persistence bounds the production in-memory set", async () => { + const { persistRetryFeedIds, readStoredRetryFeedIds } = await import( + "./use-feed-desktop-notifications.ts" + ); + const ids = new Set( + Array.from({ length: 510 }, (_, index) => `retry-${index}`), + ); + persistRetryFeedIds("bounded-viewer", ids); + assert.equal(ids.size, 500); + assert.equal(ids.has("retry-9"), false); + assert.equal(ids.has("retry-10"), true); + assert.deepEqual(readStoredRetryFeedIds("bounded-viewer"), [...ids]); +}); + +test("disabling desktop notifications fences a pending feed permission check", async (t) => { + const { act, renderHook } = await import("@testing-library/react"); + const { useFeedDesktopNotifications, readStoredRetryFeedIds } = await import( + "./use-feed-desktop-notifications.ts" + ); + const delivered = []; + let releasePermission; + let permission = new Promise((resolve) => { + releasePermission = resolve; + }); + const previousPlatform = Object.getOwnPropertyDescriptor( + navigator, + "platform", + ); + const previousInternals = window.__TAURI_INTERNALS__; + const previousIsTauri = globalThis.isTauri; + t.after(() => { + window.__TAURI_INTERNALS__ = previousInternals; + globalThis.isTauri = previousIsTauri; + if (previousPlatform) + Object.defineProperty(navigator, "platform", previousPlatform); + else delete navigator.platform; + }); + globalThis.isTauri = true; + Object.defineProperty(navigator, "platform", { + configurable: true, + value: "Win32", + }); + window.__TAURI_INTERNALS__ = { + invoke(command) { + if (command === "windows_notification_permission_state") + return permission; + assert.equal(command, "show_native_notification"); + delivered.push(command); + return Promise.resolve(); + }, + }; + const item = { + id: "toggle-alert", + kind: 9, + pubkey: "sender", + content: "pending", + createdAt: 123, + channelId: "channel-id", + channelName: "ship-room", + channelType: "stream", + tags: [], + category: "mention", + }; + const emptyFeed = { feed: { mentions: [], needsAction: [] } }; + const feed = { feed: { mentions: [item], needsAction: [] } }; + const profiles = new Map(); + const silent = new Set(["channel-id"]); + const hook = renderHook( + ({ feed, desktopEnabled }) => + useFeedDesktopNotifications( + feed, + "toggle-viewer", + { desktopEnabled, slotAlertsEnabled: { mention: true } }, + async () => true, + true, + profiles, + undefined, + [], + silent, + ), + { initialProps: { feed: emptyFeed, desktopEnabled: true } }, + ); + t.after(() => hook.unmount()); + hook.rerender({ feed, desktopEnabled: true }); + await act(async () => {}); + hook.rerender({ feed, desktopEnabled: false }); + await act(async () => { + releasePermission("granted"); + }); + assert.deepEqual(delivered, []); + assert.deepEqual(readStoredRetryFeedIds("toggle-viewer"), ["toggle-alert"]); + permission = Promise.resolve("granted"); + await act(async () => { + hook.rerender({ feed, desktopEnabled: true }); + }); + assert.equal(delivered.length, 1); +}); diff --git a/desktop/src/features/notifications/use-feed-desktop-notifications.ts b/desktop/src/features/notifications/use-feed-desktop-notifications.ts index c79035bd208..02b18745ca5 100644 --- a/desktop/src/features/notifications/use-feed-desktop-notifications.ts +++ b/desktop/src/features/notifications/use-feed-desktop-notifications.ts @@ -110,6 +110,14 @@ export function writeStoredRetryFeedIds(pubkey: string, ids: string[]) { export type FeedNotificationPermissionOutcome = "granted" | "denied" | "error"; +export function persistRetryFeedIds(pubkey: string, ids: Set) { + for (const id of ids) { + if (ids.size <= HOME_FEED_SEEN_MAX_ITEMS) break; + ids.delete(id); + } + writeStoredRetryFeedIds(pubkey, [...ids]); +} + export type FeedNotificationBatchResult = { handledIds: string[]; retryableIds: string[]; @@ -225,13 +233,13 @@ export function useFeedDesktopNotifications( }, [normalizedPubkey]); React.useEffect(() => { - if (enabled) { + if (enabled && settings.desktopEnabled) { return; } notificationGenerationRef.current += 1; inFlightItemIdsRef.current.clear(); - }, [enabled]); + }, [enabled, settings.desktopEnabled]); const autoRequestPermissionIfNeeded = React.useEffectEvent(() => ensureFeedNotificationPermission( @@ -241,13 +249,17 @@ export function useFeedDesktopNotifications( ); const deliverFeedNotification = React.useEffectEvent( - async (item: FeedItem, senderName?: string) => { + async (item: FeedItem, generation: number, senderName?: string) => { + if (!enabled || !settings.desktopEnabled) return false; const { title, body } = formatFeedNotification(item, senderName); - const didSend = await sendDesktopNotification({ - body, - target: buildFeedItemNotificationTarget(item), - title, - }); + const didSend = await sendDesktopNotification( + { + body, + target: buildFeedItemNotificationTarget(item), + title, + }, + () => generation === notificationGenerationRef.current, + ); if ( didSend && @@ -336,7 +348,7 @@ export function useFeedDesktopNotifications( inFlightItemIdsRef.current.add(item.id); retryItemIdsRef.current.add(item.id); } - writeStoredRetryFeedIds(normalizedPubkey, [...retryItemIdsRef.current]); + persistRetryFeedIds(normalizedPubkey, retryItemIdsRef.current); const generation = notificationGenerationRef.current; void deliverFeedNotificationBatch( newItems, @@ -362,7 +374,7 @@ export function useFeedDesktopNotifications( resolvedLabel && resolvedLabel !== truncateNpub(item.pubkey) ? resolvedLabel : undefined; - return deliverFeedNotification(item, senderName); + return deliverFeedNotification(item, generation, senderName); }, ).then((result) => { if (generation !== notificationGenerationRef.current) { @@ -377,7 +389,7 @@ export function useFeedDesktopNotifications( for (const id of result.retryableIds) { retryItemIdsRef.current.add(id); } - writeStoredRetryFeedIds(normalizedPubkey, [...retryItemIdsRef.current]); + persistRetryFeedIds(normalizedPubkey, retryItemIdsRef.current); if (result.handledIds.length === 0) { return; } diff --git a/desktop/src/features/notifications/useLiveNotificationDelivery.test.mjs b/desktop/src/features/notifications/useLiveNotificationDelivery.test.mjs new file mode 100644 index 00000000000..b2189d63f44 --- /dev/null +++ b/desktop/src/features/notifications/useLiveNotificationDelivery.test.mjs @@ -0,0 +1,194 @@ +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); +before(() => { + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + isTauri: true, + }); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { platform: "Win32", userAgent: "buzz-test" }, + }); + window.Notification = { permission: "granted" }; +}); +after(() => dom.window.close()); + +const { useLiveNotificationDelivery } = await import( + "./useLiveNotificationDelivery.ts" +); + +async function mount(t, initial = {}) { + const { act, renderHook } = await import("@testing-library/react"); + let permission = Promise.resolve("granted"); + let fails = false; + const posts = []; + const sounds = []; + window.__TAURI_INTERNALS__ = { + invoke(command, args) { + if (command === "windows_notification_permission_state") + return permission; + assert.equal(command, "show_native_notification"); + posts.push(args.title); + return fails + ? Promise.reject(new Error("WinRT unavailable")) + : Promise.resolve(); + }, + }; + const props = { + scope: t.name, + enabled: true, + dmEnabled: true, + threadReplyEnabled: true, + onDelivered: (item) => sounds.push(item.id), + ...initial, + }; + const hook = renderHook((options) => useLiveNotificationDelivery(options), { + initialProps: props, + }); + t.after(() => hook.unmount()); + return { + hook, + props, + act, + posts, + sounds, + setPermission(value) { + permission = value; + }, + setFails(value) { + fails = value; + }, + enqueue(slot = "dm") { + return act(async () => { + hook.result.current({ + id: "event", + slot, + payload: { title: "hello" }, + }); + }); + }, + journal() { + return JSON.parse( + window.localStorage.getItem( + `buzz-live-notification-retry.v1:${props.scope}`, + ), + ); + }, + }; +} + +for (const slot of ["dm", "thread_reply"]) { + for (const change of ["toggle", "slot", "scope", "unmount"]) { + test(`${slot} permission continuation is fenced by ${change}`, async (t) => { + const harness = await mount(t); + let release; + harness.setPermission( + new Promise((resolve) => { + release = resolve; + }), + ); + await harness.enqueue(slot); + assert.equal(harness.journal().length, 1); + if (change === "unmount") harness.hook.unmount(); + else + harness.hook.rerender({ + ...harness.props, + ...(change === "scope" + ? { scope: "other-community" } + : change === "slot" + ? { [slot === "dm" ? "dmEnabled" : "threadReplyEnabled"]: false } + : { enabled: false }), + }); + await harness.act(async () => { + release("granted"); + }); + assert.deepEqual(harness.posts, []); + assert.deepEqual(harness.sounds, []); + assert.equal(harness.journal().length, 1); + }); + } +} + +test("a failed native live alert is durable and resumes after remount", async (t) => { + const harness = await mount(t); + harness.setFails(true); + await harness.enqueue(); + assert.equal(harness.posts.length, 1); + assert.equal(harness.sounds.length, 0); + assert.equal(harness.journal().length, 1); + harness.hook.unmount(); + const restored = await mount(t); + await restored.act(async () => {}); + assert.equal(restored.posts.length, 1); + assert.deepEqual(restored.sounds, ["event"]); + assert.deepEqual(restored.journal(), []); +}); + +test("retries use backoff and stop after five attempts while retaining the journal", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout", "Date"] }); + const harness = await mount(t); + harness.setFails(true); + await harness.enqueue(); + for (const delay of [1_000, 2_000, 4_000, 8_000, 16_000, 30_000]) { + await harness.act(async () => { + t.mock.timers.tick(delay); + }); + } + assert.equal(harness.posts.length, 5); + assert.equal(harness.journal().length, 1); +}); + +test("a disable during the final send permission query prevents native posting", async (t) => { + const harness = await mount(t); + let checks = 0; + let release; + const pending = new Promise((resolve) => { + release = resolve; + }); + window.__TAURI_INTERNALS__.invoke = (command) => { + assert.equal( + command, + "windows_notification_permission_state", + "must not reach native posting", + ); + checks += 1; + return checks === 1 ? Promise.resolve("granted") : pending; + }; + await harness.enqueue(); + assert.equal(checks, 2); + harness.hook.rerender({ ...harness.props, enabled: false }); + await harness.act(async () => { + release("granted"); + }); + assert.equal(harness.journal().length, 1); + assert.deepEqual(harness.sounds, []); +}); + +test("pending live alerts are deduplicated and bounded before persistence", async (t) => { + const harness = await mount(t); + harness.setPermission(new Promise(() => {})); + await harness.act(async () => { + for (let index = 0; index < 505; index++) { + const notification = { + id: `event-${index}`, + slot: "dm", + payload: { title: `alert-${index}` }, + }; + harness.hook.result.current(notification); + harness.hook.result.current(notification); + } + }); + const journal = harness.journal(); + assert.equal(journal.length, 500); + assert.equal(new Set(journal.map((item) => item.id)).size, 500); + assert.equal(journal[0].id, "event-5"); + assert.equal(journal.at(-1).id, "event-504"); +}); diff --git a/desktop/src/features/notifications/useLiveNotificationDelivery.ts b/desktop/src/features/notifications/useLiveNotificationDelivery.ts new file mode 100644 index 00000000000..54a78999fcd --- /dev/null +++ b/desktop/src/features/notifications/useLiveNotificationDelivery.ts @@ -0,0 +1,202 @@ +import * as React from "react"; +import { + ensureDesktopNotificationPermissionGranted, + sendDesktopNotification, + type DesktopNotificationPayload, +} from "./lib/desktop"; + +export type LiveNotification = { + id: string; + slot: "dm" | "thread_reply"; + payload: DesktopNotificationPayload; +}; + +type PendingNotification = { + notification: LiveNotification; + attempts: number; + due: number; +}; +type DeliverySession = { + key: string; + pending: Map; + timer?: ReturnType; + running: boolean; + disposed: boolean; + generation: number; +}; + +const MAX_PENDING = 500; +const MAX_ATTEMPTS = 5; + +function persist(session: DeliverySession) { + window.localStorage.setItem( + session.key, + JSON.stringify( + [...session.pending.values()].map((entry) => entry.notification), + ), + ); +} + +function restore(key: string): DeliverySession["pending"] { + const raw = window.localStorage.getItem(key); + if (!raw) return new Map(); + const records: unknown = JSON.parse(raw); + if (!Array.isArray(records)) + throw new Error("Invalid live notification retry journal"); + const pending: DeliverySession["pending"] = new Map(); + for (const record of records.slice(-MAX_PENDING)) { + if ( + typeof record?.id !== "string" || + !["dm", "thread_reply"].includes(record.slot) || + typeof record.payload?.title !== "string" + ) { + throw new Error("Invalid live notification retry entry"); + } + pending.set(record.id, { notification: record, attempts: 0, due: 0 }); + } + return pending; +} + +/** + * Persists up to 500 alerts per community/viewer, evicting the oldest on overflow. + * Failed alerts retry five times per mount; exhausted entries stay durable for + * the next mount. Delivery is at-least-once if the app exits before persisting + * a successful native send. Settings changes pause delivery, not the journal. + */ +export function useLiveNotificationDelivery({ + scope, + enabled, + dmEnabled, + threadReplyEnabled, + onDelivered, +}: { + scope: string | null; + enabled: boolean; + dmEnabled: boolean; + threadReplyEnabled: boolean; + onDelivered: (notification: LiveNotification) => void; +}) { + const sessionRef = React.useRef(null); + + const canSend = React.useEffectEvent( + (session: DeliverySession, notification: LiveNotification) => + !session.disposed && + sessionRef.current === session && + enabled && + (notification.slot === "dm" ? dmEnabled : threadReplyEnabled), + ); + const delivered = React.useEffectEvent(onDelivered); + + const flush = React.useEffectEvent(async (session: DeliverySession) => { + if (session.disposed || session.running) return; + clearTimeout(session.timer); + const entry = [...session.pending.values()] + .filter( + (candidate) => + candidate.attempts < MAX_ATTEMPTS && + canSend(session, candidate.notification), + ) + .sort((left, right) => left.due - right.due)[0]; + if (!entry) return; + const delay = entry.due - Date.now(); + if (delay > 0) { + session.timer = setTimeout(() => { + void flush(session); + }, delay); + return; + } + session.running = true; + const generation = session.generation; + try { + const permitted = await ensureDesktopNotificationPermissionGranted(); + if ( + generation !== session.generation || + !canSend(session, entry.notification) + ) + return; + const sent = + permitted && + (await sendDesktopNotification( + entry.notification.payload, + () => + generation === session.generation && + canSend(session, entry.notification), + )); + if (session.disposed) return; + if (sent) { + session.pending.delete(entry.notification.id); + persist(session); + if ( + generation === session.generation && + canSend(session, entry.notification) + ) { + delivered(entry.notification); + } + } else { + entry.attempts += 1; + entry.due = + Date.now() + Math.min(1_000 * 2 ** (entry.attempts - 1), 30_000); + } + } catch (error) { + entry.attempts = MAX_ATTEMPTS; + console.error( + "Live notification delivery failed; retry journal retained", + error, + ); + } finally { + session.running = false; + if (!session.disposed) + session.timer = setTimeout(() => { + void flush(session); + }, 0); + } + }); + + React.useEffect(() => { + if (!scope) return; + const key = `buzz-live-notification-retry.v1:${scope}`; + const session: DeliverySession = { + key, + pending: restore(key), + running: false, + disposed: false, + generation: 0, + }; + sessionRef.current = session; + void flush(session); + return () => { + session.disposed = true; + session.generation += 1; + clearTimeout(session.timer); + if (sessionRef.current === session) sessionRef.current = null; + }; + }, [scope]); + + React.useEffect(() => { + if (!scope || !enabled || (!dmEnabled && !threadReplyEnabled)) return; + const session = sessionRef.current; + if (!session) return; + void flush(session); + return () => { + session.generation += 1; + clearTimeout(session.timer); + }; + }, [scope, enabled, dmEnabled, threadReplyEnabled]); + + return React.useEffectEvent((notification: LiveNotification) => { + const session = sessionRef.current; + if ( + !session || + !canSend(session, notification) || + session.pending.has(notification.id) + ) + return; + session.pending.set(notification.id, { notification, attempts: 0, due: 0 }); + for (const id of session.pending.keys()) { + if (session.pending.size <= MAX_PENDING) break; + session.pending.delete(id); + } + persist(session); + void flush(session); + }); +}