fix frontend notification permission refresh (#3768)

This commit is contained in:
zgenu 2026-06-25 23:32:29 +08:00 committed by GitHub
parent 2e789eae18
commit 69cf4f4d86
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 254 additions and 9 deletions

View File

@ -54,6 +54,7 @@ export function NotificationSettingsPage() {
<div>{t.settings.notification.description}</div>
<div>
<Switch
aria-label={t.settings.notification.title}
disabled={permission !== "granted"}
checked={
permission === "granted" && settings.notification.enabled

View File

@ -24,7 +24,7 @@ export function useNotification(): UseNotificationReturn {
useState<NotificationPermission>("default");
const [isSupported, setIsSupported] = useState(false);
const lastNotificationTime = useRef<Date>(new Date());
const lastNotificationTime = useRef<number | null>(null);
useEffect(() => {
// Check if browser supports Notification API
@ -60,19 +60,25 @@ export function useNotification(): UseNotificationReturn {
return;
}
const currentPermission = Notification.permission;
if (currentPermission !== permission) {
setPermission(currentPermission);
}
if (currentPermission !== "granted") {
console.warn("Notification permission not granted");
return;
}
const now = Date.now();
if (
new Date().getTime() - lastNotificationTime.current.getTime() <
1000
lastNotificationTime.current !== null &&
now - lastNotificationTime.current < 1000
) {
console.warn("Notification sent too soon");
return;
}
lastNotificationTime.current = new Date();
if (permission !== "granted") {
console.warn("Notification permission not granted");
return;
}
lastNotificationTime.current = now;
const notification = new Notification(title, options);

View File

@ -0,0 +1,115 @@
import { expect, test, type Page } from "@playwright/test";
import { mockLangGraphAPI } from "./utils/mock-api";
declare global {
interface Window {
__deerflowNotifications?: Array<{ title: string; body?: string }>;
}
}
async function installNotificationMock(
page: Page,
initialPermission: NotificationPermission = "default",
) {
await page.addInitScript((permission) => {
window.__deerflowNotifications = [];
class MockNotification {
static permission: NotificationPermission = permission;
static async requestPermission(): Promise<NotificationPermission> {
MockNotification.permission = "granted";
return "granted";
}
onclick: (() => void) | null = null;
onerror: ((error: Event) => void) | null = null;
closed = false;
constructor(title: string, options?: NotificationOptions) {
window.__deerflowNotifications?.push({
title,
body: options?.body,
});
}
close() {
this.closed = true;
}
}
Object.defineProperty(window, "Notification", {
configurable: true,
value: MockNotification,
});
}, initialPermission);
}
async function openNotificationSettings(page: Page) {
await page.goto("/workspace/chats/new");
const sidebar = page.locator("[data-sidebar='sidebar']");
await sidebar.getByRole("button", { name: /Settings and more/ }).click();
await page.getByRole("menuitem", { name: "Settings" }).click();
const dialog = page.getByRole("dialog", { name: "Settings" });
await expect(dialog).toBeVisible();
await dialog.getByRole("button", { name: "Notification" }).click();
return dialog;
}
test.describe("Notification settings", () => {
test("can request permission and send the first test notification immediately", async ({
page,
}) => {
mockLangGraphAPI(page);
await installNotificationMock(page);
const dialog = await openNotificationSettings(page);
await dialog
.getByRole("button", { name: "Request notification permission" })
.click();
await expect(
dialog.getByRole("switch", { name: "Notification" }),
).toBeChecked();
await dialog
.getByRole("button", { name: "Send test notification" })
.click();
await expect
.poll(() => page.evaluate(() => window.__deerflowNotifications ?? []))
.toEqual([
{
title: "DeerFlow",
body: "This is a test notification.",
},
]);
});
test("sends a completion notification when chat finishes while the page is unfocused", async ({
page,
}) => {
mockLangGraphAPI(page);
await installNotificationMock(page, "granted");
await page.addInitScript(() => {
Document.prototype.hasFocus = () => false;
});
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill("Run a one minute task");
await textarea.press("Enter");
await expect
.poll(() => page.evaluate(() => window.__deerflowNotifications ?? []))
.toEqual([
{
title: "New Chat",
body: "Hello from DeerFlow!",
},
]);
});
});

View File

@ -0,0 +1,123 @@
import { afterEach, describe, expect, test, rs } from "@rstest/core";
type NotificationInstance = {
onclick: (() => void) | null;
onerror: ((error: Event) => void) | null;
close: () => void;
};
async function loadNotificationHook({
browserPermission = "granted",
hookPermission = "granted",
}: {
browserPermission?: NotificationPermission;
hookPermission?: NotificationPermission;
} = {}) {
const notifications: Array<{
title: string;
options: NotificationOptions | undefined;
instance: NotificationInstance;
}> = [];
class MockNotification implements NotificationInstance {
static permission: NotificationPermission = browserPermission;
static requestPermission = rs.fn(async () => {
MockNotification.permission = "granted";
return "granted" as const;
});
onclick: (() => void) | null = null;
onerror: ((error: Event) => void) | null = null;
close = rs.fn();
constructor(title: string, options?: NotificationOptions) {
notifications.push({ title, options, instance: this });
}
}
rs.resetModules();
rs.doMock("react", () => ({
useCallback: <T extends (...args: never[]) => unknown>(callback: T) =>
callback,
useEffect: () => undefined,
useRef: <T>(initialValue: T) => ({ current: initialValue }),
useState: <T>(initialValue: T) => {
if (initialValue === "default") {
return [hookPermission, rs.fn()] as const;
}
if (initialValue === false) {
return [true, rs.fn()] as const;
}
return [initialValue, rs.fn()] as const;
},
}));
rs.doMock("@/core/settings", () => ({
useLocalSettings: () => [
{
notification: { enabled: true },
tokenUsage: { headerTotal: true, inlineMode: "per_turn" },
context: {
model_name: undefined,
mode: undefined,
reasoning_effort: undefined,
},
},
rs.fn(),
],
}));
rs.stubGlobal("window", { focus: rs.fn() });
rs.stubGlobal("Notification", MockNotification);
const { useNotification } = await import("@/core/notification/hooks");
return {
notifications,
useNotification,
};
}
afterEach(() => {
rs.doUnmock("react");
rs.doUnmock("@/core/settings");
rs.unstubAllGlobals();
rs.resetModules();
});
describe("useNotification", () => {
test("allows the first notification immediately after the hook is created", async () => {
const { notifications, useNotification } = await loadNotificationHook();
const { showNotification } = useNotification();
showNotification("Finished", { body: "Conversation finished" });
expect(notifications).toHaveLength(1);
expect(notifications[0]?.title).toBe("Finished");
expect(notifications[0]?.options?.body).toBe("Conversation finished");
});
test("rate limits only after a notification has been sent", async () => {
const { notifications, useNotification } = await loadNotificationHook();
const { showNotification } = useNotification();
showNotification("First");
showNotification("Second");
expect(notifications.map((notification) => notification.title)).toEqual([
"First",
]);
});
test("uses the browser's current permission when another hook requested it", async () => {
const { notifications, useNotification } = await loadNotificationHook({
browserPermission: "granted",
hookPermission: "default",
});
const { showNotification } = useNotification();
showNotification("Finished elsewhere");
expect(notifications.map((notification) => notification.title)).toEqual([
"Finished elsewhere",
]);
});
});