mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 19:59:20 +02:00
Compare commits
1 Commits
codex/comm
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3af104bfe |
7
packages/core/notifications/index.ts
Normal file
7
packages/core/notifications/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
showSystemNotification,
|
||||
detectWebNotificationSupport,
|
||||
requestWebNotificationPermission,
|
||||
isDesktopApp,
|
||||
} from "./system-notification";
|
||||
export type { WebNotificationSupport, SystemNotificationPayload } from "./system-notification";
|
||||
183
packages/core/notifications/system-notification.test.ts
Normal file
183
packages/core/notifications/system-notification.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
132
packages/core/notifications/system-notification.ts
Normal file
132
packages/core/notifications/system-notification.ts
Normal 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());
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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)}`,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user