@rapidmx/web-client 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/README.md +381 -381
  2. package/apps/admin/branding/index.tsx +39 -39
  3. package/apps/admin/data-requests/index.tsx +482 -482
  4. package/apps/admin/domains/[uid].tsx +166 -166
  5. package/apps/admin/escrow-scopes/[uid].tsx +350 -350
  6. package/apps/admin/index.tsx +129 -129
  7. package/apps/admin/mailboxes/[uid].tsx +271 -271
  8. package/apps/admin/mailboxes/new/index.tsx +30 -30
  9. package/apps/admin/plugins/index.tsx +15 -15
  10. package/apps/admin/retention-policy/index.tsx +39 -39
  11. package/apps/admin/signing-certificates/index.tsx +343 -343
  12. package/apps/escrow/audit-log/index.tsx +196 -196
  13. package/apps/escrow/matters/[uid].tsx +621 -621
  14. package/apps/shared/auth/adminAccess.ts +99 -99
  15. package/apps/shared/components/admin/diagnostics/HostCard.tsx +2 -0
  16. package/apps/shared/components/admin/diagnostics/PressureTiles.tsx +108 -0
  17. package/apps/shared/components/admin/diagnostics/PvcTable.tsx +20 -6
  18. package/apps/shared/components/admin/diagnostics/diagnosticsApi.ts +21 -2
  19. package/apps/shared/components/admin/layout/AdminShell.tsx +384 -384
  20. package/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.tsx +238 -238
  21. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -152
  22. package/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.tsx +194 -194
  23. package/apps/shared/components/admin/settings/BrandingForm.tsx +423 -423
  24. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +119 -119
  25. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +198 -198
  26. package/apps/shared/components/admin/settings/PluginsManager.tsx +2093 -1620
  27. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +182 -182
  28. package/apps/shared/components/admin/settings/pluginPreferences.ts +34 -0
  29. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +288 -288
  30. package/apps/shared/components/admin/setup/SetupWizard.tsx +446 -446
  31. package/apps/shared/components/admin/usePagedList.tsx +129 -129
  32. package/apps/shared/components/calendar/EventModal.tsx +206 -206
  33. package/apps/shared/components/calendar/MonthView.tsx +185 -185
  34. package/apps/shared/components/calendar/RecurrenceEditor.tsx +227 -227
  35. package/apps/shared/components/calendar/SplitDayView.tsx +144 -144
  36. package/apps/shared/components/calendar/TimeGridView.tsx +246 -246
  37. package/apps/shared/components/calendar/allDay.ts +124 -124
  38. package/apps/shared/components/contacts/ContactForm.tsx +383 -383
  39. package/apps/shared/components/contacts/ContactsToolbar.tsx +103 -103
  40. package/apps/shared/components/escrow/layout/EscrowShell.tsx +155 -155
  41. package/apps/shared/components/layout/AppShell.tsx +461 -461
  42. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -398
  43. package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -168
  44. package/apps/shared/components/layout/ResponsiveToolbar.tsx +315 -315
  45. package/apps/shared/components/layout/ThemeSwitch.tsx +84 -84
  46. package/apps/shared/components/layout/UserMenu.tsx +439 -439
  47. package/apps/shared/components/mail/ConversationList.tsx +332 -323
  48. package/apps/shared/components/mail/ConversationThreadPane.tsx +613 -613
  49. package/apps/shared/components/mail/MailSelectionBar.tsx +240 -240
  50. package/apps/shared/components/mail/MenuButton.tsx +404 -404
  51. package/apps/shared/components/mail/MessageDetailPane.tsx +1757 -1757
  52. package/apps/shared/components/mail/compose/ComposeContext.tsx +312 -312
  53. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -422
  54. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1681 -1681
  55. package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -147
  56. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -60
  57. package/apps/shared/components/mail/compose/quotedBody.ts +161 -161
  58. package/apps/shared/components/mail/listPreferences.ts +3 -3
  59. package/apps/shared/components/mail/reading/MessageMoreMenu.tsx +258 -258
  60. package/apps/shared/components/mail/reading/MessageSourceDialog.tsx +79 -79
  61. package/apps/shared/components/mail/reading/messageExport.ts +59 -59
  62. package/apps/shared/components/mail/reading/printMessage.ts +99 -99
  63. package/apps/shared/components/mail/reading/useMessageActions.ts +443 -443
  64. package/apps/shared/components/mail/verificationSeals.ts +125 -125
  65. package/apps/shared/components/rules/RuleBuilder.tsx +311 -311
  66. package/apps/shared/components/settings/SigningCertificateCard.tsx +344 -344
  67. package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -51
  68. package/apps/shared/keyboard/ShortcutProvider.tsx +62 -62
  69. package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -84
  70. package/apps/shared/keyboard/dispatch.ts +124 -124
  71. package/apps/shared/keyboard/format.ts +89 -89
  72. package/apps/shared/keyboard/keymap.ts +114 -114
  73. package/apps/shared/keyboard/registry.ts +65 -65
  74. package/apps/shared/keyboard/targets.ts +79 -79
  75. package/apps/shared/mail/folderOfType.ts +49 -49
  76. package/apps/shared/mail/folderTree.ts +143 -143
  77. package/apps/shared/mail/listAllPages.ts +39 -39
  78. package/apps/shared/mail/newMailNotifications.ts +171 -171
  79. package/apps/shared/mail/outbox/sendJob.ts +445 -445
  80. package/apps/shared/mail/outbox/sendOutcomes.ts +155 -155
  81. package/apps/shared/mail/reportNotices.ts +66 -66
  82. package/apps/shared/mail/senderBlocking.ts +141 -141
  83. package/apps/shared/mail/useMailConnection.ts +205 -205
  84. package/apps/shared/mail/useMailLiveUpdates.ts +277 -277
  85. package/apps/shared/mail/useMailboxUpdateAccess.ts +60 -60
  86. package/apps/shared/mail/useMarkMessageRead.ts +47 -47
  87. package/apps/shared/mail/useNewMailNotifications.ts +178 -178
  88. package/apps/shared/notifications/store.ts +560 -560
  89. package/apps/shared/search/LocalIndexLifecycle.tsx +114 -114
  90. package/apps/shared/search/localIndexBuilder.ts +481 -481
  91. package/apps/shared/signing/enrollmentStorage.ts +33 -33
  92. package/apps/shared/signing/enrollmentTracker.ts +385 -385
  93. package/apps/shared/signing/enrollmentView.ts +251 -251
  94. package/apps/shared/signing/useNow.ts +19 -19
  95. package/apps/shared/signing/useSigningEnrollmentWatcher.ts +90 -90
  96. package/apps/shared/styles/app.css +396 -396
  97. package/apps/www/calendar/index.tsx +581 -581
  98. package/apps/www/contacts/[uid].tsx +112 -112
  99. package/apps/www/index.tsx +3012 -2962
  100. package/apps/www/messages/[uid].tsx +139 -139
  101. package/apps/www/settings/auto-reply/index.tsx +136 -136
  102. package/apps/www/settings/blocked-senders/index.tsx +303 -303
  103. package/apps/www/settings/encryption/index.tsx +1290 -1290
  104. package/apps/www/settings/filters/[uid].tsx +179 -179
  105. package/apps/www/settings/filters/index.tsx +105 -105
  106. package/apps/www/settings/filters/new/index.tsx +165 -165
  107. package/apps/www/settings/labels/index.tsx +207 -207
  108. package/apps/www/settings/privacy/index.tsx +495 -495
  109. package/apps/www/settings/profile/index.tsx +251 -251
  110. package/apps/www/settings/read-receipts/index.tsx +150 -150
  111. package/apps/www/settings/sharing/index.tsx +259 -259
  112. package/apps/www/settings/signatures/[uid].tsx +175 -175
  113. package/apps/www/settings/signatures/index.tsx +91 -91
  114. package/apps/www/settings/signatures/new/index.tsx +138 -138
  115. package/apps/www/tasks/index.tsx +654 -654
  116. package/dist/apps/shared/components/admin/diagnostics/HostCard.js +2 -1
  117. package/dist/apps/shared/components/admin/diagnostics/PressureTiles.d.ts +22 -0
  118. package/dist/apps/shared/components/admin/diagnostics/PressureTiles.js +52 -0
  119. package/dist/apps/shared/components/admin/diagnostics/PvcTable.js +9 -4
  120. package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.d.ts +35 -2
  121. package/dist/apps/shared/components/admin/settings/BrandingForm.js +3 -3
  122. package/dist/apps/shared/components/admin/settings/PluginsManager.js +267 -35
  123. package/dist/apps/shared/components/admin/settings/pluginPreferences.d.ts +2 -0
  124. package/dist/apps/shared/components/admin/settings/pluginPreferences.js +35 -0
  125. package/dist/apps/shared/components/mail/ConversationList.d.ts +10 -3
  126. package/dist/apps/shared/components/mail/ConversationList.js +9 -5
  127. package/dist/apps/shared/components/mail/listPreferences.d.ts +1 -1
  128. package/dist/apps/shared/components/mail/reading/printMessage.js +11 -11
  129. package/dist/apps/shared/styles/app.css +396 -396
  130. package/dist/apps/www/index.js +51 -10
  131. package/package.json +2 -2
@@ -1,560 +1,560 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- import { getNotificationsEnabled } from "./preferences.js";
6
-
7
- /**
8
- * The app's one notification store: every pop-up - new mail, a failed send, an API error, an expired session - goes through
9
- * `notify()`. It is deliberately framework-free (no React, no DOM beyond `sessionStorage`/timers), so it can be called from a hook,
10
- * a plain function or an `apiFetch` error path alike; `NotificationCenter` (the view, mounted once in the app frame) and
11
- * `useNotifications()` only *read* it.
12
- *
13
- * Rules, all enforced here rather than by the view so they hold whoever renders:
14
- *
15
- * - **At most `MAX_VISIBLE` (3) are visible**; the rest wait in a queue and appear as room is made. A sticky notification (an error, or
16
- * anything with actions) that finds the stack full pushes out the oldest one that would have gone by itself, rather than waiting behind
17
- * it. A queued notification that has waited `QUEUE_STALE_MS` is dropped (it would be news from the past) - it is still in the history.
18
- * - **Sticky until dismissed or resolved**: errors and notifications with actions never expire (`update()` or `dismiss()` resolve them).
19
- * Everything else goes by itself after its kind's default time, or `timeoutMs`. `sticky` overrides both ways.
20
- * - **A clock that stops**: a notification's remaining time stops while it is hovered or focused (`setPaused()`) and while the tab is
21
- * hidden (`setAllPaused()`), and continues from where it was.
22
- * - **Deduplicated by `dedupeKey`**: a second notification with a key that is still on screen (or waiting) does not stack - it replaces
23
- * the first one's content, restarts its clock and raises its `count`, so a flapping error is one pop-up saying "x3".
24
- * - **The user can turn them all off** (`preferences.ts`): `notify()` then shows nothing new, and only records it in the history.
25
- * - **Nothing is lost when one goes**: the last `HISTORY_LIMIT` (30, except new-mail pop-ups, which the inbox already holds) are
26
- * kept, in memory and in `sessionStorage`, with which errors nobody has seen yet.
27
- *
28
- * Nothing is stored where there is no `window` (server-side rendering): the module state is shared between requests there, so
29
- * `notify()` only hands back an id.
30
- */
31
-
32
- export type NotificationKind = "mail" | "calendar" | "info" | "success" | "warning" | "error";
33
-
34
- export interface NotificationAction {
35
- label: string;
36
- /** Runs when the action is used. */
37
- onClick?: () => void;
38
- /** Or a link to follow (a plain `<a>`, so the router's link handling applies). */
39
- href?: string;
40
- /** Leave the notification on screen after the action ran. By default an action resolves (dismisses) it. */
41
- keepOpen?: boolean;
42
- }
43
-
44
- export interface NotifyInput {
45
- /** Reuse an id to replace a notification (it is the same one: its content is swapped and its clock restarted). Generated when absent. */
46
- id?: string;
47
- kind: NotificationKind;
48
- title: string;
49
- /** A second, muted line next to the title (a mail pop-up's sender address). */
50
- subtitle?: string;
51
- message?: string;
52
- /** A mail pop-up's preview text, under `message` (its subject). */
53
- preview?: string;
54
- /** A small muted explanation above the actions (the desktop-notification offer). */
55
- hint?: string;
56
- /** Technical detail: a list of lines, or one block of text. Shown collapsed, monospace, with a copy button. */
57
- details?: string[] | string;
58
- actions?: NotificationAction[];
59
- /** The whole pop-up is a link to this (new mail opens the message). */
60
- href?: string;
61
- /** Never expires by itself. Default: errors and anything with actions. */
62
- sticky?: boolean;
63
- /** How long it stays, in ms. Default by kind, see `DEFAULT_TIMEOUT_MS`. Ignored for a sticky one. */
64
- timeoutMs?: number;
65
- /** Notifications with the same key that are still showing are one: see the module doc comment. */
66
- dedupeKey?: string;
67
- /** Keep it out of the history (default: kept, except `mail`). */
68
- history?: boolean;
69
- }
70
-
71
- /** A notification as a view renders it. */
72
- export interface NotificationView {
73
- id: string;
74
- kind: NotificationKind;
75
- title: string;
76
- subtitle?: string;
77
- message?: string;
78
- preview?: string;
79
- hint?: string;
80
- details: string[];
81
- actions: NotificationAction[];
82
- href?: string;
83
- /** How many times this one was raised (`dedupeKey`), 1 for a single one. */
84
- count: number;
85
- sticky: boolean;
86
- createdAt: number;
87
- }
88
-
89
- export interface HistoryEntry {
90
- id: string;
91
- kind: NotificationKind;
92
- title: string;
93
- message?: string;
94
- details: string[];
95
- count: number;
96
- /** When it was last raised (epoch ms). */
97
- at: number;
98
- /** Nobody has opened the history since it arrived. */
99
- unseen: boolean;
100
- }
101
-
102
- export interface NotificationsSnapshot {
103
- /** What is on screen, oldest first. */
104
- visible: NotificationView[];
105
- /** How many are waiting for room. */
106
- queued: number;
107
- /** The last `HISTORY_LIMIT`, newest first. */
108
- history: HistoryEntry[];
109
- /** Errors in the history that nobody has looked at. */
110
- unseenErrors: number;
111
- }
112
-
113
- export const MAX_VISIBLE = 3;
114
- export const MAX_QUEUED = 20;
115
- export const QUEUE_STALE_MS = 30_000;
116
- export const HISTORY_LIMIT = 30;
117
- export const HISTORY_STORAGE_KEY = "rapidmx-notification-history";
118
- /** The most technical lines and the longest line kept per notification - a transport error can carry a whole SMTP transcript. */
119
- export const MAX_DETAIL_LINES = 50;
120
- export const MAX_DETAIL_LINE_LENGTH = 500;
121
-
122
- /** How long a non-sticky notification stays, by kind. Errors are sticky, so theirs is only for an explicit `sticky: false`. */
123
- export const DEFAULT_TIMEOUT_MS: Record<NotificationKind, number> = {
124
- mail: 8_000,
125
- // Irrelevant in practice - a reminder always carries actions, which makes it sticky (see `applyInput()`) regardless of this value.
126
- calendar: 8_000,
127
- info: 6_000,
128
- success: 5_000,
129
- warning: 10_000,
130
- error: 10_000,
131
- };
132
-
133
- interface Entry {
134
- id: string;
135
- kind: NotificationKind;
136
- title: string;
137
- subtitle?: string;
138
- message?: string;
139
- preview?: string;
140
- hint?: string;
141
- details: string[];
142
- actions: NotificationAction[];
143
- href?: string;
144
- dedupeKey?: string;
145
- sticky: boolean;
146
- timeoutMs: number;
147
- count: number;
148
- createdAt: number;
149
- history: boolean;
150
- status: "visible" | "queued";
151
- remainingMs: number;
152
- startedAt?: number;
153
- timer?: ReturnType<typeof setTimeout>;
154
- paused: boolean;
155
- }
156
-
157
- const EMPTY_SNAPSHOT: NotificationsSnapshot = { visible: [], queued: 0, history: [], unseenErrors: 0 };
158
-
159
- let entries: Entry[] = [];
160
- let history: HistoryEntry[] = [];
161
- let historyLoaded = false;
162
- let allPaused = false;
163
- let counter = 0;
164
- let snapshot: NotificationsSnapshot = EMPTY_SNAPSHOT;
165
- const listeners = new Set<() => void>();
166
-
167
- function isClient(): boolean {
168
- return typeof window !== "undefined";
169
- }
170
-
171
- function normalizeDetails(details: string[] | string | undefined): string[] {
172
- if (details === undefined) {
173
- return [];
174
- }
175
- const lines = (Array.isArray(details) ? details : details.split(/\r?\n/)).filter((line) => line.length > 0);
176
- return lines.slice(0, MAX_DETAIL_LINES).map((line) => (line.length > MAX_DETAIL_LINE_LENGTH ? `${line.slice(0, MAX_DETAIL_LINE_LENGTH)}...` : line));
177
- }
178
-
179
- function loadHistory(): void {
180
- if (historyLoaded || !isClient()) {
181
- return;
182
- }
183
- historyLoaded = true;
184
- try {
185
- const stored = JSON.parse(sessionStorage.getItem(HISTORY_STORAGE_KEY) ?? "[]") as HistoryEntry[];
186
- history = Array.isArray(stored)
187
- ? stored
188
- .filter((item) => !!item && typeof item.id === "string" && typeof item.title === "string" && typeof item.at === "number")
189
- .slice(0, HISTORY_LIMIT)
190
- .map((item) => ({ ...item, details: Array.isArray(item.details) ? item.details : [], unseen: item.unseen === true }))
191
- : [];
192
- } catch {
193
- history = [];
194
- }
195
- }
196
-
197
- function saveHistory(): void {
198
- try {
199
- sessionStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(history));
200
- } catch {
201
- // Storage unavailable or full: the history is only kept in memory, which is all it needs to be for this page.
202
- }
203
- }
204
-
205
- function view(entry: Entry): NotificationView {
206
- return {
207
- id: entry.id,
208
- kind: entry.kind,
209
- title: entry.title,
210
- subtitle: entry.subtitle,
211
- message: entry.message,
212
- preview: entry.preview,
213
- hint: entry.hint,
214
- details: entry.details,
215
- actions: entry.actions,
216
- href: entry.href,
217
- count: entry.count,
218
- sticky: entry.sticky,
219
- createdAt: entry.createdAt,
220
- };
221
- }
222
-
223
- function rebuild(): void {
224
- const visible = entries.filter((entry) => entry.status === "visible").map(view);
225
- snapshot = {
226
- visible,
227
- queued: entries.length - visible.length,
228
- history,
229
- unseenErrors: history.filter((item) => item.unseen && item.kind === "error").length,
230
- };
231
- }
232
-
233
- function emit(): void {
234
- rebuild();
235
- for (const listener of [...listeners]) {
236
- listener();
237
- }
238
- }
239
-
240
- function findEntry(id: string): Entry | undefined {
241
- return entries.find((entry) => entry.id === id);
242
- }
243
-
244
- /** Records (or refreshes) `entry` in the history, newest first. */
245
- function record(entry: Entry): void {
246
- if (!entry.history) {
247
- return;
248
- }
249
- loadHistory();
250
- const item: HistoryEntry = {
251
- id: entry.id,
252
- kind: entry.kind,
253
- title: entry.title,
254
- message: entry.message,
255
- details: entry.details,
256
- count: entry.count,
257
- at: Date.now(),
258
- unseen: true,
259
- };
260
- history = [item, ...history.filter((existing) => existing.id !== entry.id)].slice(0, HISTORY_LIMIT);
261
- saveHistory();
262
- }
263
-
264
- function disarm(entry: Entry): void {
265
- if (entry.timer !== undefined) {
266
- clearTimeout(entry.timer);
267
- entry.timer = undefined;
268
- entry.remainingMs = Math.max(0, entry.remainingMs - (Date.now() - entry.startedAt!));
269
- }
270
- }
271
-
272
- /** Starts (or resumes) the clock of a visible, non-sticky, unpaused notification. */
273
- function arm(entry: Entry): void {
274
- if (entry.status !== "visible" || entry.sticky || entry.paused || allPaused || entry.timer !== undefined) {
275
- return;
276
- }
277
- entry.startedAt = Date.now();
278
- entry.timer = setTimeout(() => {
279
- entry.timer = undefined;
280
- remove(entry.id);
281
- }, entry.remainingMs);
282
- }
283
-
284
- function visibleCount(): number {
285
- return entries.filter((entry) => entry.status === "visible").length;
286
- }
287
-
288
- function show(entry: Entry): void {
289
- entry.status = "visible";
290
- entry.remainingMs = entry.timeoutMs;
291
- arm(entry);
292
- }
293
-
294
- /** Fills free places from the queue, dropping what has waited too long. */
295
- function promote(): void {
296
- const now = Date.now();
297
- entries = entries.filter((entry) => entry.status === "visible" || entry.sticky || now - entry.createdAt < QUEUE_STALE_MS);
298
- while (visibleCount() < MAX_VISIBLE) {
299
- const next = entries.find((entry) => entry.status === "queued");
300
- if (!next) {
301
- return;
302
- }
303
- show(next);
304
- }
305
- }
306
-
307
- function remove(id: string): void {
308
- const entry = findEntry(id);
309
- if (!entry) {
310
- return;
311
- }
312
- disarm(entry);
313
- entries = entries.filter((existing) => existing !== entry);
314
- promote();
315
- emit();
316
- }
317
-
318
- /** Makes room for a sticky notification: the oldest visible one that would have gone by itself leaves (it stays in the history). */
319
- function evictForSticky(): boolean {
320
- const victim = entries.find((entry) => entry.status === "visible" && !entry.sticky);
321
- if (!victim) {
322
- return false;
323
- }
324
- disarm(victim);
325
- entries = entries.filter((entry) => entry !== victim);
326
- return true;
327
- }
328
-
329
- function applyInput(entry: Entry, input: NotifyInput): void {
330
- entry.kind = input.kind;
331
- entry.title = input.title;
332
- entry.subtitle = input.subtitle;
333
- entry.message = input.message;
334
- entry.preview = input.preview;
335
- entry.hint = input.hint;
336
- entry.details = normalizeDetails(input.details);
337
- entry.actions = input.actions ?? [];
338
- entry.href = input.href;
339
- entry.sticky = input.sticky ?? (input.kind === "error" || (input.actions?.length ?? 0) > 0);
340
- entry.timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS[input.kind];
341
- entry.history = input.history ?? input.kind !== "mail";
342
- }
343
-
344
- /**
345
- * Raises a notification and returns its id. See the module doc comment for what happens to it. Usable from anywhere - a component, a
346
- * hook, a plain function, an `apiFetch` error path - and safe where there is no window (it does nothing there).
347
- */
348
- export function notify(input: NotifyInput): string {
349
- counter += 1;
350
- const id = input.id ?? `notification-${counter}`;
351
- if (!isClient()) {
352
- return id;
353
- }
354
- const sameKey = input.dedupeKey !== undefined ? entries.find((entry) => entry.dedupeKey === input.dedupeKey) : undefined;
355
- const existing = sameKey ?? findEntry(id);
356
- if (existing) {
357
- // The same notification again: its content is replaced and it gets its full time again. Only a repeated dedupe key counts up
358
- // (an explicit id is a replacement, not another occurrence).
359
- disarm(existing);
360
- applyInput(existing, input);
361
- existing.count += sameKey ? 1 : 0;
362
- existing.remainingMs = existing.timeoutMs;
363
- arm(existing);
364
- record(existing);
365
- emit();
366
- return existing.id;
367
- }
368
- const entry: Entry = {
369
- id,
370
- dedupeKey: input.dedupeKey,
371
- kind: input.kind,
372
- title: input.title,
373
- details: [],
374
- actions: [],
375
- sticky: false,
376
- timeoutMs: 0,
377
- count: 1,
378
- createdAt: Date.now(),
379
- history: true,
380
- status: "queued",
381
- remainingMs: 0,
382
- paused: false,
383
- };
384
- applyInput(entry, input);
385
- if (!getNotificationsEnabled()) {
386
- // The user has pop-ups off: nothing is shown, but it is listed in the history like any other.
387
- record(entry);
388
- emit();
389
- return id;
390
- }
391
- const room = visibleCount() < MAX_VISIBLE;
392
- entries.push(entry);
393
- if (room) {
394
- show(entry);
395
- } else if (entry.sticky && evictForSticky()) {
396
- show(entry);
397
- } else {
398
- // Waiting for room: the queue itself is bounded, and the oldest waiting one that would not have stayed is the first to go.
399
- const waiting = entries.filter((e) => e.status === "queued");
400
- if (waiting.length > MAX_QUEUED) {
401
- const dropped = waiting.find((e) => !e.sticky) ?? waiting[0];
402
- entries = entries.filter((e) => e !== dropped);
403
- }
404
- }
405
- record(entry);
406
- emit();
407
- return id;
408
- }
409
-
410
- /**
411
- * Changes a notification that is still showing or waiting - the way to resolve it ("Sending..." becoming "Sent"). Only the given
412
- * fields change. Its clock restarts, and it may become sticky or stop being so with its new kind. Returns `false` when there is no such
413
- * notification any more (it was dismissed or expired); its history entry is updated all the same.
414
- */
415
- export function update(id: string, patch: Partial<Omit<NotifyInput, "id" | "dedupeKey">>): boolean {
416
- const entry = findEntry(id);
417
- if (!entry) {
418
- loadHistory();
419
- const item = history.find((existing) => existing.id === id);
420
- if (item) {
421
- history = history.map((existing) =>
422
- existing === item
423
- ? {
424
- ...existing,
425
- kind: patch.kind ?? existing.kind,
426
- title: patch.title ?? existing.title,
427
- message: "message" in patch ? patch.message : existing.message,
428
- details: "details" in patch ? normalizeDetails(patch.details) : existing.details,
429
- }
430
- : existing,
431
- );
432
- saveHistory();
433
- emit();
434
- }
435
- return false;
436
- }
437
- disarm(entry);
438
- applyInput(entry, {
439
- kind: entry.kind,
440
- title: entry.title,
441
- subtitle: entry.subtitle,
442
- message: entry.message,
443
- preview: entry.preview,
444
- hint: entry.hint,
445
- details: entry.details,
446
- actions: entry.actions,
447
- href: entry.href,
448
- history: entry.history,
449
- // A change of kind or actions recomputes stickiness (unless the patch says), so it is only carried over otherwise.
450
- sticky: "kind" in patch || "actions" in patch ? undefined : entry.sticky,
451
- // Its own time again, unless the kind changed (then the new kind's default) or the patch says.
452
- timeoutMs: "kind" in patch ? undefined : entry.timeoutMs,
453
- ...patch,
454
- });
455
- entry.remainingMs = entry.timeoutMs;
456
- arm(entry);
457
- record(entry);
458
- emit();
459
- return true;
460
- }
461
-
462
- /** Removes a notification from the screen (or the queue). Its history entry stays. Does nothing for an unknown id. */
463
- export function dismiss(id: string): void {
464
- remove(id);
465
- }
466
-
467
- /** Removes everything showing and waiting. */
468
- export function dismissAll(): void {
469
- for (const entry of entries) {
470
- disarm(entry);
471
- }
472
- entries = [];
473
- emit();
474
- }
475
-
476
- /** Stops (or restarts) one notification's clock: it is being hovered or holds the keyboard focus. */
477
- export function setPaused(id: string, paused: boolean): void {
478
- const entry = findEntry(id);
479
- if (!entry || entry.paused === paused) {
480
- return;
481
- }
482
- entry.paused = paused;
483
- if (paused) {
484
- disarm(entry);
485
- } else {
486
- arm(entry);
487
- }
488
- }
489
-
490
- /** Stops (or restarts) every clock: the tab is hidden, so nobody could have read what is on screen. */
491
- export function setAllPaused(paused: boolean): void {
492
- if (allPaused === paused) {
493
- return;
494
- }
495
- allPaused = paused;
496
- for (const entry of entries) {
497
- if (paused) {
498
- disarm(entry);
499
- } else {
500
- arm(entry);
501
- }
502
- }
503
- }
504
-
505
- /** Marks every history entry as seen - the user has opened the history. */
506
- export function markHistorySeen(): void {
507
- loadHistory();
508
- if (!history.some((item) => item.unseen)) {
509
- return;
510
- }
511
- history = history.map((item) => (item.unseen ? { ...item, unseen: false } : item));
512
- saveHistory();
513
- emit();
514
- }
515
-
516
- /** Empties the history. */
517
- export function clearHistory(): void {
518
- loadHistory();
519
- history = [];
520
- saveHistory();
521
- emit();
522
- }
523
-
524
- export function subscribeNotifications(listener: () => void): () => void {
525
- listeners.add(listener);
526
- return () => {
527
- listeners.delete(listener);
528
- };
529
- }
530
-
531
- export function getNotificationsSnapshot(): NotificationsSnapshot {
532
- if (!historyLoaded && isClient()) {
533
- // The history is read from storage on first use, without telling listeners (this runs during a render).
534
- loadHistory();
535
- rebuild();
536
- }
537
- return snapshot;
538
- }
539
-
540
- export function getServerNotificationsSnapshot(): NotificationsSnapshot {
541
- return EMPTY_SNAPSHOT;
542
- }
543
-
544
- /** Forgets everything and stops every clock - for a test, or a page that outlives its session. */
545
- export function resetNotifications(): void {
546
- for (const entry of entries) {
547
- disarm(entry);
548
- }
549
- entries = [];
550
- history = [];
551
- historyLoaded = false;
552
- allPaused = false;
553
- counter = 0;
554
- snapshot = EMPTY_SNAPSHOT;
555
- try {
556
- sessionStorage.removeItem(HISTORY_STORAGE_KEY);
557
- } catch {
558
- // Nothing stored.
559
- }
560
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { getNotificationsEnabled } from "./preferences.js";
6
+
7
+ /**
8
+ * The app's one notification store: every pop-up - new mail, a failed send, an API error, an expired session - goes through
9
+ * `notify()`. It is deliberately framework-free (no React, no DOM beyond `sessionStorage`/timers), so it can be called from a hook,
10
+ * a plain function or an `apiFetch` error path alike; `NotificationCenter` (the view, mounted once in the app frame) and
11
+ * `useNotifications()` only *read* it.
12
+ *
13
+ * Rules, all enforced here rather than by the view so they hold whoever renders:
14
+ *
15
+ * - **At most `MAX_VISIBLE` (3) are visible**; the rest wait in a queue and appear as room is made. A sticky notification (an error, or
16
+ * anything with actions) that finds the stack full pushes out the oldest one that would have gone by itself, rather than waiting behind
17
+ * it. A queued notification that has waited `QUEUE_STALE_MS` is dropped (it would be news from the past) - it is still in the history.
18
+ * - **Sticky until dismissed or resolved**: errors and notifications with actions never expire (`update()` or `dismiss()` resolve them).
19
+ * Everything else goes by itself after its kind's default time, or `timeoutMs`. `sticky` overrides both ways.
20
+ * - **A clock that stops**: a notification's remaining time stops while it is hovered or focused (`setPaused()`) and while the tab is
21
+ * hidden (`setAllPaused()`), and continues from where it was.
22
+ * - **Deduplicated by `dedupeKey`**: a second notification with a key that is still on screen (or waiting) does not stack - it replaces
23
+ * the first one's content, restarts its clock and raises its `count`, so a flapping error is one pop-up saying "x3".
24
+ * - **The user can turn them all off** (`preferences.ts`): `notify()` then shows nothing new, and only records it in the history.
25
+ * - **Nothing is lost when one goes**: the last `HISTORY_LIMIT` (30, except new-mail pop-ups, which the inbox already holds) are
26
+ * kept, in memory and in `sessionStorage`, with which errors nobody has seen yet.
27
+ *
28
+ * Nothing is stored where there is no `window` (server-side rendering): the module state is shared between requests there, so
29
+ * `notify()` only hands back an id.
30
+ */
31
+
32
+ export type NotificationKind = "mail" | "calendar" | "info" | "success" | "warning" | "error";
33
+
34
+ export interface NotificationAction {
35
+ label: string;
36
+ /** Runs when the action is used. */
37
+ onClick?: () => void;
38
+ /** Or a link to follow (a plain `<a>`, so the router's link handling applies). */
39
+ href?: string;
40
+ /** Leave the notification on screen after the action ran. By default an action resolves (dismisses) it. */
41
+ keepOpen?: boolean;
42
+ }
43
+
44
+ export interface NotifyInput {
45
+ /** Reuse an id to replace a notification (it is the same one: its content is swapped and its clock restarted). Generated when absent. */
46
+ id?: string;
47
+ kind: NotificationKind;
48
+ title: string;
49
+ /** A second, muted line next to the title (a mail pop-up's sender address). */
50
+ subtitle?: string;
51
+ message?: string;
52
+ /** A mail pop-up's preview text, under `message` (its subject). */
53
+ preview?: string;
54
+ /** A small muted explanation above the actions (the desktop-notification offer). */
55
+ hint?: string;
56
+ /** Technical detail: a list of lines, or one block of text. Shown collapsed, monospace, with a copy button. */
57
+ details?: string[] | string;
58
+ actions?: NotificationAction[];
59
+ /** The whole pop-up is a link to this (new mail opens the message). */
60
+ href?: string;
61
+ /** Never expires by itself. Default: errors and anything with actions. */
62
+ sticky?: boolean;
63
+ /** How long it stays, in ms. Default by kind, see `DEFAULT_TIMEOUT_MS`. Ignored for a sticky one. */
64
+ timeoutMs?: number;
65
+ /** Notifications with the same key that are still showing are one: see the module doc comment. */
66
+ dedupeKey?: string;
67
+ /** Keep it out of the history (default: kept, except `mail`). */
68
+ history?: boolean;
69
+ }
70
+
71
+ /** A notification as a view renders it. */
72
+ export interface NotificationView {
73
+ id: string;
74
+ kind: NotificationKind;
75
+ title: string;
76
+ subtitle?: string;
77
+ message?: string;
78
+ preview?: string;
79
+ hint?: string;
80
+ details: string[];
81
+ actions: NotificationAction[];
82
+ href?: string;
83
+ /** How many times this one was raised (`dedupeKey`), 1 for a single one. */
84
+ count: number;
85
+ sticky: boolean;
86
+ createdAt: number;
87
+ }
88
+
89
+ export interface HistoryEntry {
90
+ id: string;
91
+ kind: NotificationKind;
92
+ title: string;
93
+ message?: string;
94
+ details: string[];
95
+ count: number;
96
+ /** When it was last raised (epoch ms). */
97
+ at: number;
98
+ /** Nobody has opened the history since it arrived. */
99
+ unseen: boolean;
100
+ }
101
+
102
+ export interface NotificationsSnapshot {
103
+ /** What is on screen, oldest first. */
104
+ visible: NotificationView[];
105
+ /** How many are waiting for room. */
106
+ queued: number;
107
+ /** The last `HISTORY_LIMIT`, newest first. */
108
+ history: HistoryEntry[];
109
+ /** Errors in the history that nobody has looked at. */
110
+ unseenErrors: number;
111
+ }
112
+
113
+ export const MAX_VISIBLE = 3;
114
+ export const MAX_QUEUED = 20;
115
+ export const QUEUE_STALE_MS = 30_000;
116
+ export const HISTORY_LIMIT = 30;
117
+ export const HISTORY_STORAGE_KEY = "rapidmx-notification-history";
118
+ /** The most technical lines and the longest line kept per notification - a transport error can carry a whole SMTP transcript. */
119
+ export const MAX_DETAIL_LINES = 50;
120
+ export const MAX_DETAIL_LINE_LENGTH = 500;
121
+
122
+ /** How long a non-sticky notification stays, by kind. Errors are sticky, so theirs is only for an explicit `sticky: false`. */
123
+ export const DEFAULT_TIMEOUT_MS: Record<NotificationKind, number> = {
124
+ mail: 8_000,
125
+ // Irrelevant in practice - a reminder always carries actions, which makes it sticky (see `applyInput()`) regardless of this value.
126
+ calendar: 8_000,
127
+ info: 6_000,
128
+ success: 5_000,
129
+ warning: 10_000,
130
+ error: 10_000,
131
+ };
132
+
133
+ interface Entry {
134
+ id: string;
135
+ kind: NotificationKind;
136
+ title: string;
137
+ subtitle?: string;
138
+ message?: string;
139
+ preview?: string;
140
+ hint?: string;
141
+ details: string[];
142
+ actions: NotificationAction[];
143
+ href?: string;
144
+ dedupeKey?: string;
145
+ sticky: boolean;
146
+ timeoutMs: number;
147
+ count: number;
148
+ createdAt: number;
149
+ history: boolean;
150
+ status: "visible" | "queued";
151
+ remainingMs: number;
152
+ startedAt?: number;
153
+ timer?: ReturnType<typeof setTimeout>;
154
+ paused: boolean;
155
+ }
156
+
157
+ const EMPTY_SNAPSHOT: NotificationsSnapshot = { visible: [], queued: 0, history: [], unseenErrors: 0 };
158
+
159
+ let entries: Entry[] = [];
160
+ let history: HistoryEntry[] = [];
161
+ let historyLoaded = false;
162
+ let allPaused = false;
163
+ let counter = 0;
164
+ let snapshot: NotificationsSnapshot = EMPTY_SNAPSHOT;
165
+ const listeners = new Set<() => void>();
166
+
167
+ function isClient(): boolean {
168
+ return typeof window !== "undefined";
169
+ }
170
+
171
+ function normalizeDetails(details: string[] | string | undefined): string[] {
172
+ if (details === undefined) {
173
+ return [];
174
+ }
175
+ const lines = (Array.isArray(details) ? details : details.split(/\r?\n/)).filter((line) => line.length > 0);
176
+ return lines.slice(0, MAX_DETAIL_LINES).map((line) => (line.length > MAX_DETAIL_LINE_LENGTH ? `${line.slice(0, MAX_DETAIL_LINE_LENGTH)}...` : line));
177
+ }
178
+
179
+ function loadHistory(): void {
180
+ if (historyLoaded || !isClient()) {
181
+ return;
182
+ }
183
+ historyLoaded = true;
184
+ try {
185
+ const stored = JSON.parse(sessionStorage.getItem(HISTORY_STORAGE_KEY) ?? "[]") as HistoryEntry[];
186
+ history = Array.isArray(stored)
187
+ ? stored
188
+ .filter((item) => !!item && typeof item.id === "string" && typeof item.title === "string" && typeof item.at === "number")
189
+ .slice(0, HISTORY_LIMIT)
190
+ .map((item) => ({ ...item, details: Array.isArray(item.details) ? item.details : [], unseen: item.unseen === true }))
191
+ : [];
192
+ } catch {
193
+ history = [];
194
+ }
195
+ }
196
+
197
+ function saveHistory(): void {
198
+ try {
199
+ sessionStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(history));
200
+ } catch {
201
+ // Storage unavailable or full: the history is only kept in memory, which is all it needs to be for this page.
202
+ }
203
+ }
204
+
205
+ function view(entry: Entry): NotificationView {
206
+ return {
207
+ id: entry.id,
208
+ kind: entry.kind,
209
+ title: entry.title,
210
+ subtitle: entry.subtitle,
211
+ message: entry.message,
212
+ preview: entry.preview,
213
+ hint: entry.hint,
214
+ details: entry.details,
215
+ actions: entry.actions,
216
+ href: entry.href,
217
+ count: entry.count,
218
+ sticky: entry.sticky,
219
+ createdAt: entry.createdAt,
220
+ };
221
+ }
222
+
223
+ function rebuild(): void {
224
+ const visible = entries.filter((entry) => entry.status === "visible").map(view);
225
+ snapshot = {
226
+ visible,
227
+ queued: entries.length - visible.length,
228
+ history,
229
+ unseenErrors: history.filter((item) => item.unseen && item.kind === "error").length,
230
+ };
231
+ }
232
+
233
+ function emit(): void {
234
+ rebuild();
235
+ for (const listener of [...listeners]) {
236
+ listener();
237
+ }
238
+ }
239
+
240
+ function findEntry(id: string): Entry | undefined {
241
+ return entries.find((entry) => entry.id === id);
242
+ }
243
+
244
+ /** Records (or refreshes) `entry` in the history, newest first. */
245
+ function record(entry: Entry): void {
246
+ if (!entry.history) {
247
+ return;
248
+ }
249
+ loadHistory();
250
+ const item: HistoryEntry = {
251
+ id: entry.id,
252
+ kind: entry.kind,
253
+ title: entry.title,
254
+ message: entry.message,
255
+ details: entry.details,
256
+ count: entry.count,
257
+ at: Date.now(),
258
+ unseen: true,
259
+ };
260
+ history = [item, ...history.filter((existing) => existing.id !== entry.id)].slice(0, HISTORY_LIMIT);
261
+ saveHistory();
262
+ }
263
+
264
+ function disarm(entry: Entry): void {
265
+ if (entry.timer !== undefined) {
266
+ clearTimeout(entry.timer);
267
+ entry.timer = undefined;
268
+ entry.remainingMs = Math.max(0, entry.remainingMs - (Date.now() - entry.startedAt!));
269
+ }
270
+ }
271
+
272
+ /** Starts (or resumes) the clock of a visible, non-sticky, unpaused notification. */
273
+ function arm(entry: Entry): void {
274
+ if (entry.status !== "visible" || entry.sticky || entry.paused || allPaused || entry.timer !== undefined) {
275
+ return;
276
+ }
277
+ entry.startedAt = Date.now();
278
+ entry.timer = setTimeout(() => {
279
+ entry.timer = undefined;
280
+ remove(entry.id);
281
+ }, entry.remainingMs);
282
+ }
283
+
284
+ function visibleCount(): number {
285
+ return entries.filter((entry) => entry.status === "visible").length;
286
+ }
287
+
288
+ function show(entry: Entry): void {
289
+ entry.status = "visible";
290
+ entry.remainingMs = entry.timeoutMs;
291
+ arm(entry);
292
+ }
293
+
294
+ /** Fills free places from the queue, dropping what has waited too long. */
295
+ function promote(): void {
296
+ const now = Date.now();
297
+ entries = entries.filter((entry) => entry.status === "visible" || entry.sticky || now - entry.createdAt < QUEUE_STALE_MS);
298
+ while (visibleCount() < MAX_VISIBLE) {
299
+ const next = entries.find((entry) => entry.status === "queued");
300
+ if (!next) {
301
+ return;
302
+ }
303
+ show(next);
304
+ }
305
+ }
306
+
307
+ function remove(id: string): void {
308
+ const entry = findEntry(id);
309
+ if (!entry) {
310
+ return;
311
+ }
312
+ disarm(entry);
313
+ entries = entries.filter((existing) => existing !== entry);
314
+ promote();
315
+ emit();
316
+ }
317
+
318
+ /** Makes room for a sticky notification: the oldest visible one that would have gone by itself leaves (it stays in the history). */
319
+ function evictForSticky(): boolean {
320
+ const victim = entries.find((entry) => entry.status === "visible" && !entry.sticky);
321
+ if (!victim) {
322
+ return false;
323
+ }
324
+ disarm(victim);
325
+ entries = entries.filter((entry) => entry !== victim);
326
+ return true;
327
+ }
328
+
329
+ function applyInput(entry: Entry, input: NotifyInput): void {
330
+ entry.kind = input.kind;
331
+ entry.title = input.title;
332
+ entry.subtitle = input.subtitle;
333
+ entry.message = input.message;
334
+ entry.preview = input.preview;
335
+ entry.hint = input.hint;
336
+ entry.details = normalizeDetails(input.details);
337
+ entry.actions = input.actions ?? [];
338
+ entry.href = input.href;
339
+ entry.sticky = input.sticky ?? (input.kind === "error" || (input.actions?.length ?? 0) > 0);
340
+ entry.timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS[input.kind];
341
+ entry.history = input.history ?? input.kind !== "mail";
342
+ }
343
+
344
+ /**
345
+ * Raises a notification and returns its id. See the module doc comment for what happens to it. Usable from anywhere - a component, a
346
+ * hook, a plain function, an `apiFetch` error path - and safe where there is no window (it does nothing there).
347
+ */
348
+ export function notify(input: NotifyInput): string {
349
+ counter += 1;
350
+ const id = input.id ?? `notification-${counter}`;
351
+ if (!isClient()) {
352
+ return id;
353
+ }
354
+ const sameKey = input.dedupeKey !== undefined ? entries.find((entry) => entry.dedupeKey === input.dedupeKey) : undefined;
355
+ const existing = sameKey ?? findEntry(id);
356
+ if (existing) {
357
+ // The same notification again: its content is replaced and it gets its full time again. Only a repeated dedupe key counts up
358
+ // (an explicit id is a replacement, not another occurrence).
359
+ disarm(existing);
360
+ applyInput(existing, input);
361
+ existing.count += sameKey ? 1 : 0;
362
+ existing.remainingMs = existing.timeoutMs;
363
+ arm(existing);
364
+ record(existing);
365
+ emit();
366
+ return existing.id;
367
+ }
368
+ const entry: Entry = {
369
+ id,
370
+ dedupeKey: input.dedupeKey,
371
+ kind: input.kind,
372
+ title: input.title,
373
+ details: [],
374
+ actions: [],
375
+ sticky: false,
376
+ timeoutMs: 0,
377
+ count: 1,
378
+ createdAt: Date.now(),
379
+ history: true,
380
+ status: "queued",
381
+ remainingMs: 0,
382
+ paused: false,
383
+ };
384
+ applyInput(entry, input);
385
+ if (!getNotificationsEnabled()) {
386
+ // The user has pop-ups off: nothing is shown, but it is listed in the history like any other.
387
+ record(entry);
388
+ emit();
389
+ return id;
390
+ }
391
+ const room = visibleCount() < MAX_VISIBLE;
392
+ entries.push(entry);
393
+ if (room) {
394
+ show(entry);
395
+ } else if (entry.sticky && evictForSticky()) {
396
+ show(entry);
397
+ } else {
398
+ // Waiting for room: the queue itself is bounded, and the oldest waiting one that would not have stayed is the first to go.
399
+ const waiting = entries.filter((e) => e.status === "queued");
400
+ if (waiting.length > MAX_QUEUED) {
401
+ const dropped = waiting.find((e) => !e.sticky) ?? waiting[0];
402
+ entries = entries.filter((e) => e !== dropped);
403
+ }
404
+ }
405
+ record(entry);
406
+ emit();
407
+ return id;
408
+ }
409
+
410
+ /**
411
+ * Changes a notification that is still showing or waiting - the way to resolve it ("Sending..." becoming "Sent"). Only the given
412
+ * fields change. Its clock restarts, and it may become sticky or stop being so with its new kind. Returns `false` when there is no such
413
+ * notification any more (it was dismissed or expired); its history entry is updated all the same.
414
+ */
415
+ export function update(id: string, patch: Partial<Omit<NotifyInput, "id" | "dedupeKey">>): boolean {
416
+ const entry = findEntry(id);
417
+ if (!entry) {
418
+ loadHistory();
419
+ const item = history.find((existing) => existing.id === id);
420
+ if (item) {
421
+ history = history.map((existing) =>
422
+ existing === item
423
+ ? {
424
+ ...existing,
425
+ kind: patch.kind ?? existing.kind,
426
+ title: patch.title ?? existing.title,
427
+ message: "message" in patch ? patch.message : existing.message,
428
+ details: "details" in patch ? normalizeDetails(patch.details) : existing.details,
429
+ }
430
+ : existing,
431
+ );
432
+ saveHistory();
433
+ emit();
434
+ }
435
+ return false;
436
+ }
437
+ disarm(entry);
438
+ applyInput(entry, {
439
+ kind: entry.kind,
440
+ title: entry.title,
441
+ subtitle: entry.subtitle,
442
+ message: entry.message,
443
+ preview: entry.preview,
444
+ hint: entry.hint,
445
+ details: entry.details,
446
+ actions: entry.actions,
447
+ href: entry.href,
448
+ history: entry.history,
449
+ // A change of kind or actions recomputes stickiness (unless the patch says), so it is only carried over otherwise.
450
+ sticky: "kind" in patch || "actions" in patch ? undefined : entry.sticky,
451
+ // Its own time again, unless the kind changed (then the new kind's default) or the patch says.
452
+ timeoutMs: "kind" in patch ? undefined : entry.timeoutMs,
453
+ ...patch,
454
+ });
455
+ entry.remainingMs = entry.timeoutMs;
456
+ arm(entry);
457
+ record(entry);
458
+ emit();
459
+ return true;
460
+ }
461
+
462
+ /** Removes a notification from the screen (or the queue). Its history entry stays. Does nothing for an unknown id. */
463
+ export function dismiss(id: string): void {
464
+ remove(id);
465
+ }
466
+
467
+ /** Removes everything showing and waiting. */
468
+ export function dismissAll(): void {
469
+ for (const entry of entries) {
470
+ disarm(entry);
471
+ }
472
+ entries = [];
473
+ emit();
474
+ }
475
+
476
+ /** Stops (or restarts) one notification's clock: it is being hovered or holds the keyboard focus. */
477
+ export function setPaused(id: string, paused: boolean): void {
478
+ const entry = findEntry(id);
479
+ if (!entry || entry.paused === paused) {
480
+ return;
481
+ }
482
+ entry.paused = paused;
483
+ if (paused) {
484
+ disarm(entry);
485
+ } else {
486
+ arm(entry);
487
+ }
488
+ }
489
+
490
+ /** Stops (or restarts) every clock: the tab is hidden, so nobody could have read what is on screen. */
491
+ export function setAllPaused(paused: boolean): void {
492
+ if (allPaused === paused) {
493
+ return;
494
+ }
495
+ allPaused = paused;
496
+ for (const entry of entries) {
497
+ if (paused) {
498
+ disarm(entry);
499
+ } else {
500
+ arm(entry);
501
+ }
502
+ }
503
+ }
504
+
505
+ /** Marks every history entry as seen - the user has opened the history. */
506
+ export function markHistorySeen(): void {
507
+ loadHistory();
508
+ if (!history.some((item) => item.unseen)) {
509
+ return;
510
+ }
511
+ history = history.map((item) => (item.unseen ? { ...item, unseen: false } : item));
512
+ saveHistory();
513
+ emit();
514
+ }
515
+
516
+ /** Empties the history. */
517
+ export function clearHistory(): void {
518
+ loadHistory();
519
+ history = [];
520
+ saveHistory();
521
+ emit();
522
+ }
523
+
524
+ export function subscribeNotifications(listener: () => void): () => void {
525
+ listeners.add(listener);
526
+ return () => {
527
+ listeners.delete(listener);
528
+ };
529
+ }
530
+
531
+ export function getNotificationsSnapshot(): NotificationsSnapshot {
532
+ if (!historyLoaded && isClient()) {
533
+ // The history is read from storage on first use, without telling listeners (this runs during a render).
534
+ loadHistory();
535
+ rebuild();
536
+ }
537
+ return snapshot;
538
+ }
539
+
540
+ export function getServerNotificationsSnapshot(): NotificationsSnapshot {
541
+ return EMPTY_SNAPSHOT;
542
+ }
543
+
544
+ /** Forgets everything and stops every clock - for a test, or a page that outlives its session. */
545
+ export function resetNotifications(): void {
546
+ for (const entry of entries) {
547
+ disarm(entry);
548
+ }
549
+ entries = [];
550
+ history = [];
551
+ historyLoaded = false;
552
+ allPaused = false;
553
+ counter = 0;
554
+ snapshot = EMPTY_SNAPSHOT;
555
+ try {
556
+ sessionStorage.removeItem(HISTORY_STORAGE_KEY);
557
+ } catch {
558
+ // Nothing stored.
559
+ }
560
+ }