diff --git a/apps/desktop/src/services/event-listeners.test.ts b/apps/desktop/src/services/event-listeners.test.ts new file mode 100644 index 0000000000..9a1868e6d8 --- /dev/null +++ b/apps/desktop/src/services/event-listeners.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const sessionMocks = vi.hoisted(() => ({ + createSession: vi.fn().mockReturnValue("new-session"), + getOrCreateSessionForEventId: vi + .fn() + .mockImplementation( + (_store: unknown, eventId: string) => `event-${eventId}`, + ), +})); + +vi.mock("~/store/tinybase/store/sessions", () => ({ + createSession: sessionMocks.createSession, + getOrCreateSessionForEventId: sessionMocks.getOrCreateSessionForEventId, +})); + +import { getNotificationOpenConfig } from "./event-listeners"; +import { createSummaryReadyNotificationKey } from "./summary-ready-notification"; + +describe("getNotificationOpenConfig", () => { + beforeEach(() => { + sessionMocks.createSession.mockClear(); + sessionMocks.getOrCreateSessionForEventId.mockClear(); + }); + + it("opens summary notifications in the enhanced note without autostart", () => { + const store = {} as never; + + expect( + getNotificationOpenConfig( + { + key: createSummaryReadyNotificationKey("session-1", "note-1"), + source: null, + }, + store, + ), + ).toEqual({ + id: "session-1", + state: { + view: { type: "enhanced", id: "note-1" }, + autoStart: null, + }, + }); + expect(sessionMocks.createSession).not.toHaveBeenCalled(); + expect(sessionMocks.getOrCreateSessionForEventId).not.toHaveBeenCalled(); + }); + + it("opens calendar event notifications in their linked session and autostarts", () => { + const store = {} as never; + + expect( + getNotificationOpenConfig( + { + key: "event-1", + source: { type: "calendar_event", event_id: "event-1" }, + }, + store, + ), + ).toEqual({ + id: "event-event-1", + state: { view: null, autoStart: true }, + }); + expect(sessionMocks.getOrCreateSessionForEventId).toHaveBeenCalledWith( + store, + "event-1", + ); + }); + + it("falls back to a new session for generic notification clicks", () => { + const store = {} as never; + + expect( + getNotificationOpenConfig( + { + key: "generic-notification", + source: null, + }, + store, + ), + ).toEqual({ + id: "new-session", + state: { view: null, autoStart: true }, + }); + expect(sessionMocks.createSession).toHaveBeenCalledWith(store); + }); +}); diff --git a/apps/desktop/src/services/event-listeners.tsx b/apps/desktop/src/services/event-listeners.tsx index 069fa18c17..963402afb2 100644 --- a/apps/desktop/src/services/event-listeners.tsx +++ b/apps/desktop/src/services/event-listeners.tsx @@ -1,13 +1,18 @@ import { type UnlistenFn } from "@tauri-apps/api/event"; import { useEffect, useRef } from "react"; -import { events as notificationEvents } from "@hypr/plugin-notification"; +import { + events as notificationEvents, + type NotificationSource, +} from "@hypr/plugin-notification"; import { commands as updaterCommands, events as updaterEvents, } from "@hypr/plugin-updater2"; import { getCurrentWebviewWindowLabel } from "@hypr/plugin-windows"; +import { parseSummaryReadyNotificationKey } from "./summary-ready-notification"; + import * as main from "~/store/tinybase/store/main"; import { createSession, @@ -15,6 +20,42 @@ import { } from "~/store/tinybase/store/sessions"; import { useTabs } from "~/store/zustand/tabs"; +type NotificationTarget = { + key: string; + source: NotificationSource | null; +}; + +type MainStore = NonNullable>; + +export function getNotificationOpenConfig( + notification: NotificationTarget, + store: MainStore, +) { + const summaryTarget = parseSummaryReadyNotificationKey(notification.key); + if (summaryTarget) { + return { + id: summaryTarget.sessionId, + state: { + view: { type: "enhanced" as const, id: summaryTarget.enhancedNoteId }, + autoStart: null, + }, + }; + } + + const eventId = + notification.source?.type === "calendar_event" + ? notification.source.event_id + : null; + const sessionId = eventId + ? getOrCreateSessionForEventId(store, eventId) + : createSession(store); + + return { + id: sessionId, + state: { view: null, autoStart: true }, + }; +} + function useUpdaterEvents() { const openNew = useTabs((state) => state.openNew); @@ -46,7 +87,7 @@ function useUpdaterEvents() { function useNotificationEvents() { const store = main.UI.useStore(main.STORE_ID); const openNew = useTabs((state) => state.openNew); - const pendingAutoStart = useRef<{ eventId: string | null } | null>(null); + const pendingNotification = useRef(null); const storeRef = useRef(store); const openNewRef = useRef(openNew); @@ -56,16 +97,14 @@ function useNotificationEvents() { }, [store, openNew]); useEffect(() => { - if (pendingAutoStart.current && store) { - const { eventId } = pendingAutoStart.current; - pendingAutoStart.current = null; - const sessionId = eventId - ? getOrCreateSessionForEventId(store, eventId) - : createSession(store); + if (pendingNotification.current && store) { + const notification = pendingNotification.current; + pendingNotification.current = null; + const { id, state } = getNotificationOpenConfig(notification, store); openNew({ type: "sessions", - id: sessionId, - state: { view: null, autoStart: true }, + id, + state, }); } }, [store, openNew]); @@ -84,22 +123,22 @@ function useNotificationEvents() { payload.type === "notification_confirm" || payload.type === "notification_accept" ) { - const eventId = - payload.source?.type === "calendar_event" - ? payload.source.event_id - : null; const currentStore = storeRef.current; if (!currentStore) { - pendingAutoStart.current = { eventId }; + pendingNotification.current = { + key: payload.key, + source: payload.source, + }; return; } - const sessionId = eventId - ? getOrCreateSessionForEventId(currentStore, eventId) - : createSession(currentStore); + const { id, state } = getNotificationOpenConfig( + { key: payload.key, source: payload.source }, + currentStore, + ); openNewRef.current({ type: "sessions", - id: sessionId, - state: { view: null, autoStart: true }, + id, + state, }); } else if (payload.type === "notification_option_selected") { const currentStore = storeRef.current; diff --git a/apps/desktop/src/services/summary-ready-notification.ts b/apps/desktop/src/services/summary-ready-notification.ts new file mode 100644 index 0000000000..51602a3e4a --- /dev/null +++ b/apps/desktop/src/services/summary-ready-notification.ts @@ -0,0 +1,29 @@ +const SUMMARY_READY_NOTIFICATION_KEY_PREFIX = "summary-ready:"; + +export function createSummaryReadyNotificationKey( + sessionId: string, + enhancedNoteId: string, +) { + return `${SUMMARY_READY_NOTIFICATION_KEY_PREFIX}${sessionId}:${enhancedNoteId}`; +} + +export function parseSummaryReadyNotificationKey(key: string) { + if (!key.startsWith(SUMMARY_READY_NOTIFICATION_KEY_PREFIX)) { + return null; + } + + const payload = key.slice(SUMMARY_READY_NOTIFICATION_KEY_PREFIX.length); + const separatorIndex = payload.indexOf(":"); + if (separatorIndex === -1) { + return null; + } + + const sessionId = payload.slice(0, separatorIndex); + const enhancedNoteId = payload.slice(separatorIndex + 1); + + if (!sessionId || !enhancedNoteId) { + return null; + } + + return { sessionId, enhancedNoteId }; +} diff --git a/apps/desktop/src/store/zustand/ai-task/task-configs/enhance-success.test.ts b/apps/desktop/src/store/zustand/ai-task/task-configs/enhance-success.test.ts index ef59519c9d..03809946e0 100644 --- a/apps/desktop/src/store/zustand/ai-task/task-configs/enhance-success.test.ts +++ b/apps/desktop/src/store/zustand/ai-task/task-configs/enhance-success.test.ts @@ -4,6 +4,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { TaskConfig } from "."; import { enhanceSuccess } from "./enhance-success"; +import { createSummaryReadyNotificationKey } from "~/services/summary-ready-notification"; + const mocks = vi.hoisted(() => ({ isFocused: vi.fn().mockResolvedValue(true), showNotification: vi.fn().mockResolvedValue({ status: "ok", data: null }), @@ -142,7 +144,7 @@ describe("enhanceSuccess.onSuccess", () => { await enhanceSuccess.onSuccess?.(params); expect(mocks.showNotification).toHaveBeenCalledWith({ - key: null, + key: createSummaryReadyNotificationKey("session-1", "note-1"), title: "Summary ready", message: "Weekly sync", timeout: null, @@ -150,7 +152,7 @@ describe("enhanceSuccess.onSuccess", () => { start_time: null, participants: null, event_details: null, - action_label: null, + action_label: "Open summary", options: null, }); }); diff --git a/apps/desktop/src/store/zustand/ai-task/task-configs/enhance-success.ts b/apps/desktop/src/store/zustand/ai-task/task-configs/enhance-success.ts index 64dba5ff68..c0dac61d56 100644 --- a/apps/desktop/src/store/zustand/ai-task/task-configs/enhance-success.ts +++ b/apps/desktop/src/store/zustand/ai-task/task-configs/enhance-success.ts @@ -5,6 +5,8 @@ import { md2json } from "@hypr/tiptap/shared"; import { createTaskId, type TaskConfig } from "."; +import { createSummaryReadyNotificationKey } from "~/services/summary-ready-notification"; + async function maybeShowSummaryReadyNotification( store: Parameters< NonNullable["onSuccess"]> @@ -34,7 +36,7 @@ async function maybeShowSummaryReadyNotification( typeof rawSessionTitle === "string" ? rawSessionTitle.trim() : ""; void notificationCommands.showNotification({ - key: null, + key: createSummaryReadyNotificationKey(args.sessionId, args.enhancedNoteId), title: `${noteTitle} ready`, message: sessionTitle || "Your meeting summary has been generated.", timeout: null, @@ -42,7 +44,7 @@ async function maybeShowSummaryReadyNotification( start_time: null, participants: null, event_details: null, - action_label: null, + action_label: "Open summary", options: null, }); }