Files
SubMiner/src/renderer/overlay-notification-history.ts
T
sudacode d033884b09 feat(notifications): add notification history panel and overlay UX fixes
- New toggleNotificationHistory (Ctrl+N) session-scoped history panel; slides in from same edge as notification stack
- Overlay error/recovery toast follows notifications.overlayPosition; stack and history side seeded at startup
- Cold managed background startup initializes tray and visible overlay shell before tokenization warmups finish
- Add Update button to overlay update-available notifications
- Fix Ctrl+S sentence-card flow: only Anki progress notification, no duplicate status toast
- Fix overlay notification close/actions clickability above subtitle bars on Linux
- Increase pause-until-ready default timeout from 15s to 30s
2026-06-08 02:22:54 -07:00

242 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { OverlayNotificationVariant } from '../types';
import type { RendererContext } from './context';
import type { OverlayNotificationEntry } from './overlay-notifications.js';
import { syncOverlayMouseIgnoreState } from './overlay-mouse-ignore.js';
export const DEFAULT_OVERLAY_NOTIFICATION_HISTORY_MAX = 200;
const OVERLAY_NOTIFICATION_HISTORY_VARIANT_CLASSES = [
'info',
'progress',
'success',
'warning',
'error',
] as const;
export type OverlayNotificationHistoryEntry = {
id: string;
title: string;
body?: string;
image?: string;
variant: OverlayNotificationVariant;
createdAt: number;
updatedAt: number;
};
export type OverlayNotificationHistoryStoreOptions = {
max?: number;
now?: () => number;
};
function normalizeVariant(
variant: OverlayNotificationVariant | undefined,
): OverlayNotificationVariant {
return variant ?? 'info';
}
/**
* Session-scoped log of every overlay notification that was shown. Entries are keyed by id so a
* progress notification that updates in place (same id, new body) overwrites its record rather than
* piling up duplicates. Ordering is by first-seen so the panel can render newest-first.
*/
export function createOverlayNotificationHistoryStore(
options: OverlayNotificationHistoryStoreOptions = {},
) {
const max = Math.max(1, options.max ?? DEFAULT_OVERLAY_NOTIFICATION_HISTORY_MAX);
const now = options.now ?? (() => Date.now());
const entries = new Map<string, OverlayNotificationHistoryEntry>();
function record(entry: OverlayNotificationEntry): OverlayNotificationHistoryEntry {
const timestamp = now();
const existing = entries.get(entry.id);
const next: OverlayNotificationHistoryEntry = {
id: entry.id,
title: entry.title,
body: entry.body,
image: entry.image,
variant: normalizeVariant(entry.variant),
createdAt: existing?.createdAt ?? timestamp,
updatedAt: timestamp,
};
// Setting an existing key keeps its original insertion slot, so an in-place update (same id,
// new body) refreshes content without jumping the entry to the top of the panel.
entries.set(entry.id, next);
while (entries.size > max) {
const oldest = entries.keys().next().value;
if (oldest === undefined) break;
entries.delete(oldest);
}
return next;
}
function remove(id: string): void {
entries.delete(id);
}
function clear(): void {
entries.clear();
}
function list(): OverlayNotificationHistoryEntry[] {
// Newest first.
return [...entries.values()].reverse();
}
function size(): number {
return entries.size;
}
return { record, remove, clear, list, size };
}
export type OverlayNotificationHistorySide = 'left' | 'right';
/**
* The history panel slides in from the same edge the notifications use: left when notifications are
* top-left, right otherwise (including center). We read the live position class off the notification
* stack so the panel always tracks the configured/last-used position.
*/
export function resolveHistorySideFromStack(stack: Element): OverlayNotificationHistorySide {
return stack.classList.contains('position-top-left') ? 'left' : 'right';
}
export function createOverlayNotificationHistoryPanel(
ctx: RendererContext,
options: { onChanged?: () => void } = {},
) {
const store = createOverlayNotificationHistoryStore();
const panel = ctx.dom.overlayNotificationHistory;
const list = panel.querySelector<HTMLUListElement>('.notification-history-list');
const empty = panel.querySelector<HTMLElement>('.notification-history-empty');
const clearButton = panel.querySelector<HTMLButtonElement>('.notification-history-clear');
const closeButton = panel.querySelector<HTMLButtonElement>('.notification-history-close');
let open = false;
function setInteractive(value: boolean): void {
ctx.state.isOverNotificationHistory = value;
syncOverlayMouseIgnoreState(ctx);
}
function applySide(): void {
const side = resolveHistorySideFromStack(ctx.dom.overlayNotificationStack);
panel.classList.toggle('side-left', side === 'left');
panel.classList.toggle('side-right', side === 'right');
}
function formatTime(timestamp: number): string {
try {
return new Date(timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
} catch {
return '';
}
}
function buildItem(entry: OverlayNotificationHistoryEntry): HTMLLIElement {
const item = document.createElement('li');
item.className = 'notification-history-item';
for (const variant of OVERLAY_NOTIFICATION_HISTORY_VARIANT_CLASSES) {
item.classList.toggle(variant, variant === entry.variant);
}
item.dataset.notificationId = entry.id;
const trimmedImage = entry.image?.trim();
const leading = trimmedImage ? document.createElement('img') : document.createElement('span');
leading.className = trimmedImage ? 'notification-history-thumb' : 'notification-history-icon';
leading.setAttribute('aria-hidden', 'true');
if (trimmedImage) {
const image = leading as HTMLImageElement;
image.src = trimmedImage;
image.alt = '';
image.decoding = 'async';
}
const content = document.createElement('div');
content.className = 'notification-history-content';
const title = document.createElement('div');
title.className = 'notification-history-item-title';
title.textContent = entry.title;
content.append(title);
if (entry.body && entry.body.trim().length > 0) {
const body = document.createElement('div');
body.className = 'notification-history-item-body';
body.textContent = entry.body;
content.append(body);
}
const time = document.createElement('time');
time.className = 'notification-history-time';
time.dateTime = new Date(entry.createdAt).toISOString();
time.textContent = formatTime(entry.createdAt);
content.append(time);
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'notification-history-remove';
remove.setAttribute('aria-label', 'Remove from history');
remove.textContent = '×';
remove.addEventListener('click', () => {
store.remove(entry.id);
render();
});
item.append(leading, content, remove);
return item;
}
function render(): void {
if (!list || !empty) return;
const entries = store.list();
list.replaceChildren(...entries.map(buildItem));
empty.classList.toggle('hidden', entries.length > 0);
if (clearButton) clearButton.disabled = entries.length === 0;
options.onChanged?.();
}
function setOpen(next: boolean): void {
if (open === next) return;
open = next;
ctx.state.notificationHistoryOpen = next;
if (next) {
applySide();
render();
}
panel.classList.toggle('open', next);
panel.setAttribute('aria-hidden', next ? 'false' : 'true');
setInteractive(next);
options.onChanged?.();
}
clearButton?.addEventListener('click', () => {
store.clear();
render();
});
closeButton?.addEventListener('click', () => setOpen(false));
panel.addEventListener('mouseenter', () => {
if (open) setInteractive(true);
});
panel.addEventListener('mouseleave', () => setInteractive(false));
function record(entry: OverlayNotificationEntry): void {
store.record(entry);
if (open) render();
}
function toggle(): void {
setOpen(!open);
}
return {
record,
toggle,
open: () => setOpen(true),
close: () => setOpen(false),
isOpen: () => open,
};
}