mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-24 02:39:42 +02:00
feat(notifications): add system notifications toggle in settings (#2132)
* feat(notifications): add system notifications toggle in settings Add a per-user, per-workspace toggle to enable/disable native OS notification banners. Reuses the existing notification-preferences endpoint by introducing a `system_notifications` key alongside the inbox event groups; the realtime handler reads the cached preference and skips desktopAPI.showNotification when muted. Co-authored-by: multica-agent <github@multica.ai> * fix(notifications): fetch system_notifications pref lazily Settings is the only mounted reader of notificationPreferenceOptions, so a fresh app start (or any session that never visits Settings) left the cache empty and the muted preference silently fell back to default "all". Switch the inbox:new handler to ensureQueryData so the value is fetched on first use and cached for subsequent events. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -28,6 +28,7 @@ import {
|
||||
} from "../issues/ws-updaters";
|
||||
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged, onInboxIssueDeleted } from "../inbox/ws-updaters";
|
||||
import { inboxKeys } from "../inbox/queries";
|
||||
import { notificationPreferenceOptions } from "../notification-preferences/queries";
|
||||
import { workspaceKeys, workspaceListOptions } from "../workspace/queries";
|
||||
import { chatKeys } from "../chat/queries";
|
||||
import { useChatStore } from "../chat";
|
||||
@@ -268,7 +269,7 @@ export function useRealtimeSync(
|
||||
if (wsId) onIssueLabelsChanged(qc, wsId, issue_id, labels ?? []);
|
||||
});
|
||||
|
||||
const unsubInboxNew = ws.on("inbox:new", (p) => {
|
||||
const unsubInboxNew = ws.on("inbox:new", async (p) => {
|
||||
const { item } = p as InboxNewPayload;
|
||||
if (!item) return;
|
||||
const wsId = getCurrentWsId();
|
||||
@@ -278,6 +279,22 @@ export function useRealtimeSync(
|
||||
// styling is enough — no need to interrupt with a banner. `desktopAPI`
|
||||
// is injected by the preload script; its absence (web app) skips silently.
|
||||
if (typeof document !== "undefined" && document.hasFocus()) return;
|
||||
// Respect the user's system-notification preference. The Settings page
|
||||
// owns the only `useQuery` for this resource, so on a fresh app start
|
||||
// (or any session that hasn't visited Settings) the React Query cache
|
||||
// is empty — using `getQueryData` would silently default to "all" and
|
||||
// ignore the user's saved choice. `ensureQueryData` resolves to the
|
||||
// cached value if present and otherwise fetches once, populating the
|
||||
// cache for subsequent events. On network failure we fall through to
|
||||
// the default ("all") rather than swallow the banner entirely.
|
||||
if (wsId) {
|
||||
try {
|
||||
const prefData = await qc.ensureQueryData(notificationPreferenceOptions(wsId));
|
||||
if (prefData?.preferences?.system_notifications === "muted") return;
|
||||
} catch {
|
||||
// Fall through with default behavior.
|
||||
}
|
||||
}
|
||||
// Capture the source workspace slug at emit time. The user may switch
|
||||
// workspaces before clicking the banner (macOS Notification Center
|
||||
// holds banners), so routing must not read "current slug" at click
|
||||
|
||||
@@ -3,7 +3,8 @@ export type NotificationGroupKey =
|
||||
| "status_changes"
|
||||
| "comments"
|
||||
| "updates"
|
||||
| "agent_activity";
|
||||
| "agent_activity"
|
||||
| "system_notifications";
|
||||
|
||||
export type NotificationGroupValue = "all" | "muted";
|
||||
|
||||
|
||||
@@ -64,6 +64,12 @@
|
||||
"label": "Agent activity",
|
||||
"description": "When an agent task completes or fails"
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"tokens": {
|
||||
|
||||
@@ -64,6 +64,12 @@
|
||||
"label": "智能体活动",
|
||||
"description": "智能体 task 完成或失败时"
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
"title": "系统通知",
|
||||
"description": "控制 Multica 在后台时是否显示操作系统的原生通知横幅。",
|
||||
"label": "显示系统通知",
|
||||
"hint": "App 未获得焦点时,新的收件箱条目通过操作系统弹出通知横幅。"
|
||||
}
|
||||
},
|
||||
"tokens": {
|
||||
|
||||
@@ -10,13 +10,16 @@ import { Switch } from "@multica/ui/components/ui/switch";
|
||||
import { toast } from "sonner";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
const NOTIFICATION_GROUP_KEYS: NotificationGroupKey[] = [
|
||||
// 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");
|
||||
@@ -40,8 +43,10 @@ export function NotificationsTab() {
|
||||
});
|
||||
};
|
||||
|
||||
const systemEnabled = preferences.system_notifications !== "muted";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">{t(($) => $.notifications.title)}</h2>
|
||||
@@ -52,7 +57,7 @@ export function NotificationsTab() {
|
||||
|
||||
<Card>
|
||||
<CardContent className="divide-y">
|
||||
{NOTIFICATION_GROUP_KEYS.map((key) => {
|
||||
{INBOX_GROUP_KEYS.map((key: InboxGroupKey) => {
|
||||
const enabled = preferences[key] !== "muted";
|
||||
return (
|
||||
<div
|
||||
@@ -75,6 +80,32 @@ export function NotificationsTab() {
|
||||
</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>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,13 +12,18 @@ import (
|
||||
)
|
||||
|
||||
// validNotifGroups is the set of notification preference group keys that the
|
||||
// API accepts. Keys not in this set are rejected.
|
||||
// API accepts. Keys not in this set are rejected. `system_notifications` is
|
||||
// not an inbox event group — it's a delivery-channel toggle controlling
|
||||
// whether native OS notification banners fire — but it shares the same
|
||||
// preferences map so a single endpoint covers all user notification
|
||||
// preferences.
|
||||
var validNotifGroups = map[string]bool{
|
||||
"assignments": true,
|
||||
"status_changes": true,
|
||||
"comments": true,
|
||||
"updates": true,
|
||||
"agent_activity": true,
|
||||
"assignments": true,
|
||||
"status_changes": true,
|
||||
"comments": true,
|
||||
"updates": true,
|
||||
"agent_activity": true,
|
||||
"system_notifications": true,
|
||||
}
|
||||
|
||||
// validNotifValues is the set of allowed preference values per group.
|
||||
|
||||
Reference in New Issue
Block a user