Files
multica/packages/views/settings/components/browser-notification-setting.tsx
Bohan Jiang f5db77340f feat(web): native notification banners for the web app (MUL-3116) (#3883)
The in-app inbox (sidebar badge, real-time WS updates, settings, inbox
page) was already shared and worked on web. The only Desktop-only piece
was the native OS banner: handleInboxNew called desktopAPI.showNotification,
which is undefined on web, so no banner fired for new inbox items while the
app was unfocused.

Add the browser equivalent, keeping handleInboxNew as the single decision
point (focus + source-workspace mute gating stays shared with desktop):

- packages/core/platform/system-notification.ts: browser Notification engine
  (showWebNotification) + permission helpers + a click-handler registry. Lives
  in core (the caller does) but injects the click-routing decision so core
  stays headless.
- handleInboxNew: branch desktopAPI (unchanged) → else showWebNotification.
- apps/web WebNotificationBridge: registers click routing to the source
  workspace's inbox (?issue=…), mirroring desktop's DesktopInboxBridge.
- Settings → Notifications: web-only opt-in to grant browser permission
  (hidden on desktop / where the API is unavailable); en/zh-Hans/ja/ko.

Permission is an explicit settings opt-in (no auto-prompt on load, per
browser best practice). Tests cover the engine and the web path in
handleInboxNew.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-08 14:12:20 +08:00

75 lines
2.5 KiB
TypeScript

"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>
);
}