Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions desktop/src/features/notifications/hooks.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -199,7 +202,16 @@ export function useNotificationSettings(pubkey?: string) {
}, [normalizedPubkey, settings]);

const refreshPermission = React.useEffectEvent(async () => {
const nextPermission = await getDesktopNotificationPermissionState();
let nextPermission = await getDesktopNotificationPermissionState();
// Windows Tauri boots with a false "denied" from the init shim before the
// app is registered as a notification sender. Apply the same one-shot
// recovery the toggle uses so the mount-time read does not write off a
// persisted desktopEnabled=true before the user touches anything.
nextPermission = await ensureDesktopNotificationPermission({
currentPermission: nextPermission,
isWindowsTauri: isWindowsPlatform() && isTauri(),
requestAccess: requestDesktopNotificationAccess,
});
setPermission(nextPermission);
return nextPermission;
});
Expand Down Expand Up @@ -248,10 +260,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) => ({
Expand Down
99 changes: 99 additions & 0 deletions desktop/src/features/notifications/lib/permission.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
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);
}
});

test("Windows Tauri recovers a false denied at boot so persisted desktopEnabled survives relaunch", async () => {
// Simulates the mount-time refreshPermission path: the init shim stamps
// "denied" before the app is registered as a notification sender, but a
// single requestPermission() returns "granted". Without this recovery the
// mount-time effect writes desktopEnabled=false before the user touches
// anything, requiring re-enabling after every restart.
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, "boot-time recovery fires exactly once");
});

test("Windows Tauri does not recover a genuine granted permission at boot", async () => {
// If the OS already grants permission, the boot-time read should not
// trigger an unnecessary requestPermission() call.
let requestCount = 0;

const permission = await ensureDesktopNotificationPermission({
currentPermission: "granted",
isWindowsTauri: true,
requestAccess: async () => {
requestCount += 1;
return "granted";
},
});

assert.equal(permission, "granted");
assert.equal(requestCount, 0, "granted does not trigger a re-request");
});
26 changes: 26 additions & 0 deletions desktop/src/features/notifications/lib/permission.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { DesktopNotificationPermissionState } from "./desktop";

type EnsureDesktopNotificationPermissionOptions = {
currentPermission: DesktopNotificationPermissionState;
isWindowsTauri: boolean;
requestAccess: () => Promise<DesktopNotificationPermissionState>;
};

/**
* 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<DesktopNotificationPermissionState> {
if (
currentPermission === "default" ||
(currentPermission === "denied" && isWindowsTauri)
) {
return requestAccess();
}

return currentPermission;
}
34 changes: 34 additions & 0 deletions desktop/src/shared/lib/platform.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
});
9 changes: 9 additions & 0 deletions desktop/src/shared/lib/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
96 changes: 96 additions & 0 deletions desktop/tests/e2e/profile.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1386,6 +1386,102 @@ 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).not.toBeChecked();
await expect(desktopState).toContainText("Blocked");

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("Windows boot-time recovery prevents false-denied from disabling persisted notifications", async ({
page,
}) => {
// Simulates the scenario Joxyko reported: a persisted desktopEnabled=true
// is written off on every relaunch because the boot-time read sees the
// init shim's false "denied" before the app is registered as a notification
// sender. With the boot-time recovery in refreshPermission, the mount-time
// read should request once and see "granted", so the toggle stays on.
await page.addInitScript(() => {
Object.defineProperty(navigator, "platform", {
configurable: true,
value: "Win32",
});
(window as Window & { isTauri?: boolean }).isTauri = true;
});
await page.goto("/");

// Pre-seed a persisted desktopEnabled=true and set the shim to false-deny
// then grant on request — exactly what happens on a clean Windows relaunch.
await page.evaluate(() => {
const pubkey = (window as Window & { __BUZZ_E2E_PUBKEY__?: string })
.__BUZZ_E2E_PUBKEY__;
if (pubkey) {
window.localStorage.setItem(
`buzz-notification-settings.v2:${pubkey}`,
JSON.stringify({ desktopEnabled: true, homeBadgeEnabled: true, notifyWhileViewing: false, sounds: {}, slotAlertsEnabled: {}, slotAlertsSnapshot: null }),
);
}
(
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");

// The boot-time recovery should have fired during mount, so the toggle
// stays on without the user touching it.
await expect(desktopState).toContainText("On");
await expect(desktopToggle).toBeChecked();
});

test("desktop notification clicks open the matching forum thread", async ({
page,
}) => {
Expand Down
22 changes: 22 additions & 0 deletions desktop/tests/helpers/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -835,11 +835,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<NotificationPermission> {
notificationPermissionRequestCount += 1;
if (notificationPermissionRequestResult) {
MockNotification.permission = notificationPermissionRequestResult;
}
return MockNotification.permission;
}

Expand Down Expand Up @@ -872,10 +879,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__ ?? {};

Expand All @@ -902,7 +914,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,
Expand Down