Files
multica/packages/views/settings/components/notifications-tab.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

122 lines
4.3 KiB
TypeScript

"use client";
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 { 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.
const INBOX_GROUP_KEYS = [
"assignments",
"status_changes",
"comments",
"updates",
"agent_activity",
] as const;
type InboxGroupKey = (typeof INBOX_GROUP_KEYS)[number];
export function NotificationsTab() {
const { t } = useT("settings");
const wsId = useWorkspaceId();
const { data } = useQuery(notificationPreferenceOptions(wsId));
const mutation = useUpdateNotificationPreferences();
const preferences = data?.preferences ?? {};
const handleToggle = (key: NotificationGroupKey, enabled: boolean) => {
const updated: NotificationPreferences = {
...preferences,
[key]: enabled ? "all" : "muted",
};
// Remove keys set to "all" (default) to keep the object clean
if (enabled) {
delete updated[key];
}
mutation.mutate(updated, {
onError: (err) =>
toast.error(
err instanceof Error && err.message
? err.message
: t(($) => $.notifications.toast_failed),
),
});
};
const systemEnabled = preferences.system_notifications !== "muted";
return (
<div className="space-y-8">
<section className="space-y-4">
<div>
<h2 className="text-sm font-semibold">{t(($) => $.notifications.title)}</h2>
<p className="text-sm text-muted-foreground mt-1">
{t(($) => $.notifications.description)}
</p>
</div>
<Card>
<CardContent className="divide-y">
{INBOX_GROUP_KEYS.map((key: InboxGroupKey) => {
const enabled = preferences[key] !== "muted";
return (
<div
key={key}
className="flex items-center justify-between py-3 first:pt-0 last:pb-0"
>
<div className="space-y-0.5 pr-4">
<p className="text-sm font-medium">{t(($) => $.notifications.groups[key].label)}</p>
<p className="text-xs text-muted-foreground">
{t(($) => $.notifications.groups[key].description)}
</p>
</div>
<Switch
checked={enabled}
onCheckedChange={(checked) => handleToggle(key, checked)}
/>
</div>
);
})}
</CardContent>
</Card>
</section>
<section className="space-y-4">
<div>
<h2 className="text-sm font-semibold">{t(($) => $.notifications.system.title)}</h2>
<p className="text-sm text-muted-foreground mt-1">
{t(($) => $.notifications.system.description)}
</p>
</div>
<Card>
<CardContent>
<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>
<p className="text-xs text-muted-foreground">
{t(($) => $.notifications.system.hint)}
</p>
</div>
<Switch
checked={systemEnabled}
onCheckedChange={(checked) => handleToggle("system_notifications", checked)}
/>
</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>
);
}