mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 05:19:30 +02:00
Compare commits
3 Commits
agent/j/f3
...
feat/deskt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0b32be62e | ||
|
|
84fedd5924 | ||
|
|
584bf232cc |
@@ -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";
|
||||
@@ -214,6 +214,64 @@ 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,
|
||||
{
|
||||
slug,
|
||||
itemId,
|
||||
issueKey,
|
||||
title,
|
||||
body,
|
||||
}: {
|
||||
slug: string;
|
||||
itemId: string;
|
||||
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();
|
||||
// Ship the full context back — the renderer pins the route to the
|
||||
// source workspace (slug), marks the row read (itemId), and uses
|
||||
// issueKey as the ?issue=<…> selector.
|
||||
mainWindow.webContents.send("inbox:open", {
|
||||
slug,
|
||||
itemId,
|
||||
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);
|
||||
|
||||
18
apps/desktop/src/preload/index.d.ts
vendored
18
apps/desktop/src/preload/index.d.ts
vendored
@@ -14,6 +14,24 @@ 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: {
|
||||
slug: string;
|
||||
itemId: string;
|
||||
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: (payload: {
|
||||
slug: string;
|
||||
itemId: string;
|
||||
issueKey: string;
|
||||
}) => void,
|
||||
) => () => void;
|
||||
}
|
||||
|
||||
interface DaemonStatus {
|
||||
|
||||
@@ -50,6 +50,50 @@ 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. `slug`, `itemId`, and `issueKey` are
|
||||
* all round-tripped on click: slug pins routing to the source workspace
|
||||
* (the user may switch workspaces before clicking the banner), itemId
|
||||
* lets the renderer mark the row read, issueKey maps to the inbox URL
|
||||
* param.
|
||||
*/
|
||||
showNotification: (payload: {
|
||||
slug: string;
|
||||
itemId: string;
|
||||
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 echoes the `slug`, `itemId`, and `issueKey` that
|
||||
* were passed to `showNotification`.
|
||||
*/
|
||||
onInboxOpen: (
|
||||
callback: (payload: {
|
||||
slug: string;
|
||||
itemId: string;
|
||||
issueKey: string;
|
||||
}) => void,
|
||||
) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
payload: { slug: string; itemId: string; issueKey: string },
|
||||
) => callback(payload);
|
||||
ipcRenderer.on("inbox:open", handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener("inbox:open", handler);
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
interface DaemonStatus {
|
||||
|
||||
@@ -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 { StarterContentPrompt } from "@multica/views/onboarding";
|
||||
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,38 @@ 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.
|
||||
*
|
||||
* Two responsibilities:
|
||||
* 1. Mirror the unread inbox count onto the dock/taskbar badge.
|
||||
* 2. When the user clicks an OS notification, open the notified
|
||||
* workspace's inbox focused on that item. The route uses the `slug`
|
||||
* that the notification was *emitted* with — not the currently active
|
||||
* workspace — so a notification from workspace A always opens A's
|
||||
* inbox even if the user has since switched to workspace B. Marking
|
||||
* the row read is handled by InboxPage's selected-item effect, which
|
||||
* covers both click-to-select and URL-param-select paths.
|
||||
*/
|
||||
function DesktopInboxBridge() {
|
||||
const workspace = useCurrentWorkspace();
|
||||
useDesktopUnreadBadge(workspace?.id ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
return window.desktopAPI.onInboxOpen(({ slug, issueKey }) => {
|
||||
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 +150,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 />} />}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -225,6 +225,41 @@ 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;
|
||||
// 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
|
||||
// time — otherwise notifications from workspace A click through to
|
||||
// workspace B's inbox and 404.
|
||||
const slug = getCurrentSlug();
|
||||
if (!slug) return;
|
||||
const desktopAPI = (
|
||||
window as unknown as {
|
||||
desktopAPI?: {
|
||||
showNotification?: (payload: {
|
||||
slug: string;
|
||||
itemId: string;
|
||||
issueKey: string;
|
||||
title: string;
|
||||
body: string;
|
||||
}) => void;
|
||||
};
|
||||
}
|
||||
).desktopAPI;
|
||||
// `issueKey` matches the inbox page's URL selector (issue id when the
|
||||
// item is attached to an issue, otherwise the inbox item id). `itemId`
|
||||
// is the inbox row's own id, needed to fire markInboxRead on click.
|
||||
desktopAPI?.showNotification?.({
|
||||
slug,
|
||||
itemId: item.id,
|
||||
issueKey: item.issue_id ?? item.id,
|
||||
title: item.title,
|
||||
body: item.body ?? "",
|
||||
});
|
||||
});
|
||||
|
||||
// --- Timeline event handlers (global fallback) ---
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useWorkspacePaths } from "@multica/core/paths";
|
||||
import {
|
||||
inboxListOptions,
|
||||
deduplicateInboxItems,
|
||||
useInboxUnreadCount,
|
||||
} from "@multica/core/inbox/queries";
|
||||
import {
|
||||
useMarkInboxRead,
|
||||
@@ -105,7 +106,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();
|
||||
@@ -114,14 +115,23 @@ export function InboxPage() {
|
||||
const archiveAllReadMutation = useArchiveAllReadInbox();
|
||||
const archiveCompletedMutation = useArchiveCompletedInbox();
|
||||
|
||||
// Click-to-read: select + auto-mark-read
|
||||
// Auto-mark-read whenever a selected item is unread — covers both click-
|
||||
// to-select and URL-param-select (e.g. OS notification click on desktop).
|
||||
// The mutation flips `read: true` optimistically, so this effect settles
|
||||
// in one pass and can't loop. Kept in a `useEffect` rather than inlined
|
||||
// in handleSelect so URL-driven selection triggers it too.
|
||||
const markReadMutate = markReadMutation.mutate;
|
||||
const selectedId = selected?.id;
|
||||
const selectedRead = selected?.read;
|
||||
useEffect(() => {
|
||||
if (!selectedId || selectedRead) return;
|
||||
markReadMutate(selectedId, {
|
||||
onError: () => toast.error("Failed to mark as read"),
|
||||
});
|
||||
}, [selectedId, selectedRead, markReadMutate]);
|
||||
|
||||
const handleSelect = (item: InboxItem) => {
|
||||
setSelectedKey(item.issue_id ?? item.id);
|
||||
if (!item.read) {
|
||||
markReadMutation.mutate(item.id, {
|
||||
onError: () => toast.error("Failed to mark as read"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchive = (id: string) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { useImmersiveMode } from "./use-immersive-mode";
|
||||
export { useDesktopUnreadBadge } from "./use-desktop-unread-badge";
|
||||
export { DragStrip } from "./drag-strip";
|
||||
export { openExternal } from "./open-external";
|
||||
|
||||
23
packages/views/platform/use-desktop-unread-badge.ts
Normal file
23
packages/views/platform/use-desktop-unread-badge.ts
Normal 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]);
|
||||
}
|
||||
Reference in New Issue
Block a user