@rapidmx/web-client 0.18.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 -1545
  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 +328 -48
  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,277 +1,277 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- import { useEffect, useMemo, useRef, useState } from "react";
6
- import { Folder, Mailbox, Message } from "@rapidmx/react-shared/mail/mailApi.js";
7
- import { getPushClient, PushEvent } from "@rapidmx/react-shared/mail/pushClient.js";
8
- import { SendEvent, parseSendEvent } from "@rapidmx/react-shared/mail/sendEvents.js";
9
- import type { MailboxFolders } from "../components/mail/layout/MailShell.js";
10
- import { SIGN_OUT_CHANNEL } from "../search/localIndexRpcClient.js";
11
- import { FolderCounts, useFolderCounts } from "./folderCounts.js";
12
- import { FolderChange, sortFolders } from "./folderTree.js";
13
-
14
- /** How often the safety-net poll runs while the tab is visible. The push socket does the real work; this is for a network
15
- * that blocks or silently drops it, and for events published while it was reconnecting (Redis pub/sub is never replayed). */
16
- export const LIVE_POLL_INTERVAL_MS = 45_000;
17
-
18
- /** Events arriving this close together are answered with one refresh - a burst of new mail is one refetch, not one each. */
19
- export const LIVE_EVENT_DEBOUNCE_MS = 400;
20
-
21
- /** What changed, as far as a list on screen is concerned. */
22
- export interface LiveUpdates {
23
- /** Bumped each time the list should quietly refetch its first page. `0` until the first bump. */
24
- tick: number;
25
- /** The folders the events behind the latest bump touched - `null` when that isn't known (a poll, a reconnect, the tab
26
- * coming back to the front), meaning any folder may have changed. */
27
- folderUids: ReadonlySet<string> | null;
28
- }
29
-
30
- export const NO_LIVE_UPDATES: LiveUpdates = { tick: 0, folderUids: null };
31
-
32
- /** The class names `NotificationUtils.sendMessage()` publishes are the model's (`MessageMongo`, `MessageSQL`, `FolderMongo`). */
33
- const MESSAGE_EVENT = /^Message/;
34
- const FOLDER_EVENT = /^Folder/;
35
- const MESSAGE_ACTIONS = new Set(["create", "update", "delete"]);
36
-
37
- /**
38
- * The channels to subscribe to, most important first (the push client keeps the first `PUSH_MAX_CHANNELS`): every
39
- * mailbox's Inbox - where mail lands - then the mailbox uids themselves (which announce new, renamed and deleted folders, so
40
- * they must outrank any folder's own mail), then each mailbox's other folders in the sidebar's own order - a folder that has
41
- * just appeared is filed by its type like any other, so when the server's cap bites it is the last of them that go without live
42
- * updates. A folder is a channel of its own; subscribing to a mailbox does not deliver its folders' events.
43
- */
44
- export function pushChannelsFor(mailboxFolders: MailboxFolders[], mailboxes: Mailbox[]): string[] {
45
- const inboxes = mailboxFolders.flatMap((entry) => entry.folders.filter((folder) => folder.type === "inbox"));
46
- const others = mailboxFolders.flatMap((entry) => sortFolders(entry.folders.filter((folder) => folder.type !== "inbox")));
47
- return [...new Set([...inboxes.map((folder) => folder.uid), ...mailboxes.map((mailbox) => mailbox.uid), ...others.map((folder) => folder.uid)])];
48
- }
49
-
50
- function isFolder(value: unknown): value is Folder {
51
- const folder = value as Partial<Folder> | null;
52
- return !!folder && typeof folder.uid === "string" && typeof folder.mailboxUid === "string" && typeof folder.type === "string";
53
- }
54
-
55
- /** A `Folder` update event that says the folder's name or type changed (and names the folder): the counts-only ones the server publishes after every message change do not. */
56
- function isFolderChange(value: unknown): value is FolderChange {
57
- const change = value as Partial<FolderChange> | null;
58
- return !!change && typeof change.uid === "string" && (typeof change.name === "string" || typeof change.type === "string");
59
- }
60
-
61
- function isMessage(value: unknown): value is Message {
62
- const message = value as Partial<Message> | null;
63
- return (
64
- !!message &&
65
- typeof message.uid === "string" &&
66
- typeof message.folderUid === "string" &&
67
- typeof message.flags === "object" &&
68
- message.flags !== null
69
- );
70
- }
71
-
72
- /** The folder a message event is about: the message's own `folderUid`, else the channel it arrived on (when the server said). */
73
- function folderOfMessageEvent(event: PushEvent): string | undefined {
74
- const data = event.data as { folderUid?: unknown } | null | undefined;
75
- if (typeof data?.folderUid === "string") {
76
- return data.folderUid;
77
- }
78
- return event.channel;
79
- }
80
-
81
- export interface UseMailLiveUpdatesOptions {
82
- /** Nothing runs without a signed-in user. */
83
- userUid?: string;
84
- mailboxes: Mailbox[];
85
- mailboxFolders: MailboxFolders[];
86
- /** Called with a folder another client (or this one) created, so the sidebar shows it. Called only for one not already known. */
87
- onFolderCreated: (folder: Folder) => void;
88
- /** Called when a folder's name or type was changed elsewhere (a `Folder` update event that says so - one that only carries counts is not this). */
89
- onFolderChanged?: (change: FolderChange) => void;
90
- /** Called with the uid of a folder that was deleted elsewhere. */
91
- onFolderDeleted?: (folderUid: string) => void;
92
- /** Called with a mailbox's folders as the server just listed them - on every refresh (a message event, the poll, a reconnect, the tab
93
- * coming back, a send) - so a folder the tree does not know is found whichever way it appeared. */
94
- onFoldersListed?: (folders: Folder[]) => void;
95
- /** Called with each new message the push connection announces (a `create` event) - once per message, never for a list
96
- * refetch, the initial load or a reconnect (nothing published while the socket was down is replayed). The caller decides
97
- * whether it is worth telling the user about. */
98
- onMessageCreated?: (message: Message) => void;
99
- /** Called with the outcome of a message sent in the background (`send-succeeded`, `send-failed`, `send-retrying`). Every one also
100
- * refreshes what is on screen (the Outbox and Sent Items lists and counts change with it). */
101
- onSendEvent?: (event: SendEvent) => void;
102
- }
103
-
104
- export interface MailLiveUpdates {
105
- live: LiveUpdates;
106
- /** The folder badges' numbers and how to change them - see `useFolderCounts()`. Kept in an overlay of their own, **not** in
107
- * `mailboxFolders`, because the list effect reloads - and forgets the selection - whenever `mailboxFolders` changes. */
108
- folderCounts: FolderCounts;
109
- }
110
-
111
- /**
112
- * Keeps Mail current without a page reload. One shared push connection per tab (`getPushClient()`) is subscribed to every
113
- * folder of every accessible mailbox; a message event for any of them, a reconnect, a poll of the safety net (every
114
- * `LIVE_POLL_INTERVAL_MS` while the tab is visible - and the only mechanism, silently, where the socket can't connect), the
115
- * tab coming back to the front or the browser coming back online, all end in the same debounced refresh: `live` is bumped
116
- * (the list on screen refetches its first page - see `mergeFirstPage()`) and every folder's counts are re-read. A new message
117
- * also bumps its folder's badge at once and is announced to `onMessageCreated`, and the server's `Folder` update events (the
118
- * folder's real counts) are applied to the badges - see `useFolderCounts()`.
119
- *
120
- * Sign-out closes the socket for good: another tab's or this one's, heard on the same channel `AppShell` listens on.
121
- * Renders nothing and does nothing where there is no window (server-side rendering).
122
- */
123
- export function useMailLiveUpdates({
124
- userUid,
125
- mailboxes,
126
- mailboxFolders,
127
- onFolderCreated,
128
- onFolderChanged,
129
- onFolderDeleted,
130
- onFoldersListed,
131
- onMessageCreated,
132
- onSendEvent,
133
- }: UseMailLiveUpdatesOptions): MailLiveUpdates {
134
- const [live, setLive] = useState<LiveUpdates>(NO_LIVE_UPDATES);
135
- const folders = useMemo(() => mailboxFolders.flatMap((entry) => entry.folders), [mailboxFolders]);
136
- const folderCounts = useFolderCounts(mailboxes, folders, onFoldersListed);
137
- // The latest inputs, for the long-lived listeners below.
138
- const latestRef = useRef({ mailboxes, mailboxFolders, onFolderCreated, onFolderChanged, onFolderDeleted, onMessageCreated, onSendEvent, folderCounts });
139
- latestRef.current = { mailboxes, mailboxFolders, onFolderCreated, onFolderChanged, onFolderDeleted, onMessageCreated, onSendEvent, folderCounts };
140
-
141
- const channels = useMemo(() => pushChannelsFor(mailboxFolders, mailboxes), [mailboxFolders, mailboxes]);
142
- const channelKey = channels.join("|");
143
- const channelsRef = useRef(channels);
144
- channelsRef.current = channels;
145
-
146
- useEffect(() => {
147
- if (!userUid) {
148
- return;
149
- }
150
- const client = getPushClient();
151
- let stopped = false;
152
- let timer: ReturnType<typeof setTimeout> | undefined;
153
- let pendingFolders = new Set<string>();
154
- let pendingUnknown = false;
155
- let everOpen = false;
156
-
157
- // Never runs once `stopped`: whatever sets it clears the timer first.
158
- function flush() {
159
- timer = undefined;
160
- const folderUids = pendingUnknown ? null : new Set(pendingFolders);
161
- pendingFolders = new Set();
162
- pendingUnknown = false;
163
- setLive((previous) => ({ tick: previous.tick + 1, folderUids }));
164
- latestRef.current.folderCounts.refresh(0);
165
- }
166
-
167
- /** Asks for a refresh: of the given folders, or (no argument) of whatever may have changed. */
168
- function schedule(folderUid?: string) {
169
- if (stopped) {
170
- return;
171
- }
172
- if (folderUid) {
173
- pendingFolders.add(folderUid);
174
- } else {
175
- pendingUnknown = true;
176
- }
177
- timer ??= setTimeout(flush, LIVE_EVENT_DEBOUNCE_MS);
178
- }
179
-
180
- const offEvent = client.onEvent((event) => {
181
- const sendEvent = parseSendEvent(event);
182
- if (sendEvent) {
183
- // A queued message was relayed (or failed to be): it left Outbox for Sent Items, or stayed there failed - every list and count may have changed.
184
- latestRef.current.onSendEvent?.(sendEvent);
185
- schedule();
186
- } else if (MESSAGE_EVENT.test(event.type) && MESSAGE_ACTIONS.has(event.action ?? "")) {
187
- if (event.action === "create" && isMessage(event.data)) {
188
- // Once per message, whatever else the event triggers: the badge first, then whoever announces it.
189
- if (latestRef.current.folderCounts.noteCreated(event.data)) {
190
- latestRef.current.onMessageCreated?.(event.data);
191
- }
192
- }
193
- schedule(folderOfMessageEvent(event));
194
- } else if (FOLDER_EVENT.test(event.type) && event.action === "create" && isFolder(event.data)) {
195
- // A folder made by the server (on first use, or by another client) or by this one: filed in the sidebar and subscribed to at once (the
196
- // channel list follows the tree), and its real counts read - all without a reload.
197
- const folder = event.data;
198
- const known = latestRef.current.mailboxFolders.some((entry) => entry.folders.some((f) => f.uid === folder.uid));
199
- if (!known) {
200
- latestRef.current.onFolderCreated(folder);
201
- latestRef.current.folderCounts.refresh(0);
202
- }
203
- } else if (FOLDER_EVENT.test(event.type) && event.action === "update") {
204
- if (isFolderChange(event.data)) {
205
- latestRef.current.onFolderChanged?.(event.data);
206
- }
207
- latestRef.current.folderCounts.applyFolderEvent(event.data);
208
- } else if (FOLDER_EVENT.test(event.type) && event.action === "delete") {
209
- const uid = (event.data as { uid?: unknown } | null | undefined)?.uid;
210
- if (typeof uid === "string") {
211
- latestRef.current.onFolderDeleted?.(uid);
212
- }
213
- }
214
- });
215
- // Anything published while the socket was down is gone for good, so every reconnect is followed by a refresh.
216
- const offStatus = client.onStatus((status) => {
217
- if (status === "open") {
218
- if (everOpen) {
219
- schedule();
220
- }
221
- everOpen = true;
222
- }
223
- });
224
-
225
- const poll = setInterval(() => {
226
- if (document.visibilityState === "visible") {
227
- schedule();
228
- }
229
- }, LIVE_POLL_INTERVAL_MS);
230
- function refreshNow() {
231
- if (document.visibilityState === "visible") {
232
- client.reconnectNow();
233
- schedule();
234
- }
235
- }
236
- function handleOnline() {
237
- client.reconnectNow();
238
- schedule();
239
- }
240
- document.addEventListener("visibilitychange", refreshNow);
241
- window.addEventListener("focus", refreshNow);
242
- window.addEventListener("online", handleOnline);
243
-
244
- // A sign-out, in this tab or another, ended the session behind the socket: close it for good and stop refreshing.
245
- const signOut = typeof BroadcastChannel === "undefined" ? undefined : new BroadcastChannel(SIGN_OUT_CHANNEL);
246
- signOut?.addEventListener("message", (event: MessageEvent<{ type?: string }>) => {
247
- if (event.data?.type === "sign-out") {
248
- stopped = true;
249
- clearTimeout(timer);
250
- client.close();
251
- }
252
- });
253
-
254
- client.setChannels(channelsRef.current);
255
- client.start();
256
-
257
- return () => {
258
- stopped = true;
259
- clearTimeout(timer);
260
- clearInterval(poll);
261
- offEvent();
262
- offStatus();
263
- document.removeEventListener("visibilitychange", refreshNow);
264
- window.removeEventListener("focus", refreshNow);
265
- window.removeEventListener("online", handleOnline);
266
- signOut?.close();
267
- };
268
- }, [userUid]);
269
-
270
- useEffect(() => {
271
- if (userUid) {
272
- getPushClient().setChannels(channels);
273
- }
274
- }, [userUid, channelKey]);
275
-
276
- return { live, folderCounts };
277
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { useEffect, useMemo, useRef, useState } from "react";
6
+ import { Folder, Mailbox, Message } from "@rapidmx/react-shared/mail/mailApi.js";
7
+ import { getPushClient, PushEvent } from "@rapidmx/react-shared/mail/pushClient.js";
8
+ import { SendEvent, parseSendEvent } from "@rapidmx/react-shared/mail/sendEvents.js";
9
+ import type { MailboxFolders } from "../components/mail/layout/MailShell.js";
10
+ import { SIGN_OUT_CHANNEL } from "../search/localIndexRpcClient.js";
11
+ import { FolderCounts, useFolderCounts } from "./folderCounts.js";
12
+ import { FolderChange, sortFolders } from "./folderTree.js";
13
+
14
+ /** How often the safety-net poll runs while the tab is visible. The push socket does the real work; this is for a network
15
+ * that blocks or silently drops it, and for events published while it was reconnecting (Redis pub/sub is never replayed). */
16
+ export const LIVE_POLL_INTERVAL_MS = 45_000;
17
+
18
+ /** Events arriving this close together are answered with one refresh - a burst of new mail is one refetch, not one each. */
19
+ export const LIVE_EVENT_DEBOUNCE_MS = 400;
20
+
21
+ /** What changed, as far as a list on screen is concerned. */
22
+ export interface LiveUpdates {
23
+ /** Bumped each time the list should quietly refetch its first page. `0` until the first bump. */
24
+ tick: number;
25
+ /** The folders the events behind the latest bump touched - `null` when that isn't known (a poll, a reconnect, the tab
26
+ * coming back to the front), meaning any folder may have changed. */
27
+ folderUids: ReadonlySet<string> | null;
28
+ }
29
+
30
+ export const NO_LIVE_UPDATES: LiveUpdates = { tick: 0, folderUids: null };
31
+
32
+ /** The class names `NotificationUtils.sendMessage()` publishes are the model's (`MessageMongo`, `MessageSQL`, `FolderMongo`). */
33
+ const MESSAGE_EVENT = /^Message/;
34
+ const FOLDER_EVENT = /^Folder/;
35
+ const MESSAGE_ACTIONS = new Set(["create", "update", "delete"]);
36
+
37
+ /**
38
+ * The channels to subscribe to, most important first (the push client keeps the first `PUSH_MAX_CHANNELS`): every
39
+ * mailbox's Inbox - where mail lands - then the mailbox uids themselves (which announce new, renamed and deleted folders, so
40
+ * they must outrank any folder's own mail), then each mailbox's other folders in the sidebar's own order - a folder that has
41
+ * just appeared is filed by its type like any other, so when the server's cap bites it is the last of them that go without live
42
+ * updates. A folder is a channel of its own; subscribing to a mailbox does not deliver its folders' events.
43
+ */
44
+ export function pushChannelsFor(mailboxFolders: MailboxFolders[], mailboxes: Mailbox[]): string[] {
45
+ const inboxes = mailboxFolders.flatMap((entry) => entry.folders.filter((folder) => folder.type === "inbox"));
46
+ const others = mailboxFolders.flatMap((entry) => sortFolders(entry.folders.filter((folder) => folder.type !== "inbox")));
47
+ return [...new Set([...inboxes.map((folder) => folder.uid), ...mailboxes.map((mailbox) => mailbox.uid), ...others.map((folder) => folder.uid)])];
48
+ }
49
+
50
+ function isFolder(value: unknown): value is Folder {
51
+ const folder = value as Partial<Folder> | null;
52
+ return !!folder && typeof folder.uid === "string" && typeof folder.mailboxUid === "string" && typeof folder.type === "string";
53
+ }
54
+
55
+ /** A `Folder` update event that says the folder's name or type changed (and names the folder): the counts-only ones the server publishes after every message change do not. */
56
+ function isFolderChange(value: unknown): value is FolderChange {
57
+ const change = value as Partial<FolderChange> | null;
58
+ return !!change && typeof change.uid === "string" && (typeof change.name === "string" || typeof change.type === "string");
59
+ }
60
+
61
+ function isMessage(value: unknown): value is Message {
62
+ const message = value as Partial<Message> | null;
63
+ return (
64
+ !!message &&
65
+ typeof message.uid === "string" &&
66
+ typeof message.folderUid === "string" &&
67
+ typeof message.flags === "object" &&
68
+ message.flags !== null
69
+ );
70
+ }
71
+
72
+ /** The folder a message event is about: the message's own `folderUid`, else the channel it arrived on (when the server said). */
73
+ function folderOfMessageEvent(event: PushEvent): string | undefined {
74
+ const data = event.data as { folderUid?: unknown } | null | undefined;
75
+ if (typeof data?.folderUid === "string") {
76
+ return data.folderUid;
77
+ }
78
+ return event.channel;
79
+ }
80
+
81
+ export interface UseMailLiveUpdatesOptions {
82
+ /** Nothing runs without a signed-in user. */
83
+ userUid?: string;
84
+ mailboxes: Mailbox[];
85
+ mailboxFolders: MailboxFolders[];
86
+ /** Called with a folder another client (or this one) created, so the sidebar shows it. Called only for one not already known. */
87
+ onFolderCreated: (folder: Folder) => void;
88
+ /** Called when a folder's name or type was changed elsewhere (a `Folder` update event that says so - one that only carries counts is not this). */
89
+ onFolderChanged?: (change: FolderChange) => void;
90
+ /** Called with the uid of a folder that was deleted elsewhere. */
91
+ onFolderDeleted?: (folderUid: string) => void;
92
+ /** Called with a mailbox's folders as the server just listed them - on every refresh (a message event, the poll, a reconnect, the tab
93
+ * coming back, a send) - so a folder the tree does not know is found whichever way it appeared. */
94
+ onFoldersListed?: (folders: Folder[]) => void;
95
+ /** Called with each new message the push connection announces (a `create` event) - once per message, never for a list
96
+ * refetch, the initial load or a reconnect (nothing published while the socket was down is replayed). The caller decides
97
+ * whether it is worth telling the user about. */
98
+ onMessageCreated?: (message: Message) => void;
99
+ /** Called with the outcome of a message sent in the background (`send-succeeded`, `send-failed`, `send-retrying`). Every one also
100
+ * refreshes what is on screen (the Outbox and Sent Items lists and counts change with it). */
101
+ onSendEvent?: (event: SendEvent) => void;
102
+ }
103
+
104
+ export interface MailLiveUpdates {
105
+ live: LiveUpdates;
106
+ /** The folder badges' numbers and how to change them - see `useFolderCounts()`. Kept in an overlay of their own, **not** in
107
+ * `mailboxFolders`, because the list effect reloads - and forgets the selection - whenever `mailboxFolders` changes. */
108
+ folderCounts: FolderCounts;
109
+ }
110
+
111
+ /**
112
+ * Keeps Mail current without a page reload. One shared push connection per tab (`getPushClient()`) is subscribed to every
113
+ * folder of every accessible mailbox; a message event for any of them, a reconnect, a poll of the safety net (every
114
+ * `LIVE_POLL_INTERVAL_MS` while the tab is visible - and the only mechanism, silently, where the socket can't connect), the
115
+ * tab coming back to the front or the browser coming back online, all end in the same debounced refresh: `live` is bumped
116
+ * (the list on screen refetches its first page - see `mergeFirstPage()`) and every folder's counts are re-read. A new message
117
+ * also bumps its folder's badge at once and is announced to `onMessageCreated`, and the server's `Folder` update events (the
118
+ * folder's real counts) are applied to the badges - see `useFolderCounts()`.
119
+ *
120
+ * Sign-out closes the socket for good: another tab's or this one's, heard on the same channel `AppShell` listens on.
121
+ * Renders nothing and does nothing where there is no window (server-side rendering).
122
+ */
123
+ export function useMailLiveUpdates({
124
+ userUid,
125
+ mailboxes,
126
+ mailboxFolders,
127
+ onFolderCreated,
128
+ onFolderChanged,
129
+ onFolderDeleted,
130
+ onFoldersListed,
131
+ onMessageCreated,
132
+ onSendEvent,
133
+ }: UseMailLiveUpdatesOptions): MailLiveUpdates {
134
+ const [live, setLive] = useState<LiveUpdates>(NO_LIVE_UPDATES);
135
+ const folders = useMemo(() => mailboxFolders.flatMap((entry) => entry.folders), [mailboxFolders]);
136
+ const folderCounts = useFolderCounts(mailboxes, folders, onFoldersListed);
137
+ // The latest inputs, for the long-lived listeners below.
138
+ const latestRef = useRef({ mailboxes, mailboxFolders, onFolderCreated, onFolderChanged, onFolderDeleted, onMessageCreated, onSendEvent, folderCounts });
139
+ latestRef.current = { mailboxes, mailboxFolders, onFolderCreated, onFolderChanged, onFolderDeleted, onMessageCreated, onSendEvent, folderCounts };
140
+
141
+ const channels = useMemo(() => pushChannelsFor(mailboxFolders, mailboxes), [mailboxFolders, mailboxes]);
142
+ const channelKey = channels.join("|");
143
+ const channelsRef = useRef(channels);
144
+ channelsRef.current = channels;
145
+
146
+ useEffect(() => {
147
+ if (!userUid) {
148
+ return;
149
+ }
150
+ const client = getPushClient();
151
+ let stopped = false;
152
+ let timer: ReturnType<typeof setTimeout> | undefined;
153
+ let pendingFolders = new Set<string>();
154
+ let pendingUnknown = false;
155
+ let everOpen = false;
156
+
157
+ // Never runs once `stopped`: whatever sets it clears the timer first.
158
+ function flush() {
159
+ timer = undefined;
160
+ const folderUids = pendingUnknown ? null : new Set(pendingFolders);
161
+ pendingFolders = new Set();
162
+ pendingUnknown = false;
163
+ setLive((previous) => ({ tick: previous.tick + 1, folderUids }));
164
+ latestRef.current.folderCounts.refresh(0);
165
+ }
166
+
167
+ /** Asks for a refresh: of the given folders, or (no argument) of whatever may have changed. */
168
+ function schedule(folderUid?: string) {
169
+ if (stopped) {
170
+ return;
171
+ }
172
+ if (folderUid) {
173
+ pendingFolders.add(folderUid);
174
+ } else {
175
+ pendingUnknown = true;
176
+ }
177
+ timer ??= setTimeout(flush, LIVE_EVENT_DEBOUNCE_MS);
178
+ }
179
+
180
+ const offEvent = client.onEvent((event) => {
181
+ const sendEvent = parseSendEvent(event);
182
+ if (sendEvent) {
183
+ // A queued message was relayed (or failed to be): it left Outbox for Sent Items, or stayed there failed - every list and count may have changed.
184
+ latestRef.current.onSendEvent?.(sendEvent);
185
+ schedule();
186
+ } else if (MESSAGE_EVENT.test(event.type) && MESSAGE_ACTIONS.has(event.action ?? "")) {
187
+ if (event.action === "create" && isMessage(event.data)) {
188
+ // Once per message, whatever else the event triggers: the badge first, then whoever announces it.
189
+ if (latestRef.current.folderCounts.noteCreated(event.data)) {
190
+ latestRef.current.onMessageCreated?.(event.data);
191
+ }
192
+ }
193
+ schedule(folderOfMessageEvent(event));
194
+ } else if (FOLDER_EVENT.test(event.type) && event.action === "create" && isFolder(event.data)) {
195
+ // A folder made by the server (on first use, or by another client) or by this one: filed in the sidebar and subscribed to at once (the
196
+ // channel list follows the tree), and its real counts read - all without a reload.
197
+ const folder = event.data;
198
+ const known = latestRef.current.mailboxFolders.some((entry) => entry.folders.some((f) => f.uid === folder.uid));
199
+ if (!known) {
200
+ latestRef.current.onFolderCreated(folder);
201
+ latestRef.current.folderCounts.refresh(0);
202
+ }
203
+ } else if (FOLDER_EVENT.test(event.type) && event.action === "update") {
204
+ if (isFolderChange(event.data)) {
205
+ latestRef.current.onFolderChanged?.(event.data);
206
+ }
207
+ latestRef.current.folderCounts.applyFolderEvent(event.data);
208
+ } else if (FOLDER_EVENT.test(event.type) && event.action === "delete") {
209
+ const uid = (event.data as { uid?: unknown } | null | undefined)?.uid;
210
+ if (typeof uid === "string") {
211
+ latestRef.current.onFolderDeleted?.(uid);
212
+ }
213
+ }
214
+ });
215
+ // Anything published while the socket was down is gone for good, so every reconnect is followed by a refresh.
216
+ const offStatus = client.onStatus((status) => {
217
+ if (status === "open") {
218
+ if (everOpen) {
219
+ schedule();
220
+ }
221
+ everOpen = true;
222
+ }
223
+ });
224
+
225
+ const poll = setInterval(() => {
226
+ if (document.visibilityState === "visible") {
227
+ schedule();
228
+ }
229
+ }, LIVE_POLL_INTERVAL_MS);
230
+ function refreshNow() {
231
+ if (document.visibilityState === "visible") {
232
+ client.reconnectNow();
233
+ schedule();
234
+ }
235
+ }
236
+ function handleOnline() {
237
+ client.reconnectNow();
238
+ schedule();
239
+ }
240
+ document.addEventListener("visibilitychange", refreshNow);
241
+ window.addEventListener("focus", refreshNow);
242
+ window.addEventListener("online", handleOnline);
243
+
244
+ // A sign-out, in this tab or another, ended the session behind the socket: close it for good and stop refreshing.
245
+ const signOut = typeof BroadcastChannel === "undefined" ? undefined : new BroadcastChannel(SIGN_OUT_CHANNEL);
246
+ signOut?.addEventListener("message", (event: MessageEvent<{ type?: string }>) => {
247
+ if (event.data?.type === "sign-out") {
248
+ stopped = true;
249
+ clearTimeout(timer);
250
+ client.close();
251
+ }
252
+ });
253
+
254
+ client.setChannels(channelsRef.current);
255
+ client.start();
256
+
257
+ return () => {
258
+ stopped = true;
259
+ clearTimeout(timer);
260
+ clearInterval(poll);
261
+ offEvent();
262
+ offStatus();
263
+ document.removeEventListener("visibilitychange", refreshNow);
264
+ window.removeEventListener("focus", refreshNow);
265
+ window.removeEventListener("online", handleOnline);
266
+ signOut?.close();
267
+ };
268
+ }, [userUid]);
269
+
270
+ useEffect(() => {
271
+ if (userUid) {
272
+ getPushClient().setChannels(channels);
273
+ }
274
+ }, [userUid, channelKey]);
275
+
276
+ return { live, folderCounts };
277
+ }