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
This commit is contained in:
2026-06-08 23:56:04 -07:00
parent a092cbe2da
commit f534938d4b
17 changed files with 640 additions and 65 deletions
+3 -1
View File
@@ -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.
+19
View File
@@ -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<unknown> };
};
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<void> }
).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<unknown> };
+7
View File
@@ -247,6 +247,13 @@ export class AnkiConnectClient {
return (result as Record<string, unknown>[]) || [];
}
async openNoteInBrowser(noteId: number): Promise<void> {
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<string, string>): Promise<void> {
await this.invoke('updateNoteFields', {
note: {
+76 -3
View File
@@ -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<Buffer>;
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<void>;
}
).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[] = [];
+39 -13
View File
@@ -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<void> {
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<string | undefined> {
private async generateNotificationIcon(
noteId: number,
shouldWriteToFile: boolean,
): Promise<NotificationIcon | undefined> {
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);
+18 -5
View File
@@ -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<IpcServiceDeps>),
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 },
]);
});
+16 -3
View File
@@ -56,6 +56,7 @@ export interface IpcServiceDeps {
handleOverlayNotificationAction?: (
notificationId: string,
actionId: string,
noteId?: number,
) => void | Promise<void>;
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<string, unknown>;
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<void>;
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:',
+22 -1
View File
@@ -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<void> {
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(),
+12 -2
View File
@@ -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/);
});
+10 -2
View File
@@ -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,
@@ -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<string, string>;
children: FakeElement[];
classList: ReturnType<typeof createClassList>;
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<string, Array<() => 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;
}
}
});
+21 -1
View File
@@ -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';
+100 -6
View File
@@ -39,6 +39,7 @@ type FakeElement = {
dataset: Record<string, string>;
children: FakeElement[];
classList: ReturnType<typeof createClassList>;
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,
+74 -26
View File
@@ -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 {
+32
View File
@@ -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;
+3
View File
@@ -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 {
+10 -2
View File
@@ -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<OverlayNotificationAction, 'noteId'>,
) => void;
onNotificationHistoryToggle: (callback: () => void) => void;
onVisibility: (callback: (visible: boolean) => void) => void;
onSubtitlePosition: (callback: (position: SubtitlePosition | null) => void) => void;