From a5e1ea1acfde3ec55392e45f5bf9ee6cef01e201 Mon Sep 17 00:00:00 2001 From: talksik Date: Mon, 30 Mar 2026 13:52:53 -0700 Subject: [PATCH] feat: show unseen count in macos dock Resolves #93 --- js/src/electron.d.ts | 3 +++ js/src/features/network-root.tsx | 4 ++++ js/src/hooks/use-dock-badge.ts | 27 +++++++++++++++++++++++++++ js/src/main.ts | 8 ++++++++ js/src/preload.ts | 4 ++++ 5 files changed, 46 insertions(+) create mode 100644 js/src/hooks/use-dock-badge.ts diff --git a/js/src/electron.d.ts b/js/src/electron.d.ts index e3d5bdc..758d8b0 100644 --- a/js/src/electron.d.ts +++ b/js/src/electron.d.ts @@ -12,6 +12,9 @@ declare global { fetchMetadata: (url: string) => Promise; openExternal: (url: string) => Promise; }; + electronApp: { + setDockBadge: (count: number) => void; + }; } } diff --git a/js/src/features/network-root.tsx b/js/src/features/network-root.tsx index 0101a05..1d4e16b 100644 --- a/js/src/features/network-root.tsx +++ b/js/src/features/network-root.tsx @@ -11,6 +11,8 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { useViewModeStore } from "@/stores/view-mode-store"; import { useStreamParticles } from "@/hooks/use-stream-particles"; import { useStreamKeyboardNav } from "@/hooks/use-stream-keyboard-nav"; +import { useDockBadge } from "@/hooks/use-dock-badge"; +import { useAuthStore } from "@/stores/auth-store"; /** * Route-level component for /:networkId (index). @@ -24,6 +26,8 @@ export default function NetworkRoot() { const setViewMode = useViewModeStore((s) => s.setViewMode); const { streams } = useStreamParticles(path); + const userId = useAuthStore((s) => s.user?.id); + useDockBadge(streams, userId); const [composeActive, setComposeActive] = useState(false); const { selectedIndex } = useStreamKeyboardNav({ diff --git a/js/src/hooks/use-dock-badge.ts b/js/src/hooks/use-dock-badge.ts new file mode 100644 index 0000000..1e03dd3 --- /dev/null +++ b/js/src/hooks/use-dock-badge.ts @@ -0,0 +1,27 @@ +import { useEffect, useMemo } from "react"; +import type { Particle, StreamProperties } from "@/api/types"; + +type StreamParticle = Particle & { type: "stream"; properties: StreamProperties }; + +/** + * Syncs the macOS dock badge with the count of unseen streams. + * Both last_child_created_at and playback_markers store the child particle's + * created_at, so they're directly comparable. + */ +export function useDockBadge(streams: StreamParticle[], userId: string | undefined) { + const unseenCount = useMemo(() => { + if (!userId) return 0; + return streams.filter((s) => { + const lastActivity = s.last_child_created_at?.getTime(); + if (!lastActivity) return false; + const marker = s.playback_markers?.[userId]?.getTime(); + if (marker === undefined) return false; + return lastActivity > marker; + }).length; + }, [streams, userId]); + + useEffect(() => { + window.electronApp.setDockBadge(unseenCount); + return () => window.electronApp.setDockBadge(0); + }, [unseenCount]); +} diff --git a/js/src/main.ts b/js/src/main.ts index d79a084..f8e5209 100644 --- a/js/src/main.ts +++ b/js/src/main.ts @@ -163,6 +163,14 @@ async function fetchLinkMetadata(url: string): Promise { } } +// --- Dock badge --- + +ipcMain.on('app:set-dock-badge', (_event, count: number) => { + if (process.platform === 'darwin') { + app.dock?.setBadge(count > 0 ? String(count) : ''); + } +}); + ipcMain.handle('link:fetch-metadata', async (_event, url: string) => { if (typeof url !== 'string') return null; try { diff --git a/js/src/preload.ts b/js/src/preload.ts index d02fe38..449bbba 100644 --- a/js/src/preload.ts +++ b/js/src/preload.ts @@ -13,3 +13,7 @@ contextBridge.exposeInMainWorld('electronLink', { fetchMetadata: (url: string) => ipcRenderer.invoke('link:fetch-metadata', url), openExternal: (url: string) => ipcRenderer.invoke('link:open-external', url), }); + +contextBridge.exposeInMainWorld('electronApp', { + setDockBadge: (count: number) => ipcRenderer.send('app:set-dock-badge', count), +});