@rapidmx/web-client 0.15.1 → 0.16.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.
- package/README.md +373 -373
- package/apps/admin/branding/index.tsx +39 -39
- package/apps/admin/data-requests/index.tsx +482 -480
- package/apps/admin/domains/[uid].tsx +164 -164
- package/apps/admin/escrow-scopes/[uid].tsx +348 -348
- package/apps/admin/index.tsx +127 -124
- package/apps/admin/mailboxes/[uid].tsx +269 -224
- package/apps/admin/mailboxes/new/index.tsx +28 -28
- package/apps/admin/plugins/index.tsx +15 -15
- package/apps/admin/retention-policy/index.tsx +39 -39
- package/apps/admin/signing-certificates/index.tsx +343 -343
- package/apps/escrow/audit-log/index.tsx +196 -196
- package/apps/escrow/matters/[uid].tsx +621 -621
- package/apps/shared/auth/adminAccess.ts +99 -99
- package/apps/shared/components/admin/layout/AdminShell.tsx +378 -378
- package/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.tsx +238 -0
- package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -152
- package/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.tsx +194 -0
- package/apps/shared/components/admin/settings/BrandingForm.tsx +423 -423
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +119 -119
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +50 -3
- package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +198 -198
- package/apps/shared/components/admin/settings/PluginsManager.tsx +1545 -1545
- package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +182 -182
- package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +288 -288
- package/apps/shared/components/admin/setup/SetupWizard.tsx +444 -444
- package/apps/shared/components/admin/usePagedList.tsx +129 -129
- package/apps/shared/components/calendar/EventEditor.tsx +5 -10
- package/apps/shared/components/calendar/EventFormParts.tsx +16 -47
- package/apps/shared/components/calendar/EventModal.tsx +206 -206
- package/apps/shared/components/calendar/GuestInput.tsx +100 -0
- package/apps/shared/components/calendar/MonthView.tsx +185 -185
- package/apps/shared/components/calendar/RecurrenceEditor.tsx +227 -227
- package/apps/shared/components/calendar/RequestChangeForm.tsx +23 -54
- package/apps/shared/components/calendar/SplitDayView.tsx +144 -144
- package/apps/shared/components/calendar/TimeGridView.tsx +246 -246
- package/apps/shared/components/calendar/allDay.ts +124 -124
- package/apps/shared/components/calendar/eventForm.ts +2 -2
- package/apps/shared/components/calendar/eventFormat.ts +49 -22
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +201 -200
- package/apps/shared/components/contacts/ContactForm.tsx +383 -383
- package/apps/shared/components/contacts/ContactsSidebar.tsx +1 -1
- package/apps/shared/components/contacts/ContactsToolbar.tsx +103 -98
- package/apps/shared/components/contacts/layout/ContactsShell.tsx +217 -216
- package/apps/shared/components/escrow/layout/EscrowShell.tsx +155 -155
- package/apps/shared/components/layout/AppShell.tsx +480 -480
- package/apps/shared/components/layout/FloatingActionButton.tsx +31 -0
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -398
- package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -168
- package/apps/shared/components/layout/ResponsiveToolbar.tsx +315 -315
- package/apps/shared/components/layout/ThemeSwitch.tsx +84 -84
- package/apps/shared/components/layout/UserMenu.tsx +439 -424
- package/apps/shared/components/mail/ConversationList.tsx +323 -323
- package/apps/shared/components/mail/ConversationThreadPane.tsx +612 -491
- package/apps/shared/components/mail/MailSelectionBar.tsx +236 -227
- package/apps/shared/components/mail/MessageDetailPane.tsx +1559 -1559
- package/apps/shared/components/mail/compose/ComposeContext.tsx +312 -312
- package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -422
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +1681 -1677
- package/apps/shared/components/mail/compose/RecipientInput.tsx +54 -11
- package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -147
- package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -60
- package/apps/shared/components/mail/compose/quotedBody.ts +161 -161
- package/apps/shared/components/mail/layout/FolderBadgeChip.tsx +21 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +137 -113
- package/apps/shared/components/mail/layout/SidebarSection.tsx +66 -0
- package/apps/shared/components/mail/reading/PendingMessageCard.tsx +68 -0
- package/apps/shared/components/mail/verificationSeals.ts +125 -125
- package/apps/shared/components/rules/RuleBuilder.tsx +311 -311
- package/apps/shared/components/settings/SigningCertificateCard.tsx +344 -344
- package/apps/shared/components/settings/layout/SettingsShell.tsx +255 -254
- package/apps/shared/components/tasks/TasksSidebar.tsx +1 -1
- package/apps/shared/components/tasks/layout/TasksShell.tsx +218 -217
- package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -51
- package/apps/shared/keyboard/ShortcutProvider.tsx +62 -62
- package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -84
- package/apps/shared/keyboard/dispatch.ts +124 -124
- package/apps/shared/keyboard/format.ts +89 -89
- package/apps/shared/keyboard/keymap.ts +114 -114
- package/apps/shared/keyboard/registry.ts +65 -65
- package/apps/shared/keyboard/targets.ts +79 -79
- package/apps/shared/mail/folderCounts.ts +17 -0
- package/apps/shared/mail/folderTree.ts +143 -143
- package/apps/shared/mail/listAllPages.ts +39 -39
- package/apps/shared/mail/newMailNotifications.ts +171 -171
- package/apps/shared/mail/outbox/outgoingReplies.ts +192 -0
- package/apps/shared/mail/outbox/sendJob.ts +19 -1
- package/apps/shared/mail/outbox/sendOutcomes.ts +13 -4
- package/apps/shared/mail/primaryMailbox.ts +71 -0
- package/apps/shared/mail/useCollapsedSections.ts +146 -0
- package/apps/shared/mail/useMailConnection.ts +205 -203
- package/apps/shared/mail/useMailLiveUpdates.ts +277 -277
- package/apps/shared/mail/useMarkMessageRead.ts +47 -47
- package/apps/shared/mail/useNewMailNotifications.ts +178 -178
- package/apps/shared/navigation/AppRouter.tsx +300 -300
- package/apps/shared/navigation/routerContext.tsx +83 -83
- package/apps/shared/notifications/store.ts +560 -560
- package/apps/shared/search/LocalIndexLifecycle.tsx +114 -114
- package/apps/shared/search/crossMailboxSearch.ts +169 -0
- package/apps/shared/search/localIndexBuilder.ts +481 -481
- package/apps/shared/signing/enrollmentStorage.ts +33 -33
- package/apps/shared/signing/enrollmentTracker.ts +385 -385
- package/apps/shared/signing/enrollmentView.ts +251 -251
- package/apps/shared/signing/useNow.ts +19 -19
- package/apps/shared/signing/useSigningEnrollmentWatcher.ts +90 -90
- package/apps/shared/styles/app.css +396 -386
- package/apps/www/calendar/index.tsx +578 -571
- package/apps/www/contacts/[uid].tsx +109 -109
- package/apps/www/contacts/index.tsx +6 -0
- package/apps/www/index.tsx +2847 -2479
- package/apps/www/messages/[uid].tsx +135 -135
- package/apps/www/settings/auto-reply/index.tsx +133 -133
- package/apps/www/settings/encryption/index.tsx +1287 -1287
- package/apps/www/settings/filters/[uid].tsx +176 -176
- package/apps/www/settings/filters/index.tsx +102 -102
- package/apps/www/settings/filters/new/index.tsx +140 -140
- package/apps/www/settings/labels/index.tsx +204 -204
- package/apps/www/settings/privacy/index.tsx +492 -492
- package/apps/www/settings/profile/index.tsx +248 -248
- package/apps/www/settings/read-receipts/index.tsx +147 -147
- package/apps/www/settings/sharing/index.tsx +256 -256
- package/apps/www/settings/signatures/[uid].tsx +172 -172
- package/apps/www/settings/signatures/index.tsx +88 -88
- package/apps/www/settings/signatures/new/index.tsx +135 -135
- package/apps/www/tasks/index.tsx +649 -649
- package/dist/apps/admin/data-requests/index.js +1 -1
- package/dist/apps/admin/index.js +2 -1
- package/dist/apps/admin/mailboxes/[uid].js +12 -2
- package/dist/apps/shared/components/admin/layout/AdminShell.js +2 -2
- package/dist/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.d.ts +35 -0
- package/dist/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.js +115 -0
- package/dist/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.d.ts +16 -0
- package/dist/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.js +92 -0
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +3 -3
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +25 -3
- package/dist/apps/shared/components/calendar/EventEditor.js +5 -10
- package/dist/apps/shared/components/calendar/EventFormParts.d.ts +3 -2
- package/dist/apps/shared/components/calendar/EventFormParts.js +5 -16
- package/dist/apps/shared/components/calendar/GuestInput.d.ts +45 -0
- package/dist/apps/shared/components/calendar/GuestInput.js +30 -0
- package/dist/apps/shared/components/calendar/RequestChangeForm.js +11 -22
- package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -2
- package/dist/apps/shared/components/calendar/eventFormat.d.ts +21 -8
- package/dist/apps/shared/components/calendar/eventFormat.js +50 -23
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +3 -2
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +1 -1
- package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
- package/dist/apps/shared/components/contacts/ContactsToolbar.js +4 -2
- package/dist/apps/shared/components/contacts/layout/ContactsShell.js +4 -3
- package/dist/apps/shared/components/layout/FloatingActionButton.d.ts +13 -0
- package/dist/apps/shared/components/layout/FloatingActionButton.js +9 -0
- package/dist/apps/shared/components/layout/UserMenu.d.ts +4 -1
- package/dist/apps/shared/components/layout/UserMenu.js +6 -5
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +127 -22
- package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +6 -1
- package/dist/apps/shared/components/mail/MailSelectionBar.js +3 -3
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +8 -4
- package/dist/apps/shared/components/mail/compose/RecipientInput.d.ts +21 -1
- package/dist/apps/shared/components/mail/compose/RecipientInput.js +18 -10
- package/dist/apps/shared/components/mail/layout/FolderBadgeChip.d.ts +6 -0
- package/dist/apps/shared/components/mail/layout/FolderBadgeChip.js +9 -0
- package/dist/apps/shared/components/mail/layout/MailShell.d.ts +5 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +64 -45
- package/dist/apps/shared/components/mail/layout/SidebarSection.d.ts +23 -0
- package/dist/apps/shared/components/mail/layout/SidebarSection.js +16 -0
- package/dist/apps/shared/components/mail/reading/PendingMessageCard.d.ts +14 -0
- package/dist/apps/shared/components/mail/reading/PendingMessageCard.js +16 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +6 -5
- package/dist/apps/shared/components/tasks/TasksSidebar.js +1 -1
- package/dist/apps/shared/components/tasks/layout/TasksShell.js +4 -3
- package/dist/apps/shared/mail/folderCounts.d.ts +10 -0
- package/dist/apps/shared/mail/folderCounts.js +12 -0
- package/dist/apps/shared/mail/outbox/outgoingReplies.d.ts +80 -0
- package/dist/apps/shared/mail/outbox/outgoingReplies.js +131 -0
- package/dist/apps/shared/mail/outbox/sendJob.d.ts +5 -1
- package/dist/apps/shared/mail/outbox/sendJob.js +14 -1
- package/dist/apps/shared/mail/outbox/sendOutcomes.js +13 -4
- package/dist/apps/shared/mail/primaryMailbox.d.ts +29 -0
- package/dist/apps/shared/mail/primaryMailbox.js +61 -0
- package/dist/apps/shared/mail/useCollapsedSections.d.ts +32 -0
- package/dist/apps/shared/mail/useCollapsedSections.js +110 -0
- package/dist/apps/shared/mail/useMailConnection.js +3 -1
- package/dist/apps/shared/search/crossMailboxSearch.d.ts +72 -0
- package/dist/apps/shared/search/crossMailboxSearch.js +136 -0
- package/dist/apps/shared/styles/app.css +396 -386
- package/dist/apps/www/calendar/index.js +3 -2
- package/dist/apps/www/contacts/index.js +3 -1
- package/dist/apps/www/index.js +426 -189
- package/package.json +2 -2
|
@@ -4,6 +4,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
6
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
7
|
+
import { flushSync } from "react-dom";
|
|
7
8
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
8
9
|
import { listAttachments } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
9
10
|
import { listConversationMessages } from "@rapidmx/react-shared/mail/conversationsApi.js";
|
|
@@ -11,8 +12,10 @@ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
|
11
12
|
import MessageDetailPane from "./MessageDetailPane.js";
|
|
12
13
|
import { EncryptedPreview, displaySubject } from "./reading/EncryptedPreview.js";
|
|
13
14
|
import { CollapsedCard, SkeletonCards, SubjectCard } from "./reading/MessageCard.js";
|
|
15
|
+
import PendingMessageCard from "./reading/PendingMessageCard.js";
|
|
14
16
|
import { useMailShell } from "./layout/MailShell.js";
|
|
15
17
|
import { setReadState } from "../../mail/messageReadState.js";
|
|
18
|
+
import { adoptedOutgoing, belongsToThread, settleOutgoing, useOutgoingReplies } from "../../mail/outbox/outgoingReplies.js";
|
|
16
19
|
import { dateClass, isUnread, senderClass } from "./unreadStyle.js";
|
|
17
20
|
/** One request's worth of the thread. The server's own default for `listConversationMessages()`. */
|
|
18
21
|
export const THREAD_PAGE_SIZE = 100;
|
|
@@ -77,6 +80,20 @@ async function loadThread(mailboxUid, conversationId) {
|
|
|
77
80
|
messages.reverse();
|
|
78
81
|
return { messages, truncated: more };
|
|
79
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* `next` - the thread as it was just read again - for the pane to show in place of `previous`. A message the pane holds a newer copy of (one that
|
|
85
|
+
* was read, flagged or labelled here a moment ago, whose change the read may have started before) keeps that copy, and a read that changed
|
|
86
|
+
* nothing gives back `previous` itself, so the pane is not drawn again.
|
|
87
|
+
*/
|
|
88
|
+
function mergeThread(previous, next) {
|
|
89
|
+
const held = new Map(previous.map((message) => [message.uid, message]));
|
|
90
|
+
const merged = next.map((message) => {
|
|
91
|
+
const own = held.get(message.uid);
|
|
92
|
+
return own && own.version > message.version ? own : message;
|
|
93
|
+
});
|
|
94
|
+
const unchanged = merged.length === previous.length && merged.every((message, index) => message.uid === previous[index].uid && message.version === previous[index].version);
|
|
95
|
+
return unchanged ? previous : merged;
|
|
96
|
+
}
|
|
80
97
|
/** Whether the browser is showing a focus ring on `element` - it was focused from the keyboard (or by a key press's handler), not by a click. */
|
|
81
98
|
function hasFocusRing(element) {
|
|
82
99
|
try {
|
|
@@ -105,7 +122,9 @@ function hasFocusRing(element) {
|
|
|
105
122
|
* that - mounting a body iframe per message up front would be wasteful in a long thread.
|
|
106
123
|
*/
|
|
107
124
|
export default function ConversationThreadPane({ conversation, mailboxUid, selectedUid, folders, labels, onMessagePatched, onMessageRemoved, onLabelCreated, onFolderCreated, shortcuts, }) {
|
|
108
|
-
const { trackMessageChange } = useMailShell();
|
|
125
|
+
const { trackMessageChange, live } = useMailShell();
|
|
126
|
+
// The replies and forwards this tab has sent, drawn at the top of the thread they continue until the server's own copy is in it.
|
|
127
|
+
const outgoing = useOutgoingReplies();
|
|
109
128
|
const [messages, setMessages] = useState([]);
|
|
110
129
|
const [attachmentsByUid, setAttachmentsByUid] = useState({});
|
|
111
130
|
const [expandedUids, setExpandedUids] = useState(new Set());
|
|
@@ -134,11 +153,19 @@ export default function ConversationThreadPane({ conversation, mailboxUid, selec
|
|
|
134
153
|
/** The message whose header button had the focus when it was toggled: expanding or collapsing swaps that button for the other state's, so the
|
|
135
154
|
* focus is put back on the new one. */
|
|
136
155
|
const refocusRef = useRef(null);
|
|
156
|
+
/** The pending cards that have already been scrolled to, so that only a message that has just been sent takes the view and the focus. */
|
|
157
|
+
const announcedRef = useRef(new Set());
|
|
158
|
+
/** Messages that left the thread here (archived, moved, a scheduled send taken back): a read of the thread again still lists them, since a conversation spans folders. */
|
|
159
|
+
const removedRef = useRef(new Set());
|
|
160
|
+
/** The last live update this pane has answered - one already in the shell's hands when the pane opened is not news. */
|
|
161
|
+
const seenLiveRef = useRef(live);
|
|
137
162
|
const conversationId = conversation?.conversationId;
|
|
138
163
|
useEffect(() => {
|
|
139
164
|
const generation = ++generationRef.current;
|
|
140
165
|
markReadRequestedRef.current = new Set();
|
|
141
166
|
attachmentsRequestedRef.current = new Set();
|
|
167
|
+
announcedRef.current = new Set();
|
|
168
|
+
removedRef.current = new Set();
|
|
142
169
|
appliedSelectionRef.current = null;
|
|
143
170
|
loadedIdRef.current = null;
|
|
144
171
|
setMessages([]);
|
|
@@ -159,6 +186,8 @@ export default function ConversationThreadPane({ conversation, mailboxUid, selec
|
|
|
159
186
|
if (generation !== generationRef.current)
|
|
160
187
|
return;
|
|
161
188
|
loadedIdRef.current = conversationId;
|
|
189
|
+
// A message sent from here whose Sent Items copy is already in the thread is drawn as that copy, not as a pending card.
|
|
190
|
+
settleOutgoing(mailboxUid, loaded.messages);
|
|
162
191
|
setMessages(loaded.messages);
|
|
163
192
|
setTruncated(loaded.truncated);
|
|
164
193
|
})
|
|
@@ -283,6 +312,77 @@ export default function ConversationThreadPane({ conversation, mailboxUid, selec
|
|
|
283
312
|
}
|
|
284
313
|
}
|
|
285
314
|
}, [expandedUids, messages]);
|
|
315
|
+
/**
|
|
316
|
+
* Reads the thread again, quietly: what is on screen stays until the answer is here (a read that fails changes nothing), a message that arrived
|
|
317
|
+
* is added at the top, and the reader's expanded and collapsed cards are left as they are. A message this tab sent - which the server has
|
|
318
|
+
* now filed in Sent Items under the same `uid` - takes the place of its pending card, expanded as that card was, with the focus if the
|
|
319
|
+
* card had it. A thread that has not finished its first load is not read again: that load is the fresh read.
|
|
320
|
+
*/
|
|
321
|
+
function refreshThread() {
|
|
322
|
+
const id = loadedIdRef.current;
|
|
323
|
+
if (!id) {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
const generation = generationRef.current;
|
|
327
|
+
loadThread(mailboxUid, id).then((loaded) => {
|
|
328
|
+
if (generation !== generationRef.current)
|
|
329
|
+
return;
|
|
330
|
+
const current = loaded.messages.filter((message) => !removedRef.current.has(message.uid));
|
|
331
|
+
const adopted = adoptedOutgoing(mailboxUid, current);
|
|
332
|
+
const refocus = adopted.find((uid) => document.activeElement === headerRefs.current[uid]);
|
|
333
|
+
// Drawn first, and only then is the pending card forgotten: there is never a frame with neither it nor the real message.
|
|
334
|
+
flushSync(() => {
|
|
335
|
+
setMessages((previous) => mergeThread(previous, current));
|
|
336
|
+
setTruncated(loaded.truncated);
|
|
337
|
+
if (adopted.length > 0) {
|
|
338
|
+
setExpandedUids((previous) => new Set([...previous, ...adopted]));
|
|
339
|
+
}
|
|
340
|
+
if (refocus) {
|
|
341
|
+
setPendingFocusUid(refocus);
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
settleOutgoing(mailboxUid, current);
|
|
345
|
+
},
|
|
346
|
+
// Quietly: the next live update reads it again, and what is shown is still right.
|
|
347
|
+
() => undefined);
|
|
348
|
+
}
|
|
349
|
+
// Another message of this conversation may have arrived - a recipient's reply, one sent from another tab or device, or this tab's own reply
|
|
350
|
+
// filed in Sent Items - whenever the shell announces a live update that touched one of this mailbox's folders (or does not say which).
|
|
351
|
+
useEffect(() => {
|
|
352
|
+
if (live === seenLiveRef.current)
|
|
353
|
+
return;
|
|
354
|
+
seenLiveRef.current = live;
|
|
355
|
+
if (live.folderUids === null || folders.some((folder) => live.folderUids.has(folder.uid))) {
|
|
356
|
+
refreshThread();
|
|
357
|
+
}
|
|
358
|
+
}, [live]);
|
|
359
|
+
// The server has relayed a message this tab sent: its Sent Items copy is read for at once, without waiting for the live update that follows.
|
|
360
|
+
const sentKey = outgoing
|
|
361
|
+
.filter((reply) => reply.state === "sent" && reply.mailboxUid === mailboxUid)
|
|
362
|
+
.map((reply) => reply.uid)
|
|
363
|
+
.join(",");
|
|
364
|
+
useEffect(() => {
|
|
365
|
+
if (sentKey) {
|
|
366
|
+
refreshThread();
|
|
367
|
+
}
|
|
368
|
+
}, [sentKey]);
|
|
369
|
+
// The messages sent from here that continue this thread and whose real copy it does not hold yet, newest first (the pane's order). A message
|
|
370
|
+
// that replies to nothing here - a new message, or a reply to another conversation - is not in this list, and never is.
|
|
371
|
+
const pendingCards = outgoing
|
|
372
|
+
.filter((reply) => reply.mailboxUid === mailboxUid && !messages.some((message) => message.uid === reply.uid) && belongsToThread(reply, messages))
|
|
373
|
+
.reverse();
|
|
374
|
+
// A message that has just been sent is scrolled into view and takes the focus - the compose window it was sent from has just closed, and the focus
|
|
375
|
+
// with it - once. A failed one, or one that was already there when the thread was opened, is left where it is.
|
|
376
|
+
const pendingKey = pendingCards.map((reply) => reply.uid).join(",");
|
|
377
|
+
useEffect(() => {
|
|
378
|
+
const fresh = pendingCards.filter((reply) => reply.state === "sending" && !announcedRef.current.has(reply.uid));
|
|
379
|
+
if (fresh.length === 0)
|
|
380
|
+
return;
|
|
381
|
+
for (const reply of fresh) {
|
|
382
|
+
announcedRef.current.add(reply.uid);
|
|
383
|
+
}
|
|
384
|
+
setPendingFocusUid(fresh[0].uid);
|
|
385
|
+
}, [pendingKey]);
|
|
286
386
|
/** A newer copy of one of the thread's messages, kept here and handed to the list - with the copy it
|
|
287
387
|
* replaces where the caller was given one, so a conversation row can tell what actually changed. */
|
|
288
388
|
function patchMessage(updated, previous) {
|
|
@@ -291,6 +391,7 @@ export default function ConversationThreadPane({ conversation, mailboxUid, selec
|
|
|
291
391
|
}
|
|
292
392
|
/** A message that left the folder being listed - it leaves the thread too, as it left the list. */
|
|
293
393
|
function removeMessage(updated) {
|
|
394
|
+
removedRef.current.add(updated.uid);
|
|
294
395
|
setMessages((prev) => prev.filter((message) => message.uid !== updated.uid));
|
|
295
396
|
onMessageRemoved(updated);
|
|
296
397
|
}
|
|
@@ -329,26 +430,30 @@ export default function ConversationThreadPane({ conversation, mailboxUid, selec
|
|
|
329
430
|
: undefined
|
|
330
431
|
: error
|
|
331
432
|
? undefined
|
|
332
|
-
: `${messages.length} message${messages.length === 1 ? "" : "s"}`, children: truncated && !loading && !error && (_jsxs("p", { className: "text-xs text-text-muted mt-1", children: ["Only the oldest ", THREAD_MESSAGE_LIMIT, " messages of this conversation are shown here. The rest are still in the message list."] })) }), loading ? (_jsx(SkeletonCards, { messageCount: conversation.messageCount })) : error ? (_jsx("div", { className: "p-2 sm:p-3", children: _jsx(Alert, { children: error }) })) : (
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
433
|
+
: `${messages.length + pendingCards.length} message${messages.length + pendingCards.length === 1 ? "" : "s"}`, children: truncated && !loading && !error && (_jsxs("p", { className: "text-xs text-text-muted mt-1", children: ["Only the oldest ", THREAD_MESSAGE_LIMIT, " messages of this conversation are shown here. The rest are still in the message list."] })) }), loading ? (_jsx(SkeletonCards, { messageCount: conversation.messageCount })) : error ? (_jsx("div", { className: "p-2 sm:p-3", children: _jsx(Alert, { children: error }) })) : (_jsxs("ul", { className: "flex-1 min-h-0 overflow-y-auto p-2 sm:p-3 flex flex-col gap-3", children: [pendingCards.map((reply) => (_jsx("li", { ref: (node) => {
|
|
434
|
+
rowRefs.current[reply.uid] = node;
|
|
435
|
+
}, "data-outgoing": "true", children: _jsx(PendingMessageCard, { reply: reply, headerRef: (node) => {
|
|
436
|
+
headerRefs.current[reply.uid] = node;
|
|
437
|
+
} }) }, reply.uid))), messages.map((message) => {
|
|
438
|
+
const uid = message.uid;
|
|
439
|
+
const expanded = expandedUids.has(uid);
|
|
440
|
+
const bodyId = `thread-message-${uid}`;
|
|
441
|
+
const messageUnread = isUnread(message);
|
|
442
|
+
return (_jsx("li", { ref: (node) => {
|
|
443
|
+
rowRefs.current[uid] = node;
|
|
444
|
+
}, "data-unread": messageUnread ? "true" : undefined, children: expanded ? (_jsx(MessageDetailPane, { inThread: true, threadSubject: subject, threadHeader: {
|
|
445
|
+
bodyId,
|
|
446
|
+
unread: messageUnread,
|
|
447
|
+
onToggle: () => toggleExpanded(uid),
|
|
448
|
+
buttonRef: (node) => {
|
|
347
449
|
headerRefs.current[uid] = node;
|
|
348
|
-
},
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
450
|
+
},
|
|
451
|
+
}, footer: uid === footerUid, shortcuts: !!shortcuts && uid === keyboardUid, message: message, attachments: attachmentsByUid[uid] ?? [], isSentItems: folderTypeOf(message) === "sent_items", isOutbox: folderTypeOf(message) === "outbox", draftsFolderUid: folders.find((folder) => folder.type === "drafts")?.uid, folders: folders, onMoved: removeMessage, onFolderCreated: onFolderCreated, onRecalled: patchMessage, onReceiptHandled: patchMessage, onScheduledSendCanceled: removeMessage, onArchived: removeMessage, labels: labels, onLabelsChanged: patchMessage, onLabelCreated: onLabelCreated })) : (_jsxs(_Fragment, { children: [_jsx(CollapsedCard, { from: message.from, date: new Date(message.receivedDate).toLocaleString(), preview: message.bodyPreview || (message.encrypted ? _jsx(EncryptedPreview, {}) : ""), unread: messageUnread, senderClassName: senderClass(messageUnread), dateClassName: dateClass(messageUnread), buttonRef: (node) => {
|
|
452
|
+
headerRefs.current[uid] = node;
|
|
453
|
+
}, buttonProps: {
|
|
454
|
+
onClick: () => toggleExpanded(uid),
|
|
455
|
+
"aria-expanded": false,
|
|
456
|
+
"aria-controls": bodyId,
|
|
457
|
+
} }), _jsx("div", { id: bodyId, hidden: true })] })) }, uid));
|
|
458
|
+
})] }))] }));
|
|
354
459
|
}
|
|
@@ -47,6 +47,11 @@ export interface MailSelectionBarProps {
|
|
|
47
47
|
/** The keyboard acts on this selection (Delete, Ctrl+Q, Ctrl+U, Insert - registered by the page): Mark read, Mark unread, Flag and
|
|
48
48
|
* Delete name their shortcut in the tooltip and `aria-keyshortcuts`. */
|
|
49
49
|
shortcuts?: boolean;
|
|
50
|
+
/** Why Move to is unavailable, when the selection is spread over mailboxes and a folder can only belong to one (search results over several
|
|
51
|
+
* mailboxes). Shown as the button's tooltip. */
|
|
52
|
+
moveDisabledReason?: string;
|
|
53
|
+
/** The same for Apply label: a label belongs to one mailbox, and `labels` are one mailbox's. */
|
|
54
|
+
labelsDisabledReason?: string;
|
|
50
55
|
}
|
|
51
56
|
/**
|
|
52
57
|
* The header that replaces the list's own toolbar while select mode is on: how many rows are selected,
|
|
@@ -58,4 +63,4 @@ export interface MailSelectionBarProps {
|
|
|
58
63
|
* target folder is the one already being viewed is disabled with a reason rather than hidden, so the set
|
|
59
64
|
* of actions doesn't shift around between folders.
|
|
60
65
|
*/
|
|
61
|
-
export default function MailSelectionBar({ selected, listed, totals, onSelectAll, onClearSelection, onCancel, folders, currentFolderUid, labels, mailboxUid, onLabelCreated, onFolderCreated, onApplyLabels, onSetRead, onSetFlagged, onArchive, onMoveTo, onReportJunk, onDelete, busy, shortcuts, }: MailSelectionBarProps): React.JSX.Element;
|
|
66
|
+
export default function MailSelectionBar({ selected, listed, totals, onSelectAll, onClearSelection, onCancel, folders, currentFolderUid, labels, mailboxUid, onLabelCreated, onFolderCreated, onApplyLabels, onSetRead, onSetFlagged, onArchive, onMoveTo, onReportJunk, onDelete, busy, shortcuts, moveDisabledReason, labelsDisabledReason, }: MailSelectionBarProps): React.JSX.Element;
|
|
@@ -22,7 +22,7 @@ function actionClassName() {
|
|
|
22
22
|
* target folder is the one already being viewed is disabled with a reason rather than hidden, so the set
|
|
23
23
|
* of actions doesn't shift around between folders.
|
|
24
24
|
*/
|
|
25
|
-
export default function MailSelectionBar({ selected, listed, totals, onSelectAll, onClearSelection, onCancel, folders, currentFolderUid, labels, mailboxUid, onLabelCreated, onFolderCreated, onApplyLabels, onSetRead, onSetFlagged, onArchive, onMoveTo, onReportJunk, onDelete, busy, shortcuts, }) {
|
|
25
|
+
export default function MailSelectionBar({ selected, listed, totals, onSelectAll, onClearSelection, onCancel, folders, currentFolderUid, labels, mailboxUid, onLabelCreated, onFolderCreated, onApplyLabels, onSetRead, onSetFlagged, onArchive, onMoveTo, onReportJunk, onDelete, busy, shortcuts, moveDisabledReason, labelsDisabledReason, }) {
|
|
26
26
|
const env = useKeyEnvironment();
|
|
27
27
|
/** `title` and `aria-keyshortcuts` for an action the keyboard also does; `reason` (why it is disabled) wins the tooltip. */
|
|
28
28
|
const hint = (label, shortcut, reason) => shortcuts
|
|
@@ -49,7 +49,7 @@ export default function MailSelectionBar({ selected, listed, totals, onSelectAll
|
|
|
49
49
|
// partially applied, and is left exactly as it is unless the reader touches that row.
|
|
50
50
|
const appliedToAll = labels.filter((label) => !none && selected.every((m) => m.labelUids?.includes(label.uid)));
|
|
51
51
|
const appliedToSome = labels.filter((label) => selected.some((m) => m.labelUids?.includes(label.uid)) && !appliedToAll.includes(label));
|
|
52
|
-
return (_jsxs("div", { className: "border-b border-border", children: [_jsx(MoveToFolderDialog, { open: movePrompt, onClose: () => setMovePrompt(false), mailboxUid: mailboxUid, folders: folders, currentFolderUid: currentFolderUid, count: selected.length, onMove: onMoveTo, onFolderCreated: onFolderCreated }), _jsxs("div", { className: "flex items-center gap-2 px-3 py-1.5 bg-surface-alt", children: [_jsxs("span", { "aria-live": "polite", className: "text-sm font-semibold text-text", children: [selectedCount, totals ? ` ${totals.noun}${selectedCount === 1 ? "" : "s"}` : "", " selected"] }), _jsx("button", { type: "button", onClick: onSelectAll, disabled: allSelected || listedCount === 0, className: actionClassName(), children: "Select all" }), _jsx("button", { type: "button", onClick: onClearSelection, disabled: selectedCount === 0, className: actionClassName(), children: "Clear" }), _jsx("button", { type: "button", onClick: onCancel, className: `${actionClassName()} ml-auto`, children: "Cancel" })] }), _jsxs("div", { className: "flex flex-wrap items-center gap-0.5 px-1.5 py-1", children: [_jsx("button", { type: "button", onClick: () => onSetRead(true), disabled: none || busy, className: actionClassName(), ...hint("Mark read", SHORTCUTS.mail.markRead), children: "Mark read" }), _jsx("button", { type: "button", onClick: () => onSetRead(false), disabled: none || busy, className: actionClassName(), ...hint("Mark unread", SHORTCUTS.mail.markUnread), children: "Mark unread" }), _jsx("button", { type: "button", onClick: () => onSetFlagged(true), disabled: none || busy, className: actionClassName(), ...hint("Flag", SHORTCUTS.mail.flag), children: "Flag" }), _jsx("button", { type: "button", onClick: () => onSetFlagged(false), disabled: none || busy, className: actionClassName(), children: "Unflag" }), _jsx("button", { type: "button", onClick: onArchive, disabled: none || busy || !!archiveReason, title: archiveReason, className: actionClassName(), children: "Archive" }), _jsx(LabelMenuButton, { "aria-label": "Apply label", label: "Apply label", className: "text-sm", labels: labels, mailboxUid: mailboxUid, onLabelCreated: onLabelCreated, applied: appliedToAll.map((label) => label.uid), partial: appliedToSome.map((label) => label.uid), onCommit: onApplyLabels, busy: busy, disabled: none || busy, note: selected.length === 1
|
|
52
|
+
return (_jsxs("div", { className: "border-b border-border", children: [_jsx(MoveToFolderDialog, { open: movePrompt, onClose: () => setMovePrompt(false), mailboxUid: mailboxUid, folders: folders, currentFolderUid: currentFolderUid, count: selected.length, onMove: onMoveTo, onFolderCreated: onFolderCreated }), _jsxs("div", { className: "flex items-center gap-2 px-3 py-1.5 bg-surface-alt", children: [_jsxs("span", { "aria-live": "polite", className: "text-sm font-semibold text-text", children: [selectedCount, totals ? ` ${totals.noun}${selectedCount === 1 ? "" : "s"}` : "", " selected"] }), _jsx("button", { type: "button", onClick: onSelectAll, disabled: allSelected || listedCount === 0, className: actionClassName(), children: "Select all" }), _jsx("button", { type: "button", onClick: onClearSelection, disabled: selectedCount === 0, className: actionClassName(), children: "Clear" }), _jsx("button", { type: "button", onClick: onCancel, className: `${actionClassName()} ml-auto`, children: "Cancel" })] }), _jsxs("div", { className: "flex flex-wrap items-center gap-0.5 px-1.5 py-1", children: [_jsx("button", { type: "button", onClick: () => onSetRead(true), disabled: none || busy, className: actionClassName(), ...hint("Mark read", SHORTCUTS.mail.markRead), children: "Mark read" }), _jsx("button", { type: "button", onClick: () => onSetRead(false), disabled: none || busy, className: actionClassName(), ...hint("Mark unread", SHORTCUTS.mail.markUnread), children: "Mark unread" }), _jsx("button", { type: "button", onClick: () => onSetFlagged(true), disabled: none || busy, className: actionClassName(), ...hint("Flag", SHORTCUTS.mail.flag), children: "Flag" }), _jsx("button", { type: "button", onClick: () => onSetFlagged(false), disabled: none || busy, className: actionClassName(), children: "Unflag" }), _jsx("button", { type: "button", onClick: onArchive, disabled: none || busy || !!archiveReason, title: archiveReason, className: actionClassName(), children: "Archive" }), _jsx(LabelMenuButton, { "aria-label": "Apply label", label: "Apply label", className: "text-sm", labels: labels, mailboxUid: mailboxUid, onLabelCreated: onLabelCreated, applied: appliedToAll.map((label) => label.uid), partial: appliedToSome.map((label) => label.uid), onCommit: onApplyLabels, busy: busy, disabled: none || busy || !!labelsDisabledReason, title: labelsDisabledReason, note: selected.length === 1
|
|
53
53
|
? "Ticked labels are applied, unticked ones removed."
|
|
54
|
-
: "Ticked labels are applied to every selected message and unticked ones removed from all of them; a dash means only some have that label, and leaving it alone keeps it that way.", emptyNote: "This mailbox has no labels yet.", commit: { label: "Apply" }, clear: { label: "Remove all labels" } }), _jsx("button", { type: "button", onClick: () => setMovePrompt(true), disabled: none || busy, className: actionClassName(), children: "Move to" }), _jsx("button", { type: "button", onClick: onReportJunk, disabled: none || busy || !!junkReason, title: junkReason, className: actionClassName(), children: "Report junk" }), _jsx("button", { type: "button", onClick: onDelete, disabled: none || busy || !!deleteReason, className: actionClassName(), ...hint("Delete", SHORTCUTS.mail.delete, deleteReason), children: "Delete" })] })] }));
|
|
54
|
+
: "Ticked labels are applied to every selected message and unticked ones removed from all of them; a dash means only some have that label, and leaving it alone keeps it that way.", emptyNote: "This mailbox has no labels yet.", commit: { label: "Apply" }, clear: { label: "Remove all labels" } }), _jsx("button", { type: "button", onClick: () => setMovePrompt(true), disabled: none || busy || !!moveDisabledReason, title: moveDisabledReason, className: actionClassName(), children: "Move to" }), _jsx("button", { type: "button", onClick: onReportJunk, disabled: none || busy || !!junkReason, title: junkReason, className: actionClassName(), children: "Report junk" }), _jsx("button", { type: "button", onClick: onDelete, disabled: none || busy || !!deleteReason, className: actionClassName(), ...hint("Delete", SHORTCUTS.mail.delete, deleteReason), children: "Delete" })] })] }));
|
|
55
55
|
}
|
|
@@ -10,6 +10,7 @@ import { assembleDraft, attachmentContentUrl, createDraft, deleteMessage, getMai
|
|
|
10
10
|
import { listMailSignatures } from "@rapidmx/react-shared/mail/mailSignaturesApi.js";
|
|
11
11
|
import { buildComposeBodyHtml } from "@rapidmx/react-shared/mail/compose/composeQuoting.js";
|
|
12
12
|
import { peekMailboxWritability, useMailboxWritability } from "../writableMailboxes.js";
|
|
13
|
+
import { orderMailboxes, primaryMailboxUid } from "../../../mail/primaryMailbox.js";
|
|
13
14
|
import { resolveRecipientEncryption } from "@rapidmx/react-shared/crypto/composeSecurity.js";
|
|
14
15
|
import { getUnlockedKeys, subscribeKeySession } from "@rapidmx/react-shared/crypto/keySession.js";
|
|
15
16
|
import { findActivePublicKey, getEncryptionPolicy, lookupKeys } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
|
|
@@ -219,11 +220,13 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
219
220
|
if (cancelled) {
|
|
220
221
|
return;
|
|
221
222
|
}
|
|
222
|
-
|
|
223
|
+
// Listed with the caller's own (primary) mailbox first, and defaulted to below.
|
|
224
|
+
const ordered = orderMailboxes(result, userUid);
|
|
225
|
+
setMailboxes(ordered);
|
|
223
226
|
// Never default to a mailbox already known to be view-only (see the effect below for one
|
|
224
227
|
// that turns out to be).
|
|
225
|
-
const candidates =
|
|
226
|
-
setFromMailboxUid((current) => current ?? (candidates
|
|
228
|
+
const candidates = ordered.filter((mb) => peekMailboxWritability(mb, userUid, trusted) !== false);
|
|
229
|
+
setFromMailboxUid((current) => current ?? primaryMailboxUid(candidates, userUid));
|
|
227
230
|
})
|
|
228
231
|
.catch((err) => {
|
|
229
232
|
// Only fatal when there's no mailbox to fall back on - a session that already names one just
|
|
@@ -481,7 +484,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
481
484
|
if (!fromIsViewOnly || hasUploads) {
|
|
482
485
|
return;
|
|
483
486
|
}
|
|
484
|
-
const ordered =
|
|
487
|
+
const ordered = orderMailboxes(mailboxes, userUid);
|
|
485
488
|
const alternative = ordered.find((mb) => writability[mb.uid] === true) ?? ordered.find((mb) => writability[mb.uid] === undefined);
|
|
486
489
|
if (alternative) {
|
|
487
490
|
handleFromChange(alternative.uid);
|
|
@@ -685,6 +688,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
685
688
|
encryptRequested: current.encryptRequested,
|
|
686
689
|
forcePlaintext,
|
|
687
690
|
saved: saveInFlightRef.current,
|
|
691
|
+
threading: session.threading,
|
|
688
692
|
};
|
|
689
693
|
if (request.to.length === 0) {
|
|
690
694
|
setSendError("At least one recipient is required.");
|
|
@@ -22,6 +22,26 @@ export interface RecipientInputProps {
|
|
|
22
22
|
disabled?: boolean;
|
|
23
23
|
/** Focuses the input when it mounts - a new message starts in To. */
|
|
24
24
|
autoFocus?: boolean;
|
|
25
|
+
/** Called whenever the field's entries change: every committed entry (chips - a picked suggestion, a finished or
|
|
26
|
+
* pasted one, or a typed one committed by Enter or leaving the field) and the text still being typed (`""` after a
|
|
27
|
+
* commit). Lets a field that keeps its entries as something other than text (attendees) read them without parsing
|
|
28
|
+
* `value` and guessing which part of it is still being typed. */
|
|
29
|
+
onEdit?: (chips: string[], pending: string) => void;
|
|
30
|
+
/** Text being typed already when the input mounts (the last recipient of `value`), so a field that is remounted
|
|
31
|
+
* (a popover growing into a form) keeps it in the input instead of turning it into a chip. */
|
|
32
|
+
initialPending?: string;
|
|
33
|
+
/** Chips this returns true for are not drawn (they stay in the value, and out of the suggestions): a field that
|
|
34
|
+
* lists some entries itself. Backspace in an empty input removes the last chip that is drawn. */
|
|
35
|
+
hideChip?: (chip: string) => boolean;
|
|
36
|
+
placeholder?: string;
|
|
37
|
+
/** The input's accessible name, for a field without a `<label htmlFor>`. */
|
|
38
|
+
ariaLabel?: string;
|
|
39
|
+
/** The chip list's accessible name; `${label} recipients` by default. */
|
|
40
|
+
listLabel?: string;
|
|
41
|
+
/** Classes for the field's outer element, after its own (a border, padding). */
|
|
42
|
+
className?: string;
|
|
43
|
+
/** The suggestions' `z-index`, for a field inside an overlay that stacks above the default's (`z-[60]`). */
|
|
44
|
+
dropdownZIndex?: number;
|
|
25
45
|
}
|
|
26
46
|
/**
|
|
27
47
|
* A To/Cc/Bcc field: committed recipients as removable chips, and a WAI-ARIA combobox for the one being typed that
|
|
@@ -33,4 +53,4 @@ export interface RecipientInputProps {
|
|
|
33
53
|
* least two characters, and a newer query aborts an older request. Arrow keys move through them, Enter or Tab picks
|
|
34
54
|
* one (as `Name <address>`), Escape closes them, and Backspace in an empty input removes the last chip.
|
|
35
55
|
*/
|
|
36
|
-
export default function RecipientInput({ id, label, value, onChange, onBlur, onCommit, mailboxUid, fetchSuggestions, debounceMs, disabled, autoFocus, }: RecipientInputProps): React.JSX.Element;
|
|
56
|
+
export default function RecipientInput({ id, label, value, onChange, onBlur, onCommit, mailboxUid, fetchSuggestions, debounceMs, disabled, autoFocus, onEdit, initialPending, hideChip, placeholder, ariaLabel, listLabel, className, dropdownZIndex, }: RecipientInputProps): React.JSX.Element;
|
|
@@ -41,8 +41,8 @@ function dropdownPosition(anchor) {
|
|
|
41
41
|
* least two characters, and a newer query aborts an older request. Arrow keys move through them, Enter or Tab picks
|
|
42
42
|
* one (as `Name <address>`), Escape closes them, and Backspace in an empty input removes the last chip.
|
|
43
43
|
*/
|
|
44
|
-
export default function RecipientInput({ id, label, value, onChange, onBlur, onCommit, mailboxUid, fetchSuggestions = fetchRecipientSuggestions, debounceMs = RECIPIENT_SUGGESTION_DEBOUNCE_MS, disabled, autoFocus, }) {
|
|
45
|
-
const [pending, setPending] = useState(
|
|
44
|
+
export default function RecipientInput({ id, label, value, onChange, onBlur, onCommit, mailboxUid, fetchSuggestions = fetchRecipientSuggestions, debounceMs = RECIPIENT_SUGGESTION_DEBOUNCE_MS, disabled, autoFocus, onEdit, initialPending = "", hideChip, placeholder, ariaLabel, listLabel, className, dropdownZIndex, }) {
|
|
45
|
+
const [pending, setPending] = useState(initialPending);
|
|
46
46
|
const [suggestions, setSuggestions] = useState([]);
|
|
47
47
|
const [open, setOpen] = useState(false);
|
|
48
48
|
const [activeIndex, setActiveIndex] = useState(0);
|
|
@@ -58,6 +58,8 @@ export default function RecipientInput({ id, label, value, onChange, onBlur, onC
|
|
|
58
58
|
const chips = pendingActive ? tokens.slice(0, -1) : tokens;
|
|
59
59
|
const inputValue = pendingActive ? pending : "";
|
|
60
60
|
const query = pendingActive ? pendingText : "";
|
|
61
|
+
const drawn = chips.map((chip, index) => ({ chip, index })).filter(({ chip }) => !hideChip?.(chip));
|
|
62
|
+
const lastDrawn = drawn.length > 0 ? drawn[drawn.length - 1].index : -1;
|
|
61
63
|
const listboxId = `${id}-suggestions`;
|
|
62
64
|
const optionId = (index) => `${id}-suggestion-${index}`;
|
|
63
65
|
useEffect(() => {
|
|
@@ -113,6 +115,7 @@ export default function RecipientInput({ id, label, value, onChange, onBlur, onC
|
|
|
113
115
|
});
|
|
114
116
|
function emit(nextChips, nextPending) {
|
|
115
117
|
onChange([...nextChips, nextPending.trim()].filter((part) => part.length > 0).join(", "));
|
|
118
|
+
onEdit?.(nextChips, nextPending.trim());
|
|
116
119
|
}
|
|
117
120
|
function handleInputChange(e) {
|
|
118
121
|
const { finished, rest } = splitTypedRecipients(e.target.value);
|
|
@@ -122,17 +125,22 @@ export default function RecipientInput({ id, label, value, onChange, onBlur, onC
|
|
|
122
125
|
emit([...chips, ...finished], nextPending);
|
|
123
126
|
}
|
|
124
127
|
function commitPending() {
|
|
125
|
-
const
|
|
128
|
+
const committed = [...chips, pendingText].filter((part) => part.length > 0);
|
|
126
129
|
setPending("");
|
|
127
130
|
setOpen(false);
|
|
128
|
-
|
|
131
|
+
if (pendingText) {
|
|
132
|
+
onEdit?.(committed, "");
|
|
133
|
+
}
|
|
134
|
+
return committed.join(", ");
|
|
129
135
|
}
|
|
130
136
|
function select(suggestion) {
|
|
131
|
-
const
|
|
137
|
+
const committed = [...chips, formatRecipient({ address: suggestion.address, displayName: suggestion.displayName || undefined })];
|
|
138
|
+
const next = committed.join(", ");
|
|
132
139
|
setPending("");
|
|
133
140
|
setOpen(false);
|
|
134
141
|
setSuggestions([]);
|
|
135
142
|
onChange(next);
|
|
143
|
+
onEdit?.(committed, "");
|
|
136
144
|
onCommit?.(next);
|
|
137
145
|
}
|
|
138
146
|
function removeChip(index) {
|
|
@@ -181,9 +189,9 @@ export default function RecipientInput({ id, label, value, onChange, onBlur, onC
|
|
|
181
189
|
}
|
|
182
190
|
return;
|
|
183
191
|
case "Backspace":
|
|
184
|
-
if (inputValue === "" &&
|
|
192
|
+
if (inputValue === "" && lastDrawn >= 0) {
|
|
185
193
|
e.preventDefault();
|
|
186
|
-
emit(chips.
|
|
194
|
+
emit(chips.filter((_, i) => i !== lastDrawn), "");
|
|
187
195
|
}
|
|
188
196
|
return;
|
|
189
197
|
}
|
|
@@ -192,12 +200,12 @@ export default function RecipientInput({ id, label, value, onChange, onBlur, onC
|
|
|
192
200
|
const next = commitPending();
|
|
193
201
|
onBlur?.(next);
|
|
194
202
|
}
|
|
195
|
-
return (_jsxs("div", { ref: anchorRef, className:
|
|
203
|
+
return (_jsxs("div", { ref: anchorRef, className: `flex-1 min-w-0 flex flex-wrap items-center gap-1${className ? ` ${className}` : ""}`, children: [drawn.length > 0 && (_jsx("ul", { role: "list", "aria-label": listLabel ?? `${label} recipients`, className: "contents", children: drawn.map(({ chip, index }) => {
|
|
196
204
|
const recipient = parseRecipient(chip);
|
|
197
205
|
const valid = isValidRecipientAddress(recipient.address);
|
|
198
206
|
const name = recipient.displayName || recipient.address;
|
|
199
207
|
return (_jsxs("li", { title: chip, className: `flex items-center gap-1 max-w-full min-w-0 rounded-pill border pl-2 pr-0.5 py-px text-xs ${valid ? "bg-surface-alt border-border text-text" : "bg-danger-bg border-danger text-danger"}`, children: [_jsx("span", { className: "truncate", children: name }), !valid && _jsx("span", { className: "sr-only", children: " (not a valid email address)" }), _jsx("button", { type: "button", tabIndex: -1, "aria-label": `Remove ${recipient.address}`, disabled: disabled, onMouseDown: (e) => e.preventDefault(), onClick: () => removeChip(index), className: "shrink-0 w-4 h-4 flex items-center justify-center rounded-pill text-text-muted hover:bg-border hover:text-text", children: _jsx(HiOutlineXMark, { size: 10 }) })] }, `${index}-${chip}`));
|
|
200
|
-
}) })), _jsx("input", { ref: inputRef, id: id, type: "text", role: "combobox", autoComplete: "off", "aria-autocomplete": "list", "aria-expanded": expanded, "aria-controls": listboxId, "aria-activedescendant": expanded ? optionId(active) : undefined, disabled: disabled, autoFocus: autoFocus, className: "flex-1 min-w-[6rem] text-sm bg-transparent outline-none py-0.5", value: inputValue, onChange: handleInputChange, onKeyDown: handleKeyDown, onBlur: handleBlur }), _jsx("span", { className: "sr-only", "aria-live": "polite", children: expanded ? `${visible.length} ${visible.length === 1 ? "suggestion" : "suggestions"}` : "" }), expanded &&
|
|
208
|
+
}) })), _jsx("input", { ref: inputRef, id: id, type: "text", role: "combobox", autoComplete: "off", "aria-autocomplete": "list", "aria-label": ariaLabel, placeholder: placeholder, "aria-expanded": expanded, "aria-controls": listboxId, "aria-activedescendant": expanded ? optionId(active) : undefined, disabled: disabled, autoFocus: autoFocus, className: "flex-1 min-w-[6rem] text-sm bg-transparent outline-none py-0.5", value: inputValue, onChange: handleInputChange, onKeyDown: handleKeyDown, onBlur: handleBlur }), _jsx("span", { className: "sr-only", "aria-live": "polite", children: expanded ? `${visible.length} ${visible.length === 1 ? "suggestion" : "suggestions"}` : "" }), expanded &&
|
|
201
209
|
position &&
|
|
202
|
-
createPortal(_jsx("ul", { id: listboxId, role: "listbox", "aria-label": `${label} suggestions`, onMouseDown: (e) => e.preventDefault(), style: { position: "fixed", left: position.left, width: position.width, top: position.top, bottom: position.bottom, maxHeight: position.maxHeight }, className: "z-[60] overflow-y-auto bg-surface border border-border rounded-md shadow-modal py-1", children: visible.map((suggestion, index) => (_jsxs("li", { id: optionId(index), role: "option", "aria-selected": index === active, onMouseDown: (e) => e.preventDefault(), onMouseEnter: () => setActiveIndex(index), onClick: () => select(suggestion), className: `flex items-center gap-2 px-3 py-1.5 cursor-pointer ${index === active ? "bg-primary/10" : ""}`, children: [_jsxs("span", { className: "flex-1 min-w-0", children: [_jsx("span", { className: "block truncate text-sm text-text", children: suggestion.displayName || suggestion.address }), suggestion.displayName && _jsx("span", { className: "block truncate text-xs text-text-muted", children: suggestion.address })] }), _jsx("span", { className: "shrink-0 text-[11px] leading-4 px-1.5 rounded-pill bg-surface-alt text-text-muted", children: KIND_LABELS[suggestion.kind] ?? "Directory" })] }, `${index}-${suggestion.address}`))) }), document.body)] }));
|
|
210
|
+
createPortal(_jsx("ul", { id: listboxId, role: "listbox", "aria-label": `${label} suggestions`, onMouseDown: (e) => e.preventDefault(), style: { position: "fixed", left: position.left, width: position.width, top: position.top, bottom: position.bottom, maxHeight: position.maxHeight, zIndex: dropdownZIndex }, className: "z-[60] overflow-y-auto bg-surface border border-border rounded-md shadow-modal py-1", children: visible.map((suggestion, index) => (_jsxs("li", { id: optionId(index), role: "option", "aria-selected": index === active, onMouseDown: (e) => e.preventDefault(), onMouseEnter: () => setActiveIndex(index), onClick: () => select(suggestion), className: `flex items-center gap-2 px-3 py-1.5 cursor-pointer ${index === active ? "bg-primary/10" : ""}`, children: [_jsxs("span", { className: "flex-1 min-w-0", children: [_jsx("span", { className: "block truncate text-sm text-text", children: suggestion.displayName || suggestion.address }), suggestion.displayName && _jsx("span", { className: "block truncate text-xs text-text-muted", children: suggestion.address })] }), _jsx("span", { className: "shrink-0 text-[11px] leading-4 px-1.5 rounded-pill bg-surface-alt text-text-muted", children: KIND_LABELS[suggestion.kind] ?? "Directory" })] }, `${index}-${suggestion.address}`))) }), document.body)] }));
|
|
203
211
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { FolderBadge } from "../../../mail/folderCounts.js";
|
|
3
|
+
/** A folder's badge: an accent pill with the unread count, or - for the folders that show how many they hold - plain muted text. */
|
|
4
|
+
export default function FolderBadgeChip({ badge }: {
|
|
5
|
+
badge: FolderBadge;
|
|
6
|
+
}): React.JSX.Element;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { badgeLabel } from "../../../mail/folderCounts.js";
|
|
3
|
+
/** A folder's badge: an accent pill with the unread count, or - for the folders that show how many they hold - plain muted text. */
|
|
4
|
+
export default function FolderBadgeChip({ badge }) {
|
|
5
|
+
return (_jsxs("span", { className: [
|
|
6
|
+
"text-xs rounded-pill py-0.5 px-1.5",
|
|
7
|
+
badge.kind === "unread" ? "font-bold bg-primary/15 text-primary-dark" : "font-medium text-text-muted",
|
|
8
|
+
].join(" "), children: [_jsx("span", { "aria-hidden": "true", children: badge.value }), _jsxs("span", { className: "sr-only", children: [" ", badgeLabel(badge)] })] }));
|
|
9
|
+
}
|
|
@@ -83,5 +83,10 @@ export { MAILBOX_LIST_LIMIT };
|
|
|
83
83
|
* behavior change from before this, and a multi-mailbox user sees every mailbox's folders (and the new
|
|
84
84
|
* aggregate section) simultaneously, matching the shared-mailbox feature's own "display as a separate set
|
|
85
85
|
* of folders" requirement.
|
|
86
|
+
*
|
|
87
|
+
* An address that names nothing opens "All Mailboxes > Inbox" when there is more than one mailbox (that is when the section exists), and
|
|
88
|
+
* the one mailbox's own Inbox otherwise; a `?mailboxUid=`, `?folderUid=` or `?aggregate=` always wins. Nothing is selected until the address
|
|
89
|
+
* has been read. What needs a single mailbox while an all-mailboxes view is open (Compose, the new-message shortcut, key unlocking) uses the
|
|
90
|
+
* primary mailbox.
|
|
86
91
|
*/
|
|
87
92
|
export default function MailShell({ userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, pluginNav, children, }: PropsWithChildren<MailShellProps>): React.JSX.Element;
|