feat(desktop): dock unread badge + focus-gated inbox notifications

Wire two OS-level integrations for inbox activity. Both degrade cleanly on
web and unsupported platforms.

- Unread badge on the macOS dock / Linux Unity launcher. Derived from the
  same inbox list the UI renders, deduplicated per issue, capped as "99+"
  on macOS via `app.dock.setBadge` (setBadgeCount truncates at 99). New
  `useInboxUnreadCount` hook (core/inbox) + `useDesktopUnreadBadge`
  (views/platform) keep renderer and main in sync via a `badge:set` IPC.
- Native OS notification on `inbox:new`, fired from the renderer only when
  `document.hasFocus()` is false — in-focus feedback is the existing inbox
  sidebar's unread styling, so we don't fight macOS's deliberate foreground
  suppression. Clicking the banner focuses the main window and navigates
  to `/inbox?issue=<key>` via the shared `multica:navigate` bus.

Refactors `inbox-page.tsx` to read the unread count through the new hook
(was a per-render inline filter).
This commit is contained in:
Jiang Bohan
2026-04-21 17:06:54 +08:00
parent 642844c736
commit 584bf232cc
9 changed files with 184 additions and 4 deletions

View File

@@ -1,4 +1,4 @@
import { app, BrowserWindow, ipcMain, nativeImage } from "electron";
import { app, BrowserWindow, ipcMain, nativeImage, Notification } from "electron";
import { homedir } from "os";
import { join } from "path";
import { electronApp, optimizer, is } from "@electron-toolkit/utils";
@@ -201,6 +201,49 @@ if (!gotTheLock) {
mainWindow?.setWindowButtonVisibility(!immersive);
});
// IPC: show a native OS notification for a new inbox item. The renderer
// only fires this when the app is unfocused (it gates on
// `document.hasFocus()`), so we don't fight macOS foreground suppression
// here. Clicking the banner focuses the main window and routes to the
// inbox item via a renderer-side listener.
ipcMain.on(
"notification:show",
(
_event,
{
issueKey,
title,
body,
}: { issueKey: string; title: string; body: string },
) => {
if (!Notification.isSupported()) return;
const notification = new Notification({ title, body });
notification.on("click", () => {
if (!mainWindow) return;
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
mainWindow.webContents.send("inbox:open", issueKey);
});
notification.show();
},
);
// IPC: update the dock / taskbar unread badge. Values above 99 render as
// "99+". macOS is the primary target (user-visible dock badge); Linux
// Unity launchers also respect `setBadgeCount`. Windows' taskbar overlay
// needs a pre-rendered PNG and is deferred — the OS notification + the
// in-app inbox sidebar cover the core UX there for now.
ipcMain.on("badge:set", (_event, rawCount: number) => {
const count = Math.max(0, Math.floor(rawCount));
if (process.platform === "darwin") {
const label = count === 0 ? "" : count > 99 ? "99+" : String(count);
app.dock?.setBadge(label);
} else {
app.setBadgeCount(count);
}
});
createWindow();
setupAutoUpdater(() => mainWindow);

View File

@@ -9,6 +9,16 @@ interface DesktopAPI {
openExternal: (url: string) => Promise<void>;
/** Hide macOS traffic lights for full-screen modals; restore when false. */
setImmersiveMode: (immersive: boolean) => Promise<void>;
/** Show a native OS notification for a new inbox item. */
showNotification: (payload: {
issueKey: string;
title: string;
body: string;
}) => void;
/** Update the OS dock / taskbar unread badge. Pass 0 to clear. */
setUnreadBadge: (count: number) => void;
/** Listen for "open inbox row" requests from notification clicks. Returns an unsubscribe function. */
onInboxOpen: (callback: (issueKey: string) => void) => () => void;
}
interface DaemonStatus {

View File

@@ -25,6 +25,37 @@ const desktopAPI = {
/** Toggle immersive mode — hide macOS traffic lights for full-screen modals */
setImmersiveMode: (immersive: boolean) =>
ipcRenderer.invoke("window:setImmersive", immersive),
/**
* Show a native OS notification for a new inbox item. Fired from the
* renderer only when the app is unfocused — in-focus feedback is the
* inbox sidebar's unread styling. `issueKey` is round-tripped on click so
* the main process can route the user back to the exact inbox row.
*/
showNotification: (payload: {
issueKey: string;
title: string;
body: string;
}) => ipcRenderer.send("notification:show", payload),
/**
* Update the OS dock / taskbar unread badge. Pass 0 to clear. Values
* above 99 render as "99+" (capping is handled in the main process).
*/
setUnreadBadge: (count: number) =>
ipcRenderer.send("badge:set", Math.max(0, Math.floor(count))),
/**
* Subscribe to "open this inbox row" requests sent by the main process
* when the user clicks an OS notification banner. Returns an unsubscribe
* function. The payload is the same `issueKey` that was passed to
* `showNotification`.
*/
onInboxOpen: (callback: (issueKey: string) => void) => {
const handler = (_event: Electron.IpcRendererEvent, issueKey: string) =>
callback(issueKey);
ipcRenderer.on("inbox:open", handler);
return () => {
ipcRenderer.removeListener("inbox:open", handler);
};
},
};
interface DaemonStatus {

View File

@@ -13,8 +13,9 @@ import { ModalRegistry } from "@multica/views/modals/registry";
import { AppSidebar } from "@multica/views/layout";
import { SearchCommand, SearchTrigger } from "@multica/views/search";
import { ChatFab, ChatWindow } from "@multica/views/chat";
import { WorkspaceSlugProvider } from "@multica/core/paths";
import { WorkspaceSlugProvider, paths, useCurrentWorkspace } from "@multica/core/paths";
import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform";
import { useDesktopUnreadBadge } from "@multica/views/platform";
import { DesktopNavigationProvider } from "@/platform/navigation";
import { TabBar } from "./tab-bar";
import { TabContent } from "./tab-content";
@@ -96,6 +97,34 @@ function useInternalLinkHandler() {
}, []);
}
/**
* Bridge between the renderer and the Electron main process for inbox-level
* OS integration. Mounted inside WorkspaceSlugProvider so it can resolve the
* current workspace's id for the badge hook and its slug for click-routing.
*
* Two responsibilities:
* 1. Mirror the unread inbox count onto the dock/taskbar badge.
* 2. When the user clicks an OS notification, open a new tab on the
* workspace's inbox focused on the notified item.
*/
function DesktopInboxBridge() {
const workspace = useCurrentWorkspace();
useDesktopUnreadBadge(workspace?.id ?? null);
useEffect(() => {
return window.desktopAPI.onInboxOpen((issueKey) => {
const slug = getCurrentSlug();
if (!slug) return;
const inboxPath = `${paths.workspace(slug).inbox()}?issue=${encodeURIComponent(issueKey)}`;
window.dispatchEvent(
new CustomEvent("multica:navigate", { detail: { path: inboxPath } }),
);
});
}, []);
return null;
}
export function DesktopShell() {
useInternalLinkHandler();
useActiveTitleSync();
@@ -117,6 +146,7 @@ export function DesktopShell() {
users see the window-level overlay (new-workspace flow)
triggered by IndexRedirect, not a route. */}
<WorkspaceSlugProvider slug={slug}>
<DesktopInboxBridge />
<div className="flex h-screen">
<SidebarProvider className="flex-1">
{slug && <AppSidebar topSlot={<SidebarTopBar />} searchSlot={<SearchTrigger />} />}

View File

@@ -1,4 +1,4 @@
import { queryOptions } from "@tanstack/react-query";
import { queryOptions, useQuery } from "@tanstack/react-query";
import { api } from "../api";
import type { InboxItem } from "../types";
@@ -14,6 +14,22 @@ export function inboxListOptions(wsId: string) {
});
}
/**
* Unread inbox count for the given workspace, aligned with what the inbox
* list UI renders: archived items excluded, then deduplicated by issue so a
* single issue with three unread notifications counts once.
*/
export function useInboxUnreadCount(wsId: string | null | undefined): number {
const { data } = useQuery({
queryKey: inboxKeys.list(wsId ?? ""),
queryFn: () => api.listInbox(),
enabled: !!wsId,
select: (items: InboxItem[]) =>
deduplicateInboxItems(items).filter((i) => !i.read).length,
});
return data ?? 0;
}
/**
* Deduplicate inbox items by issue_id (one entry per issue, Linear-style).
* Exported for consumers to use in useMemo — not in queryOptions select

View File

@@ -194,6 +194,31 @@ export function useRealtimeSync(
if (!item) return;
const wsId = getCurrentWsId();
if (wsId) onInboxNew(qc, wsId, item);
// Fire a native OS notification only when the app isn't focused. When
// the user is already looking at Multica, the inbox sidebar's unread
// 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;
const desktopAPI = (
window as unknown as {
desktopAPI?: {
showNotification?: (payload: {
issueKey: string;
title: string;
body: string;
}) => void;
};
}
).desktopAPI;
// `issueKey` matches the inbox page's selector: it's the issue id when
// the item is attached to an issue, otherwise the inbox item id. The
// click handler in the main process round-trips this back to the
// renderer so `/inbox?issue=<key>` selects the correct row.
desktopAPI?.showNotification?.({
issueKey: item.issue_id ?? item.id,
title: item.title,
body: item.body ?? "",
});
});
// --- Timeline event handlers (global fallback) ---

View File

@@ -8,6 +8,7 @@ import { useWorkspacePaths } from "@multica/core/paths";
import {
inboxListOptions,
deduplicateInboxItems,
useInboxUnreadCount,
} from "@multica/core/inbox/queries";
import {
useMarkInboxRead,
@@ -89,7 +90,7 @@ export function InboxPage() {
});
const isMobile = useIsMobile();
const unreadCount = items.filter((i) => !i.read).length;
const unreadCount = useInboxUnreadCount(wsId);
const markReadMutation = useMarkInboxRead();
const archiveMutation = useArchiveInbox();

View File

@@ -1 +1,2 @@
export { useImmersiveMode } from "./use-immersive-mode";
export { useDesktopUnreadBadge } from "./use-desktop-unread-badge";

View File

@@ -0,0 +1,23 @@
import { useEffect } from "react";
import { useInboxUnreadCount } from "@multica/core/inbox/queries";
type BadgeCapableAPI = {
setUnreadBadge?: (count: number) => void;
};
function getDesktopAPI(): BadgeCapableAPI | undefined {
if (typeof window === "undefined") return undefined;
return (window as unknown as { desktopAPI?: BadgeCapableAPI }).desktopAPI;
}
/**
* Mirror the inbox unread count onto the OS dock/taskbar badge. No-op on web
* (no `desktopAPI`) and on the login screen (no workspace ⇒ count defaults
* to 0, which clears any stale badge from a previous session).
*/
export function useDesktopUnreadBadge(wsId: string | null | undefined): void {
const count = useInboxUnreadCount(wsId);
useEffect(() => {
getDesktopAPI()?.setUnreadBadge?.(count);
}, [count]);
}