mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-29 02:19:19 +02:00
Compare commits
1 Commits
agent/lamb
...
agent/j/e9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3191d93e09 |
@@ -4,6 +4,7 @@ import { DashboardLayout } from "@multica/views/layout";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
import { SearchCommand, SearchTrigger } from "@multica/views/search";
|
||||
import { ChatFab, ChatWindow } from "@multica/views/chat";
|
||||
import { WebNotificationBridge } from "@/components/web-notification-bridge";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -15,6 +16,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
<SearchCommand />
|
||||
<ChatWindow />
|
||||
<ChatFab />
|
||||
<WebNotificationBridge />
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
||||
44
apps/web/components/web-notification-bridge.tsx
Normal file
44
apps/web/components/web-notification-bridge.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
registerSystemNotificationClickHandler,
|
||||
type SystemNotificationPayload,
|
||||
} from "@multica/core/platform";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import { useNavigation } from "@multica/views/navigation";
|
||||
|
||||
/**
|
||||
* Routes browser notification clicks to the source workspace's inbox, focused
|
||||
* on the clicked item. The web counterpart of the desktop `DesktopInboxBridge`:
|
||||
* desktop receives the click via Electron IPC, web wires it through the
|
||||
* Notification API's `onclick` (registered here into the core singleton).
|
||||
*
|
||||
* The route uses the `slug` the notification was emitted with — the SOURCE
|
||||
* workspace — not the active one, so a click always opens the right inbox even
|
||||
* after the user switches workspaces (#3766). An empty slug (unresolved
|
||||
* source) is ignored. Marking the row read is handled by InboxPage's
|
||||
* selected-item effect, which covers the `?issue=` URL-param path.
|
||||
*/
|
||||
export function WebNotificationBridge() {
|
||||
const { push } = useNavigation();
|
||||
// The adapter identity changes with the current route; the ref keeps the
|
||||
// registered click handler stable while always calling the latest push.
|
||||
const pushRef = useRef(push);
|
||||
useEffect(() => {
|
||||
pushRef.current = push;
|
||||
}, [push]);
|
||||
|
||||
useEffect(() => {
|
||||
registerSystemNotificationClickHandler(
|
||||
({ slug, issueKey }: SystemNotificationPayload) => {
|
||||
if (!slug) return;
|
||||
const inboxPath = `${paths.workspace(slug).inbox()}?issue=${encodeURIComponent(issueKey)}`;
|
||||
pushRef.current(inboxPath);
|
||||
},
|
||||
);
|
||||
return () => registerSystemNotificationClickHandler(null);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -6,3 +6,12 @@ export { createPersistStorage } from "./persist-storage";
|
||||
export { createWorkspaceAwareStorage, setCurrentWorkspace, getCurrentSlug, getCurrentWsId, subscribeToCurrentSlug, registerForWorkspaceRehydration } from "./workspace-storage";
|
||||
export { clearWorkspaceStorage } from "./storage-cleanup";
|
||||
export { isMac, modKey, enterKey, formatShortcut } from "./keyboard";
|
||||
export {
|
||||
registerSystemNotificationClickHandler,
|
||||
isWebNotificationSupported,
|
||||
getWebNotificationPermission,
|
||||
requestWebNotificationPermission,
|
||||
showWebNotification,
|
||||
type SystemNotificationPayload,
|
||||
type WebNotificationPermission,
|
||||
} from "./system-notification";
|
||||
|
||||
142
packages/core/platform/system-notification.test.ts
Normal file
142
packages/core/platform/system-notification.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getWebNotificationPermission,
|
||||
isWebNotificationSupported,
|
||||
registerSystemNotificationClickHandler,
|
||||
requestWebNotificationPermission,
|
||||
showWebNotification,
|
||||
type SystemNotificationPayload,
|
||||
} from "./system-notification";
|
||||
|
||||
// The core test environment is Node — `window`/`Notification` don't exist.
|
||||
// These tests install a minimal `window.Notification` stub on `globalThis`
|
||||
// (the same surface `getNotificationCtor` reads) to exercise the browser path.
|
||||
|
||||
type Created = { title: string; options?: NotificationOptions; instance: FakeNotification };
|
||||
|
||||
class FakeNotification {
|
||||
static permission: NotificationPermission = "granted";
|
||||
static requestPermission = vi.fn(async () => FakeNotification.permission);
|
||||
onclick: (() => void) | null = null;
|
||||
close = vi.fn();
|
||||
constructor(
|
||||
public title: string,
|
||||
public options?: NotificationOptions,
|
||||
) {
|
||||
created.push({ title, options, instance: this });
|
||||
}
|
||||
}
|
||||
|
||||
let created: Created[] = [];
|
||||
|
||||
function installWindow(
|
||||
ctor: unknown,
|
||||
focus: () => void = () => {},
|
||||
): void {
|
||||
(globalThis as Record<string, unknown>).window = { Notification: ctor, focus };
|
||||
}
|
||||
|
||||
function payload(
|
||||
overrides: Partial<SystemNotificationPayload> = {},
|
||||
): SystemNotificationPayload {
|
||||
return {
|
||||
slug: "workspace-a",
|
||||
itemId: "item-1",
|
||||
issueKey: "issue-1",
|
||||
title: "Mentioned you",
|
||||
body: "in a comment",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
created = [];
|
||||
FakeNotification.permission = "granted";
|
||||
FakeNotification.requestPermission.mockClear();
|
||||
registerSystemNotificationClickHandler(null);
|
||||
delete (globalThis as Record<string, unknown>).window;
|
||||
});
|
||||
|
||||
describe("isWebNotificationSupported / getWebNotificationPermission", () => {
|
||||
it("reports unsupported when there is no window", () => {
|
||||
expect(isWebNotificationSupported()).toBe(false);
|
||||
expect(getWebNotificationPermission()).toBe("unsupported");
|
||||
});
|
||||
|
||||
it("reflects the browser's current permission when supported", () => {
|
||||
installWindow(FakeNotification);
|
||||
FakeNotification.permission = "denied";
|
||||
expect(isWebNotificationSupported()).toBe(true);
|
||||
expect(getWebNotificationPermission()).toBe("denied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("requestWebNotificationPermission", () => {
|
||||
it("returns 'unsupported' without the API", async () => {
|
||||
await expect(requestWebNotificationPermission()).resolves.toBe("unsupported");
|
||||
});
|
||||
|
||||
it("prompts only when permission is 'default'", async () => {
|
||||
installWindow(FakeNotification);
|
||||
FakeNotification.permission = "default";
|
||||
FakeNotification.requestPermission.mockResolvedValueOnce("granted");
|
||||
|
||||
await expect(requestWebNotificationPermission()).resolves.toBe("granted");
|
||||
expect(FakeNotification.requestPermission).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not re-prompt once already decided", async () => {
|
||||
installWindow(FakeNotification);
|
||||
FakeNotification.permission = "denied";
|
||||
|
||||
await expect(requestWebNotificationPermission()).resolves.toBe("denied");
|
||||
expect(FakeNotification.requestPermission).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("showWebNotification", () => {
|
||||
it("does nothing when the API is unavailable", () => {
|
||||
expect(() => showWebNotification(payload())).not.toThrow();
|
||||
expect(created).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does nothing unless permission is granted", () => {
|
||||
installWindow(FakeNotification);
|
||||
FakeNotification.permission = "default";
|
||||
showWebNotification(payload());
|
||||
expect(created).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("shows a banner with body + a dedup tag when granted", () => {
|
||||
installWindow(FakeNotification);
|
||||
showWebNotification(payload());
|
||||
expect(created).toHaveLength(1);
|
||||
expect(created[0]?.title).toBe("Mentioned you");
|
||||
expect(created[0]?.options).toMatchObject({ body: "in a comment", tag: "item-1" });
|
||||
});
|
||||
|
||||
it("routes a click to the registered handler and closes the banner", () => {
|
||||
const focus = vi.fn();
|
||||
installWindow(FakeNotification, focus);
|
||||
const onClick = vi.fn();
|
||||
registerSystemNotificationClickHandler(onClick);
|
||||
|
||||
showWebNotification(payload());
|
||||
created[0]?.instance.onclick?.();
|
||||
|
||||
expect(focus).toHaveBeenCalledTimes(1);
|
||||
expect(created[0]?.instance.close).toHaveBeenCalledTimes(1);
|
||||
expect(onClick).toHaveBeenCalledWith(payload());
|
||||
});
|
||||
|
||||
it("swallows constructors that throw (e.g. service-worker-only engines)", () => {
|
||||
class ThrowingNotification {
|
||||
static permission: NotificationPermission = "granted";
|
||||
constructor() {
|
||||
throw new Error("requires a ServiceWorkerRegistration");
|
||||
}
|
||||
}
|
||||
installWindow(ThrowingNotification);
|
||||
expect(() => showWebNotification(payload())).not.toThrow();
|
||||
});
|
||||
});
|
||||
123
packages/core/platform/system-notification.ts
Normal file
123
packages/core/platform/system-notification.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
// Native system notification bridge for the WEB app.
|
||||
//
|
||||
// The desktop app renders OS banners through its Electron main process
|
||||
// (`window.desktopAPI.showNotification`); the web app has no such bridge, so it
|
||||
// uses the browser Notification API here. `handleInboxNew` (realtime sync) is
|
||||
// the single decision point — it already gates on focus + the source
|
||||
// workspace's mute preference — and calls `showWebNotification` as the web path
|
||||
// when no `desktopAPI` is present.
|
||||
//
|
||||
// Lives in `packages/core` (not `packages/views`) because the caller
|
||||
// (`handleInboxNew`) lives in core and `views` cannot be imported from core
|
||||
// (dependency direction is views → core). The click-routing decision, which
|
||||
// needs the app router, is injected by the host app via
|
||||
// `registerSystemNotificationClickHandler` instead — core stays headless.
|
||||
|
||||
export interface SystemNotificationPayload {
|
||||
/**
|
||||
* Source workspace slug. Empty when it couldn't be resolved — the click is
|
||||
* then a no-op rather than routing to the wrong workspace (#3766).
|
||||
*/
|
||||
slug: string;
|
||||
/** Inbox row id — lets the click mark the item read. */
|
||||
itemId: string;
|
||||
/** `?issue=<…>` selector for the inbox page (issue id, else the item id). */
|
||||
issueKey: string;
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
type ClickHandler = (payload: SystemNotificationPayload) => void;
|
||||
|
||||
// Module-level singleton — mirrors how the desktop preload registers its
|
||||
// behavior once at boot. The web shell registers a router-aware handler; while
|
||||
// unregistered (SSR, tests, pre-mount) a click is a silent no-op.
|
||||
let clickHandler: ClickHandler | null = null;
|
||||
|
||||
/**
|
||||
* Register how a clicked web notification routes (focus + navigate to the
|
||||
* source workspace's inbox, focused on the item). Called once by the web app
|
||||
* shell; pass `null` to unregister. Desktop does NOT use this — it routes
|
||||
* through its own Electron IPC bridge (`onInboxOpen`).
|
||||
*/
|
||||
export function registerSystemNotificationClickHandler(
|
||||
handler: ClickHandler | null,
|
||||
): void {
|
||||
clickHandler = handler;
|
||||
}
|
||||
|
||||
// Read the Notification constructor off `window` (rather than the bare global)
|
||||
// so it's both SSR-safe and injectable from the core Node test environment,
|
||||
// where `window`/`Notification` don't exist by default.
|
||||
function getNotificationCtor(): typeof Notification | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const ctor = (window as { Notification?: typeof Notification }).Notification;
|
||||
return typeof ctor === "function" ? ctor : null;
|
||||
}
|
||||
|
||||
/** True when the browser exposes the Notification API (false on SSR / old engines). */
|
||||
export function isWebNotificationSupported(): boolean {
|
||||
return getNotificationCtor() !== null;
|
||||
}
|
||||
|
||||
export type WebNotificationPermission = NotificationPermission | "unsupported";
|
||||
|
||||
/** Current permission, or "unsupported" when the API is unavailable. */
|
||||
export function getWebNotificationPermission(): WebNotificationPermission {
|
||||
const ctor = getNotificationCtor();
|
||||
return ctor ? ctor.permission : "unsupported";
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt for notification permission. Resolves to the resulting permission, or
|
||||
* "unsupported". Only "default" triggers the browser prompt — an
|
||||
* already-decided permission (granted/denied) is returned as-is.
|
||||
*/
|
||||
export async function requestWebNotificationPermission(): Promise<WebNotificationPermission> {
|
||||
const ctor = getNotificationCtor();
|
||||
if (!ctor) return "unsupported";
|
||||
if (ctor.permission !== "default") return ctor.permission;
|
||||
try {
|
||||
return await ctor.requestPermission();
|
||||
} catch {
|
||||
// Older Safari used a callback signature and can reject the promise form.
|
||||
return ctor.permission;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a native browser notification for a new inbox item. No-op unless the
|
||||
* Notification API is supported AND permission is "granted" — the caller
|
||||
* (`handleInboxNew`) owns the WHETHER (focus + mute gating); this owns only the
|
||||
* rendering. Clicking the banner focuses the tab and routes via the registered
|
||||
* click handler.
|
||||
*/
|
||||
export function showWebNotification(payload: SystemNotificationPayload): void {
|
||||
const ctor = getNotificationCtor();
|
||||
if (!ctor || ctor.permission !== "granted") return;
|
||||
let notification: Notification;
|
||||
try {
|
||||
notification = new ctor(payload.title, {
|
||||
body: payload.body,
|
||||
// Collapse repeat banners for the same inbox row (e.g. a reconnect
|
||||
// replays the `inbox:new` event).
|
||||
tag: payload.itemId,
|
||||
});
|
||||
} catch {
|
||||
// Some engines require an active ServiceWorkerRegistration to construct a
|
||||
// Notification (notably Chrome on Android). Degrade silently — the in-app
|
||||
// inbox and unread badge still surface the new item.
|
||||
return;
|
||||
}
|
||||
notification.onclick = () => {
|
||||
try {
|
||||
window.focus();
|
||||
} catch {
|
||||
// Best-effort; some browsers disallow programmatic focus.
|
||||
}
|
||||
notification.close();
|
||||
clickHandler?.(payload);
|
||||
};
|
||||
}
|
||||
@@ -468,4 +468,74 @@ describe("handleInboxNew", () => {
|
||||
expect.objectContaining({ slug: "" }),
|
||||
);
|
||||
});
|
||||
|
||||
// --- Web path: no desktopAPI → the browser Notification API ---
|
||||
// Same focus/mute gating as desktop, but the desktop bridge is absent and a
|
||||
// granted browser Notification stub is installed on `window`.
|
||||
let webBanners: { title: string; options?: NotificationOptions }[] = [];
|
||||
class FakeNotification {
|
||||
static permission: NotificationPermission = "granted";
|
||||
onclick: (() => void) | null = null;
|
||||
close = vi.fn();
|
||||
constructor(
|
||||
public title: string,
|
||||
public options?: NotificationOptions,
|
||||
) {
|
||||
webBanners.push({ title, options });
|
||||
}
|
||||
}
|
||||
function installBrowserNotification(
|
||||
permission: NotificationPermission = "granted",
|
||||
) {
|
||||
webBanners = [];
|
||||
FakeNotification.permission = permission;
|
||||
(globalThis as Record<string, unknown>).window = {
|
||||
Notification: FakeNotification,
|
||||
focus: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete (globalThis as Record<string, unknown>).window;
|
||||
});
|
||||
|
||||
it("shows a browser banner on web (no desktopAPI) when granted and not muted", async () => {
|
||||
const qc = createQueryClient();
|
||||
qc.setQueryData<Workspace[]>(workspaceKeys.list(), [workspace()]);
|
||||
qc.setQueryData(notificationPreferenceKeys.all("ws-a"), {
|
||||
preferences: { system_notifications: "all" },
|
||||
});
|
||||
installBrowserNotification("granted");
|
||||
|
||||
await handleInboxNew(qc, inboxItem());
|
||||
|
||||
expect(webBanners).toHaveLength(1);
|
||||
expect(webBanners[0]?.title).toBe("Mentioned you");
|
||||
});
|
||||
|
||||
it("shows no browser banner when the SOURCE workspace is muted", async () => {
|
||||
const qc = createQueryClient();
|
||||
qc.setQueryData<Workspace[]>(workspaceKeys.list(), [workspace()]);
|
||||
qc.setQueryData(notificationPreferenceKeys.all("ws-a"), {
|
||||
preferences: { system_notifications: "muted" },
|
||||
});
|
||||
installBrowserNotification("granted");
|
||||
|
||||
await handleInboxNew(qc, inboxItem());
|
||||
|
||||
expect(webBanners).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("shows no browser banner when permission is not granted", async () => {
|
||||
const qc = createQueryClient();
|
||||
qc.setQueryData<Workspace[]>(workspaceKeys.list(), [workspace()]);
|
||||
qc.setQueryData(notificationPreferenceKeys.all("ws-a"), {
|
||||
preferences: { system_notifications: "all" },
|
||||
});
|
||||
installBrowserNotification("default");
|
||||
|
||||
await handleInboxNew(qc, inboxItem());
|
||||
|
||||
expect(webBanners).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,6 +37,10 @@ import {
|
||||
notificationPreferenceKeys,
|
||||
} from "../notification-preferences/queries";
|
||||
import { workspaceKeys, workspaceListOptions } from "../workspace/queries";
|
||||
import {
|
||||
showWebNotification,
|
||||
type SystemNotificationPayload,
|
||||
} from "../platform/system-notification";
|
||||
import type { Workspace } from "../types/workspace";
|
||||
import { chatKeys } from "../chat/queries";
|
||||
import { useChatStore } from "../chat";
|
||||
@@ -254,33 +258,35 @@ export async function handleInboxNew(
|
||||
// Fall through with default behavior.
|
||||
}
|
||||
}
|
||||
const desktopAPI = (
|
||||
globalThis 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.
|
||||
// A null slug (workspace list unavailable / item from a workspace this
|
||||
// client can't see) still shows the banner — the user should learn about
|
||||
// the inbox item — but with an empty slug so the click is a no-op
|
||||
// (DesktopInboxBridge ignores empty slugs) instead of routing wrong.
|
||||
desktopAPI?.showNotification?.({
|
||||
// (the inbox bridge ignores empty slugs) instead of routing wrong.
|
||||
const payload: SystemNotificationPayload = {
|
||||
slug: slug ?? "",
|
||||
itemId: item.id,
|
||||
issueKey: item.issue_id ?? item.id,
|
||||
title: item.title,
|
||||
body: item.body ?? "",
|
||||
});
|
||||
};
|
||||
const desktopAPI = (
|
||||
globalThis as unknown as {
|
||||
desktopAPI?: {
|
||||
showNotification?: (payload: SystemNotificationPayload) => void;
|
||||
};
|
||||
}
|
||||
).desktopAPI;
|
||||
if (desktopAPI?.showNotification) {
|
||||
// Desktop: native OS banner rendered by the Electron main process.
|
||||
desktopAPI.showNotification(payload);
|
||||
return;
|
||||
}
|
||||
// Web: the browser Notification API. No-op without granted permission or on
|
||||
// SSR — the in-app inbox + unread badge still reflect the new item.
|
||||
showWebNotification(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -84,6 +84,14 @@
|
||||
"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."
|
||||
},
|
||||
"browser": {
|
||||
"label": "Browser notifications",
|
||||
"hint": "Allow this browser to show notification banners for new inbox items.",
|
||||
"enable": "Enable",
|
||||
"granted": "This browser can show notification banners.",
|
||||
"denied": "Blocked by your browser. Enable notifications for this site in your browser settings.",
|
||||
"enabled_badge": "Enabled"
|
||||
}
|
||||
},
|
||||
"tokens": {
|
||||
|
||||
@@ -84,6 +84,14 @@
|
||||
"description": "Multica がバックグラウンドにあるときに表示される OS のネイティブ通知バナーを制御します。",
|
||||
"label": "システム通知を表示",
|
||||
"hint": "アプリにフォーカスがないときに、新しいインボックス項目について OS のバナーを表示します。"
|
||||
},
|
||||
"browser": {
|
||||
"label": "ブラウザ通知",
|
||||
"hint": "このブラウザで新しいインボックス項目の通知バナーを表示できるようにします。",
|
||||
"enable": "有効にする",
|
||||
"granted": "このブラウザは通知バナーを表示できます。",
|
||||
"denied": "ブラウザによってブロックされています。ブラウザの設定でこのサイトの通知を許可してください。",
|
||||
"enabled_badge": "有効"
|
||||
}
|
||||
},
|
||||
"tokens": {
|
||||
|
||||
@@ -84,6 +84,14 @@
|
||||
"description": "Multica가 백그라운드에 있을 때 표시되는 운영체제 알림 배너를 제어합니다.",
|
||||
"label": "시스템 알림 표시",
|
||||
"hint": "앱에 포커스가 없을 때 새 인박스 항목에 대해 운영체제 배너를 표시합니다."
|
||||
},
|
||||
"browser": {
|
||||
"label": "브라우저 알림",
|
||||
"hint": "이 브라우저에서 새 인박스 항목에 대한 알림 배너를 표시하도록 허용합니다.",
|
||||
"enable": "사용",
|
||||
"granted": "이 브라우저에서 알림 배너를 표시할 수 있습니다.",
|
||||
"denied": "브라우저에서 차단되었습니다. 브라우저 설정에서 이 사이트의 알림을 허용하세요.",
|
||||
"enabled_badge": "사용 중"
|
||||
}
|
||||
},
|
||||
"tokens": {
|
||||
|
||||
@@ -84,6 +84,14 @@
|
||||
"description": "控制 Multica 在后台时是否显示操作系统的原生通知横幅。",
|
||||
"label": "显示系统通知",
|
||||
"hint": "App 未获得焦点时,新的收件箱条目通过操作系统弹出通知横幅。"
|
||||
},
|
||||
"browser": {
|
||||
"label": "浏览器通知",
|
||||
"hint": "允许当前浏览器为新的收件箱条目弹出通知横幅。",
|
||||
"enable": "启用",
|
||||
"granted": "当前浏览器可以弹出通知横幅。",
|
||||
"denied": "已被浏览器拦截。请在浏览器设置中为本站点开启通知权限。",
|
||||
"enabled_badge": "已启用"
|
||||
}
|
||||
},
|
||||
"tokens": {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
getWebNotificationPermission,
|
||||
isWebNotificationSupported,
|
||||
requestWebNotificationPermission,
|
||||
type WebNotificationPermission,
|
||||
} from "@multica/core/platform";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Card, CardContent } from "@multica/ui/components/ui/card";
|
||||
import { isDesktopShell } from "../../platform";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
/**
|
||||
* Web-only control for the browser permission that native notification banners
|
||||
* require. Desktop delivers banners through the OS via Electron (no browser
|
||||
* permission involved), so this renders nothing there. It also renders nothing
|
||||
* when the Notification API is unavailable (SSR, older browsers).
|
||||
*
|
||||
* Capability and permission are read from `window`, so the first paint defers
|
||||
* to a post-mount effect to keep SSR and client markup identical (no hydration
|
||||
* mismatch).
|
||||
*/
|
||||
export function BrowserNotificationSetting() {
|
||||
const { t } = useT("settings");
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [permission, setPermission] =
|
||||
useState<WebNotificationPermission>("default");
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
setPermission(getWebNotificationPermission());
|
||||
}, []);
|
||||
|
||||
// Pre-mount, on desktop, or where the API is missing → nothing to manage.
|
||||
if (!mounted || isDesktopShell() || !isWebNotificationSupported()) return null;
|
||||
|
||||
const handleEnable = async () => {
|
||||
setPermission(await requestWebNotificationPermission());
|
||||
};
|
||||
|
||||
const statusHint =
|
||||
permission === "granted"
|
||||
? t(($) => $.notifications.browser.granted)
|
||||
: permission === "denied"
|
||||
? t(($) => $.notifications.browser.denied)
|
||||
: t(($) => $.notifications.browser.hint);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-0.5 pr-4">
|
||||
<p className="text-sm font-medium">
|
||||
{t(($) => $.notifications.browser.label)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{statusHint}</p>
|
||||
</div>
|
||||
{permission === "default" && (
|
||||
<Button size="sm" variant="outline" onClick={handleEnable}>
|
||||
{t(($) => $.notifications.browser.enable)}
|
||||
</Button>
|
||||
)}
|
||||
{permission === "granted" && (
|
||||
<span className="shrink-0 text-xs font-medium text-muted-foreground">
|
||||
{t(($) => $.notifications.browser.enabled_badge)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { Card, CardContent } from "@multica/ui/components/ui/card";
|
||||
import { Switch } from "@multica/ui/components/ui/switch";
|
||||
import { toast } from "sonner";
|
||||
import { useT } from "../../i18n";
|
||||
import { BrowserNotificationSetting } from "./browser-notification-setting";
|
||||
|
||||
// Inbox event groups rendered in the per-event toggle list. `system_notifications`
|
||||
// is a sibling preference key but lives in its own section below.
|
||||
@@ -110,6 +111,10 @@ export function NotificationsTab() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Web-only: the browser permission banners require. Renders nothing on
|
||||
desktop (OS-native delivery) or where the Notification API is absent. */}
|
||||
<BrowserNotificationSetting />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user