feat(notifications): configurable toast and browser-notification display time (#564)

Squash-merged so the toast-history half, dropped during review, stays out of master's history.
This commit is contained in:
Devvyn
2026-10-10 02:52:38 +02:00
committed by GitHub
parent 1b3f40bba7
commit b3d3c647cf
10 changed files with 165 additions and 7 deletions
+33
View File
@@ -23,6 +23,10 @@ type Manager = {
getStorageKey: () => string;
normalizePreferences: (preferences: Record<string, unknown>) => NotificationPreferences;
notify: (notification: Record<string, unknown>) => void;
getToastDurationMs: () => number;
unreadCount: number;
markAllRead: () => void;
clearAll: () => void;
};
const openWindows: JSDOM[] = [];
@@ -48,6 +52,10 @@ function loadManager(
STUCK_THRESHOLD_DEFAULT_MS: number;
GROUPING_TIMEOUT_MS: number;
NOTIFICATION_LIST_CAP: number;
AUTO_CLOSE_NOTIFICATION_MS: number;
DEFAULT_TOAST_DURATION_MS: number;
MIN_NOTIFICATION_DURATION_MS: number;
MAX_NOTIFICATION_DURATION_MS: number;
};
win.MobileDetection = {
getDeviceType: () => device.deviceType ?? 'desktop',
@@ -56,6 +64,10 @@ function loadManager(
win.STUCK_THRESHOLD_DEFAULT_MS = 600_000;
win.GROUPING_TIMEOUT_MS = 5_000;
win.NOTIFICATION_LIST_CAP = 100;
win.AUTO_CLOSE_NOTIFICATION_MS = 8_000;
win.DEFAULT_TOAST_DURATION_MS = 3_000;
win.MIN_NOTIFICATION_DURATION_MS = 1_000;
win.MAX_NOTIFICATION_DURATION_MS = 300_000;
win.requestAnimationFrame = ((callback: FrameRequestCallback) => {
callback(0);
return 1;
@@ -66,6 +78,7 @@ function loadManager(
}
win.eval(`
window.escapeHtml = (value) => String(value).replace(/[&<>"']/g, (c) => '&#' + c.charCodeAt(0) + ';');
${SOURCE}
window.__testNotificationManager = NotificationManager;
`);
@@ -148,3 +161,23 @@ describe('notification noise defaults', () => {
expect(manager.getStorageKey()).toBe('codeman-notification-prefs-mobile');
});
});
describe('notification display time', () => {
it('defaults to 3s toasts and 8s browser notifications', () => {
const { manager } = loadManager();
expect(manager.getToastDurationMs()).toBe(3000);
expect((manager.preferences as unknown as Record<string, number>).browserAutoCloseMs).toBe(8000);
});
it('honours a configured toast time and clamps unusable values', () => {
const { manager } = loadManager({ toastDurationMs: 15_000 });
expect(manager.getToastDurationMs()).toBe(15_000);
const clamp = (value: unknown) =>
(manager.normalizePreferences({ toastDurationMs: value }) as unknown as Record<string, number>).toastDurationMs;
expect(clamp(10)).toBe(1000);
expect(clamp(9_999_999)).toBe(300_000);
expect(clamp('soon')).toBe(3000);
expect(clamp(undefined)).toBe(3000);
});
});
+65
View File
@@ -0,0 +1,65 @@
/**
* @fileoverview showToast() display time and drawer logging: a toast with no explicit
* `duration` uses the notification preference, an explicit `duration` (0 = sticky) still wins,
* and every toast is recorded in the notification drawer.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { afterEach, describe, expect, it, vi } from 'vitest';
const windows: JSDOM[] = [];
function loadApp(notificationManager?: unknown) {
const dom = new JSDOM('<!doctype html><body></body>', { url: 'http://localhost/' });
windows.push(dom);
const CodemanApp = function CodemanApp() {} as unknown as { prototype: Record<string, unknown> };
const context = vm.createContext({
CodemanApp,
document: dom.window.document,
requestAnimationFrame: (cb: () => void) => cb(),
setTimeout,
clearTimeout,
console,
});
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/panels-ui.js'), 'utf8');
vm.runInContext(source, context, { filename: 'panels-ui.js' });
const app = new (CodemanApp as unknown as new () => Record<string, any>)();
app.notificationManager = notificationManager;
return app;
}
afterEach(() => {
vi.useRealTimers();
for (const dom of windows.splice(0)) dom.window.close();
});
describe('showToast', () => {
it('uses the configured display time when no duration is given', () => {
vi.useFakeTimers();
const app = loadApp({ getToastDurationMs: () => 10_000 });
app.showToast('hello');
expect(windows[0].window.document.querySelectorAll('.toast')).toHaveLength(1);
vi.advanceTimersByTime(9_000);
expect(windows[0].window.document.querySelector('.toast.show')).not.toBeNull();
vi.advanceTimersByTime(1_500);
expect(windows[0].window.document.querySelector('.toast.show')).toBeNull();
});
it('falls back to 3s without a notification manager', () => {
vi.useFakeTimers();
const app = loadApp();
app.showToast('hello');
vi.advanceTimersByTime(3_100);
expect(windows[0].window.document.querySelector('.toast.show')).toBeNull();
});
it('lets an explicit duration of 0 stay until dismissed', () => {
vi.useFakeTimers();
const app = loadApp({ getToastDurationMs: () => 1_000 });
app.showToast('sticky', 'error', { duration: 0 });
vi.advanceTimersByTime(60_000);
expect(windows[0].window.document.querySelector('.toast.show')).not.toBeNull();
});
});