diff --git a/desktop/src/features/notifications/hooks.ts b/desktop/src/features/notifications/hooks.ts index d70ac60b22..8cda78bb76 100644 --- a/desktop/src/features/notifications/hooks.ts +++ b/desktop/src/features/notifications/hooks.ts @@ -1,14 +1,17 @@ import * as React from "react"; +import { isTauri } from "@tauri-apps/api/core"; import { useHomeFeedQuery } from "@/features/home/hooks"; import { useUsersBatchQuery } from "@/features/profile/hooks"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { Channel, FeedItem, HomeFeedResponse } from "@/shared/api/types"; +import { isWindowsPlatform } from "@/shared/lib/platform"; import { getDesktopNotificationPermissionState, requestDesktopNotificationAccess, type DesktopNotificationPermissionState, } from "./lib/desktop"; +import { ensureDesktopNotificationPermission } from "./lib/permission"; import { COMING_SOON_SLOTS, DEFAULT_SLOT_ALERTS_ENABLED, @@ -248,10 +251,12 @@ export function useNotificationSettings(pubkey?: string) { try { let nextPermission = await refreshPermission(); - if (nextPermission === "default") { - nextPermission = await requestDesktopNotificationAccess(); - setPermission(nextPermission); - } + nextPermission = await ensureDesktopNotificationPermission({ + currentPermission: nextPermission, + isWindowsTauri: isWindowsPlatform() && isTauri(), + requestAccess: requestDesktopNotificationAccess, + }); + setPermission(nextPermission); if (nextPermission !== "granted") { setSettings((current) => ({ diff --git a/desktop/src/features/notifications/lib/permission.test.mjs b/desktop/src/features/notifications/lib/permission.test.mjs new file mode 100644 index 0000000000..d57f809777 --- /dev/null +++ b/desktop/src/features/notifications/lib/permission.test.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { ensureDesktopNotificationPermission } from "./permission.ts"; + +test("Windows Tauri retries a false denied permission and accepts the granted result", async () => { + let requestCount = 0; + + const permission = await ensureDesktopNotificationPermission({ + currentPermission: "denied", + isWindowsTauri: true, + requestAccess: async () => { + requestCount += 1; + return "granted"; + }, + }); + + assert.equal(permission, "granted"); + assert.equal(requestCount, 1); +}); + +test("non-Windows-Tauri environments keep denied permission without requesting again", async () => { + for (const environment of [ + "Windows web", + "non-Windows Tauri", + "non-Windows web", + ]) { + let requestCount = 0; + + const permission = await ensureDesktopNotificationPermission({ + currentPermission: "denied", + isWindowsTauri: false, + requestAccess: async () => { + requestCount += 1; + return "granted"; + }, + }); + + assert.equal(permission, "denied", environment); + assert.equal(requestCount, 0, environment); + } +}); + +test("default permission still requests access on every platform", async () => { + for (const isWindowsTauri of [false, true]) { + let requestCount = 0; + + const permission = await ensureDesktopNotificationPermission({ + currentPermission: "default", + isWindowsTauri, + requestAccess: async () => { + requestCount += 1; + return "granted"; + }, + }); + + assert.equal(permission, "granted"); + assert.equal(requestCount, 1); + } +}); diff --git a/desktop/src/features/notifications/lib/permission.ts b/desktop/src/features/notifications/lib/permission.ts new file mode 100644 index 0000000000..f9f3a6a1a5 --- /dev/null +++ b/desktop/src/features/notifications/lib/permission.ts @@ -0,0 +1,26 @@ +import type { DesktopNotificationPermissionState } from "./desktop"; + +type EnsureDesktopNotificationPermissionOptions = { + currentPermission: DesktopNotificationPermissionState; + isWindowsTauri: boolean; + requestAccess: () => Promise; +}; + +/** + * Requests access for the normal default state and retries the Windows Tauri + * notification shim's known false-denied state. + */ +export async function ensureDesktopNotificationPermission({ + currentPermission, + isWindowsTauri, + requestAccess, +}: EnsureDesktopNotificationPermissionOptions): Promise { + if ( + currentPermission === "default" || + (currentPermission === "denied" && isWindowsTauri) + ) { + return requestAccess(); + } + + return currentPermission; +} diff --git a/desktop/src/shared/lib/platform.test.mjs b/desktop/src/shared/lib/platform.test.mjs new file mode 100644 index 0000000000..099bb132da --- /dev/null +++ b/desktop/src/shared/lib/platform.test.mjs @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { isWindowsPlatform } from "./platform.ts"; + +function withNavigatorPlatform(platform, callback) { + const originalNavigator = Object.getOwnPropertyDescriptor( + globalThis, + "navigator", + ); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { platform, userAgent: "" }, + }); + + try { + callback(); + } finally { + if (originalNavigator) { + Object.defineProperty(globalThis, "navigator", originalNavigator); + } else { + delete globalThis.navigator; + } + } +} + +test("Windows platform detection accepts Win32 without matching Darwin", () => { + withNavigatorPlatform("Win32", () => { + assert.equal(isWindowsPlatform(), true); + }); + withNavigatorPlatform("Darwin", () => { + assert.equal(isWindowsPlatform(), false); + }); +}); diff --git a/desktop/src/shared/lib/platform.ts b/desktop/src/shared/lib/platform.ts index 42e7f5b944..b8c2c3ecfc 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) diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index eefdef1fdd..7031e73e5c 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -1313,6 +1313,50 @@ test("notification settings drive the Inbox badge and desktop alerts", async ({ await expect.poll(getAppBadgeCount).toBe(baseline); }); +test("Windows retries a false denied notification permission from settings", async ({ + page, +}) => { + await page.addInitScript(() => { + Object.defineProperty(navigator, "platform", { + configurable: true, + value: "Win32", + }); + (window as Window & { isTauri?: boolean }).isTauri = true; + }); + await page.goto("/"); + + await page.evaluate(() => { + ( + window as Window & { + __BUZZ_E2E_SET_NOTIFICATION_PERMISSION__?: ( + permission: NotificationPermission, + requestResult?: NotificationPermission, + ) => void; + } + ).__BUZZ_E2E_SET_NOTIFICATION_PERMISSION__?.("denied", "granted"); + }); + + await openSettings(page, "notifications"); + const desktopToggle = page.getByTestId("notifications-desktop-toggle"); + const desktopState = page.getByTestId("notifications-desktop-state"); + + await desktopToggle.click(); + await expect(desktopToggle).toBeChecked(); + await expect(desktopState).toContainText("On"); + await expect + .poll(() => + page.evaluate( + () => + ( + window as Window & { + __BUZZ_E2E_GET_NOTIFICATION_PERMISSION_REQUEST_COUNT__?: () => number; + } + ).__BUZZ_E2E_GET_NOTIFICATION_PERMISSION_REQUEST_COUNT__?.() ?? 0, + ), + ) + .toBe(1); +}); + test("desktop notification clicks open the matching forum thread", async ({ page, }) => { diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index f7a6c4ccec..4dfe15e297 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -826,11 +826,18 @@ export async function installBridge(page: Page, options: BridgeOptions) { title: string; }> = []; const notificationInstances: MockNotification[] = []; + let notificationPermissionRequestCount = 0; + let notificationPermissionRequestResult: NotificationPermission | null = + null; class MockNotification extends EventTarget { static permission: NotificationPermission = "granted"; static async requestPermission(): Promise { + notificationPermissionRequestCount += 1; + if (notificationPermissionRequestResult) { + MockNotification.permission = notificationPermissionRequestResult; + } return MockNotification.permission; } @@ -863,10 +870,15 @@ export async function installBridge(page: Page, options: BridgeOptions) { __BUZZ_E2E_APP_BADGE_COUNT__?: number; __BUZZ_E2E_APP_BADGE_STATE__?: string; __BUZZ_E2E_CLICK_NOTIFICATION__?: (index: number) => boolean; + __BUZZ_E2E_GET_NOTIFICATION_PERMISSION_REQUEST_COUNT__?: () => number; __BUZZ_E2E_NOTIFICATIONS__?: Array<{ body: string | null; title: string; }>; + __BUZZ_E2E_SET_NOTIFICATION_PERMISSION__?: ( + permission: NotificationPermission, + requestResult?: NotificationPermission, + ) => void; }; const currentConfig = testWindow.__BUZZ_E2E__ ?? {}; @@ -893,7 +905,17 @@ export async function installBridge(page: Page, options: BridgeOptions) { notification.onclick?.(event); return true; }; + testWindow.__BUZZ_E2E_GET_NOTIFICATION_PERMISSION_REQUEST_COUNT__ = () => + notificationPermissionRequestCount; testWindow.__BUZZ_E2E_NOTIFICATIONS__ = notificationLog; + testWindow.__BUZZ_E2E_SET_NOTIFICATION_PERMISSION__ = ( + permission, + requestResult, + ) => { + MockNotification.permission = permission; + notificationPermissionRequestCount = 0; + notificationPermissionRequestResult = requestResult ?? null; + }; }, { identity,