@rapidmx/web-client 0.20.0 → 0.21.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 (118) 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/layout/AdminShell.tsx +384 -384
  16. package/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.tsx +238 -238
  17. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -152
  18. package/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.tsx +194 -194
  19. package/apps/shared/components/admin/settings/BrandingForm.tsx +423 -423
  20. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +119 -119
  21. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +198 -198
  22. package/apps/shared/components/admin/settings/PluginsManager.tsx +2093 -2093
  23. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +182 -182
  24. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +288 -288
  25. package/apps/shared/components/admin/setup/SetupWizard.tsx +446 -446
  26. package/apps/shared/components/admin/usePagedList.tsx +129 -129
  27. package/apps/shared/components/calendar/EventModal.tsx +206 -206
  28. package/apps/shared/components/calendar/MonthView.tsx +185 -185
  29. package/apps/shared/components/calendar/RecurrenceEditor.tsx +227 -227
  30. package/apps/shared/components/calendar/SplitDayView.tsx +144 -144
  31. package/apps/shared/components/calendar/TimeGridView.tsx +246 -246
  32. package/apps/shared/components/calendar/allDay.ts +124 -124
  33. package/apps/shared/components/contacts/ContactForm.tsx +383 -383
  34. package/apps/shared/components/contacts/ContactsToolbar.tsx +103 -103
  35. package/apps/shared/components/escrow/layout/EscrowShell.tsx +155 -155
  36. package/apps/shared/components/layout/AppShell.tsx +463 -461
  37. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -398
  38. package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -168
  39. package/apps/shared/components/layout/ResponsiveToolbar.tsx +315 -315
  40. package/apps/shared/components/layout/ThemeSwitch.tsx +84 -84
  41. package/apps/shared/components/layout/UserMenu.tsx +439 -439
  42. package/apps/shared/components/mail/ConversationList.tsx +332 -332
  43. package/apps/shared/components/mail/ConversationThreadPane.tsx +613 -613
  44. package/apps/shared/components/mail/MailSelectionBar.tsx +240 -240
  45. package/apps/shared/components/mail/MenuButton.tsx +404 -404
  46. package/apps/shared/components/mail/MessageDetailPane.tsx +1757 -1757
  47. package/apps/shared/components/mail/compose/ComposeContext.tsx +312 -312
  48. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -422
  49. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1705 -1681
  50. package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -147
  51. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -60
  52. package/apps/shared/components/mail/compose/quotedBody.ts +161 -161
  53. package/apps/shared/components/mail/reading/MessageMoreMenu.tsx +258 -258
  54. package/apps/shared/components/mail/reading/MessageSourceDialog.tsx +79 -79
  55. package/apps/shared/components/mail/reading/messageExport.ts +59 -59
  56. package/apps/shared/components/mail/reading/printMessage.ts +99 -99
  57. package/apps/shared/components/mail/reading/useMessageActions.ts +443 -443
  58. package/apps/shared/components/mail/verificationSeals.ts +125 -125
  59. package/apps/shared/components/rules/RuleBuilder.tsx +311 -311
  60. package/apps/shared/components/settings/SigningCertificateCard.tsx +344 -344
  61. package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -51
  62. package/apps/shared/keyboard/ShortcutProvider.tsx +62 -62
  63. package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -84
  64. package/apps/shared/keyboard/dispatch.ts +124 -124
  65. package/apps/shared/keyboard/format.ts +89 -89
  66. package/apps/shared/keyboard/keymap.ts +114 -114
  67. package/apps/shared/keyboard/registry.ts +65 -65
  68. package/apps/shared/keyboard/targets.ts +79 -79
  69. package/apps/shared/mail/folderOfType.ts +49 -49
  70. package/apps/shared/mail/folderTree.ts +143 -143
  71. package/apps/shared/mail/listAllPages.ts +39 -39
  72. package/apps/shared/mail/newMailNotifications.ts +171 -171
  73. package/apps/shared/mail/outbox/sendJob.ts +445 -445
  74. package/apps/shared/mail/outbox/sendOutcomes.ts +155 -155
  75. package/apps/shared/mail/reportNotices.ts +66 -66
  76. package/apps/shared/mail/senderBlocking.ts +141 -141
  77. package/apps/shared/mail/useMailConnection.ts +205 -205
  78. package/apps/shared/mail/useMailLiveUpdates.ts +277 -277
  79. package/apps/shared/mail/useMailboxUpdateAccess.ts +60 -60
  80. package/apps/shared/mail/useMarkMessageRead.ts +47 -47
  81. package/apps/shared/mail/useNewMailNotifications.ts +178 -178
  82. package/apps/shared/notifications/store.ts +560 -560
  83. package/apps/shared/search/LocalIndexLifecycle.tsx +114 -114
  84. package/apps/shared/search/localIndexBuilder.ts +481 -481
  85. package/apps/shared/signing/enrollmentStorage.ts +33 -33
  86. package/apps/shared/signing/enrollmentTracker.ts +385 -385
  87. package/apps/shared/signing/enrollmentView.ts +251 -251
  88. package/apps/shared/signing/useNow.ts +19 -19
  89. package/apps/shared/signing/useSigningEnrollmentWatcher.ts +90 -90
  90. package/apps/shared/styles/app.css +396 -396
  91. package/apps/www/calendar/index.tsx +581 -581
  92. package/apps/www/contacts/[uid].tsx +112 -112
  93. package/apps/www/index.tsx +3012 -3012
  94. package/apps/www/messages/[uid].tsx +139 -139
  95. package/apps/www/settings/auto-reply/index.tsx +136 -136
  96. package/apps/www/settings/blocked-senders/index.tsx +303 -303
  97. package/apps/www/settings/encryption/index.tsx +1290 -1290
  98. package/apps/www/settings/filters/[uid].tsx +179 -179
  99. package/apps/www/settings/filters/index.tsx +105 -105
  100. package/apps/www/settings/filters/new/index.tsx +165 -165
  101. package/apps/www/settings/labels/index.tsx +207 -207
  102. package/apps/www/settings/privacy/index.tsx +495 -495
  103. package/apps/www/settings/profile/index.tsx +251 -251
  104. package/apps/www/settings/read-receipts/index.tsx +150 -150
  105. package/apps/www/settings/sharing/index.tsx +259 -259
  106. package/apps/www/settings/signatures/[uid].tsx +175 -175
  107. package/apps/www/settings/signatures/index.tsx +91 -91
  108. package/apps/www/settings/signatures/new/index.tsx +138 -138
  109. package/apps/www/tasks/index.tsx +654 -654
  110. package/dist/apps/shared/components/admin/layout/AdminShell.js +2 -2
  111. package/dist/apps/shared/components/admin/settings/BrandingForm.js +3 -3
  112. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +2 -2
  113. package/dist/apps/shared/components/layout/AppShell.js +4 -2
  114. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +7 -1
  115. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +20 -1
  116. package/dist/apps/shared/components/mail/reading/printMessage.js +11 -11
  117. package/dist/apps/shared/styles/app.css +396 -396
  118. package/package.json +2 -2
@@ -1,332 +1,332 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- import React, { useState } from "react";
6
- import { HiChevronDown, HiChevronRight, HiOutlineFlag, HiOutlinePaperClip } from "react-icons/hi2";
7
- import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
- import { Message } from "@rapidmx/react-shared/mail/mailApi.js";
9
- import { formatMailAddress } from "@rapidmx/react-shared/mail/mailAddress.js";
10
- import MailAddress from "./MailAddress.js";
11
- import { EncryptedPreview, conversationLooksEncrypted } from "./reading/EncryptedPreview.js";
12
- import { ConversationSummary, listConversationMessages } from "@rapidmx/react-shared/mail/conversationsApi.js";
13
- import { ROW_FOCUS_CLASS, UnreadBar, UnreadLabel, dateClass, isUnread, rowClass, senderClass, subjectClass } from "./unreadStyle.js";
14
- import SwipeRow from "./SwipeRow.js";
15
- import InviteRowChip from "./invite/InviteRowChip.js";
16
-
17
- /** A conversation row of the list. `mailboxUid` is set only by a view that merges several mailboxes ("All Mailboxes"), whose rows are
18
- * each from a different one; a list of one mailbox's own leaves it out and every row is that mailbox's. */
19
- export type ListedConversation = ConversationSummary & { mailboxUid?: string };
20
-
21
- /** What tells a row apart from the others: the same conversation id in two mailboxes (a message sent to both) is two rows. */
22
- export function conversationRowKey(conversation: ListedConversation): string {
23
- return conversation.mailboxUid ? `${conversation.mailboxUid}\u0000${conversation.conversationId}` : conversation.conversationId;
24
- }
25
-
26
- export interface ConversationListProps {
27
- conversations: ListedConversation[];
28
- /** The mailbox the conversations were listed from - `listConversationMessages()` is mailbox-scoped. A row that names its own mailbox uses that. */
29
- mailboxUid: string;
30
- /** The message currently open in the reading pane, so the row standing for it can be marked. */
31
- selectedUid: string | null;
32
- /**
33
- * Opens a conversation in the reading pane, positioned at one of its messages: the one a child row
34
- * stands for, or the conversation's own `latestMessageUid` for a parent row. The conversation goes
35
- * with the uid because the pane shows the whole thread, not just that message.
36
- */
37
- onOpenMessage: (conversation: ListedConversation, uid: string) => void;
38
- /** Newer copies of messages this list already fetched - the reading pane marks the message it opens as
39
- * read, which this list would otherwise keep showing as unread until the thread is collapsed and
40
- * expanded again. */
41
- messageOverrides?: Record<string, Message>;
42
- /** Called with one of a conversation's own messages carrying the answer the reader just gave to its meeting request (from that row's RSVP chip), so the caller can keep it. An answer given from the conversation's own (parent) row is kept by this list itself. */
43
- onMeetingResponded?: (updated: Message) => void;
44
- /** Turns each parent row into a checkbox row: select mode here ticks whole conversations, since a
45
- * conversation is what this list's rows are. A child row stays a plain "open this message" button - a
46
- * mixed conversation/message selection has no sensible bulk semantics (see `MailListToolbar`). */
47
- selectMode?: boolean;
48
- /** The `conversationId`s currently ticked. */
49
- selectedConversationIds?: Set<string>;
50
- onToggleSelected?: (conversation: ConversationSummary) => void;
51
- /**
52
- * What swiping a parent row does on a phone: right to left archives the conversation (resolving whether it went through), left
53
- * to right asks where to move it. Absent, or `enabled: false` (a desktop), rows don't follow a finger.
54
- */
55
- swipe?: {
56
- enabled: boolean;
57
- onArchive: (conversation: ConversationSummary) => Promise<boolean>;
58
- onMove: (conversation: ConversationSummary) => void;
59
- };
60
- /**
61
- * Whether a conversation's own messages read newest first, matching the order sense the rows themselves
62
- * are in ("Newest on top"). `listConversationMessages()` always answers oldest first - the order a
63
- * thread is read in - so this reverses that copy for display rather than asking for it differently.
64
- */
65
- newestFirst?: boolean;
66
- }
67
-
68
- /** Every participant as `Name <address>`, for the tooltip and the accessible text of the "+N" a crowded row folds them into. */
69
- function participantList(conversation: ConversationSummary): string {
70
- return conversation.participants.map((participant) => formatMailAddress(participant)).join(", ");
71
- }
72
-
73
- /**
74
- * The nested, Outlook-style conversation list: one parent row per conversation - its participants, subject,
75
- * message count, unread count, flag/attachment hints and the latest message's preview - expanding via its
76
- * own chevron into the conversation's individual messages as child rows, fetched on first expand with
77
- * `listConversationMessages()` (one request for the whole thread, across folders, oldest first).
78
- *
79
- * Opening a row opens the whole conversation in `ConversationThreadPane`, positioned at the message the row
80
- * stands for: the latest one for a parent row, that one for a child row.
81
- *
82
- * The chevron is a button of its own beside the row's own button rather than inside it (a button can't
83
- * nest inside a button), so expanding and opening are separately reachable by keyboard and each carries
84
- * its own name.
85
- */
86
- export default function ConversationList({
87
- conversations,
88
- mailboxUid,
89
- selectedUid,
90
- onOpenMessage,
91
- messageOverrides,
92
- onMeetingResponded,
93
- selectMode,
94
- selectedConversationIds,
95
- onToggleSelected,
96
- swipe,
97
- newestFirst,
98
- }: ConversationListProps) {
99
- const [expanded, setExpanded] = useState<Set<string>>(new Set());
100
- const [messagesById, setMessagesById] = useState<Record<string, Message[]>>({});
101
- const [loadingIds, setLoadingIds] = useState<Set<string>>(new Set());
102
- const [errorsById, setErrorsById] = useState<Record<string, string>>({});
103
- // The answers given from a row's RSVP chip, by message uid: a row draws them at once, whichever row (the conversation's, or one of its messages) they were given from.
104
- const [answers, setAnswers] = useState<Record<string, Message["meetingResponse"]>>({});
105
-
106
- function answered(updated: Message) {
107
- setAnswers((prev) => ({ ...prev, [updated.uid]: updated.meetingResponse }));
108
- }
109
-
110
- function toggle(conversation: ListedConversation) {
111
- const id = conversationRowKey(conversation);
112
- setExpanded((prev) => {
113
- const next = new Set(prev);
114
- if (next.has(id)) {
115
- next.delete(id);
116
- } else {
117
- next.add(id);
118
- }
119
- return next;
120
- });
121
- if (expanded.has(id) || messagesById[id] || loadingIds.has(id)) {
122
- return;
123
- }
124
- setLoadingIds((prev) => new Set(prev).add(id));
125
- setErrorsById((prev) => {
126
- const next = { ...prev };
127
- delete next[id];
128
- return next;
129
- });
130
- listConversationMessages(conversation.mailboxUid ?? mailboxUid, conversation.conversationId)
131
- .then((loaded) => setMessagesById((prev) => ({ ...prev, [id]: loaded })))
132
- .catch((err) =>
133
- setErrorsById((prev) => ({
134
- ...prev,
135
- [id]: err instanceof ApiRequestError ? err.message : "Could not load this conversation's messages.",
136
- }))
137
- )
138
- .finally(() =>
139
- setLoadingIds((prev) => {
140
- const next = new Set(prev);
141
- next.delete(id);
142
- return next;
143
- })
144
- );
145
- }
146
-
147
- if (conversations.length === 0) {
148
- return <p className="p-4 text-sm text-text-muted">No conversations in this folder.</p>;
149
- }
150
-
151
- return (
152
- // `overflow-x-clip`: the panels a swiped row drags along stand outside its edges (see `SwipeRow`).
153
- <ul className={swipe?.enabled ? "overflow-x-clip" : undefined}>
154
- {conversations.map((conversation) => {
155
- const id = conversationRowKey(conversation);
156
- const isExpanded = expanded.has(id);
157
- const unread = conversation.unreadCount > 0;
158
- const loaded = messagesById[id];
159
- // `listConversationMessages()` answers oldest first; the rows read in whichever sense the
160
- // list itself is arranged in. Copied before reversing - the fetched array is cached.
161
- const children = loaded && newestFirst ? [...loaded].reverse() : loaded;
162
- const panelId = `conversation-messages-${conversation.mailboxUid ? `${conversation.mailboxUid}-` : ""}${conversation.conversationId}`;
163
- const ticked = selectedConversationIds?.has(conversation.conversationId) ?? false;
164
- return (
165
- <li key={id}>
166
- <SwipeRow
167
- as="div"
168
- enabled={!!swipe?.enabled && !selectMode}
169
- onArchive={() => swipe!.onArchive(conversation)}
170
- onMove={() => swipe!.onMove(conversation)}
171
- data-message-uid={conversation.latestMessageUid}
172
- data-unread={unread ? "true" : undefined}
173
- className={rowClass({ unread, selected: conversation.latestMessageUid === selectedUid || ticked }, "flex items-stretch")}
174
- >
175
- <UnreadBar unread={unread} />
176
- {selectMode && (
177
- <span className="shrink-0 flex items-center pl-3">
178
- <input
179
- type="checkbox"
180
- checked={ticked}
181
- onChange={() => onToggleSelected?.(conversation)}
182
- aria-label={`Select conversation: ${conversation.subject || "(no subject)"}`}
183
- className="w-4 h-4 accent-primary"
184
- />
185
- </span>
186
- )}
187
- <button
188
- type="button"
189
- onClick={() => toggle(conversation)}
190
- aria-expanded={isExpanded}
191
- aria-controls={panelId}
192
- aria-label={`${isExpanded ? "Collapse" : "Expand"} conversation: ${conversation.subject || "(no subject)"}`}
193
- className={["shrink-0 px-2 text-text-muted hover:text-text", ROW_FOCUS_CLASS].join(" ")}
194
- >
195
- {isExpanded ? (
196
- <HiChevronDown size={16} aria-hidden="true" />
197
- ) : (
198
- <HiChevronRight size={16} aria-hidden="true" />
199
- )}
200
- </button>
201
- {/* The open button and, for a meeting request, its RSVP chip: a button can't hold a button, so the chip is the button's sibling. */}
202
- <div className="flex-1 min-w-0 flex flex-col">
203
- <button
204
- type="button"
205
- data-row-open
206
- onClick={() => onOpenMessage(conversation, conversation.latestMessageUid)}
207
- className={["w-full text-left pr-4 py-3", ROW_FOCUS_CLASS].join(" ")}
208
- >
209
- <UnreadLabel unread={unread} />
210
- <div className="flex items-center justify-between gap-2 text-sm">
211
- {/* The first participant in full - name and address - and the rest as a count, with everyone's
212
- address in its tooltip; the latest sender when the summary lists none. */}
213
- <MailAddress
214
- recipient={conversation.participants[0] ?? conversation.latestFrom}
215
- className={["flex-1", senderClass(unread)].join(" ")}
216
- />
217
- {conversation.participants.length > 1 && (
218
- <span className="text-xs text-text-muted shrink-0 font-normal" title={participantList(conversation)}>
219
- +{conversation.participants.length - 1}
220
- <span className="sr-only"> more: {participantList(conversation)}</span>
221
- </span>
222
- )}
223
- <span className={["text-xs shrink-0", dateClass(unread)].join(" ")}>
224
- {new Date(conversation.latestDate).toLocaleDateString()}
225
- </span>
226
- </div>
227
- <div className={["text-sm truncate", subjectClass(unread)].join(" ")}>{conversation.subject || "(no subject)"}</div>
228
- <div className="text-xs text-text-muted truncate font-normal">
229
- {/* An encrypted latest message has no preview (the server never had its plaintext): say so, with a lock, not nothing. */}
230
- {conversationLooksEncrypted(conversation) ? <EncryptedPreview /> : conversation.latestPreview}
231
- </div>
232
- <div className="flex items-center gap-2 mt-1 text-xs text-text-muted font-normal">
233
- {conversation.messageCount > 1 && <span>{conversation.messageCount} messages</span>}
234
- {/* A one-message conversation's unread state is already the row's own
235
- bolding - counting it "1 unread" on every such row is just noise. */}
236
- {unread && conversation.messageCount > 1 && (
237
- <span className="py-0.5 px-2 rounded-pill bg-primary/15 text-primary-dark font-bold">
238
- {conversation.unreadCount} unread
239
- </span>
240
- )}
241
- {conversation.hasAttachments && (
242
- <HiOutlinePaperClip size={13} aria-label="Has attachments" />
243
- )}
244
- {conversation.flagged && (
245
- <HiOutlineFlag size={13} aria-label="Flagged" className="text-danger" />
246
- )}
247
- </div>
248
- </button>
249
- <InviteRowChip
250
- message={
251
- {
252
- uid: conversation.latestMessageUid,
253
- meetingMethod: conversation.latestMeetingMethod ?? undefined,
254
- meetingResponse:
255
- answers[conversation.latestMessageUid] ??
256
- messageOverrides?.[conversation.latestMessageUid]?.meetingResponse ??
257
- conversation.latestMeetingResponse ??
258
- undefined,
259
- } as Message
260
- }
261
- onResponded={answered}
262
- className="mr-4"
263
- />
264
- </div>
265
- </SwipeRow>
266
- <ul id={panelId} hidden={!isExpanded}>
267
- {errorsById[id] && (
268
- <li role="alert" className="pl-8 pr-4 py-2 text-xs text-danger border-b border-border">
269
- {errorsById[id]}
270
- </li>
271
- )}
272
- {loadingIds.has(id) && (
273
- <li className="pl-8 pr-4 py-2 text-xs text-text-muted border-b border-border">
274
- Loading messages&hellip;
275
- </li>
276
- )}
277
- {children?.map((fetched) => {
278
- const message = messageOverrides?.[fetched.uid] ?? fetched;
279
- const messageUnread = isUnread(message);
280
- return (
281
- <li
282
- key={message.uid}
283
- data-message-uid={message.uid}
284
- data-unread={messageUnread ? "true" : undefined}
285
- className={rowClass({ unread: messageUnread, selected: message.uid === selectedUid })}
286
- >
287
- <UnreadBar unread={messageUnread} />
288
- <button
289
- type="button"
290
- data-row-open
291
- onClick={() => onOpenMessage(conversation, message.uid)}
292
- className={["w-full text-left pl-8 pr-4 py-2", ROW_FOCUS_CLASS].join(" ")}
293
- >
294
- <UnreadLabel unread={messageUnread} />
295
- <div className="flex items-center justify-between gap-2 text-sm">
296
- <MailAddress recipient={message.from} className={senderClass(messageUnread)} />
297
- <span className={["text-xs shrink-0", dateClass(messageUnread)].join(" ")}>
298
- {new Date(message.receivedDate).toLocaleDateString()}
299
- </span>
300
- </div>
301
- <div className="flex items-center gap-2 text-xs text-text-muted font-normal">
302
- <span className="truncate">{message.bodyPreview || (message.encrypted ? <EncryptedPreview /> : null)}</span>
303
- {message.hasAttachments && (
304
- <HiOutlinePaperClip size={12} aria-label="Has attachments" />
305
- )}
306
- {message.flags.flagged && (
307
- <HiOutlineFlag
308
- size={12}
309
- aria-label="Flagged"
310
- className="text-danger"
311
- />
312
- )}
313
- </div>
314
- </button>
315
- <InviteRowChip
316
- message={{ ...message, meetingResponse: answers[message.uid] ?? message.meetingResponse }}
317
- onResponded={(updated) => {
318
- answered(updated);
319
- onMeetingResponded?.(updated);
320
- }}
321
- className="ml-8 mr-4"
322
- />
323
- </li>
324
- );
325
- })}
326
- </ul>
327
- </li>
328
- );
329
- })}
330
- </ul>
331
- );
332
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useState } from "react";
6
+ import { HiChevronDown, HiChevronRight, HiOutlineFlag, HiOutlinePaperClip } from "react-icons/hi2";
7
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
+ import { Message } from "@rapidmx/react-shared/mail/mailApi.js";
9
+ import { formatMailAddress } from "@rapidmx/react-shared/mail/mailAddress.js";
10
+ import MailAddress from "./MailAddress.js";
11
+ import { EncryptedPreview, conversationLooksEncrypted } from "./reading/EncryptedPreview.js";
12
+ import { ConversationSummary, listConversationMessages } from "@rapidmx/react-shared/mail/conversationsApi.js";
13
+ import { ROW_FOCUS_CLASS, UnreadBar, UnreadLabel, dateClass, isUnread, rowClass, senderClass, subjectClass } from "./unreadStyle.js";
14
+ import SwipeRow from "./SwipeRow.js";
15
+ import InviteRowChip from "./invite/InviteRowChip.js";
16
+
17
+ /** A conversation row of the list. `mailboxUid` is set only by a view that merges several mailboxes ("All Mailboxes"), whose rows are
18
+ * each from a different one; a list of one mailbox's own leaves it out and every row is that mailbox's. */
19
+ export type ListedConversation = ConversationSummary & { mailboxUid?: string };
20
+
21
+ /** What tells a row apart from the others: the same conversation id in two mailboxes (a message sent to both) is two rows. */
22
+ export function conversationRowKey(conversation: ListedConversation): string {
23
+ return conversation.mailboxUid ? `${conversation.mailboxUid}\u0000${conversation.conversationId}` : conversation.conversationId;
24
+ }
25
+
26
+ export interface ConversationListProps {
27
+ conversations: ListedConversation[];
28
+ /** The mailbox the conversations were listed from - `listConversationMessages()` is mailbox-scoped. A row that names its own mailbox uses that. */
29
+ mailboxUid: string;
30
+ /** The message currently open in the reading pane, so the row standing for it can be marked. */
31
+ selectedUid: string | null;
32
+ /**
33
+ * Opens a conversation in the reading pane, positioned at one of its messages: the one a child row
34
+ * stands for, or the conversation's own `latestMessageUid` for a parent row. The conversation goes
35
+ * with the uid because the pane shows the whole thread, not just that message.
36
+ */
37
+ onOpenMessage: (conversation: ListedConversation, uid: string) => void;
38
+ /** Newer copies of messages this list already fetched - the reading pane marks the message it opens as
39
+ * read, which this list would otherwise keep showing as unread until the thread is collapsed and
40
+ * expanded again. */
41
+ messageOverrides?: Record<string, Message>;
42
+ /** Called with one of a conversation's own messages carrying the answer the reader just gave to its meeting request (from that row's RSVP chip), so the caller can keep it. An answer given from the conversation's own (parent) row is kept by this list itself. */
43
+ onMeetingResponded?: (updated: Message) => void;
44
+ /** Turns each parent row into a checkbox row: select mode here ticks whole conversations, since a
45
+ * conversation is what this list's rows are. A child row stays a plain "open this message" button - a
46
+ * mixed conversation/message selection has no sensible bulk semantics (see `MailListToolbar`). */
47
+ selectMode?: boolean;
48
+ /** The `conversationId`s currently ticked. */
49
+ selectedConversationIds?: Set<string>;
50
+ onToggleSelected?: (conversation: ConversationSummary) => void;
51
+ /**
52
+ * What swiping a parent row does on a phone: right to left archives the conversation (resolving whether it went through), left
53
+ * to right asks where to move it. Absent, or `enabled: false` (a desktop), rows don't follow a finger.
54
+ */
55
+ swipe?: {
56
+ enabled: boolean;
57
+ onArchive: (conversation: ConversationSummary) => Promise<boolean>;
58
+ onMove: (conversation: ConversationSummary) => void;
59
+ };
60
+ /**
61
+ * Whether a conversation's own messages read newest first, matching the order sense the rows themselves
62
+ * are in ("Newest on top"). `listConversationMessages()` always answers oldest first - the order a
63
+ * thread is read in - so this reverses that copy for display rather than asking for it differently.
64
+ */
65
+ newestFirst?: boolean;
66
+ }
67
+
68
+ /** Every participant as `Name <address>`, for the tooltip and the accessible text of the "+N" a crowded row folds them into. */
69
+ function participantList(conversation: ConversationSummary): string {
70
+ return conversation.participants.map((participant) => formatMailAddress(participant)).join(", ");
71
+ }
72
+
73
+ /**
74
+ * The nested, Outlook-style conversation list: one parent row per conversation - its participants, subject,
75
+ * message count, unread count, flag/attachment hints and the latest message's preview - expanding via its
76
+ * own chevron into the conversation's individual messages as child rows, fetched on first expand with
77
+ * `listConversationMessages()` (one request for the whole thread, across folders, oldest first).
78
+ *
79
+ * Opening a row opens the whole conversation in `ConversationThreadPane`, positioned at the message the row
80
+ * stands for: the latest one for a parent row, that one for a child row.
81
+ *
82
+ * The chevron is a button of its own beside the row's own button rather than inside it (a button can't
83
+ * nest inside a button), so expanding and opening are separately reachable by keyboard and each carries
84
+ * its own name.
85
+ */
86
+ export default function ConversationList({
87
+ conversations,
88
+ mailboxUid,
89
+ selectedUid,
90
+ onOpenMessage,
91
+ messageOverrides,
92
+ onMeetingResponded,
93
+ selectMode,
94
+ selectedConversationIds,
95
+ onToggleSelected,
96
+ swipe,
97
+ newestFirst,
98
+ }: ConversationListProps) {
99
+ const [expanded, setExpanded] = useState<Set<string>>(new Set());
100
+ const [messagesById, setMessagesById] = useState<Record<string, Message[]>>({});
101
+ const [loadingIds, setLoadingIds] = useState<Set<string>>(new Set());
102
+ const [errorsById, setErrorsById] = useState<Record<string, string>>({});
103
+ // The answers given from a row's RSVP chip, by message uid: a row draws them at once, whichever row (the conversation's, or one of its messages) they were given from.
104
+ const [answers, setAnswers] = useState<Record<string, Message["meetingResponse"]>>({});
105
+
106
+ function answered(updated: Message) {
107
+ setAnswers((prev) => ({ ...prev, [updated.uid]: updated.meetingResponse }));
108
+ }
109
+
110
+ function toggle(conversation: ListedConversation) {
111
+ const id = conversationRowKey(conversation);
112
+ setExpanded((prev) => {
113
+ const next = new Set(prev);
114
+ if (next.has(id)) {
115
+ next.delete(id);
116
+ } else {
117
+ next.add(id);
118
+ }
119
+ return next;
120
+ });
121
+ if (expanded.has(id) || messagesById[id] || loadingIds.has(id)) {
122
+ return;
123
+ }
124
+ setLoadingIds((prev) => new Set(prev).add(id));
125
+ setErrorsById((prev) => {
126
+ const next = { ...prev };
127
+ delete next[id];
128
+ return next;
129
+ });
130
+ listConversationMessages(conversation.mailboxUid ?? mailboxUid, conversation.conversationId)
131
+ .then((loaded) => setMessagesById((prev) => ({ ...prev, [id]: loaded })))
132
+ .catch((err) =>
133
+ setErrorsById((prev) => ({
134
+ ...prev,
135
+ [id]: err instanceof ApiRequestError ? err.message : "Could not load this conversation's messages.",
136
+ }))
137
+ )
138
+ .finally(() =>
139
+ setLoadingIds((prev) => {
140
+ const next = new Set(prev);
141
+ next.delete(id);
142
+ return next;
143
+ })
144
+ );
145
+ }
146
+
147
+ if (conversations.length === 0) {
148
+ return <p className="p-4 text-sm text-text-muted">No conversations in this folder.</p>;
149
+ }
150
+
151
+ return (
152
+ // `overflow-x-clip`: the panels a swiped row drags along stand outside its edges (see `SwipeRow`).
153
+ <ul className={swipe?.enabled ? "overflow-x-clip" : undefined}>
154
+ {conversations.map((conversation) => {
155
+ const id = conversationRowKey(conversation);
156
+ const isExpanded = expanded.has(id);
157
+ const unread = conversation.unreadCount > 0;
158
+ const loaded = messagesById[id];
159
+ // `listConversationMessages()` answers oldest first; the rows read in whichever sense the
160
+ // list itself is arranged in. Copied before reversing - the fetched array is cached.
161
+ const children = loaded && newestFirst ? [...loaded].reverse() : loaded;
162
+ const panelId = `conversation-messages-${conversation.mailboxUid ? `${conversation.mailboxUid}-` : ""}${conversation.conversationId}`;
163
+ const ticked = selectedConversationIds?.has(conversation.conversationId) ?? false;
164
+ return (
165
+ <li key={id}>
166
+ <SwipeRow
167
+ as="div"
168
+ enabled={!!swipe?.enabled && !selectMode}
169
+ onArchive={() => swipe!.onArchive(conversation)}
170
+ onMove={() => swipe!.onMove(conversation)}
171
+ data-message-uid={conversation.latestMessageUid}
172
+ data-unread={unread ? "true" : undefined}
173
+ className={rowClass({ unread, selected: conversation.latestMessageUid === selectedUid || ticked }, "flex items-stretch")}
174
+ >
175
+ <UnreadBar unread={unread} />
176
+ {selectMode && (
177
+ <span className="shrink-0 flex items-center pl-3">
178
+ <input
179
+ type="checkbox"
180
+ checked={ticked}
181
+ onChange={() => onToggleSelected?.(conversation)}
182
+ aria-label={`Select conversation: ${conversation.subject || "(no subject)"}`}
183
+ className="w-4 h-4 accent-primary"
184
+ />
185
+ </span>
186
+ )}
187
+ <button
188
+ type="button"
189
+ onClick={() => toggle(conversation)}
190
+ aria-expanded={isExpanded}
191
+ aria-controls={panelId}
192
+ aria-label={`${isExpanded ? "Collapse" : "Expand"} conversation: ${conversation.subject || "(no subject)"}`}
193
+ className={["shrink-0 px-2 text-text-muted hover:text-text", ROW_FOCUS_CLASS].join(" ")}
194
+ >
195
+ {isExpanded ? (
196
+ <HiChevronDown size={16} aria-hidden="true" />
197
+ ) : (
198
+ <HiChevronRight size={16} aria-hidden="true" />
199
+ )}
200
+ </button>
201
+ {/* The open button and, for a meeting request, its RSVP chip: a button can't hold a button, so the chip is the button's sibling. */}
202
+ <div className="flex-1 min-w-0 flex flex-col">
203
+ <button
204
+ type="button"
205
+ data-row-open
206
+ onClick={() => onOpenMessage(conversation, conversation.latestMessageUid)}
207
+ className={["w-full text-left pr-4 py-3", ROW_FOCUS_CLASS].join(" ")}
208
+ >
209
+ <UnreadLabel unread={unread} />
210
+ <div className="flex items-center justify-between gap-2 text-sm">
211
+ {/* The first participant in full - name and address - and the rest as a count, with everyone's
212
+ address in its tooltip; the latest sender when the summary lists none. */}
213
+ <MailAddress
214
+ recipient={conversation.participants[0] ?? conversation.latestFrom}
215
+ className={["flex-1", senderClass(unread)].join(" ")}
216
+ />
217
+ {conversation.participants.length > 1 && (
218
+ <span className="text-xs text-text-muted shrink-0 font-normal" title={participantList(conversation)}>
219
+ +{conversation.participants.length - 1}
220
+ <span className="sr-only"> more: {participantList(conversation)}</span>
221
+ </span>
222
+ )}
223
+ <span className={["text-xs shrink-0", dateClass(unread)].join(" ")}>
224
+ {new Date(conversation.latestDate).toLocaleDateString()}
225
+ </span>
226
+ </div>
227
+ <div className={["text-sm truncate", subjectClass(unread)].join(" ")}>{conversation.subject || "(no subject)"}</div>
228
+ <div className="text-xs text-text-muted truncate font-normal">
229
+ {/* An encrypted latest message has no preview (the server never had its plaintext): say so, with a lock, not nothing. */}
230
+ {conversationLooksEncrypted(conversation) ? <EncryptedPreview /> : conversation.latestPreview}
231
+ </div>
232
+ <div className="flex items-center gap-2 mt-1 text-xs text-text-muted font-normal">
233
+ {conversation.messageCount > 1 && <span>{conversation.messageCount} messages</span>}
234
+ {/* A one-message conversation's unread state is already the row's own
235
+ bolding - counting it "1 unread" on every such row is just noise. */}
236
+ {unread && conversation.messageCount > 1 && (
237
+ <span className="py-0.5 px-2 rounded-pill bg-primary/15 text-primary-dark font-bold">
238
+ {conversation.unreadCount} unread
239
+ </span>
240
+ )}
241
+ {conversation.hasAttachments && (
242
+ <HiOutlinePaperClip size={13} aria-label="Has attachments" />
243
+ )}
244
+ {conversation.flagged && (
245
+ <HiOutlineFlag size={13} aria-label="Flagged" className="text-danger" />
246
+ )}
247
+ </div>
248
+ </button>
249
+ <InviteRowChip
250
+ message={
251
+ {
252
+ uid: conversation.latestMessageUid,
253
+ meetingMethod: conversation.latestMeetingMethod ?? undefined,
254
+ meetingResponse:
255
+ answers[conversation.latestMessageUid] ??
256
+ messageOverrides?.[conversation.latestMessageUid]?.meetingResponse ??
257
+ conversation.latestMeetingResponse ??
258
+ undefined,
259
+ } as Message
260
+ }
261
+ onResponded={answered}
262
+ className="mr-4"
263
+ />
264
+ </div>
265
+ </SwipeRow>
266
+ <ul id={panelId} hidden={!isExpanded}>
267
+ {errorsById[id] && (
268
+ <li role="alert" className="pl-8 pr-4 py-2 text-xs text-danger border-b border-border">
269
+ {errorsById[id]}
270
+ </li>
271
+ )}
272
+ {loadingIds.has(id) && (
273
+ <li className="pl-8 pr-4 py-2 text-xs text-text-muted border-b border-border">
274
+ Loading messages&hellip;
275
+ </li>
276
+ )}
277
+ {children?.map((fetched) => {
278
+ const message = messageOverrides?.[fetched.uid] ?? fetched;
279
+ const messageUnread = isUnread(message);
280
+ return (
281
+ <li
282
+ key={message.uid}
283
+ data-message-uid={message.uid}
284
+ data-unread={messageUnread ? "true" : undefined}
285
+ className={rowClass({ unread: messageUnread, selected: message.uid === selectedUid })}
286
+ >
287
+ <UnreadBar unread={messageUnread} />
288
+ <button
289
+ type="button"
290
+ data-row-open
291
+ onClick={() => onOpenMessage(conversation, message.uid)}
292
+ className={["w-full text-left pl-8 pr-4 py-2", ROW_FOCUS_CLASS].join(" ")}
293
+ >
294
+ <UnreadLabel unread={messageUnread} />
295
+ <div className="flex items-center justify-between gap-2 text-sm">
296
+ <MailAddress recipient={message.from} className={senderClass(messageUnread)} />
297
+ <span className={["text-xs shrink-0", dateClass(messageUnread)].join(" ")}>
298
+ {new Date(message.receivedDate).toLocaleDateString()}
299
+ </span>
300
+ </div>
301
+ <div className="flex items-center gap-2 text-xs text-text-muted font-normal">
302
+ <span className="truncate">{message.bodyPreview || (message.encrypted ? <EncryptedPreview /> : null)}</span>
303
+ {message.hasAttachments && (
304
+ <HiOutlinePaperClip size={12} aria-label="Has attachments" />
305
+ )}
306
+ {message.flags.flagged && (
307
+ <HiOutlineFlag
308
+ size={12}
309
+ aria-label="Flagged"
310
+ className="text-danger"
311
+ />
312
+ )}
313
+ </div>
314
+ </button>
315
+ <InviteRowChip
316
+ message={{ ...message, meetingResponse: answers[message.uid] ?? message.meetingResponse }}
317
+ onResponded={(updated) => {
318
+ answered(updated);
319
+ onMeetingResponded?.(updated);
320
+ }}
321
+ className="ml-8 mr-4"
322
+ />
323
+ </li>
324
+ );
325
+ })}
326
+ </ul>
327
+ </li>
328
+ );
329
+ })}
330
+ </ul>
331
+ );
332
+ }