diff --git a/Cargo.lock b/Cargo.lock index 1317d17c1..7f4936fcc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -338,11 +338,15 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" name = "buzz-foundation" version = "0.0.0" dependencies = [ + "block2", "buzzodz-plugins", "futures-lite", "gtk", "libc", "mac-notification-sys", + "objc2", + "objc2-foundation", + "objc2-user-notifications", "portable-pty", "serde", "tauri", @@ -2508,6 +2512,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" dependencies = [ + "bitflags 2.13.1", + "block2", "objc2", "objc2-foundation", ] diff --git a/docs/notifications.md b/docs/notifications.md index 3b1e4971f..b7bb0b4b0 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -73,8 +73,9 @@ mac-notification-sys on macOS, the freedesktop notification interface through zbus on Linux, and tauri-winrt-notification on Windows. Linux uses the already locked zbus dependency directly because notify-rust's send-then-listen wrapper can lose early actions. No dependency upgrade or new native FFI is needed. -Permission and sound remain system-controlled; no permission-only plugin -or synthetic desktop permission prompt is installed. The main-window-only bridge +Banner permission and sound remain system-controlled; no permission-only plugin +or synthetic desktop notification is installed. The macOS Dock settings below +provide an explicit system authorization action. The main-window-only bridge carries display text and an opaque presentation ID, never an account, credential or navigation destination. Its Tauri response channel is registered before native submission. @@ -94,9 +95,9 @@ framework's stale minimized-state focus guard. Compositor focus policy still applies. Dismissal never navigates. Observable send/focus failures reach Settings without retry; a focus error does not discard navigation. -Desktop permission state is not observable through these backends. Settings +Banner permission state is not observable through these backends. Settings describes permission and sound as system-controlled, without ineffective desktop -permission or sound controls. The bridge accepts a submission before waiting for +banner permission or sound controls. The bridge accepts a submission before waiting for interaction: acceptance is **not** proof that a visible banner appeared. The macOS backend does not expose all delivery failures, and no uniform withdrawal/receipt guarantee is promised. @@ -116,3 +117,58 @@ For macOS, Windows and Linux, manual acceptance includes background and minimize Buzz, two distinct message/thread targets, immediate banner click, banner fade then Notification Center click, dismissal without navigation, and old-account or revoked-access rejection. A macOS pass is not Windows/Linux acceptance. + + +## macOS Dock unread badge + +The host projects one dot from the selected community's existing unread selectors: +observed unread messages (including thread replies) or explicit channel-unread +intent. It is not an exact message count or evidence of complete history. Unknown +and observed-zero both omit the dot. Existing bounded evidence/read-state owns +startup and updates; this projection adds no relay reads, network subscriptions, +or storage. Personal space, account/session changes, access loss and host disposal +clear or recompute the indicator. Disabling Channels does not stop host ownership. +Desktop alert preferences do not alter this unread indicator. + +One ordered host writer calls a main-window-only command using Tauri's standard +`set_badge_label` API. macOS draws the badge; no custom artwork is supplied. +Windows, Linux and browsers have no shell unread indicator or badge Settings in +this version, and do not bind the unread projection or invoke the Dock commands. +Their existing banner behavior is unchanged. No taskbar overlay, tray icon/menu, +new image assets or tray dependency is added. + +Observable setter failures appear in Settings; **Check Dock permission** retries +using current unread intent. There is no automatic retry loop or claim of OS +display acknowledgement. + +### macOS permission setup + +Settings → Notifications → Dock unread badge shows the actual macOS badge +setting. **Allow notifications and badges** explicitly requests Alert, Sound and +Badge for a fresh NotDetermined identity. **Set up Dock badges** explicitly requests +Badge alone when an already Authorized identity reports NotSupported. Startup, +focus, and **Check Dock permission** only read settings; they never register or +repair permissions. Denied authorization and explicitly Disabled badges are never +re-requested. macOS System Settings controls badge opt-out. Errors withhold the dot +and are shown; a later focus or explicit check can retry a failed read. + +This permission capability requires an actual macOS `.app` bundle. Unbundled +`tauri dev` never calls UserNotifications or borrows Terminal's badge permission. +The native bridge is necessary because the official Tauri notification plugin's +current desktop permission methods return Granted without querying these settings. +Existing banner delivery/clicks and their acceptance limits above are unchanged. +Windows and Linux do not use the macOS permission bridge or display its controls. + +### Validation boundary + +Tests use real relay/unread services for projection transitions, deferred native +boundaries for ordering, default-adapter command dispatch, and mounted Settings +controls for explicit setup. Native tests cover the authorization/setting matrix, +no startup mutation, error recovery and rejection of unbundled framework calls. +No browser journeys are added: these contracts are below the browser layer. + +These checks do not prove a visible Dock badge. Native macOS acceptance must +exercise startup/arrival/read clearing, account/community/access changes, reload +and exit under an isolated packaged identity. First permission, explicit +missing-badge setup, deny/disable and legacy-banner interaction also need native +acceptance. Distribution signing and packaged account support remain separate work. diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8b52c5dc5..5e65fd95f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -27,6 +27,10 @@ libc = "0.2" [target.'cfg(target_os = "macos")'.dependencies] mac-notification-sys = "=0.6.15" +block2 = "=0.6.2" +objc2 = "=0.6.4" +objc2-foundation = { version = "=0.3.2", default-features = false, features = ["NSBundle", "NSString", "NSError"] } +objc2-user-notifications = { version = "=0.3.2", default-features = false, features = ["UNUserNotificationCenter", "UNNotificationSettings", "block2"] } [target.'cfg(target_os = "windows")'.dependencies] tauri-winrt-notification = "=0.8.1" diff --git a/src-tauri/src/dock.rs b/src-tauri/src/dock.rs new file mode 100644 index 000000000..10a410970 --- /dev/null +++ b/src-tauri/src/dock.rs @@ -0,0 +1,371 @@ +//! Dock authorization is separate from the legacy banner delivery backend. +use serde::Serialize; + +#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum Permission { + #[cfg(target_os = "macos")] + Default, + #[cfg(target_os = "macos")] + Setup, + #[cfg(target_os = "macos")] + Enabled, + #[cfg(target_os = "macos")] + Disabled, + #[cfg(target_os = "macos")] + Denied, + Unavailable, +} + +#[tauri::command] +pub(crate) fn unread_indicator_set( + window: tauri::WebviewWindow, + unread: bool, +) -> Result<(), String> { + if window.label() != "main" { + return Err("Dock badges belong to the main window".into()); + } + #[cfg(target_os = "macos")] + return window + .set_badge_label(unread.then(|| "•".into())) + .map_err(|e| e.to_string()); + #[cfg(not(target_os = "macos"))] + { + let _ = unread; + Err("Dock badges are only available on macOS".into()) + } +} + +#[tauri::command] +pub(crate) async fn dock_permission( + window: tauri::WebviewWindow, + request: bool, +) -> Result { + if window.label() != "main" { + return Err("Dock badges belong to the main window".into()); + } + #[cfg(target_os = "macos")] + return tauri::async_runtime::spawn_blocking(move || macos::permission(request)) + .await + .map_err(|error| error.to_string())?; + #[cfg(not(target_os = "macos"))] + { + let _ = request; + Ok(Permission::Unavailable) + } +} + +#[cfg(target_os = "macos")] +mod macos { + use super::Permission; + use block2::RcBlock; + use objc2::runtime::Bool; + use objc2_foundation::{NSBundle, NSError}; + use objc2_user_notifications::{ + UNAuthorizationOptions as Options, UNAuthorizationStatus as Authorization, + UNNotificationSetting as Setting, UNNotificationSettings, UNUserNotificationCenter, + }; + use std::{path::Path, ptr::NonNull, sync::mpsc, time::Duration}; + + fn bundled() -> bool { + let bundle = NSBundle::mainBundle(); + bundle.bundleIdentifier().is_some() + && bundle.executablePath().is_some_and(|executable| { + bundle_layout( + Path::new(&bundle.bundlePath().to_string()), + Path::new(&executable.to_string()), + ) + }) + } + fn bundle_layout(bundle: &Path, executable: &Path) -> bool { + let Some(macos) = executable.parent() else { + return false; + }; + let Some(contents) = macos.parent() else { + return false; + }; + bundle.extension().is_some_and(|ext| ext == "app") + && macos.file_name() == Some("MacOS".as_ref()) + && contents.file_name() == Some("Contents".as_ref()) + && contents.parent() == Some(bundle) + } + fn settings() -> Result<(Authorization, Setting), String> { + // Calling UNUserNotificationCenter outside an app bundle raises an ObjC + // exception. Check actual identity/layout, including for debug bundles. + if !bundled() { + return Err("Dock permission requires a bundled macOS app".into()); + } + let (tx, rx) = mpsc::sync_channel(1); + let handler = RcBlock::new(move |settings: NonNull| { + // SAFETY: Apple's completion parameter is valid for this callback. + let settings = unsafe { settings.as_ref() }; + let _ = tx.send((settings.authorizationStatus(), settings.badgeSetting())); + }); + UNUserNotificationCenter::currentNotificationCenter() + .getNotificationSettingsWithCompletionHandler(&handler); + rx.recv_timeout(Duration::from_secs(10)) + .map_err(|_| "Dock settings request timed out".into()) + } + fn authorize(options: Options) -> Result<(), String> { + if !bundled() { + return Err("Dock permission requires a bundled macOS app".into()); + } + let (tx, rx) = mpsc::sync_channel(1); + let handler = RcBlock::new(move |_: Bool, error: *mut NSError| { + // SAFETY: Apple's optional error is valid for this callback. + let error = unsafe { error.as_ref() }; + let _ = tx.send(error.map_or(Ok(()), |error| Err(error.to_string()))); + }); + UNUserNotificationCenter::currentNotificationCenter() + .requestAuthorizationWithOptions_completionHandler(options, &handler); + rx.recv_timeout(Duration::from_secs(60)) + .map_err(|_| "Dock authorization request timed out".to_string())? + } + fn options(authorization: Authorization, badge: Setting, explicit: bool) -> Option { + if !explicit { + None + } else if authorization == Authorization::NotDetermined { + Some(Options::Alert | Options::Sound | Options::Badge) + } else if authorization == Authorization::Authorized && badge == Setting::NotSupported { + Some(Options::Badge) + } else { + None + } + } + fn project(authorization: Authorization, badge: Setting) -> Permission { + if authorization == Authorization::NotDetermined { + Permission::Default + } else if authorization == Authorization::Denied { + Permission::Denied + } else if authorization == Authorization::Authorized && badge == Setting::NotSupported { + Permission::Setup + } else if matches!( + authorization, + Authorization::Authorized | Authorization::Provisional | Authorization::Ephemeral + ) { + if badge == Setting::Enabled { + Permission::Enabled + } else if badge == Setting::Disabled { + Permission::Disabled + } else { + Permission::Unavailable + } + } else { + Permission::Unavailable + } + } + pub(super) fn permission(explicit: bool) -> Result { + if !bundled() { + return Ok(Permission::Unavailable); + } + check(explicit, settings, authorize) + } + fn check( + explicit: bool, + mut settings: impl FnMut() -> Result<(Authorization, Setting), String>, + mut authorize: impl FnMut(Options) -> Result<(), String>, + ) -> Result { + let (mut authorization, mut badge) = settings()?; + if let Some(options) = options(authorization, badge, explicit) { + authorize(options)?; + (authorization, badge) = settings()?; + } + Ok(project(authorization, badge)) + } + + #[cfg(test)] + mod tests { + use super::*; + #[test] + fn authorization_matrix_preserves_choices_and_only_prompts_on_explicit_action() { + for auth in [ + Authorization::NotDetermined, + Authorization::Denied, + Authorization::Authorized, + Authorization::Provisional, + Authorization::Ephemeral, + ] { + for badge in [Setting::NotSupported, Setting::Disabled, Setting::Enabled] { + for explicit in [false, true] { + let expected = if explicit && auth == Authorization::NotDetermined { + Some(Options::Alert | Options::Sound | Options::Badge) + } else if explicit + && auth == Authorization::Authorized + && badge == Setting::NotSupported + { + Some(Options::Badge) + } else { + None + }; + let mut reads = 0; + let mut requests = Vec::new(); + let permission = check( + explicit, + || { + reads += 1; + Ok(if reads == 1 { + (auth, badge) + } else { + (Authorization::Authorized, Setting::Enabled) + }) + }, + |options| { + requests.push(options); + Ok(()) + }, + ) + .unwrap(); + assert_eq!(requests, expected.into_iter().collect::>()); + assert_eq!(reads, if expected.is_some() { 2 } else { 1 }); + assert_eq!( + permission, + if expected.is_some() { + Permission::Enabled + } else { + project(auth, badge) + } + ); + assert_eq!( + project(auth, badge) == Permission::Enabled, + badge == Setting::Enabled + && matches!( + auth, + Authorization::Authorized + | Authorization::Provisional + | Authorization::Ephemeral + ) + ); + } + } + } + assert_eq!( + project(Authorization::Authorized, Setting::NotSupported), + Permission::Setup + ); + assert_eq!( + project(Authorization::Authorized, Setting::Disabled), + Permission::Disabled + ); + } + #[test] + fn failed_explicit_setup_remains_recoverable_without_startup_mutation() { + // Fail initial settings, authorization, then post-authorization settings. + for failure in 0..3 { + let mut reads = 0; + let mut requests = 0; + assert!(check( + true, + || { + reads += 1; + if (failure == 0 && reads == 1) || (failure == 2 && reads == 2) { + Err("settings failed".into()) + } else { + Ok((Authorization::Authorized, Setting::NotSupported)) + } + }, + |_| { + requests += 1; + if failure == 1 { + Err("authorization failed".into()) + } else { + Ok(()) + } + } + ) + .is_err()); + assert_eq!(requests, if failure == 0 { 0 } else { 1 }); + assert_eq!( + check( + false, + || Ok((Authorization::Authorized, Setting::NotSupported)), + |_| panic!("refresh must not repair permission") + ) + .unwrap(), + Permission::Setup + ); + let mut reads = 0; + assert_eq!( + check( + true, + || { + reads += 1; + Ok(( + Authorization::Authorized, + if reads == 1 { + Setting::NotSupported + } else { + Setting::Enabled + }, + )) + }, + |options| { + assert_eq!(options, Options::Badge); + Ok(()) + } + ) + .unwrap(), + Permission::Enabled + ); + assert_eq!(reads, 2); + } + } + #[test] + fn no_prompt_on_startup_then_explicit_action_uses_fresh_settings() { + assert_eq!( + check( + false, + || Ok((Authorization::NotDetermined, Setting::NotSupported)), + |_| panic!("startup must not request permission") + ) + .unwrap(), + Permission::Default + ); + let mut reads = 0; + assert_eq!( + check( + true, + || { + reads += 1; + Ok(if reads == 1 { + (Authorization::NotDetermined, Setting::NotSupported) + } else { + (Authorization::Denied, Setting::Disabled) + }) + }, + |options| { + assert_eq!(options, Options::Alert | Options::Sound | Options::Badge); + Ok(()) + } + ) + .unwrap(), + Permission::Denied + ); + assert_eq!(reads, 2); + } + #[test] + fn unbundled_process_never_calls_notification_center() { + assert!(!bundled()); + assert_eq!(permission(false).unwrap(), Permission::Unavailable); + assert_eq!(permission(true).unwrap(), Permission::Unavailable); + assert!(settings().is_err()); + assert!(authorize(Options::Badge).is_err()); + } + #[test] + fn bundle_requires_the_real_executable_layout() { + assert!(bundle_layout( + Path::new("/Applications/Buzz.app"), + Path::new("/Applications/Buzz.app/Contents/MacOS/Buzz") + )); + for path in [ + "/Applications/Other.app/Contents/MacOS/Buzz", + "/Applications/Buzz.app/Buzz", + "/target/debug/Buzz", + ] { + assert!(!bundle_layout( + Path::new("/Applications/Buzz.app"), + Path::new(path) + )); + } + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a2a84f1ca..9ea43e728 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,5 @@ +mod dock; +use dock::{dock_permission, unread_indicator_set}; mod notifications; mod terminal; use notifications::{notification_show, Notifications}; @@ -164,6 +166,8 @@ pub fn run() { .manage(PluginManager(Manager::from_env())) .invoke_handler(tauri::generate_handler![ notification_show, + dock_permission, + unread_indicator_set, terminal_create_owner, terminal_spawn, terminal_read, diff --git a/src/app/NotificationSettings.tsx b/src/app/NotificationSettings.tsx index 706f6729f..2f6689c2f 100644 --- a/src/app/NotificationSettings.tsx +++ b/src/app/NotificationSettings.tsx @@ -1,3 +1,4 @@ +import { UnreadIndicatorSettings } from "./UnreadIndicatorSettings"; import { useSyncExternalStore } from "react"; import type { NotificationsService } from "../features/notifications/service"; @@ -104,6 +105,7 @@ export function NotificationSettings({ Message alerts cover the selected community while Buzz is running. Reading history and reconnecting stay quiet.

+ {state.preferencesError && (

{state.preferencesError}

diff --git a/src/app/UnreadIndicatorSettings.test.tsx b/src/app/UnreadIndicatorSettings.test.tsx new file mode 100644 index 000000000..e772d1724 --- /dev/null +++ b/src/app/UnreadIndicatorSettings.test.tsx @@ -0,0 +1,144 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { act, cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, expect, it, vi } from "vitest"; +import { + createUnreadIndicator, + type IndicatorPermission, +} from "../features/notifications/indicator"; +import { UnreadIndicatorSettings } from "./UnreadIndicatorSettings"; + +const native = vi.hoisted(() => ({ value: true })); +const invoke = vi.hoisted(() => vi.fn()); +vi.mock("@tauri-apps/api/core", () => ({ + isTauri: () => native.value, + invoke, +})); + +const cleanups: (() => Promise)[] = []; +afterEach(async () => { + cleanup(); + for (const stop of cleanups.splice(0)) await stop(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); + native.value = true; +}); +function setup(initial: IndicatorPermission) { + const permission = vi.fn(async (): Promise => initial); + const dock = createUnreadIndicator({ + permission, + set: vi.fn(async () => {}), + }); + cleanups.push(dock.dispose); + render(); + return { permission, dock }; +} +it("requests permission only through the explicit Settings button and updates status", async () => { + const h = setup("default"); + await act(() => h.dock.refresh()); + expect(h.permission.mock.calls).toEqual([[false]]); + expect(screen.getByText(/not a message count/)).toBeInTheDocument(); + let resolve!: (permission: IndicatorPermission) => void; + h.permission.mockImplementationOnce( + () => + new Promise((done) => { + resolve = done; + }), + ); + const user = userEvent.setup(); + await user.click( + screen.getByRole("button", { name: "Allow notifications and badges" }), + ); + expect( + screen.getByRole("button", { name: "Allow notifications and badges" }), + ).toBeDisabled(); + expect( + screen.getByText("Waiting for system permission…"), + ).toBeInTheDocument(); + await act(async () => { + resolve("enabled"); + await h.dock.refresh(); + }); + expect( + screen.getByText("Dock badges are allowed by macOS."), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Allow notifications and badges" }), + ).not.toBeInTheDocument(); +}); +it.each(["disabled", "denied", "unavailable"] as const)( + "never offers a request over %s", + async (permission) => { + const h = setup(permission); + await act(() => h.dock.refresh()); + expect( + screen.queryByRole("button", { name: "Allow notifications and badges" }), + ).not.toBeInTheDocument(); + await userEvent + .setup() + .click(screen.getByRole("button", { name: "Check Dock permission" })); + expect(h.permission.mock.calls).toEqual([[false], [false]]); + }, +); + +it("offers missing-badge setup only as an explicit action", async () => { + const h = setup("setup"); + await act(() => h.dock.refresh()); + expect(h.permission.mock.calls).toEqual([[false]]); + expect( + screen.queryByRole("button", { name: "Allow notifications and badges" }), + ).not.toBeInTheDocument(); + await userEvent + .setup() + .click(screen.getByRole("button", { name: "Set up Dock badges" })); + expect(h.permission.mock.calls).toEqual([[false], [true]]); +}); +it.each([ + [true, "Win32"], + [true, "Linux x86_64"], + [true, ""], + [false, "MacIntel"], +] as const)( + "hides Dock settings and makes no IPC calls for native=%s platform=%s", + async (tauri, platform) => { + native.value = tauri; + vi.stubGlobal("navigator", { platform }); + const indicator = createUnreadIndicator(); + cleanups.push(indicator.dispose); + const { container } = render( + , + ); + await act(async () => { + indicator.setUnread(true); + await indicator.refresh(); + await indicator.request(); + await indicator.dispose(); + }); + expect(container).toBeEmptyDOMElement(); + expect(invoke).not.toHaveBeenCalled(); + }, +); + +it("shows a Dock write error and retries through the existing permission check", async () => { + const set = vi.fn(async (_unread: boolean) => {}); + const indicator = createUnreadIndicator({ + permission: async () => "enabled", + set, + }); + cleanups.push(indicator.dispose); + render(); + await act(() => indicator.refresh()); + set.mockRejectedValueOnce(new Error("Dock unavailable")); + await act(async () => { + indicator.setUnread(true); + }); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Dock unavailable", + ); + await userEvent + .setup() + .click(screen.getByRole("button", { name: "Check Dock permission" })); + expect(set).toHaveBeenLastCalledWith(true); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); +}); diff --git a/src/app/UnreadIndicatorSettings.tsx b/src/app/UnreadIndicatorSettings.tsx new file mode 100644 index 000000000..bd5c7e1f3 --- /dev/null +++ b/src/app/UnreadIndicatorSettings.tsx @@ -0,0 +1,62 @@ +import { useSyncExternalStore } from "react"; +import type { UnreadIndicator } from "../features/notifications/indicator"; + +export function UnreadIndicatorSettings({ + indicator, +}: { + indicator: UnreadIndicator; +}) { + const state = useSyncExternalStore(indicator.subscribe, indicator.snapshot); + if (!indicator.available) return null; + return ( +
+

Dock unread badge

+

+ A dot in the macOS Dock shows observed unread activity or a channel + marked unread in the selected community. It is not a message count. + Desktop alert choices do not change this badge. +

+

+ {state.requesting + ? "Waiting for system permission…" + : { + default: + "Allow notifications and badges to show the unread dot in the Dock.", + setup: "Set up Dock badges to show the unread dot.", + enabled: "Dock badges are allowed by macOS.", + disabled: + "Badges are off. Change the badge setting in macOS System Settings.", + denied: + "Notifications are blocked. Allow them in macOS System Settings.", + unavailable: + "Dock permission is unavailable. Run the bundled macOS app to use badges.", + }[state.permission]} +

+
+ {(state.permission === "default" || state.permission === "setup") && ( + + )} + +
+ {state.error && ( +

+ {state.error} +

+ )} +
+ ); +} diff --git a/src/app/services.ts b/src/app/services.ts index ed644fe58..e9477de95 100644 --- a/src/app/services.ts +++ b/src/app/services.ts @@ -1,4 +1,5 @@ // FOUNDATION: Compose the bundled distribution, plugin runtime, and services here. +import { bindUnreadIndicator } from "../features/notifications/indicator-unread"; import { provideNavigation } from "../features/navigation/service"; import { NotificationsService } from "../features/notifications/service"; import { @@ -41,6 +42,10 @@ export function createServices() { (target) => notificationAuthorized(communities, target), ); ctx.effect(() => bindMessageNotifications(notifications, communities)); + if (notifications.indicator.available) + ctx.effect(() => + bindUnreadIndicator(communities, notifications.indicator.setUnread), + ); let disposal: Promise | undefined; return { notifications, diff --git a/src/features/notifications/desktop.test.ts b/src/features/notifications/desktop.test.ts index 8955287f9..ee05871d4 100644 --- a/src/features/notifications/desktop.test.ts +++ b/src/features/notifications/desktop.test.ts @@ -1,7 +1,7 @@ import { Context } from "@deepseek-ai/cordis"; import { renderToStaticMarkup } from "react-dom/server"; import { createElement } from "react"; -import { afterEach, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { NotificationSettings } from "../../app/NotificationSettings"; import { PluginRuntime } from "../../plugins/runtime"; import { provideNavigation } from "../navigation/service"; @@ -10,16 +10,24 @@ import { NotificationsService } from "./service"; import { messageNotificationText } from "./content"; const sdk = vi.hoisted(() => ({ - invoke: vi.fn(async () => {}), + show: vi.fn(async (..._args: unknown[]) => {}), + permission: vi.fn(async (_args: unknown) => "enabled"), + indicator: vi.fn(async (_args: unknown) => {}), })); const native = vi.hoisted(() => ({ value: true })); vi.mock("@tauri-apps/api/core", () => ({ isTauri: () => native.value, - invoke: sdk.invoke, + invoke: (command: string, args: unknown) => { + if (command === "notification_show") return sdk.show(command, args); + if (command === "dock_permission") return sdk.permission(args); + if (command === "unread_indicator_set") return sdk.indicator(args); + throw new Error(`Unexpected native command: ${command}`); + }, Channel: class { constructor(public onmessage: (response: unknown) => void) {} }, })); +beforeEach(() => vi.stubGlobal("navigator", { platform: "MacIntel" })); vi.mock("react", async (original) => ({ ...(await original()), useSyncExternalStore: (_subscribe: unknown, snapshot: () => unknown) => @@ -29,6 +37,7 @@ const contexts: Context[] = []; afterEach(async () => { for (const ctx of contexts.splice(0)) await ctx.fiber.dispose(); vi.resetAllMocks(); + vi.unstubAllGlobals(); native.value = true; }); function setup() { @@ -65,7 +74,7 @@ it("the default service sends desktop banners via the native bridge and shared p await submit("first"); await submit("first"); await flush(); - expect(sdk.invoke).toHaveBeenCalledExactlyOnceWith("notification_show", { + expect(sdk.show).toHaveBeenCalledExactlyOnceWith("notification_show", { title: "Buzz", body: "New mentions", id: expect.any(String), @@ -76,28 +85,28 @@ it("the default service sends desktop banners via the native bridge and shared p service.updatePreferences({ enabled: true, categories: { mention: false } }); await submit("category-off"); await flush(); - expect(sdk.invoke).toHaveBeenCalledTimes(1); + expect(sdk.show).toHaveBeenCalledTimes(1); }); -it("desktop permission remains system-managed without a permission RPC or shim", async () => { +it("banner permission stays system-managed while Dock permission is queried separately", async () => { const { service, submit } = setup(); await flush(); await service.refreshPermission(); await service.requestPermission(); expect(service.snapshot().permission).toBe("unknown"); - expect(sdk.invoke).not.toHaveBeenCalled(); + expect(sdk.show).not.toHaveBeenCalled(); + expect(sdk.permission).toHaveBeenCalledExactlyOnceWith({ request: false }); + expect(service.indicator.snapshot().permission).toBe("enabled"); + expect(sdk.indicator).toHaveBeenCalledExactlyOnceWith({ unread: false }); await submit("first"); await flush(); - expect(sdk.invoke).toHaveBeenCalledOnce(); - expect(sdk.invoke).toHaveBeenCalledWith( - "notification_show", - expect.anything(), - ); + expect(sdk.show).toHaveBeenCalledOnce(); + expect(sdk.show).toHaveBeenCalledWith("notification_show", expect.anything()); }); it("observable SDK failures surface once without retry or a browser fallback", async () => { const { service, submit } = setup(); - sdk.invoke.mockImplementationOnce(() => { + sdk.show.mockImplementationOnce(() => { throw new Error("SDK unavailable"); }); await submit("failed"); @@ -105,10 +114,10 @@ it("observable SDK failures surface once without retry or a browser fallback", a expect(service.snapshot().error).toBe("SDK unavailable"); await submit("failed"); await flush(); - expect(sdk.invoke).toHaveBeenCalledOnce(); + expect(sdk.show).toHaveBeenCalledOnce(); await submit("next"); await flush(); - expect(sdk.invoke).toHaveBeenCalledTimes(2); + expect(sdk.show).toHaveBeenCalledTimes(2); }); it("desktop settings explain OS sound and running-app exact clicks", async () => { @@ -136,7 +145,7 @@ it("non-Tauri runs select the unchanged browser adapter, never the native SDK", const platform = createNotifications(); expect(platform.label).toBe("Browser notifications"); expect(await platform.permission()).toBe("unsupported"); - expect(sdk.invoke).not.toHaveBeenCalled(); + expect(sdk.show).not.toHaveBeenCalled(); }); it("the production desktop adapter forwards the message title and preview unchanged", async () => { @@ -162,7 +171,7 @@ it("the production desktop adapter forwards the message title and preview unchan ), ); await flush(); - expect(sdk.invoke).toHaveBeenCalledExactlyOnceWith("notification_show", { + expect(sdk.show).toHaveBeenCalledExactlyOnceWith("notification_show", { title: "Pinky mentioned you in #Room", body: "Hello Wes", id: expect.any(String), @@ -171,7 +180,7 @@ it("the production desktop adapter forwards the message title and preview unchan }); function presentation(index = 0) { - const call = sdk.invoke.mock.calls[index] as unknown as [ + const call = sdk.show.mock.calls[index] as unknown as [ string, { id: string; @@ -240,7 +249,7 @@ it("native close/error never opens or retries; focus failure still preserves exa expect(service.snapshot().error).toBe("Window focus failed"); await service.refreshPermission(); await flush(); - expect(sdk.invoke).toHaveBeenCalledTimes(3); + expect(sdk.show).toHaveBeenCalledTimes(3); }); it("account replacement and service disposal fence previously displayed native clicks", async () => { @@ -265,7 +274,7 @@ it("the click channel exists before native submission, including immediate activ const { navigation, submit } = setup(); const open = vi.fn(); navigation.subscribe(() => open(navigation.snapshot().entry.target)); - sdk.invoke.mockImplementationOnce(async (...args: unknown[]) => { + sdk.show.mockImplementationOnce(async (...args: unknown[]) => { const { id, onEvent } = args[1] as ReturnType; onEvent.onmessage({ id, kind: "activated" }); }); @@ -294,7 +303,7 @@ it("native presentation rejects at capacity before sending instead of evicting l failed, ), ).rejects.toThrow("maximum 128"); - expect(sdk.invoke).toHaveBeenCalledTimes(128); + expect(sdk.show).toHaveBeenCalledTimes(128); const first = presentation(0); first.onEvent.onmessage({ id: first.id, kind: "activated" }); expect(activate).toHaveBeenCalledOnce(); @@ -303,6 +312,60 @@ it("native presentation rejects at capacity before sending instead of evicting l activate, failed, ); - expect(sdk.invoke).toHaveBeenCalledTimes(129); + expect(sdk.show).toHaveBeenCalledTimes(129); platform.dispose(); }); + +it.each(["Win32", "Linux x86_64"])( + "%s has no Dock IPC or Settings and keeps existing banners", + async (platform) => { + vi.stubGlobal("navigator", { platform }); + const { service, submit, ctx } = setup(); + await service.indicator.refresh(); + expect(service.indicator.available).toBe(false); + expect( + renderToStaticMarkup( + createElement(NotificationSettings, { notifications: service }), + ), + ).not.toContain("Dock unread badge"); + service.indicator.setUnread(true); + await flush(); + await service.indicator.request(); + expect(sdk.permission).not.toHaveBeenCalled(); + await submit("banner"); + await flush(); + expect(sdk.show).toHaveBeenCalledOnce(); + await ctx.fiber.dispose(); + expect(sdk.indicator).not.toHaveBeenCalled(); + expect(sdk.permission).not.toHaveBeenCalled(); + }, +); + +it("browser runs create no shell indicator", async () => { + native.value = false; + const { service, ctx } = setup(); + service.indicator.setUnread(true); + await service.indicator.refresh(); + await ctx.fiber.dispose(); + expect(service.indicator.available).toBe(false); + expect(sdk.permission).not.toHaveBeenCalled(); + expect(sdk.indicator).not.toHaveBeenCalled(); +}); + +it("the macOS default binds explicit permission and ordered unread/clear commands", async () => { + sdk.permission.mockResolvedValueOnce("default"); + const { service, ctx } = setup(); + service.indicator.setUnread(true); + await service.indicator.refresh(); + expect(sdk.indicator.mock.calls).toEqual([[{ unread: false }]]); + await service.indicator.request(); + expect(sdk.permission.mock.calls).toEqual([ + [{ request: false }], + [{ request: true }], + ]); + expect(sdk.indicator).toHaveBeenLastCalledWith({ unread: true }); + service.updatePreferences({ enabled: false }); + expect(sdk.indicator).toHaveBeenLastCalledWith({ unread: true }); + await ctx.fiber.dispose(); + expect(sdk.indicator).toHaveBeenLastCalledWith({ unread: false }); +}); diff --git a/src/features/notifications/indicator-unread.test.ts b/src/features/notifications/indicator-unread.test.ts new file mode 100644 index 000000000..4550313d0 --- /dev/null +++ b/src/features/notifications/indicator-unread.test.ts @@ -0,0 +1,206 @@ +import { afterEach, expect, it, vi } from "vitest"; +import type { Communities } from "../communities/service"; +import { createRelaySession } from "../relay/session"; +import type { RelayEvent } from "../relay/events"; +import { readJournal, type ReadJournal } from "../relay/read-state-storage"; +import { keypair, message, metadata, roster, signed } from "../relay/testing"; +import { bindUnreadIndicator } from "./indicator-unread"; + +const cleanups: (() => void)[] = []; +afterEach(() => { + for (const stop of cleanups.splice(0).reverse()) stop(); +}); +function setup() { + const viewer = keypair(), + peer = keypair(), + relay = keypair(); + let incoming = (_events: readonly RelayEvent[]) => {}; + let journal: ReadJournal | undefined; + const query = vi.fn(async () => [] as RelayEvent[]); + const owner = createRelaySession( + { + viewer: viewer.pubkey, + relayAuthor: relay.pubkey, + query, + media: () => undefined, + readState: { + decode: async () => [], + sign: async () => { + throw new Error("No publication in this fixture"); + }, + publish: async () => {}, + }, + subscribe(callbacks) { + incoming = callbacks.receive; + return { update() {}, retry() {}, dispose() {} }; + }, + }, + { + readStateStorage: { + async update(change) { + journal = readJournal(change(journal), viewer.pubkey); + return journal; + }, + close() {}, + }, + // Local read intent is the contract; no publication lease is acquired. + readPublisherLock: async () => {}, + }, + ); + cleanups.push(owner.dispose); + const client = { + viewer: viewer.pubkey as string | undefined, + selected: "https://one.example" as string | null, + memberships: [{ id: "https://one.example" }], + }; + const relayState = { + status: "ready", + viewer: viewer.pubkey, + session: owner.session, + }; + const listeners = new Set<() => void>(); + const subscribe = (fn: () => void) => { + listeners.add(fn); + return () => { + listeners.delete(fn); + }; + }; + const communities = { + snapshot: () => client, + subscribe, + relay: { snapshot: () => relayState, subscribe }, + } as unknown as Communities; + const project = vi.fn(); + const bind = () => { + const stop = bindUnreadIndicator(communities, project); + cleanups.push(stop); + return stop; + }; + return { + ...owner, + viewer, + peer, + relay, + client, + relayState, + project, + query, + bind, + notify() { + for (const fn of listeners) fn(); + }, + emit(events: readonly RelayEvent[]) { + incoming(events); + }, + grant(id = "room", time = 10) { + incoming([ + roster(relay, id, [viewer.pubkey], time), + metadata(relay, id, id, time), + ]); + }, + }; +} +const target = { kind: "channel", channelId: "room" } as const; +it("starts clear for unknown evidence; projects arrivals and explicit read clearing without new reads", async () => { + const h = setup(); + h.grant(); + h.bind(); + expect(h.project.mock.calls).toEqual([[false]]); + expect(h.session.unread.snapshot(target).observedCount).toBeNull(); + const row = message(h.peer, "room", "arrival", 11); + h.emit([row]); + expect(h.project.mock.calls).toEqual([[false], [true]]); + await h.session.unread.markThrough(target, row.id); + expect(h.project.mock.calls).toEqual([[false], [true], [false]]); + expect(h.query).not.toHaveBeenCalled(); +}); +it("restores existing unread at binding startup, including thread-only activity and manual unread", async () => { + const h = setup(); + h.grant(); + const root = message(h.viewer, "room", "my thread", 11); + const reply = message(h.peer, "room", "reply", 12, [ + ["e", root.id, "", "reply"], + ]); + h.emit([root, reply]); + h.bind(); + expect(h.project).toHaveBeenLastCalledWith(true); + await h.session.unread.markThrough( + { kind: "thread", channelId: "room", rootId: root.id }, + reply.id, + ); + expect(h.project).toHaveBeenLastCalledWith(false); + await h.session.unread.markUnreadLocal(target); + expect(h.project).toHaveBeenLastCalledWith(true); + await h.session.unread.markThrough(target, root.id); + expect(h.project).toHaveBeenLastCalledWith(false); +}); +it("uses all accessible channels, ignores own/auxiliary traffic, and clears on deletion or access loss", () => { + const h = setup(); + h.bind(); + h.grant(); + h.emit([message(h.viewer, "room", "own", 11)]); + expect(h.project.mock.calls).toEqual([[false]]); + h.grant("second"); + const row = message(h.peer, "second", "unread elsewhere", 12); + h.emit([row]); + expect(h.project).toHaveBeenLastCalledWith(true); + h.emit([ + signed(h.peer, { + kind: 5, + content: "", + tags: [ + ["h", "second"], + ["e", row.id], + ], + }), + ]); + expect(h.project).toHaveBeenLastCalledWith(false); + h.emit([message(h.peer, "room", "private", 13)]); + expect(h.project).toHaveBeenLastCalledWith(true); + h.emit([roster(h.relay, "room", [], 14)]); + expect(h.project).toHaveBeenLastCalledWith(false); +}); +it.each(["personal", "viewer", "disconnected", "membership"])( + "clears and detaches old evidence on %s transition", + (transition) => { + const h = setup(); + h.grant(); + h.bind(); + h.emit([message(h.peer, "room", "before switch", 11)]); + if (transition === "personal") h.client.selected = null; + if (transition === "viewer") h.client.viewer = keypair().pubkey; + if (transition === "disconnected") h.relayState.status = "disconnected"; + if (transition === "membership") h.client.memberships = []; + h.notify(); + expect(h.project).toHaveBeenLastCalledWith(false); + const calls = h.project.mock.calls.length; + h.emit([message(h.peer, "room", "retired session", 12)]); + expect(h.project).toHaveBeenCalledTimes(calls); + }, +); +it("retargets an already-open community and ignores the prior session after switching and teardown", () => { + const h = setup(), + other = setup(); + h.grant(); + other.grant(); + const stop = h.bind(); + h.emit([message(h.peer, "room", "first community", 11)]); + h.client.selected = "https://two.example"; + h.client.memberships.push({ id: h.client.selected }); + h.client.viewer = other.viewer.pubkey; + h.relayState.viewer = other.viewer.pubkey; + h.relayState.session = other.session; + h.notify(); + expect(h.project).toHaveBeenLastCalledWith(false); + h.emit([message(h.peer, "room", "background community", 12)]); + expect(h.project).toHaveBeenLastCalledWith(false); + other.emit([message(other.peer, "room", "selected community", 13)]); + expect(h.project).toHaveBeenLastCalledWith(true); + stop(); + expect(h.project).toHaveBeenLastCalledWith(false); + const calls = h.project.mock.calls.length; + other.emit([message(other.peer, "room", "after stop", 14)]); + h.notify(); + stop(); + expect(h.project).toHaveBeenCalledTimes(calls); +}); diff --git a/src/features/notifications/indicator-unread.ts b/src/features/notifications/indicator-unread.ts new file mode 100644 index 000000000..cfcc32c4e --- /dev/null +++ b/src/features/notifications/indicator-unread.ts @@ -0,0 +1,96 @@ +import type { Communities } from "../communities/service"; +import type { RelaySession } from "../relay/session"; + +/** A host projection of existing evidence, never a count or a new read owner. */ +export function bindUnreadIndicator( + communities: Communities, + project: (unread: boolean) => void, +) { + let closed = false; + let session: RelaySession | undefined; + let identity = ""; + let previous: boolean | undefined; + let stopRoster = () => {}; + const channels = new Map void>(); + const publish = (unread: boolean) => { + if (unread === previous) return; + previous = unread; + project(unread); + }; + const clear = () => { + stopRoster(); + for (const stop of channels.values()) stop(); + channels.clear(); + }; + const update = () => { + if (closed) return; + const client = communities.snapshot(); + const relay = communities.relay.snapshot(); + const next = `${client.viewer ?? ""}:${client.selected ?? ""}`; + const owned = + relay.status === "ready" && + client.viewer && + relay.viewer === client.viewer && + client.memberships.some((item) => item.id === client.selected) + ? relay.session + : undefined; + if (session === owned && identity === next) return; + clear(); + session = owned; + identity = next; + publish(false); + if (!owned) return; + const viewer = client.viewer as string; + const valid = () => !closed && session === owned && identity === next; + const changed = () => { + if (!valid()) return; + publish( + [...channels.keys()].some((channelId) => { + const snapshot = owned.unread.snapshot({ + kind: "channel", + channelId, + }); + return ( + snapshot.manual !== "none" || (snapshot.observedCount ?? 0) > 0 + ); + }), + ); + }; + const roster = () => { + if (!valid()) return; + const ids = new Set( + owned.channels + .list() + .channels.filter((channel) => channel.members?.includes(viewer)) + .map((channel) => channel.id), + ); + for (const [id, stop] of channels) { + if (ids.has(id)) continue; + stop(); + channels.delete(id); + } + for (const channelId of ids) { + if (!channels.has(channelId)) + channels.set( + channelId, + owned.unread.subscribe({ kind: "channel", channelId }, changed), + ); + } + changed(); + }; + stopRoster = owned.channels.subscribeList(roster); + roster(); + }; + const stop = communities.subscribe(update); + const stopRelay = communities.relay.subscribe(update); + update(); + return () => { + if (closed) return; + closed = true; + stop(); + stopRelay(); + clear(); + session = undefined; + publish(false); + }; +} diff --git a/src/features/notifications/indicator.test.ts b/src/features/notifications/indicator.test.ts new file mode 100644 index 000000000..a2c3ce7fb --- /dev/null +++ b/src/features/notifications/indicator.test.ts @@ -0,0 +1,131 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { createUnreadIndicator, type IndicatorPermission } from "./indicator"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} +const cleanups: (() => Promise)[] = []; +afterEach(async () => { + for (const stop of cleanups.splice(0)) await stop(); +}); +function setup(initial: IndicatorPermission = "enabled") { + const permission = vi.fn( + async (_request: boolean): Promise => initial, + ); + const set = vi.fn(async (_unread: boolean) => {}); + const host = new EventTarget(); + const dock = createUnreadIndicator({ permission, set }, host); + cleanups.push(dock.dispose); + return { dock, set, permission, host }; +} +it("silently checks on startup and projects unread after allow, independently of alert preferences", async () => { + const h = setup("default"); + h.dock.setUnread(true); + await h.dock.refresh(); + expect(h.permission.mock.calls).toEqual([[false]]); + expect(h.set.mock.calls).toEqual([[false]]); + h.permission.mockResolvedValueOnce("enabled"); + await h.dock.request(); + expect(h.permission.mock.calls).toEqual([[false], [true]]); + expect(h.set.mock.calls).toEqual([[false], [true]]); + h.dock.setUnread(false); + await h.dock.dispose(); + expect(h.set).toHaveBeenLastCalledWith(false); +}); +it.each(["default", "setup", "disabled", "denied", "unavailable"] as const)( + "withholds the dot for %s and clears on changed system permission", + async (permission) => { + const h = setup(); + await h.dock.refresh(); + h.dock.setUnread(true); + await vi.waitFor(() => expect(h.set).toHaveBeenLastCalledWith(true)); + h.permission.mockResolvedValueOnce(permission); + await h.dock.refresh(); + expect(h.set).toHaveBeenLastCalledWith(false); + h.dock.setUnread(true); + expect(h.set).toHaveBeenLastCalledWith(false); + }, +); +it("refreshes on focus, fails closed, reports errors, and only retries on a later action", async () => { + const h = setup(); + await h.dock.refresh(); + h.dock.setUnread(true); + const count = h.permission.mock.calls.length; + h.permission.mockRejectedValueOnce(new Error("OS unavailable")); + h.host.dispatchEvent(new Event("focus")); + await h.dock.refresh(); + expect(h.dock.snapshot()).toMatchObject({ + permission: "unavailable", + error: "OS unavailable", + }); + expect(h.set).toHaveBeenLastCalledWith(false); + expect(h.permission).toHaveBeenCalledTimes(count + 1); + await h.dock.refresh(); + expect(h.dock.snapshot()).toMatchObject({ + permission: "enabled", + error: null, + }); + expect(h.set).toHaveBeenLastCalledWith(true); +}); +it("coalesces rapid changes behind one pending native write and makes teardown clear win", async () => { + const h = setup(); + await h.dock.refresh(); + const pending = deferred(); + h.set.mockImplementationOnce(() => pending.promise); + h.dock.setUnread(true); + expect(h.set.mock.calls).toEqual([[false], [true]]); + h.dock.setUnread(false); + h.dock.setUnread(true); + const closing = h.dock.dispose(); + h.dock.setUnread(true); + expect(h.set.mock.calls).toEqual([[false], [true]]); + pending.resolve(); + await closing; + expect(h.set.mock.calls).toEqual([[false], [true], [false]]); + const count = h.permission.mock.calls.length; + h.host.dispatchEvent(new Event("focus")); + await h.dock.refresh(); + expect(h.permission).toHaveBeenCalledTimes(count); +}); +it("late permission completion cannot revive a disposed badge", async () => { + const h = setup(); + await h.dock.refresh(); + const pending = deferred(); + h.permission.mockImplementationOnce(() => pending.promise); + const check = h.dock.request(); + h.dock.setUnread(true); + await h.dock.dispose(); + pending.resolve("enabled"); + await check; + expect(h.set).toHaveBeenLastCalledWith(false); +}); +it("reports native write failure without a retry loop and accepts a later current-intent action", async () => { + const h = setup(); + await h.dock.refresh(); + h.set.mockRejectedValueOnce(new Error("Dock unavailable")); + h.dock.setUnread(true); + await vi.waitFor(() => + expect(h.dock.snapshot().error).toBe("Dock unavailable"), + ); + expect(h.set.mock.calls).toEqual([[false], [true]]); + await h.dock.refresh(); + expect(h.set.mock.calls).toEqual([[false], [true], [true]]); +}); +it("keeps an explicit request when a focus check was already pending", async () => { + const h = setup("default"); + await h.dock.refresh(); + const pending = deferred(); + h.permission.mockImplementationOnce(() => pending.promise); + const check = h.dock.refresh(); + const request = h.dock.request(); + pending.resolve("default"); + await check; + await request; + expect(h.permission.mock.calls).toEqual([[false], [false], [true]]); +}); diff --git a/src/features/notifications/indicator.ts b/src/features/notifications/indicator.ts new file mode 100644 index 000000000..4bef9fc8e --- /dev/null +++ b/src/features/notifications/indicator.ts @@ -0,0 +1,129 @@ +import { invoke, isTauri } from "@tauri-apps/api/core"; + +export type IndicatorPermission = + | "default" + | "setup" + | "enabled" + | "disabled" + | "denied" + | "unavailable"; +export interface IndicatorPlatform { + permission(request: boolean): Promise; + set(unread: boolean): Promise; +} +export function indicatorPlatform(): IndicatorPlatform | undefined { + const os = globalThis.navigator?.platform ?? ""; + if (!isTauri() || !/Mac/i.test(os)) return; + return { + permission: (request) => invoke("dock_permission", { request }), + set: (unread) => invoke("unread_indicator_set", { unread }), + }; +} + +/** One ordered native projection. Pending work always converges on current intent. */ +export function createUnreadIndicator( + platform = indicatorPlatform(), + host: + | Pick + | undefined = typeof window === "undefined" ? undefined : window, +) { + let closed = false, + unread = false; + let state = Object.freeze({ + permission: "unavailable" as IndicatorPermission, + requesting: false as boolean, + error: null as string | null, + }); + const listeners = new Set<() => void>(); + let desired = false; + let applied: boolean | undefined; + let writing: Promise | undefined; + let checking: Promise | undefined; + const publish = (patch: Partial) => { + if (closed) return; + state = Object.freeze({ ...state, ...patch }); + for (const listener of listeners) listener(); + }; + const failed = (error: unknown) => + publish({ error: error instanceof Error ? error.message : String(error) }); + function project() { + desired = !closed && unread && state.permission === "enabled"; + if (!platform || writing || desired === applied) return; + let attempted = desired; + writing = (async () => { + while (applied !== desired) { + const next = desired; + attempted = next; + try { + await platform.set(next); + applied = next; + } catch (error) { + failed(error); + // No retry loop. A later user action or unread transition can retry. + if (desired === next) break; + } + } + })().finally(() => { + writing = undefined; + if (desired !== attempted) project(); + }); + } + function check(request: boolean): Promise { + if (closed || !platform) return Promise.resolve(); + if (checking) { + return request && !state.requesting + ? checking.then(() => check(true)) + : checking; + } + publish({ requesting: request, error: null }); + checking = Promise.resolve() + .then(() => platform.permission(request)) + .then((permission) => { + publish({ permission }); + }) + .catch((error: unknown) => { + publish({ permission: "unavailable" }); + failed(error); + }) + .finally(() => { + checking = undefined; + publish({ requesting: false }); + project(); + }); + return checking; + } + const refresh = () => { + void check(false); + }; + project(); // Clear an earlier frontend's unread state before observing any new state. + if (platform) { + host?.addEventListener("focus", refresh); + refresh(); + } + return { + available: !!platform, + snapshot: () => state, + subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + refresh: () => check(false), + request: () => check(true), + setUnread(value: boolean) { + if (!closed) { + unread = value; + project(); + } + }, + async dispose() { + closed = true; + host?.removeEventListener("focus", refresh); + listeners.clear(); + project(); + while (writing) await writing; + }, + }; +} +export type UnreadIndicator = ReturnType; diff --git a/src/features/notifications/service.ts b/src/features/notifications/service.ts index 98c8b1c61..ff0758bc1 100644 --- a/src/features/notifications/service.ts +++ b/src/features/notifications/service.ts @@ -1,3 +1,4 @@ +import { createUnreadIndicator } from "./indicator"; import { Service, type Context } from "@deepseek-ai/cordis"; import { createContributions } from "../../plugins/contributions"; import { parseOpenTarget, type OpenTarget } from "../navigation/targets"; @@ -64,6 +65,7 @@ export type NotificationSnapshot = Readonly<{ /** Running-session delivery only: no notification journal, inbox, or recovery protocol. */ export class NotificationsService extends Service implements Notifications { + readonly indicator = createUnreadIndicator(); private readonly contributions; private readonly listeners = new Set<() => void>(); private readonly pending = new Set(); @@ -131,6 +133,7 @@ export class NotificationsService extends Service implements Notifications { this.listeners.clear(); if (typeof window !== "undefined") window.removeEventListener("focus", refresh); + return this.indicator.dispose(); }; }); void this.refreshPermission();