diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 67f91ad42..862bf0d11 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -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); diff --git a/apps/desktop/src/preload/index.d.ts b/apps/desktop/src/preload/index.d.ts index d739b624b..f8592e336 100644 --- a/apps/desktop/src/preload/index.d.ts +++ b/apps/desktop/src/preload/index.d.ts @@ -9,6 +9,16 @@ interface DesktopAPI { openExternal: (url: string) => Promise; /** Hide macOS traffic lights for full-screen modals; restore when false. */ setImmersiveMode: (immersive: boolean) => Promise; + /** 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 { diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 5ff3ea196..33be4f8c9 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -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 { diff --git a/apps/desktop/src/renderer/src/components/desktop-layout.tsx b/apps/desktop/src/renderer/src/components/desktop-layout.tsx index 78c0e4a46..9765ffe67 100644 --- a/apps/desktop/src/renderer/src/components/desktop-layout.tsx +++ b/apps/desktop/src/renderer/src/components/desktop-layout.tsx @@ -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. */} +
{slug && } searchSlot={} />} diff --git a/packages/core/inbox/queries.ts b/packages/core/inbox/queries.ts index f5971a53c..23a3f1be7 100644 --- a/packages/core/inbox/queries.ts +++ b/packages/core/inbox/queries.ts @@ -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 diff --git a/packages/core/realtime/use-realtime-sync.ts b/packages/core/realtime/use-realtime-sync.ts index c7b10438e..aabeb072e 100644 --- a/packages/core/realtime/use-realtime-sync.ts +++ b/packages/core/realtime/use-realtime-sync.ts @@ -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=` selects the correct row. + desktopAPI?.showNotification?.({ + issueKey: item.issue_id ?? item.id, + title: item.title, + body: item.body ?? "", + }); }); // --- Timeline event handlers (global fallback) --- diff --git a/packages/views/inbox/components/inbox-page.tsx b/packages/views/inbox/components/inbox-page.tsx index 098af2283..df7b0c3cf 100644 --- a/packages/views/inbox/components/inbox-page.tsx +++ b/packages/views/inbox/components/inbox-page.tsx @@ -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(); diff --git a/packages/views/platform/index.ts b/packages/views/platform/index.ts index 2822f4952..a71a68838 100644 --- a/packages/views/platform/index.ts +++ b/packages/views/platform/index.ts @@ -1 +1,2 @@ export { useImmersiveMode } from "./use-immersive-mode"; +export { useDesktopUnreadBadge } from "./use-desktop-unread-badge"; diff --git a/packages/views/platform/use-desktop-unread-badge.ts b/packages/views/platform/use-desktop-unread-badge.ts new file mode 100644 index 000000000..91801390e --- /dev/null +++ b/packages/views/platform/use-desktop-unread-badge.ts @@ -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]); +}