From f534938d4b91145c302dd28bc4f87b80b7425d8c Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 8 Jun 2026 23:56:04 -0700 Subject: [PATCH] feat(notifications): add Open in Anki action and in-place progress updat - Add openNoteInBrowser to AnkiConnectClient via guiBrowse IPC - Add Open in Anki action button to mined-card overlay notifications and history entries - Fall back to a direct AnkiConnectClient when the live integration is unavailable - Embed notification images as base64 data URIs so history panel shows thumbnails - Update same-id progress notifications in place to avoid spinner flicker - Thread noteId through IPC overlay notification action payload --- changes/overlay-notifications.md | 4 +- src/anki-connect.test.ts | 19 ++ src/anki-connect.ts | 7 + src/anki-integration.test.ts | 79 +++++++- src/anki-integration.ts | 52 +++-- src/core/services/ipc.test.ts | 23 ++- src/core/services/ipc.ts | 19 +- src/main.ts | 23 ++- src/main/main-wiring.test.ts | 14 +- src/preload.ts | 12 +- .../overlay-notification-history.test.ts | 178 ++++++++++++++++++ src/renderer/overlay-notification-history.ts | 22 ++- src/renderer/overlay-notifications.test.ts | 106 ++++++++++- src/renderer/overlay-notifications.ts | 100 +++++++--- src/renderer/style.css | 32 ++++ src/types/notification.ts | 3 + src/types/runtime.ts | 12 +- 17 files changed, 640 insertions(+), 65 deletions(-) diff --git a/changes/overlay-notifications.md b/changes/overlay-notifications.md index 47075c58..17c4b902 100644 --- a/changes/overlay-notifications.md +++ b/changes/overlay-notifications.md @@ -11,7 +11,9 @@ breaking: true - Initialized the tray and visible overlay shell before deferred tokenization warmups finish on visible-overlay startup, while keeping playback paused until SubMiner reports autoplay readiness. - Kept playback feedback such as subtitle visibility, subtitle track, and subtitle delay text on overlay/OSD surfaces only; desktop/system notifications are reserved for real notifications like mined cards, errors, and updates. - Reused the active primary/secondary subtitle mode overlay notification while cycling modes so rapid toggles update one card instead of stacking duplicate feedback. -- Fixed mined-card overlay notifications so `overlay` and `both` modes show the same generated card thumbnail as system notifications. +- Updated repeated progress notifications such as subsync syncing in place so their spinner stays live instead of flickering on every tick. +- Fixed mined-card overlay notifications so `overlay` and `both` modes show generated card thumbnails in both live cards and the notification history panel. +- Added Open in Anki buttons to mined-card overlay notifications and their history entries, with a direct AnkiConnect fallback when the live integration is unavailable. - Added an Update button to overlay update-available notifications so users can start the app update flow from the notification. - Fixed sentence-card mining so the Ctrl+S flow shows only the Anki update progress notification instead of also stacking a generic SubMiner toast. - Fixed overlay notification layering so notification close/actions stay clickable above subtitle bars on Linux overlays. diff --git a/src/anki-connect.test.ts b/src/anki-connect.test.ts index af997abf..c876745d 100644 --- a/src/anki-connect.test.ts +++ b/src/anki-connect.test.ts @@ -87,6 +87,25 @@ test('AnkiConnectClient lists decks and note type fields', async () => { ); }); +test('AnkiConnectClient opens a note in the Anki browser', async () => { + const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as { + client: { post: (url: string, body: { action: string; params: unknown }) => Promise }; + }; + const calls: Array<{ action: string; params: unknown }> = []; + client.client = { + post: async (_url, body) => { + calls.push({ action: body.action, params: body.params }); + return { data: { result: [], error: null } }; + }, + }; + + await ( + client as unknown as { openNoteInBrowser: (noteId: number) => Promise } + ).openNoteInBrowser(12345); + + assert.deepEqual(calls, [{ action: 'guiBrowse', params: { query: 'nid:12345' } }]); +}); + test('AnkiConnectClient derives field names from sampled notes in a deck', async () => { const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as { client: { post: (url: string, body: { action: string; params: unknown }) => Promise }; diff --git a/src/anki-connect.ts b/src/anki-connect.ts index 79a80f20..3d0cb66e 100644 --- a/src/anki-connect.ts +++ b/src/anki-connect.ts @@ -247,6 +247,13 @@ export class AnkiConnectClient { return (result as Record[]) || []; } + async openNoteInBrowser(noteId: number): Promise { + if (!Number.isInteger(noteId) || noteId <= 0) { + throw new Error('Invalid Anki note id'); + } + await this.invoke('guiBrowse', { query: `nid:${noteId}` }); + } + async updateNoteFields(noteId: number, fields: Record): Promise { await this.invoke('updateNoteFields', { note: { diff --git a/src/anki-integration.test.ts b/src/anki-integration.test.ts index 615fe5e8..e3b4546d 100644 --- a/src/anki-integration.test.ts +++ b/src/anki-integration.test.ts @@ -3,7 +3,6 @@ import assert from 'node:assert/strict'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { pathToFileURL } from 'url'; import { AnkiIntegration } from './anki-integration'; import { FieldGroupingMergeCollaborator } from './anki-integration/field-grouping-merge'; import { AnkiConnectConfig } from './types'; @@ -13,6 +12,7 @@ type TestOverlayNotificationPayload = { body?: string; image?: string; variant?: string; + actions?: Array<{ id: string; label: string; noteId?: number }>; }; interface IntegrationTestContext { @@ -414,7 +414,7 @@ test('AnkiIntegration marks partial update notifications as failures in OSD mode assert.deepEqual(osdMessages, ['x Updated card: taberu (image failed)']); }); -test('AnkiIntegration includes generated notification image on overlay mined-card notifications', async () => { +test('AnkiIntegration embeds generated notification image on overlay mined-card notifications', async () => { const desktopNotifications: Array<{ title: string; body?: string; icon?: string }> = []; const overlayNotifications: TestOverlayNotificationPayload[] = []; const generatedFrom: Array<{ videoPath: string; timestamp: number }> = []; @@ -478,7 +478,13 @@ test('AnkiIntegration includes generated notification image on overlay mined-car assert.equal(overlayNotifications.length, 1); assert.equal(overlayNotifications[0]?.title, 'Anki Card Updated'); assert.equal(overlayNotifications[0]?.body, 'Updated card: 食べる'); - assert.equal(overlayNotifications[0]?.image, pathToFileURL(notificationIconPath).toString()); + assert.equal( + overlayNotifications[0]?.image, + `data:image/png;base64,${Buffer.from('png').toString('base64')}`, + ); + assert.deepEqual(overlayNotifications[0]?.actions, [ + { id: 'open-anki-card', label: 'Open in Anki', noteId: 42 }, + ]); assert.deepEqual(desktopNotifications, [ { title: 'Anki Card Updated', @@ -489,6 +495,73 @@ test('AnkiIntegration includes generated notification image on overlay mined-car assert.deepEqual(cleanupPaths, [notificationIconPath]); }); +test('AnkiIntegration keeps overlay notification image when temp icon write fails', async () => { + const desktopNotifications: Array<{ title: string; body?: string; icon?: string }> = []; + const overlayNotifications: TestOverlayNotificationPayload[] = []; + const cleanupPaths: string[] = []; + + const integration = new AnkiIntegration( + { + behavior: { + notificationType: 'both', + }, + }, + {} as never, + { + currentVideoPath: '/tmp/show.mkv', + currentTimePos: 123.45, + } as never, + undefined, + (title, options) => { + desktopNotifications.push({ title, body: options.body, icon: options.icon }); + }, + undefined, + undefined, + {}, + undefined, + (payload) => { + overlayNotifications.push(payload as TestOverlayNotificationPayload); + }, + ); + + ( + integration as unknown as { + mediaGenerator: { + generateNotificationIcon: () => Promise; + writeNotificationIconToFile: () => string; + scheduleNotificationIconCleanup: (filePath: string) => void; + }; + } + ).mediaGenerator = { + generateNotificationIcon: async () => Buffer.from('png'), + writeNotificationIconToFile: () => { + throw new Error('disk full'); + }, + scheduleNotificationIconCleanup: (filePath) => { + cleanupPaths.push(filePath); + }, + }; + + await ( + integration as unknown as { + showNotification: (noteId: number, label: string | number) => Promise; + } + ).showNotification(42, '食べる'); + + assert.equal( + overlayNotifications[0]?.image, + `data:image/png;base64,${Buffer.from('png').toString('base64')}`, + ); + assert.deepEqual(desktopNotifications, [ + { + title: 'Anki Card Updated', + body: 'Updated card: 食べる', + icon: undefined, + }, + ]); + assert.deepEqual(cleanupPaths, []); +}); + test('AnkiIntegration routes workflow status notifications through configured surfaces', async () => { const osdMessages: string[] = []; const desktopMessages: string[] = []; diff --git a/src/anki-integration.ts b/src/anki-integration.ts index 9afa1834..56bb4e00 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -20,7 +20,6 @@ import { AnkiConnectClient } from './anki-connect'; import { SubtitleTimingTracker } from './subtitle-timing-tracker'; import { MediaGenerator } from './media-generator'; import path from 'path'; -import { pathToFileURL } from 'url'; import { AnkiConnectConfig, KikuDuplicateCardInfo, @@ -30,6 +29,7 @@ import { } from './types/anki'; import { AiConfig } from './types/integrations'; import { MpvClient } from './types/runtime'; +import { OPEN_ANKI_CARD_ACTION_ID } from './types/notification'; import type { NotificationType, OverlayNotificationPayload } from './types/notification'; import type { NPlusOneMatchMode, SubtitleMiningContext } from './types/subtitle'; import { DEFAULT_ANKI_CONNECT_CONFIG } from './config'; @@ -121,8 +121,13 @@ function shouldPreferMediaTitleForMiscInfo(rawPath: string, filename: string): b ); } -function toOverlayNotificationImageSource(filePath: string): string { - return pathToFileURL(filePath).toString(); +function toOverlayNotificationImageSource(iconBuffer: Buffer): string { + return `data:image/png;base64,${iconBuffer.toString('base64')}`; +} + +interface NotificationIcon { + filePath?: string; + overlayImageSource: string; } export class AnkiIntegration { @@ -535,6 +540,10 @@ export class AnkiIntegration { return this.config.knownWords?.matchMode ?? DEFAULT_ANKI_CONNECT_CONFIG.knownWords.matchMode; } + async openNoteInAnki(noteId: number): Promise { + await this.client.openNoteInBrowser(noteId); + } + private isKnownWordCacheEnabled(): boolean { return ( this.config.knownWords?.highlightEnabled === true || this.config.nPlusOne?.enabled === true @@ -1092,9 +1101,9 @@ export class AnkiIntegration { const shouldShowSystemNotification = (type === 'system' || type === 'both' || type === 'osd-system') && this.notificationCallback !== null; - const notificationIconPath = + const notificationIcon = shouldShowOverlayNotification || shouldShowSystemNotification - ? await this.generateNotificationIconPath(noteId) + ? await this.generateNotificationIcon(noteId, shouldShowSystemNotification) : undefined; if (shouldShowOverlayNotification && this.overlayNotificationCallback) { @@ -1102,27 +1111,31 @@ export class AnkiIntegration { id: 'anki-update-progress', title: 'Anki Card Updated', body: message, - ...(notificationIconPath - ? { image: toOverlayNotificationImageSource(notificationIconPath) } - : {}), + ...(notificationIcon ? { image: notificationIcon.overlayImageSource } : {}), variant: errorSuffix === undefined ? 'success' : 'error', persistent: false, + actions: [{ id: OPEN_ANKI_CARD_ACTION_ID, label: 'Open in Anki', noteId }], }); } if (shouldShowSystemNotification && this.notificationCallback) { this.notificationCallback('Anki Card Updated', { body: message, - icon: notificationIconPath, + icon: notificationIcon?.filePath, }); } - if (notificationIconPath) { - this.mediaGenerator.scheduleNotificationIconCleanup(notificationIconPath); + if (notificationIcon) { + if (notificationIcon.filePath) { + this.mediaGenerator.scheduleNotificationIconCleanup(notificationIcon.filePath); + } } } - private async generateNotificationIconPath(noteId: number): Promise { + private async generateNotificationIcon( + noteId: number, + shouldWriteToFile: boolean, + ): Promise { if (!this.mpvClient?.currentVideoPath) { return undefined; } @@ -1138,7 +1151,20 @@ export class AnkiIntegration { timestamp, ); if (iconBuffer && iconBuffer.length > 0) { - return this.mediaGenerator.writeNotificationIconToFile(iconBuffer, noteId); + const notificationIcon: NotificationIcon = { + overlayImageSource: toOverlayNotificationImageSource(iconBuffer), + }; + if (shouldWriteToFile) { + try { + notificationIcon.filePath = this.mediaGenerator.writeNotificationIconToFile( + iconBuffer, + noteId, + ); + } catch (err) { + log.warn('Failed to write notification icon:', (err as Error).message); + } + } + return notificationIcon; } } catch (err) { log.warn('Failed to generate notification icon:', (err as Error).message); diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index 21401848..5c6d7b4d 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -1269,12 +1269,16 @@ test('registerIpcHandlers validates dispatchSessionAction payloads', async () => test('registerIpcHandlers forwards valid overlay notification actions', () => { const { registrar, handlers } = createFakeIpcRegistrar(); - const actions: Array<{ notificationId: string; actionId: string }> = []; + const actions: Array<{ notificationId: string; actionId: string; noteId?: number }> = []; registerIpcHandlers( createRegisterIpcDeps({ - handleOverlayNotificationAction: (notificationId: string, actionId: string) => { - actions.push({ notificationId, actionId }); - }, + handleOverlayNotificationAction: (( + notificationId: string, + actionId: string, + noteId?: number, + ) => { + actions.push({ notificationId, actionId, noteId }); + }) as IpcServiceDeps['handleOverlayNotificationAction'], } as Partial), registrar, ); @@ -1285,10 +1289,19 @@ test('registerIpcHandlers forwards valid overlay notification actions', () => { actionHandler({}, null); actionHandler({}, { notificationId: '', actionId: 'install-update' }); actionHandler({}, { notificationId: 'subminer-update-available', actionId: 42 }); + actionHandler( + {}, + { notificationId: 'anki-update-progress', actionId: 'open-anki-card', noteId: -1 }, + ); actionHandler({}, { notificationId: 'subminer-update-available', actionId: 'install-update' }); + actionHandler( + {}, + { notificationId: 'anki-update-progress', actionId: 'open-anki-card', noteId: 42 }, + ); assert.deepEqual(actions, [ - { notificationId: 'subminer-update-available', actionId: 'install-update' }, + { notificationId: 'subminer-update-available', actionId: 'install-update', noteId: undefined }, + { notificationId: 'anki-update-progress', actionId: 'open-anki-card', noteId: 42 }, ]); }); diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index 023eabae..78c8374f 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -56,6 +56,7 @@ export interface IpcServiceDeps { handleOverlayNotificationAction?: ( notificationId: string, actionId: string, + noteId?: number, ) => void | Promise; openYomitanSettings: () => void; quitApp: () => void; @@ -230,14 +231,21 @@ function parseSubtitleMiningContext(payload: unknown): SubtitleMiningContext | n function parseOverlayNotificationActionPayload( payload: unknown, -): { notificationId: string; actionId: string } | null { +): { notificationId: string; actionId: string; noteId?: number } | null { if (!payload || typeof payload !== 'object') return null; const record = payload as Record; const notificationId = record.notificationId; const actionId = record.actionId; + const noteId = record.noteId; if (typeof notificationId !== 'string' || notificationId.trim().length === 0) return null; if (typeof actionId !== 'string' || actionId.trim().length === 0) return null; - return { notificationId, actionId }; + if ( + noteId !== undefined && + (typeof noteId !== 'number' || !Number.isInteger(noteId) || noteId <= 0) + ) { + return null; + } + return { notificationId, actionId, ...(typeof noteId === 'number' ? { noteId } : {}) }; } export interface IpcDepsRuntimeOptions { @@ -262,6 +270,7 @@ export interface IpcDepsRuntimeOptions { handleOverlayNotificationAction?: ( notificationId: string, actionId: string, + noteId?: number, ) => void | Promise; openYomitanSettings: () => void; quitApp: () => void; @@ -501,7 +510,11 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar const parsedPayload = parseOverlayNotificationActionPayload(payload); if (!parsedPayload) return; void Promise.resolve( - deps.handleOverlayNotificationAction?.(parsedPayload.notificationId, parsedPayload.actionId), + deps.handleOverlayNotificationAction?.( + parsedPayload.notificationId, + parsedPayload.actionId, + parsedPayload.noteId, + ), ).catch((error) => { console.warn( 'Failed to handle overlay notification action:', diff --git a/src/main.ts b/src/main.ts index 5c8c2bbb..048d6b94 100644 --- a/src/main.ts +++ b/src/main.ts @@ -145,6 +145,7 @@ import type { UpdateChannel, WindowGeometry, } from './types'; +import { OPEN_ANKI_CARD_ACTION_ID } from './types'; import { AnkiIntegration } from './anki-integration'; import { SubtitleTimingTracker } from './subtitle-timing-tracker'; import { RuntimeOptionsManager } from './runtime-options'; @@ -3480,6 +3481,17 @@ function dismissOverlayNotification(id: string): void { sendOverlayNotificationEvent({ id, dismiss: true }); } +async function openAnkiCardFromNotification(noteId: number): Promise { + const activeIntegrationOpen = appState.ankiIntegration?.openNoteInAnki(noteId); + if (activeIntegrationOpen) { + await activeIntegrationOpen; + return; + } + + const fallbackClient = new AnkiConnectClient(getResolvedConfig().ankiConnect.url); + await fallbackClient.openNoteInBrowser(noteId); +} + function toggleNotificationHistoryPanel(): void { broadcastToOverlayWindows(IPC_CHANNELS.event.notificationHistoryToggle); } @@ -6979,7 +6991,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ linuxOverlayInteractiveHint = interactive; applyLinuxOverlayInputShapeFromLatestMeasurement(); }, - handleOverlayNotificationAction: (notificationId, actionId) => { + handleOverlayNotificationAction: (notificationId, actionId, noteId) => { if ( notificationId === UPDATE_AVAILABLE_NOTIFICATION_ID && actionId === INSTALL_UPDATE_ACTION_ID @@ -6993,6 +7005,15 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ logger.warn('Failed to install update from overlay notification action:', error); }); } + if (actionId === OPEN_ANKI_CARD_ACTION_ID && noteId !== undefined) { + void openAnkiCardFromNotification(noteId).catch((error) => { + logger.warn('Failed to open Anki card from overlay notification action:', error); + showConfiguredStatusNotification('Failed to open Anki card in Anki.', { + id: 'open-anki-card-failed', + variant: 'error', + }); + }); + } }, onYoutubePickerResolve: (request) => youtubeFlowRuntime.resolveActivePicker(request), openYomitanSettings: () => openYomitanSettings(), diff --git a/src/main/main-wiring.test.ts b/src/main/main-wiring.test.ts index 294dc425..9d90dbec 100644 --- a/src/main/main-wiring.test.ts +++ b/src/main/main-wiring.test.ts @@ -165,10 +165,17 @@ test('subtitle sidebar media path tag is assigned after prefetch succeeds', () = test('update overlay notification action triggers install flow', () => { const source = readMainSource(); - assert.match(source, /handleOverlayNotificationAction:\s*\(notificationId,\s*actionId\)\s*=>/); + assert.match( + source, + /handleOverlayNotificationAction:\s*\(notificationId,\s*actionId,\s*noteId\)\s*=>/, + ); assert.match(source, /notificationId === UPDATE_AVAILABLE_NOTIFICATION_ID/); assert.match(source, /actionId === INSTALL_UPDATE_ACTION_ID/); assert.match(source, /installWhenAvailable:\s*true/); + assert.match(source, /actionId === OPEN_ANKI_CARD_ACTION_ID && noteId !== undefined/); + assert.match(source, /appState\.ankiIntegration\?\.openNoteInAnki\(noteId\)/); + assert.match(source, /new AnkiConnectClient\(getResolvedConfig\(\)\.ankiConnect\.url/); + assert.match(source, /fallbackClient\.openNoteInBrowser\(noteId\)/); }); test('subtitle change re-prioritizes prefetch around live playback before tokenizing current line', () => { @@ -344,7 +351,10 @@ test('stats server Yomitan note creation honors configured Anki server override )?.groups?.body; assert.ok(addYomitanNoteBlock); - assert.match(addYomitanNoteBlock, /const ankiConnectConfig = getResolvedConfig\(\)\.ankiConnect;/); + assert.match( + addYomitanNoteBlock, + /const ankiConnectConfig = getResolvedConfig\(\)\.ankiConnect;/, + ); assert.match(addYomitanNoteBlock, /shouldForceOverrideYomitanAnkiServer\(ankiConnectConfig\)/); assert.doesNotMatch(addYomitanNoteBlock, /forceOverride:\s*true/); }); diff --git a/src/preload.ts b/src/preload.ts index f1828c83..0d1d551f 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -240,8 +240,16 @@ const electronAPI: ElectronAPI = { }, onOverlayPointerRecoveryRequested: onOverlayPointerRecoveryRequestEvent, onOverlayNotification: onOverlayNotificationEvent, - sendOverlayNotificationAction: (notificationId: string, actionId: string) => { - ipcRenderer.send(IPC_CHANNELS.command.overlayNotificationAction, { notificationId, actionId }); + sendOverlayNotificationAction: ( + notificationId: string, + actionId: string, + options?: { noteId?: number }, + ) => { + ipcRenderer.send(IPC_CHANNELS.command.overlayNotificationAction, { + notificationId, + actionId, + ...(options?.noteId !== undefined ? { noteId: options.noteId } : {}), + }); }, onNotificationHistoryToggle: onNotificationHistoryToggleEvent, diff --git a/src/renderer/overlay-notification-history.test.ts b/src/renderer/overlay-notification-history.test.ts index df8aa347..167a843b 100644 --- a/src/renderer/overlay-notification-history.test.ts +++ b/src/renderer/overlay-notification-history.test.ts @@ -130,6 +130,21 @@ test('history store defaults missing variant to info', () => { assert.equal(store.list()[0]?.variant, 'info'); }); +test('history store preserves notification actions', () => { + const store = createOverlayNotificationHistoryStore(); + store.record( + entry({ + id: 'anki-update-progress', + title: 'Anki Card Updated', + actions: [{ id: 'open-anki-card', label: 'Open in Anki', noteId: 42 }], + }), + ); + + assert.deepEqual(store.list()[0]?.actions, [ + { id: 'open-anki-card', label: 'Open in Anki', noteId: 42 }, + ]); +}); + test('panel side mirrors the notification stack position', () => { const stackWith = (positionClass: string) => ({ classList: { contains: (token: string) => token === positionClass } }) as unknown as Element; @@ -159,6 +174,59 @@ function createClassList(initialTokens: string[] = []) { }; } +type FakeElement = { + tagName: string; + className: string; + textContent: string; + type: string; + dataset: Record; + children: FakeElement[]; + classList: ReturnType; + append: (...children: FakeElement[]) => void; + replaceChildren: (...children: FakeElement[]) => void; + setAttribute: (name: string, value: string) => void; + addEventListener: (type: string, listener: () => void) => void; + dispatchEventType: (type: string) => void; +}; + +function createFakeElement(tagName = 'div'): FakeElement { + const listeners = new Map void>>(); + const element: FakeElement = { + tagName: tagName.toUpperCase(), + className: '', + textContent: '', + type: '', + dataset: {}, + children: [], + classList: createClassList(), + append: (...children) => { + element.children.push(...children); + }, + replaceChildren: (...children) => { + element.children = [...children]; + }, + setAttribute: () => undefined, + addEventListener: (type, listener) => { + listeners.set(type, [...(listeners.get(type) ?? []), listener]); + }, + dispatchEventType: (type) => { + for (const listener of listeners.get(type) ?? []) listener(); + }, + }; + return element; +} + +function findChildByClass(element: FakeElement, className: string): FakeElement | null { + if (element.className.split(/\s+/).includes(className)) { + return element; + } + for (const child of element.children) { + const match = findChildByClass(child, className); + if (match) return match; + } + return null; +} + function createPanelHarness(stackPositionClass: string) { const stack = { classList: createClassList([stackPositionClass]), @@ -198,6 +266,7 @@ function createPanelHarness(stackPositionClass: string) { const controller = createOverlayNotificationHistoryPanel({ dom: { + overlay: createFakeElement(), overlayNotificationHistory: panel, overlayNotificationStack: stack, }, @@ -235,3 +304,112 @@ test('history panel resyncs the closed side before first open', () => { assert.equal(panel.classList.contains('side-right'), false); assert.equal(panel.classList.contains('open'), false); }); + +test('history panel action buttons send action ids and note ids', () => { + const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document'); + const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + const renderedItems: FakeElement[] = []; + const sentActions: Array<{ notificationId: string; actionId: string; noteId?: number }> = []; + const stack = { + classList: createClassList(['position-top-right']), + }; + const clearButton = createFakeElement('button'); + const closeButton = createFakeElement('button'); + const list = { + replaceChildren: (...children: FakeElement[]) => { + renderedItems.splice(0, renderedItems.length, ...children); + }, + }; + const empty = createFakeElement(); + const panel = { + classList: createClassList(['notification-history', 'side-right']), + setAttribute: () => undefined, + addEventListener: () => undefined, + querySelector: (selector: string) => { + switch (selector) { + case '.notification-history-list': + return list; + case '.notification-history-empty': + return empty; + case '.notification-history-clear': + return clearButton; + case '.notification-history-close': + return closeButton; + default: + return null; + } + }, + }; + + Object.defineProperty(globalThis, 'document', { + configurable: true, + writable: true, + value: { + createElement: (tagName: string) => createFakeElement(tagName), + }, + }); + Object.defineProperty(globalThis, 'window', { + configurable: true, + writable: true, + value: { + electronAPI: { + sendOverlayNotificationAction: ( + notificationId: string, + actionId: string, + options?: { noteId?: number }, + ) => { + sentActions.push({ notificationId, actionId, noteId: options?.noteId }); + }, + }, + }, + }); + + try { + const controller = createOverlayNotificationHistoryPanel({ + dom: { + overlay: createFakeElement(), + overlayNotificationHistory: panel, + overlayNotificationStack: stack, + }, + state: { + isOverNotificationHistory: false, + notificationHistoryOpen: false, + }, + platform: { + shouldToggleMouseIgnore: false, + }, + } as never); + + controller.record( + entry({ + id: 'anki-update-progress', + title: 'Anki Card Updated', + actions: [{ id: 'open-anki-card', label: 'Open in Anki', noteId: 42 }], + }), + ); + controller.open(); + + const button = renderedItems[0] + ? findChildByClass(renderedItems[0], 'notification-history-action') + : null; + if (!button) { + assert.fail('Expected notification history action button.'); + } + button.dispatchEventType('click'); + + assert.deepEqual(sentActions, [ + { notificationId: 'anki-update-progress', actionId: 'open-anki-card', noteId: 42 }, + ]); + } finally { + if (originalDocument) { + Object.defineProperty(globalThis, 'document', originalDocument); + } else { + delete (globalThis as { document?: unknown }).document; + } + if (originalWindow) { + Object.defineProperty(globalThis, 'window', originalWindow); + } else { + delete (globalThis as { window?: unknown }).window; + } + } +}); diff --git a/src/renderer/overlay-notification-history.ts b/src/renderer/overlay-notification-history.ts index 100f8529..b796de0b 100644 --- a/src/renderer/overlay-notification-history.ts +++ b/src/renderer/overlay-notification-history.ts @@ -1,4 +1,4 @@ -import type { OverlayNotificationVariant } from '../types'; +import type { OverlayNotificationAction, OverlayNotificationVariant } from '../types'; import type { RendererContext } from './context'; import type { OverlayNotificationEntry } from './overlay-notifications.js'; import { syncOverlayMouseIgnoreState } from './overlay-mouse-ignore.js'; @@ -19,6 +19,7 @@ export type OverlayNotificationHistoryEntry = { body?: string; image?: string; variant: OverlayNotificationVariant; + actions?: OverlayNotificationAction[]; createdAt: number; updatedAt: number; }; @@ -57,6 +58,7 @@ export function createOverlayNotificationHistoryStore( body: entry.body, image: entry.image, variant: normalizeVariant(entry.variant), + actions: entry.actions?.map((action) => ({ ...action })), createdAt: existing?.createdAt ?? timestamp, updatedAt: timestamp, }; @@ -177,6 +179,24 @@ export function createOverlayNotificationHistoryPanel( time.textContent = formatTime(entry.createdAt); content.append(time); + if (entry.actions && entry.actions.length > 0) { + const actions = document.createElement('div'); + actions.className = 'notification-history-actions'; + for (const action of entry.actions) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'notification-history-action'; + button.textContent = action.label; + button.addEventListener('click', () => { + window.electronAPI.sendOverlayNotificationAction?.(entry.id, action.id, { + noteId: action.noteId, + }); + }); + actions.append(button); + } + content.append(actions); + } + const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'notification-history-remove'; diff --git a/src/renderer/overlay-notifications.test.ts b/src/renderer/overlay-notifications.test.ts index 5f0b1ae1..f08602d5 100644 --- a/src/renderer/overlay-notifications.test.ts +++ b/src/renderer/overlay-notifications.test.ts @@ -39,6 +39,7 @@ type FakeElement = { dataset: Record; children: FakeElement[]; classList: ReturnType; + replaceChildrenCalls: number; append: (...children: FakeElement[]) => void; replaceChildren: (...children: FakeElement[]) => void; remove: () => void; @@ -61,10 +62,18 @@ function createFakeElement(tagName = 'div'): FakeElement { dataset: {}, children: [], classList: createClassList(), + replaceChildrenCalls: 0, append: (...children) => { - element.children.push(...children); + for (const child of children) { + const existingIndex = element.children.indexOf(child); + if (existingIndex >= 0) { + element.children.splice(existingIndex, 1); + } + element.children.push(child); + } }, replaceChildren: (...children) => { + element.replaceChildrenCalls += 1; element.children = [...children]; }, setAttribute: (name, value) => { @@ -210,7 +219,7 @@ test('overlay notification action buttons send action ids', () => { const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document'); const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); const stack = createFakeElement(); - const sentActions: Array<{ notificationId: string; actionId: string }> = []; + const sentActions: Array<{ notificationId: string; actionId: string; noteId?: number }> = []; Object.defineProperty(globalThis, 'document', { configurable: true, @@ -228,8 +237,12 @@ test('overlay notification action buttons send action ids', () => { return 1; }, electronAPI: { - sendOverlayNotificationAction: (notificationId: string, actionId: string) => { - sentActions.push({ notificationId, actionId }); + sendOverlayNotificationAction: ( + notificationId: string, + actionId: string, + options?: { noteId?: number }, + ) => { + sentActions.push({ notificationId, actionId, noteId: options?.noteId }); }, }, }, @@ -250,7 +263,7 @@ test('overlay notification action buttons send action ids', () => { title: 'SubMiner update available', body: 'SubMiner v0.15.0 is available', persistent: true, - actions: [{ id: 'install-update', label: 'Update' }], + actions: [{ id: 'open-anki-card', label: 'Open in Anki', noteId: 42 }], }); const card = stack.children[0]; @@ -265,7 +278,7 @@ test('overlay notification action buttons send action ids', () => { button.dispatchEventType('click'); assert.deepEqual(sentActions, [ - { notificationId: 'subminer-update-available', actionId: 'install-update' }, + { notificationId: 'subminer-update-available', actionId: 'open-anki-card', noteId: 42 }, ]); } finally { if (originalDocument) { @@ -281,6 +294,87 @@ test('overlay notification action buttons send action ids', () => { } }); +test('overlay notification renderer updates same-id progress without replacing the spinner', () => { + const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document'); + const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + const stack = createFakeElement(); + + Object.defineProperty(globalThis, 'document', { + configurable: true, + writable: true, + value: { + createElement: (tagName: string) => createFakeElement(tagName), + }, + }); + Object.defineProperty(globalThis, 'window', { + configurable: true, + writable: true, + value: { + clearTimeout: () => undefined, + setTimeout: () => { + return 1; + }, + }, + }); + + try { + const renderer = createOverlayNotificationRenderer({ + dom: { + overlayNotificationStack: stack, + }, + state: { + isOverOverlayNotification: false, + }, + } as never); + + renderer.show({ + id: 'subsync-status', + title: 'Subsync', + body: 'Subsync: syncing |', + variant: 'progress', + persistent: true, + }); + + const card = stack.children[0]; + if (!card) { + assert.fail('Expected overlay notification card.'); + } + const spinner = findChildByClass(card, 'overlay-notification-icon'); + if (!spinner) { + assert.fail('Expected overlay notification spinner.'); + } + const cardReplacements = card.replaceChildrenCalls; + + renderer.show({ + id: 'subsync-status', + title: 'Subsync', + body: 'Subsync: syncing /', + variant: 'progress', + persistent: true, + }); + + assert.equal(stack.children.length, 1); + assert.equal(stack.children[0], card); + assert.equal(card.replaceChildrenCalls, cardReplacements); + assert.equal(findChildByClass(card, 'overlay-notification-icon'), spinner); + assert.equal( + findChildByClass(card, 'overlay-notification-body')?.textContent, + 'Subsync: syncing /', + ); + } finally { + if (originalDocument) { + Object.defineProperty(globalThis, 'document', originalDocument); + } else { + delete (globalThis as { document?: unknown }).document; + } + if (originalWindow) { + Object.defineProperty(globalThis, 'window', originalWindow); + } else { + delete (globalThis as { window?: unknown }).window; + } + } +}); + test('overlay notification cards use larger display dimensions', () => { assert.match( overlayNotificationCss, diff --git a/src/renderer/overlay-notifications.ts b/src/renderer/overlay-notifications.ts index 8d27464c..43bce2f9 100644 --- a/src/renderer/overlay-notifications.ts +++ b/src/renderer/overlay-notifications.ts @@ -143,6 +143,27 @@ function setInteractiveState(ctx: RendererContext, value: boolean): void { syncOverlayMouseIgnoreState(ctx); } +function hasElementClass(element: Element | undefined, className: string): boolean { + if (!element) return false; + const legacyClassName = (element as { className?: unknown }).className; + return ( + element.classList.contains(className) || + (typeof legacyClassName === 'string' && legacyClassName.split(/\s+/).includes(className)) + ); +} + +function isNotificationCardIcon(element: Element | undefined): boolean { + return hasElementClass(element, 'overlay-notification-icon'); +} + +function isNotificationCardContent(element: Element | undefined): element is HTMLElement { + return hasElementClass(element, 'overlay-notification-content'); +} + +function isNotificationCardCloseButton(element: Element | undefined): boolean { + return hasElementClass(element, 'overlay-notification-close'); +} + export function createOverlayNotificationRenderer( ctx: RendererContext, options: { onChanged?: () => void; onShow?: (entry: OverlayNotificationEntry) => void } = {}, @@ -207,39 +228,19 @@ export function createOverlayNotificationRenderer( } } - function populateCard(card: HTMLElement, entry: OverlayNotificationEntry): void { - const imageSource = normalizeImageSource(entry.image); - card.classList.add('overlay-notification-card'); - for (const variant of OVERLAY_NOTIFICATION_VARIANT_CLASSES) { - card.classList.toggle(variant, variant === normalizeVariant(entry.variant)); - } - card.classList.toggle('has-image', Boolean(imageSource)); - card.dataset.notificationId = entry.id; - card.setAttribute('role', 'status'); - - const leadingEl = imageSource ? document.createElement('img') : document.createElement('span'); - leadingEl.className = imageSource ? 'overlay-notification-image' : 'overlay-notification-icon'; - leadingEl.setAttribute('aria-hidden', 'true'); - if (imageSource) { - const image = leadingEl as HTMLImageElement; - image.src = imageSource; - image.alt = ''; - image.decoding = 'async'; - } - - const content = document.createElement('div'); + function populateContent(content: HTMLElement, entry: OverlayNotificationEntry): void { content.className = 'overlay-notification-content'; const title = document.createElement('div'); title.className = 'overlay-notification-title'; title.textContent = entry.title; - content.append(title); + const children: HTMLElement[] = [title]; if (entry.body && entry.body.trim().length > 0) { const body = document.createElement('div'); body.className = 'overlay-notification-body'; body.textContent = entry.body; - content.append(body); + children.push(body); } if (entry.actions && entry.actions.length > 0) { @@ -251,12 +252,59 @@ export function createOverlayNotificationRenderer( button.className = 'overlay-notification-action'; button.textContent = action.label; button.addEventListener('click', () => { - window.electronAPI.sendOverlayNotificationAction?.(entry.id, action.id); + window.electronAPI.sendOverlayNotificationAction?.(entry.id, action.id, { + noteId: action.noteId, + }); remove(entry.id); }); actions.append(button); } - content.append(actions); + children.push(actions); + } + content.replaceChildren(...children); + } + + function createContent(entry: OverlayNotificationEntry): HTMLElement { + const content = document.createElement('div'); + populateContent(content, entry); + return content; + } + + function populateCard(card: HTMLElement, entry: OverlayNotificationEntry): void { + const imageSource = normalizeImageSource(entry.image); + card.classList.add('overlay-notification-card'); + for (const variant of OVERLAY_NOTIFICATION_VARIANT_CLASSES) { + card.classList.toggle(variant, variant === normalizeVariant(entry.variant)); + } + card.classList.toggle('has-image', Boolean(imageSource)); + card.dataset.notificationId = entry.id; + card.setAttribute('role', 'status'); + + const leadingNode = card.children[0]; + const contentNode = card.children[1]; + const closeNode = card.children[2]; + if ( + leadingNode && + contentNode && + closeNode && + !imageSource && + !entry.actions?.length && + isNotificationCardIcon(leadingNode) && + isNotificationCardContent(contentNode) && + isNotificationCardCloseButton(closeNode) + ) { + populateContent(contentNode, entry); + return; + } + + const leadingEl = imageSource ? document.createElement('img') : document.createElement('span'); + leadingEl.className = imageSource ? 'overlay-notification-image' : 'overlay-notification-icon'; + leadingEl.setAttribute('aria-hidden', 'true'); + if (imageSource) { + const image = leadingEl as HTMLImageElement; + image.src = imageSource; + image.alt = ''; + image.decoding = 'async'; } const closeButton = document.createElement('button'); @@ -266,7 +314,7 @@ export function createOverlayNotificationRenderer( closeButton.textContent = '×'; closeButton.addEventListener('click', () => remove(entry.id)); - card.replaceChildren(leadingEl, content, closeButton); + card.replaceChildren(leadingEl, createContent(entry), closeButton); } function render(): void { diff --git a/src/renderer/style.css b/src/renderer/style.css index d6d6de7f..d9c3b480 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -719,6 +719,38 @@ body:focus-visible, color: var(--ctp-overlay1); } +.notification-history-actions { + display: flex; + flex-wrap: wrap; + gap: 7px; + margin-top: 9px; +} + +.notification-history-action { + min-height: 24px; + max-width: 100%; + padding: 4px 9px; + border: 1px solid color-mix(in srgb, var(--notification-history-accent) 38%, var(--ctp-surface2)); + border-radius: 6px; + background: color-mix(in srgb, var(--notification-history-accent) 18%, var(--ctp-surface0)); + color: var(--ctp-text); + font: inherit; + font-size: 11px; + font-weight: 700; + line-height: 1.2; + overflow-wrap: anywhere; + cursor: pointer; + transition: + background 120ms ease, + border-color 120ms ease, + color 120ms ease; +} + +.notification-history-action:hover { + border-color: color-mix(in srgb, var(--notification-history-accent) 70%, var(--ctp-surface2)); + background: color-mix(in srgb, var(--notification-history-accent) 28%, var(--ctp-surface0)); +} + .notification-history-remove { width: 22px; height: 22px; diff --git a/src/types/notification.ts b/src/types/notification.ts index acd7d513..69222ee7 100644 --- a/src/types/notification.ts +++ b/src/types/notification.ts @@ -14,9 +14,12 @@ export type OverlayNotificationPosition = (typeof OVERLAY_NOTIFICATION_POSITION_ export type OverlayNotificationVariant = 'info' | 'success' | 'warning' | 'error' | 'progress'; +export const OPEN_ANKI_CARD_ACTION_ID = 'open-anki-card'; + export interface OverlayNotificationAction { id: string; label: string; + noteId?: number; } export interface OverlayNotificationPayload { diff --git a/src/types/runtime.ts b/src/types/runtime.ts index 1e4c4258..23d26fc2 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -41,7 +41,11 @@ import type { RuntimeOptionState, RuntimeOptionValue, } from './runtime-options'; -import type { OverlayNotificationEventPayload, OverlayNotificationPosition } from './notification'; +import type { + OverlayNotificationAction, + OverlayNotificationEventPayload, + OverlayNotificationPosition, +} from './notification'; export interface WindowGeometry { x: number; @@ -407,7 +411,11 @@ export interface ElectronAPI { onSubtitle: (callback: (data: SubtitleData) => void) => void; onOverlayPointerRecoveryRequested: (callback: () => void) => void; onOverlayNotification: (callback: (payload: OverlayNotificationEventPayload) => void) => void; - sendOverlayNotificationAction?: (notificationId: string, actionId: string) => void; + sendOverlayNotificationAction?: ( + notificationId: string, + actionId: string, + options?: Pick, + ) => void; onNotificationHistoryToggle: (callback: () => void) => void; onVisibility: (callback: (visible: boolean) => void) => void; onSubtitlePosition: (callback: (position: SubtitlePosition | null) => void) => void;