diff --git a/desktop/src/features/notifications/lib/desktop.ts b/desktop/src/features/notifications/lib/desktop.ts index 0844c24de51..617f1bb0b47 100644 --- a/desktop/src/features/notifications/lib/desktop.ts +++ b/desktop/src/features/notifications/lib/desktop.ts @@ -415,10 +415,44 @@ export async function revealDesktopAppWindow(): Promise { } } +let autoPermissionRequest: Promise | null = + null; +let autoPermissionRequestSettled = false; + +/** + * Desktop alerts default to on, but the OS permission is only requested when + * the Settings toggle is switched on — which never happens for a fresh install + * because the toggle already reads as on. Without this, channel, thread, and DM + * alerts are silently dropped forever while the OS (e.g. macOS Notification + * Center) has never even registered the app. Ask once per process, at the + * first alert Buzz actually tries to deliver, mirroring the home-feed path. + */ +async function resolvePermissionForDelivery(): Promise { + const permission = await getDesktopNotificationPermissionState(); + if (permission !== "default" || !isTauri() || autoPermissionRequestSettled) { + return permission; + } + + // Alerts that arrive while the prompt is open share its answer; once it has + // settled, never prompt again from here (Settings can still re-request). + autoPermissionRequest ??= requestDesktopNotificationAccess() + .catch((error) => { + console.warn( + "[desktop] notification permission request failed — notification dropped:", + error, + ); + return "default" as const; + }) + .finally(() => { + autoPermissionRequestSettled = true; + }); + return autoPermissionRequest; +} + export async function sendDesktopNotification( payload: DesktopNotificationPayload, ): Promise { - if ((await getDesktopNotificationPermissionState()) !== "granted") { + if ((await resolvePermissionForDelivery()) !== "granted") { return false; } diff --git a/desktop/src/features/notifications/lib/desktopPermissionAutoRequest.test.mjs b/desktop/src/features/notifications/lib/desktopPermissionAutoRequest.test.mjs new file mode 100644 index 00000000000..e6016711d0b --- /dev/null +++ b/desktop/src/features/notifications/lib/desktopPermissionAutoRequest.test.mjs @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +// A fresh macOS install: Desktop alerts default to on in Settings, but the +// native authorization has never been requested (UNAuthorizationStatus +// NotDetermined → "default"). +let nativePermission = "default"; +let requestResult = "granted"; +const calls = []; + +globalThis.isTauri = true; +globalThis.window = { Notification: class {} }; +Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { platform: "MacIntel", userAgent: "Macintosh" }, +}); +globalThis.__TAURI_INTERNALS__ = { + invoke: async (command, args) => { + calls.push({ command, args }); + if (command === "notification_permission_state") { + return nativePermission; + } + if (command === "request_notification_access") { + nativePermission = requestResult; + return requestResult; + } + if (command === "show_native_notification") { + return null; + } + throw new Error(`unmocked Tauri command: ${command}`); + }, + transformCallback: () => 1, +}; +window.__TAURI_INTERNALS__ = globalThis.__TAURI_INTERNALS__; + +const { sendDesktopNotification } = await import("./desktop.ts"); + +function commandsCalled(command) { + return calls.filter((call) => call.command === command); +} + +test("first alert requests the never-asked OS permission once, then delivers", async () => { + const [first, second] = await Promise.all([ + sendDesktopNotification({ title: "DM", body: "hey" }), + sendDesktopNotification({ title: "Thread reply", body: "yo" }), + ]); + + assert.equal(first, true); + assert.equal(second, true); + assert.equal(commandsCalled("request_notification_access").length, 1); + assert.deepEqual( + commandsCalled("show_native_notification").map((call) => call.args.title), + ["DM", "Thread reply"], + ); +}); + +test("does not prompt again later in the same process", async () => { + // Simulate the user resetting the permission while Buzz keeps running. + nativePermission = "default"; + requestResult = "denied"; + + const delivered = await sendDesktopNotification({ title: "Mention" }); + + assert.equal(delivered, false); + assert.equal(commandsCalled("request_notification_access").length, 1); +});