Compare commits

...

1 Commits

Author SHA1 Message Date
Jiayuan Zhang
f3af104bfe feat(notifications): wire web browser desktop notifications
The Settings → System Notifications toggle persisted but never fired
on the web app — `inbox:new` only invoked `desktopAPI.showNotification`,
which is undefined outside Electron. Web users saw the toggle, enabled
it, and got nothing (GH #2339).

- Extract a shared `showSystemNotification` helper in `core/notifications`
  that bridges the Electron preload IPC AND the browser Notifications API.
  On web, the click handler focuses the tab and navigates to the inbox
  with the issue selector.
- Settings tab now requests browser permission when the toggle flips on,
  shows actionable hints when permission is denied / not yet granted /
  unsupported, and offers a "Send test notification" button so users can
  verify their setup without waiting for an inbox event.
- Refresh permission state on visibilitychange so the UI reflects changes
  the user makes in browser site settings.
- Tests cover desktop bridge, granted/denied/missing permission, and
  click-routing.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-10 14:38:27 +08:00
8 changed files with 426 additions and 19 deletions

View File

@@ -0,0 +1,7 @@
export {
showSystemNotification,
detectWebNotificationSupport,
requestWebNotificationPermission,
isDesktopApp,
} from "./system-notification";
export type { WebNotificationSupport, SystemNotificationPayload } from "./system-notification";

View File

@@ -0,0 +1,183 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
detectWebNotificationSupport,
isDesktopApp,
showSystemNotification,
} from "./system-notification";
interface NotificationMock {
title: string;
options?: NotificationOptions;
listeners: Map<string, EventListener>;
close: () => void;
}
const notificationInstances: NotificationMock[] = [];
class FakeNotification {
static permission: NotificationPermission = "default";
static requestPermission = vi.fn();
title: string;
options?: NotificationOptions;
listeners = new Map<string, EventListener>();
close = vi.fn();
constructor(title: string, options?: NotificationOptions) {
this.title = title;
this.options = options;
notificationInstances.push(this as unknown as NotificationMock);
}
addEventListener(type: string, listener: EventListener) {
this.listeners.set(type, listener);
}
}
const originalNotification = (globalThis as { Notification?: unknown }).Notification;
const originalWindow = (globalThis as { window?: unknown }).window;
beforeEach(() => {
notificationInstances.length = 0;
FakeNotification.permission = "default";
const win: Record<string, unknown> = {
focus: vi.fn(),
location: { assign: vi.fn() },
};
(globalThis as { window?: unknown }).window = win;
(globalThis as { Notification?: unknown }).Notification = FakeNotification;
});
afterEach(() => {
if (originalWindow === undefined) {
delete (globalThis as { window?: unknown }).window;
} else {
(globalThis as { window?: unknown }).window = originalWindow;
}
if (originalNotification === undefined) {
delete (globalThis as { Notification?: unknown }).Notification;
} else {
(globalThis as { Notification?: unknown }).Notification = originalNotification;
}
});
describe("detectWebNotificationSupport", () => {
it("reports api_unavailable when Notification is missing", () => {
delete (globalThis as { Notification?: unknown }).Notification;
expect(detectWebNotificationSupport()).toBe("api_unavailable");
});
it("reports permission_default when permission has not been asked", () => {
FakeNotification.permission = "default";
expect(detectWebNotificationSupport()).toBe("permission_default");
});
it("reports permission_denied when permission is denied", () => {
FakeNotification.permission = "denied";
expect(detectWebNotificationSupport()).toBe("permission_denied");
});
it("reports supported when permission is granted", () => {
FakeNotification.permission = "granted";
expect(detectWebNotificationSupport()).toBe("supported");
});
});
describe("showSystemNotification", () => {
it("uses desktopAPI when available", () => {
const showNotification = vi.fn();
(globalThis as { window?: { desktopAPI?: unknown } }).window = {
desktopAPI: { showNotification },
};
const result = showSystemNotification({
slug: "acme",
itemId: "item-1",
issueKey: "issue-1",
title: "Hello",
body: "World",
inboxPath: "/acme/inbox?issue=issue-1",
});
expect(result).toBe("delivered_desktop");
expect(showNotification).toHaveBeenCalledWith({
slug: "acme",
itemId: "item-1",
issueKey: "issue-1",
title: "Hello",
body: "World",
});
});
it("creates a web Notification when permission is granted", () => {
FakeNotification.permission = "granted";
const result = showSystemNotification({
slug: "acme",
itemId: "item-1",
issueKey: "issue-1",
title: "Hello",
body: "World",
inboxPath: "/acme/inbox?issue=issue-1",
});
expect(result).toBe("supported");
expect(notificationInstances).toHaveLength(1);
expect(notificationInstances[0]?.title).toBe("Hello");
expect(notificationInstances[0]?.options).toMatchObject({
body: "World",
tag: "item-1",
});
});
it("skips when permission is denied", () => {
FakeNotification.permission = "denied";
const result = showSystemNotification({
slug: "acme",
itemId: "item-1",
issueKey: "issue-1",
title: "Hello",
body: "World",
inboxPath: "/acme/inbox?issue=issue-1",
});
expect(result).toBe("permission_denied");
expect(notificationInstances).toHaveLength(0);
});
it("navigates to inbox path on click", () => {
FakeNotification.permission = "granted";
const assign = vi.fn();
(globalThis as { window?: unknown }).window = {
focus: vi.fn(),
location: { assign },
};
showSystemNotification({
slug: "acme",
itemId: "item-1",
issueKey: "issue-1",
title: "Hello",
body: "World",
inboxPath: "/acme/inbox?issue=issue-1",
});
const click = notificationInstances[0]?.listeners.get("click");
expect(click).toBeTypeOf("function");
click?.(new Event("click"));
expect(assign).toHaveBeenCalledWith("/acme/inbox?issue=issue-1");
});
});
describe("isDesktopApp", () => {
it("is false when desktopAPI is missing", () => {
expect(isDesktopApp()).toBe(false);
});
it("is true when desktopAPI is injected", () => {
(globalThis as { window?: unknown }).window = {
desktopAPI: { showNotification: vi.fn() },
};
expect(isDesktopApp()).toBe(true);
});
});

View File

@@ -0,0 +1,132 @@
import { createLogger } from "../logger";
const logger = createLogger("system-notification");
interface DesktopNotificationPayload {
slug: string;
itemId: string;
issueKey: string;
title: string;
body: string;
}
interface DesktopAPI {
showNotification?: (payload: DesktopNotificationPayload) => void;
}
function getDesktopAPI(): DesktopAPI | undefined {
if (typeof window === "undefined") return undefined;
return (window as unknown as { desktopAPI?: DesktopAPI }).desktopAPI;
}
export type WebNotificationSupport =
| "supported"
| "permission_default"
| "permission_denied"
| "api_unavailable"
| "no_window";
export function detectWebNotificationSupport(): WebNotificationSupport {
if (typeof window === "undefined") return "no_window";
if (typeof Notification === "undefined") return "api_unavailable";
switch (Notification.permission) {
case "granted":
return "supported";
case "denied":
return "permission_denied";
default:
return "permission_default";
}
}
export interface SystemNotificationPayload extends DesktopNotificationPayload {
/** Path to navigate to when the user clicks the banner (web fallback only). */
inboxPath: string;
}
/**
* Fire a native OS notification for an inbox item, abstracting over the
* Electron preload bridge (`window.desktopAPI`) and the browser
* Notifications API. Returns a status string useful for diagnostics; the
* caller is expected to have already gated on focus + the user's
* `system_notifications` preference.
*
* On the desktop app the click handler routing is wired in the main process
* (see apps/desktop/src/main/index.ts). On web we wire it here: the Notification
* click event focuses the tab and navigates to the inbox path with the issue
* selector pre-populated, mirroring the desktop UX as closely as the browser
* sandbox allows.
*/
export function showSystemNotification(payload: SystemNotificationPayload): WebNotificationSupport | "delivered_desktop" {
const desktopAPI = getDesktopAPI();
if (desktopAPI?.showNotification) {
desktopAPI.showNotification({
slug: payload.slug,
itemId: payload.itemId,
issueKey: payload.issueKey,
title: payload.title,
body: payload.body,
});
return "delivered_desktop";
}
const support = detectWebNotificationSupport();
if (support !== "supported") {
logger.debug("skip web notification", { support, title: payload.title });
return support;
}
try {
const notification = new Notification(payload.title, {
body: payload.body,
tag: payload.itemId,
});
notification.addEventListener("click", () => {
try {
window.focus();
} catch {
// Some browsers reject window.focus() outside a user gesture; ignore.
}
window.location.assign(payload.inboxPath);
notification.close();
});
return "supported";
} catch (err) {
logger.warn("web notification failed", err);
return "api_unavailable";
}
}
/**
* Prompt the browser for notification permission. Must be invoked from a
* user gesture (click, keypress) or the request is silently denied in many
* browsers. Returns the resulting permission state, or "unsupported" if the
* Notifications API is missing entirely.
*/
export async function requestWebNotificationPermission(): Promise<
"granted" | "denied" | "default" | "unsupported"
> {
if (typeof window === "undefined" || typeof Notification === "undefined") {
return "unsupported";
}
if (Notification.permission === "granted" || Notification.permission === "denied") {
return Notification.permission;
}
try {
const result = await Notification.requestPermission();
return result;
} catch (err) {
logger.warn("requestPermission failed", err);
return "denied";
}
}
/**
* True when this build is the Electron desktop app — the preload script
* injects `window.desktopAPI`. Used by the settings UI to hide the
* browser-permission affordance, since the main process owns notifications
* on desktop.
*/
export function isDesktopApp(): boolean {
return Boolean(getDesktopAPI());
}

View File

@@ -39,6 +39,7 @@
"./notification-preferences": "./notification-preferences/index.ts",
"./notification-preferences/queries": "./notification-preferences/queries.ts",
"./notification-preferences/mutations": "./notification-preferences/mutations.ts",
"./notifications": "./notifications/index.ts",
"./chat": "./chat/index.ts",
"./chat/queries": "./chat/queries.ts",
"./chat/mutations": "./chat/mutations.ts",

View File

@@ -29,10 +29,11 @@ import {
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged, onInboxIssueDeleted } from "../inbox/ws-updaters";
import { inboxKeys } from "../inbox/queries";
import { notificationPreferenceOptions } from "../notification-preferences/queries";
import { showSystemNotification } from "../notifications";
import { workspaceKeys, workspaceListOptions } from "../workspace/queries";
import { chatKeys } from "../chat/queries";
import { useChatStore } from "../chat";
import { resolvePostAuthDestination, useHasOnboarded } from "../paths";
import { paths, resolvePostAuthDestination, useHasOnboarded } from "../paths";
import type {
MemberAddedPayload,
WorkspaceDeletedPayload,
@@ -305,28 +306,19 @@ export function useRealtimeSync(
// workspace B's inbox and 404.
const slug = getCurrentSlug();
if (!slug) return;
const desktopAPI = (
window as unknown as {
desktopAPI?: {
showNotification?: (payload: {
slug: string;
itemId: string;
issueKey: string;
title: string;
body: string;
}) => void;
};
}
).desktopAPI;
// `issueKey` matches the inbox page's URL selector (issue id when the
// item is attached to an issue, otherwise the inbox item id). `itemId`
// is the inbox row's own id, needed to fire markInboxRead on click.
desktopAPI?.showNotification?.({
// showSystemNotification handles both the Electron preload bridge and
// the browser Notifications API (web), so this single call works
// across all platforms — see notifications/system-notification.ts.
showSystemNotification({
slug,
itemId: item.id,
issueKey: item.issue_id ?? item.id,
title: item.title,
body: item.body ?? "",
inboxPath: `${paths.workspace(slug).inbox()}?issue=${encodeURIComponent(item.issue_id ?? item.id)}`,
});
});

View File

@@ -69,7 +69,15 @@
"title": "System Notifications",
"description": "Control native OS notification banners shown when Multica is in the background.",
"label": "Show system notifications",
"hint": "Show a banner from your operating system for new inbox items when the app isn't focused."
"hint": "Show a banner from your operating system for new inbox items when the app isn't focused.",
"permission_default_hint": "Browser notifications are not yet authorized for this site. Toggling on again will prompt your browser for permission.",
"permission_denied_hint": "Browser notifications are blocked. Open the site permissions for this page in your browser settings, allow Notifications, then reload.",
"api_unavailable_hint": "This browser does not support Web Notifications. Try a recent version of Chrome, Edge, Firefox, or Safari, or use the Multica desktop app.",
"permission_denied_toast": "Browser blocked notification permission. Update your browser site settings to allow it.",
"unsupported_toast": "This browser does not support notifications.",
"test_button": "Send test notification",
"test_title": "Multica notifications are working",
"test_body": "You'll see banners like this when new inbox items arrive while Multica isn't focused."
}
},
"tokens": {

View File

@@ -69,7 +69,15 @@
"title": "系统通知",
"description": "控制 Multica 在后台时是否显示操作系统的原生通知横幅。",
"label": "显示系统通知",
"hint": "App 未获得焦点时,新的收件箱条目通过操作系统弹出通知横幅。"
"hint": "App 未获得焦点时,新的收件箱条目通过操作系统弹出通知横幅。",
"permission_default_hint": "浏览器尚未授权当前站点发送通知。再次打开开关时,浏览器会向你请求权限。",
"permission_denied_hint": "浏览器已拒绝通知权限。请在浏览器的站点权限设置中允许「通知」并刷新页面。",
"api_unavailable_hint": "当前浏览器不支持 Web 通知。请使用较新版本的 Chrome、Edge、Firefox、Safari或改用 Multica 桌面端。",
"permission_denied_toast": "浏览器拒绝了通知权限。请在站点权限里手动允许。",
"unsupported_toast": "当前浏览器不支持通知。",
"test_button": "发送测试通知",
"test_title": "Multica 通知已生效",
"test_body": "Multica 不在前台时收到新的收件箱条目,会以这种横幅形式提醒你。"
}
},
"tokens": {

View File

@@ -1,12 +1,21 @@
"use client";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useWorkspaceId } from "@multica/core/hooks";
import { notificationPreferenceOptions } from "@multica/core/notification-preferences/queries";
import { useUpdateNotificationPreferences } from "@multica/core/notification-preferences/mutations";
import type { NotificationGroupKey, NotificationPreferences } from "@multica/core/types";
import {
detectWebNotificationSupport,
isDesktopApp,
requestWebNotificationPermission,
showSystemNotification,
type WebNotificationSupport,
} from "@multica/core/notifications";
import { Card, CardContent } from "@multica/ui/components/ui/card";
import { Switch } from "@multica/ui/components/ui/switch";
import { Button } from "@multica/ui/components/ui/button";
import { toast } from "sonner";
import { useT } from "../../i18n";
@@ -45,6 +54,51 @@ export function NotificationsTab() {
const systemEnabled = preferences.system_notifications !== "muted";
// Browser permission state — desktop app handles notifications natively
// through the main process, so this UI only shows for the web app.
const desktop = isDesktopApp();
const [support, setSupport] = useState<WebNotificationSupport>(() =>
desktop ? "supported" : detectWebNotificationSupport(),
);
// Re-check permission on mount and when the page is re-shown (the user may
// change browser-level permission in another tab, or grant via the URL bar).
useEffect(() => {
if (desktop) return;
const refresh = () => setSupport(detectWebNotificationSupport());
refresh();
document.addEventListener("visibilitychange", refresh);
return () => document.removeEventListener("visibilitychange", refresh);
}, [desktop]);
const handleSystemToggle = async (enabled: boolean) => {
if (enabled && !desktop) {
// Permission requests must originate from a user gesture; doing it
// here (synchronously inside the click handler) keeps that contract.
const result = await requestWebNotificationPermission();
setSupport(detectWebNotificationSupport());
if (result === "denied") {
toast.error(t(($) => $.notifications.system.permission_denied_toast));
} else if (result === "unsupported") {
toast.error(t(($) => $.notifications.system.unsupported_toast));
}
}
handleToggle("system_notifications", enabled);
};
const handleTest = () => {
showSystemNotification({
slug: "",
itemId: "test",
issueKey: "test",
title: t(($) => $.notifications.system.test_title),
body: t(($) => $.notifications.system.test_body),
inboxPath: "/",
});
};
const showPermissionHint = !desktop && systemEnabled && support !== "supported";
return (
<div className="space-y-8">
<section className="space-y-4">
@@ -90,7 +144,7 @@ export function NotificationsTab() {
</div>
<Card>
<CardContent>
<CardContent className="space-y-3">
<div className="flex items-center justify-between">
<div className="space-y-0.5 pr-4">
<p className="text-sm font-medium">{t(($) => $.notifications.system.label)}</p>
@@ -100,9 +154,31 @@ export function NotificationsTab() {
</div>
<Switch
checked={systemEnabled}
onCheckedChange={(checked) => handleToggle("system_notifications", checked)}
onCheckedChange={handleSystemToggle}
/>
</div>
{showPermissionHint && (
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-900 dark:border-amber-900/40 dark:bg-amber-950/30 dark:text-amber-200">
{support === "permission_denied" && (
<p>{t(($) => $.notifications.system.permission_denied_hint)}</p>
)}
{support === "permission_default" && (
<p>{t(($) => $.notifications.system.permission_default_hint)}</p>
)}
{support === "api_unavailable" && (
<p>{t(($) => $.notifications.system.api_unavailable_hint)}</p>
)}
</div>
)}
{systemEnabled && support === "supported" && (
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={handleTest}>
{t(($) => $.notifications.system.test_button)}
</Button>
</div>
)}
</CardContent>
</Card>
</section>