@rapidmx/web-client 0.6.0 → 0.8.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/apps/shared/components/admin/settings/BrandingForm.tsx +398 -385
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +119 -112
- package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +198 -152
- package/apps/shared/components/admin/settings/PluginsManager.tsx +1288 -1235
- package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +9 -1
- package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +288 -290
- package/apps/shared/components/admin/setup/SetupWizard.tsx +443 -416
- package/apps/shared/components/layout/AppShell.tsx +288 -288
- package/apps/shared/components/mail/ConversationList.tsx +225 -33
- package/apps/shared/components/mail/ConversationThreadPane.tsx +434 -216
- package/apps/shared/components/mail/MailListToolbar.tsx +265 -0
- package/apps/shared/components/mail/MailSelectionBar.tsx +222 -0
- package/apps/shared/components/mail/MenuButton.tsx +384 -0
- package/apps/shared/components/mail/MessageDetailPane.tsx +280 -215
- package/apps/shared/components/mail/MoveToFolderDialog.tsx +263 -0
- package/apps/shared/components/mail/compose/ComposeContext.tsx +29 -2
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +1700 -1652
- package/apps/shared/components/mail/compose/RecipientInput.tsx +356 -0
- package/apps/shared/components/mail/compose/RichTextEditor.tsx +38 -3
- package/apps/shared/components/mail/compose/quotedBody.ts +100 -0
- package/apps/shared/components/mail/compose/recipients.ts +104 -0
- package/apps/shared/components/mail/labelMenu.tsx +278 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +445 -420
- package/apps/shared/components/mail/listPreferences.ts +228 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +0 -1
- package/apps/shared/styles/app.css +28 -5
- package/apps/www/index.tsx +1854 -1314
- package/apps/www/messages/[uid].tsx +5 -4
- package/dist/apps/shared/components/admin/settings/BrandingForm.d.ts +7 -3
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +6 -5
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.d.ts +4 -1
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +2 -2
- package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.d.ts +9 -2
- package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +20 -4
- package/dist/apps/shared/components/admin/settings/PluginsManager.d.ts +8 -1
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +33 -14
- package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.d.ts +4 -1
- package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +2 -2
- package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +1 -1
- package/dist/apps/shared/components/admin/setup/SetupWizard.js +14 -10
- package/dist/apps/shared/components/mail/ConversationList.d.ts +42 -6
- package/dist/apps/shared/components/mail/ConversationList.js +84 -10
- package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +52 -13
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +217 -60
- package/dist/apps/shared/components/mail/MailListToolbar.d.ts +55 -0
- package/dist/apps/shared/components/mail/MailListToolbar.js +149 -0
- package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +59 -0
- package/dist/apps/shared/components/mail/MailSelectionBar.js +48 -0
- package/dist/apps/shared/components/mail/MenuButton.d.ts +71 -0
- package/dist/apps/shared/components/mail/MenuButton.js +211 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +27 -19
- package/dist/apps/shared/components/mail/MessageDetailPane.js +126 -129
- package/dist/apps/shared/components/mail/MoveToFolderDialog.d.ts +57 -0
- package/dist/apps/shared/components/mail/MoveToFolderDialog.js +138 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +16 -1
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +3 -1
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +52 -20
- package/dist/apps/shared/components/mail/compose/RecipientInput.d.ts +36 -0
- package/dist/apps/shared/components/mail/compose/RecipientInput.js +203 -0
- package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +10 -1
- package/dist/apps/shared/components/mail/compose/RichTextEditor.js +21 -3
- package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +37 -0
- package/dist/apps/shared/components/mail/compose/quotedBody.js +85 -0
- package/dist/apps/shared/components/mail/compose/recipients.d.ts +24 -0
- package/dist/apps/shared/components/mail/compose/recipients.js +87 -0
- package/dist/apps/shared/components/mail/labelMenu.d.ts +97 -0
- package/dist/apps/shared/components/mail/labelMenu.js +134 -0
- package/dist/apps/shared/components/mail/layout/MailShell.d.ts +8 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +15 -3
- package/dist/apps/shared/components/mail/listPreferences.d.ts +101 -0
- package/dist/apps/shared/components/mail/listPreferences.js +186 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +0 -1
- package/dist/apps/shared/styles/app.css +28 -5
- package/dist/apps/www/index.js +506 -112
- package/dist/apps/www/messages/[uid].js +2 -3
- package/package.json +2 -2
- package/apps/www/settings/focused-inbox/index.tsx +0 -150
- package/dist/apps/www/settings/focused-inbox/index.d.ts +0 -4
- package/dist/apps/www/settings/focused-inbox/index.js +0 -71
package/dist/apps/www/index.js
CHANGED
|
@@ -4,11 +4,11 @@ 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 { useCallback, useEffect, useRef, useState } from "react";
|
|
7
|
-
import { HiOutlineLockClosed } from "react-icons/hi2";
|
|
7
|
+
import { HiOutlineFlag, HiOutlineLockClosed, HiOutlinePaperClip } from "react-icons/hi2";
|
|
8
8
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
9
|
-
import { getMessage, getMessageRawContent, listMessages } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
9
|
+
import { archiveMessage, bulkUpdateMessages, createFolder, getMessage, getMessageRawContent, listMessages, moveMessages, setMessagesFlagged, setMessagesRead, } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
10
10
|
import { listLabels } from "@rapidmx/react-shared/mail/labelsApi.js";
|
|
11
|
-
import { listConversations } from "@rapidmx/react-shared/mail/conversationsApi.js";
|
|
11
|
+
import { listConversationMessages, listConversations, } from "@rapidmx/react-shared/mail/conversationsApi.js";
|
|
12
12
|
import { search as searchMailbox } from "@rapidmx/react-shared/search/searchApi.js";
|
|
13
13
|
import { parseSearchQuery } from "@rapidmx/react-shared/search/queryGrammar.js";
|
|
14
14
|
import { normalizeServerScores } from "@rapidmx/react-shared/search/searchScoring.js";
|
|
@@ -22,6 +22,9 @@ import MailShell, { useMailShell, } from "../shared/components/mail/layout/MailS
|
|
|
22
22
|
import MessageDetailPane from "../shared/components/mail/MessageDetailPane.js";
|
|
23
23
|
import ConversationList from "../shared/components/mail/ConversationList.js";
|
|
24
24
|
import ConversationThreadPane from "../shared/components/mail/ConversationThreadPane.js";
|
|
25
|
+
import MailListToolbar from "../shared/components/mail/MailListToolbar.js";
|
|
26
|
+
import MailSelectionBar from "../shared/components/mail/MailSelectionBar.js";
|
|
27
|
+
import { CONVERSATION_SORT_NOTE, CONVERSATION_SORT_UNAVAILABLE, MAIL_LIST_CLASSIFICATION_FILTERS, getMailListPreferences, setMailListPreferences, sortConversations, } from "../shared/components/mail/listPreferences.js";
|
|
25
28
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
26
29
|
import Skeleton from "@rapidmx/react-shared/components/feedback/Skeleton.js";
|
|
27
30
|
import { useUnlockPrompt } from "../shared/components/layout/UnlockPromptProvider.js";
|
|
@@ -263,10 +266,12 @@ const LOAD_MORE_ROOT_MARGIN_PX = 200;
|
|
|
263
266
|
/** How many full pages in a row that added no new rows the list keeps loading on its own before it shows a
|
|
264
267
|
* "Load more" button instead. */
|
|
265
268
|
const MAX_EMPTY_PAGE_CONTINUATIONS = 3;
|
|
266
|
-
/** `true` when `more` has at least one
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
269
|
+
/** `true` when `more` has at least one row `shown` doesn't - i.e. appending it actually adds rows. Generic
|
|
270
|
+
* over the row's own identity so both the message list (`uid`) and the conversation list
|
|
271
|
+
* (`conversationId`) page the same way. */
|
|
272
|
+
function hasUnseenRows(shown, more, idOf) {
|
|
273
|
+
const seen = new Set(shown.map(idOf));
|
|
274
|
+
return more.some((row) => !seen.has(idOf(row)));
|
|
270
275
|
}
|
|
271
276
|
/** The load-more sentinel's actual current geometry against its scroll container, with the same margin the
|
|
272
277
|
* IntersectionObserver uses. */
|
|
@@ -275,20 +280,23 @@ function isWithinLoadMoreRange(sentinel, root) {
|
|
|
275
280
|
const rootRect = root.getBoundingClientRect();
|
|
276
281
|
return rect.top <= rootRect.bottom + LOAD_MORE_ROOT_MARGIN_PX && rect.bottom >= rootRect.top - LOAD_MORE_ROOT_MARGIN_PX;
|
|
277
282
|
}
|
|
278
|
-
/** Appends `more` to `shown`, skipping any
|
|
283
|
+
/** Appends `more` to `shown`, skipping any row already shown - a later page can repeat rows (Tier 1 and
|
|
279
284
|
* Tier 2/3 cursors advance independently, so the same message can come back from a different tier on a
|
|
280
|
-
* later page; a plain folder listing's pages shift when new mail arrives between fetches).
|
|
281
|
-
|
|
282
|
-
|
|
285
|
+
* later page; a plain folder listing's pages shift when new mail arrives between fetches). Generic for the
|
|
286
|
+
* same reason `hasUnseenRows()` is. */
|
|
287
|
+
function appendUnseenRows(shown, more, idOf) {
|
|
288
|
+
const seen = new Set(shown.map(idOf));
|
|
283
289
|
const unseen = [];
|
|
284
|
-
for (const
|
|
285
|
-
if (!seen.has(
|
|
286
|
-
seen.add(
|
|
287
|
-
unseen.push(
|
|
290
|
+
for (const row of more) {
|
|
291
|
+
if (!seen.has(idOf(row))) {
|
|
292
|
+
seen.add(idOf(row));
|
|
293
|
+
unseen.push(row);
|
|
288
294
|
}
|
|
289
295
|
}
|
|
290
296
|
return unseen.length === 0 ? shown : [...shown, ...unseen];
|
|
291
297
|
}
|
|
298
|
+
const messageUid = (message) => message.uid;
|
|
299
|
+
const conversationKey = (conversation) => conversation.conversationId;
|
|
292
300
|
/** Flattens and sorts a per-mailbox fetch into one merged, newest-first list - the aggregate ("All
|
|
293
301
|
* Inboxes" etc.) equivalent of `mergeSearchResults()` above, but simpler: an aggregated message has no
|
|
294
302
|
* natural relevance score to normalize, so this only ever sorts by `receivedDate`. */
|
|
@@ -309,13 +317,17 @@ function mergeInboxMessages(perMailbox) {
|
|
|
309
317
|
* composite-cursor "load more" per mailbox is a natural v2 if usage shows people scrolling past the first
|
|
310
318
|
* page in aggregate view often.
|
|
311
319
|
*/
|
|
312
|
-
async function fetchAggregateMessages(mailboxFolders, type) {
|
|
320
|
+
async function fetchAggregateMessages(mailboxFolders, type, filter) {
|
|
313
321
|
const perMailbox = await Promise.all(mailboxFolders.map(async ({ mailbox, folders }) => {
|
|
314
322
|
const folder = folders.find((f) => f.type === type);
|
|
315
323
|
if (!folder) {
|
|
316
324
|
return { mailbox, messages: [] };
|
|
317
325
|
}
|
|
318
|
-
|
|
326
|
+
// The filter is a server-side one per mailbox; the *sort* deliberately isn't offered here (see
|
|
327
|
+
// this function's own pagination scope trim) - each mailbox contributes its own newest page and
|
|
328
|
+
// they're merged newest-first, which a different sort key couldn't be made honest across an
|
|
329
|
+
// arbitrary number of independently-paged folders.
|
|
330
|
+
const messages = await listMessages(folder.uid, { limit: MESSAGE_PAGE_SIZE, filter }).catch(() => []);
|
|
319
331
|
return { mailbox, messages };
|
|
320
332
|
}));
|
|
321
333
|
return mergeInboxMessages(perMailbox);
|
|
@@ -324,10 +336,9 @@ export default function InboxPage(props) {
|
|
|
324
336
|
return (_jsx(MailShell, { ...props, children: _jsx(InboxContent, { userUid: props.userUid }) }));
|
|
325
337
|
}
|
|
326
338
|
function InboxContent({ userUid }) {
|
|
327
|
-
const { folderUid, mailboxUid, mailboxes, mailboxFolders, aggregateFolderType } = useMailShell();
|
|
339
|
+
const { folderUid, mailboxUid, mailboxes, mailboxFolders, aggregateFolderType, onFolderCreated } = useMailShell();
|
|
328
340
|
const isMobile = useIsMobile();
|
|
329
341
|
const { requestUnlock } = useUnlockPrompt();
|
|
330
|
-
const [viewMode, setViewMode] = useState("date");
|
|
331
342
|
const [messages, setMessages] = useState([]);
|
|
332
343
|
const [conversations, setConversations] = useState([]);
|
|
333
344
|
const [loading, setLoading] = useState(true);
|
|
@@ -335,12 +346,41 @@ function InboxContent({ userUid }) {
|
|
|
335
346
|
const [hasMore, setHasMore] = useState(false);
|
|
336
347
|
const [error, setError] = useState(null);
|
|
337
348
|
const [selectedUid, setSelectedUid] = useState(null);
|
|
338
|
-
|
|
339
|
-
|
|
349
|
+
// The thread the conversation list opened, and which of its messages was picked - the reading pane
|
|
350
|
+
// shows the whole conversation, positioned at that message (see `ConversationThreadPane`).
|
|
351
|
+
const [openThread, setOpenThread] = useState(null);
|
|
352
|
+
// Messages the page has a newer copy of than `ConversationList` fetched (so far only the one the reading
|
|
353
|
+
// pane just marked read), applied over its own child rows so they don't stay bold after being read.
|
|
354
|
+
const [conversationPatches, setConversationPatches] = useState({});
|
|
355
|
+
// Folders this session created on demand for a bulk Delete/Report junk - see `resolveFolderOfType()`.
|
|
356
|
+
const lazyFoldersRef = useRef(new Map());
|
|
357
|
+
const [selectMode, setSelectMode] = useState(false);
|
|
358
|
+
const [selectedUids, setSelectedUids] = useState(new Set());
|
|
359
|
+
// Select mode over the *conversation* list ticks whole conversations rather than messages: the rows
|
|
360
|
+
// there are conversations, and a bulk action on one means "every message of it that is in this folder".
|
|
361
|
+
// Their messages are fetched (once, cached here) as each conversation is ticked, so the selection bar
|
|
362
|
+
// and every bulk action below keep working on the `Message[]` they already take.
|
|
363
|
+
const [selectedConversationIds, setSelectedConversationIds] = useState(new Set());
|
|
364
|
+
const [conversationMessagesById, setConversationMessagesById] = useState({});
|
|
365
|
+
// A ticked conversation whose messages are still being fetched - every bulk action is held meanwhile,
|
|
366
|
+
// or it would act on a selection that is still arriving.
|
|
367
|
+
const [resolvingSelection, setResolvingSelection] = useState(0);
|
|
368
|
+
const [bulkBusy, setBulkBusy] = useState(false);
|
|
369
|
+
const [bulkError, setBulkError] = useState(null);
|
|
370
|
+
// Bumped to force the list effect below to re-run - a bulk update is deliberately neither atomic nor
|
|
371
|
+
// all-or-nothing (see `bulkUpdateMessages()`), so a rejection means refetching rather than guessing
|
|
372
|
+
// which half of the selection actually landed.
|
|
373
|
+
const [refreshKey, setRefreshKey] = useState(0);
|
|
340
374
|
const [searchInput, setSearchInput] = useState("");
|
|
341
375
|
const [searchQuery, setSearchQuery] = useState("");
|
|
342
376
|
const [snippets, setSnippets] = useState({});
|
|
343
|
-
|
|
377
|
+
// The *open mailbox's* labels, for the Filter menu's Labels submenu, select mode's Apply label, and -
|
|
378
|
+
// unless the selected message belongs to another mailbox - the reading pane's own Labels menu.
|
|
379
|
+
const [mailboxLabels, setMailboxLabels] = useState([]);
|
|
380
|
+
// The selected message's own mailbox's labels, when that is a different mailbox from the open one: in
|
|
381
|
+
// search and aggregate views it can be, and its labels must not be offered as a filter for this one.
|
|
382
|
+
// Empty (and never fetched) otherwise - see `labels` below.
|
|
383
|
+
const [otherMailboxLabels, setOtherMailboxLabels] = useState([]);
|
|
344
384
|
// Tier 2's own reported window coverage for the current search - undefined outside a search, or
|
|
345
385
|
// before Tier 2 has resolved yet for this search pass.
|
|
346
386
|
const [coverage, setCoverage] = useState(undefined);
|
|
@@ -372,10 +412,67 @@ function InboxContent({ userUid }) {
|
|
|
372
412
|
const [searchAllMailKey, setSearchAllMailKey] = useState(null);
|
|
373
413
|
const searchAllMailScope = `${mailboxUid ?? ""}\n${folderUid ?? ""}\n${searchQuery}`;
|
|
374
414
|
const searchAllMail = searchAllMailKey === searchAllMailScope;
|
|
415
|
+
// The mailbox unlock/decrypt call sites below treat as "the" mailbox when there's no single selected
|
|
416
|
+
// one (aggregate mode) - mirrors `MailShell`'s own identical `defaultMailboxUid` fallback. An
|
|
417
|
+
// aggregate-view row from a *different*, not-yet-unlocked mailbox stays locked until that mailbox's
|
|
418
|
+
// own folder view is opened directly - an accepted limitation, not a bug (see `MailShell`'s own doc
|
|
419
|
+
// comment on the same tradeoff for its `LocalIndexLifecycle`/`KeyEnrollmentGate` wiring).
|
|
420
|
+
const activeMailboxUid = mailboxUid ?? mailboxes.find((mb) => mb.ownerUserUid === userUid)?.uid ?? mailboxes[0]?.uid;
|
|
421
|
+
const mailboxKeys = mailboxes.find((mb) => mb.uid === activeMailboxUid)?.keys ?? [];
|
|
422
|
+
// The Sort/Filter menus' and "Show as conversations"' current settings, remembered per mailbox across
|
|
423
|
+
// reloads (`listPreferences.ts`). Read during render, not in an effect, so the very first listing
|
|
424
|
+
// already uses the remembered arrangement rather than fetching the default one and immediately
|
|
425
|
+
// refetching it - and kept per mailbox rather than as one value plus a "which mailbox is this?" check,
|
|
426
|
+
// so switching mailbox simply reads the other entry. What this session has changed wins over the store,
|
|
427
|
+
// which a storage-blocked browser refuses to keep.
|
|
428
|
+
const [preferencesByMailbox, setPreferencesByMailbox] = useState({});
|
|
429
|
+
const preferences = preferencesByMailbox[activeMailboxUid] ?? getMailListPreferences(activeMailboxUid);
|
|
430
|
+
function updatePreferences(patch) {
|
|
431
|
+
const next = { ...preferences, ...patch };
|
|
432
|
+
setPreferencesByMailbox((prev) => ({ ...prev, [activeMailboxUid]: next }));
|
|
433
|
+
setMailListPreferences(activeMailboxUid, next);
|
|
434
|
+
}
|
|
375
435
|
// Search stays real-folder-only - Tier 1/2/3 are all deeply mailbox/folder-scoped, and extending them
|
|
376
436
|
// to span an arbitrary number of mailboxes is out of scope for this pass (see the aggregate-fetch
|
|
377
437
|
// branch below, which the search effect never reaches while `folderUid` is unset).
|
|
378
|
-
const isSearching =
|
|
438
|
+
const isSearching = !preferences.showAsConversations && searchQuery.length > 0 && !aggregateFolderType;
|
|
439
|
+
// Focused/Other is an Inbox-only concept (`FocusedInboxUtils.classifyMessage()` short-circuits to
|
|
440
|
+
// Focused for every other folder), and
|
|
441
|
+
// search results are ranked across folders rather than listed from one, so neither tab is offered
|
|
442
|
+
// there. Not offered for an aggregate view either (each mailbox classifies independently; merging
|
|
443
|
+
// that is out of scope).
|
|
444
|
+
const currentFolders = mailboxFolders.find((mf) => mf.mailbox.uid === activeMailboxUid)?.folders ?? [];
|
|
445
|
+
const currentFolderIsInbox = currentFolders.find((f) => f.uid === folderUid)?.type === "inbox";
|
|
446
|
+
const offerClassificationFilters = currentFolderIsInbox && !isSearching && !aggregateFolderType;
|
|
447
|
+
// A remembered Focused/Other filter must not silently narrow a folder that has no Focused Inbox to
|
|
448
|
+
// speak of - it stays remembered for when the Inbox is opened again, but doesn't apply meanwhile.
|
|
449
|
+
const effectiveFilter = (preferences.filter === "focused" || preferences.filter === "other") && !offerClassificationFilters
|
|
450
|
+
? "all"
|
|
451
|
+
: preferences.filter;
|
|
452
|
+
// Left out entirely rather than sent empty, so a list with no label filter asks for exactly the URL it
|
|
453
|
+
// always did.
|
|
454
|
+
const labelFilter = preferences.labelUids.length > 0 ? { labelUids: preferences.labelUids } : {};
|
|
455
|
+
// Every server-side list parameter the toolbar controls, in one place so the first page and each
|
|
456
|
+
// `loadMore()` page can't drift apart.
|
|
457
|
+
const listParams = {
|
|
458
|
+
limit: MESSAGE_PAGE_SIZE,
|
|
459
|
+
sortBy: preferences.sortBy,
|
|
460
|
+
sortOrder: preferences.sortOrder,
|
|
461
|
+
filter: effectiveFilter,
|
|
462
|
+
...labelFilter,
|
|
463
|
+
};
|
|
464
|
+
// A dependency of the list effect and of `loadMore()`, which can't take the array itself (a new one
|
|
465
|
+
// every render would refetch on every render).
|
|
466
|
+
const labelFilterKey = preferences.labelUids.join(",");
|
|
467
|
+
/** The sort the *server* is being asked for, as one dependency value. Empty while conversations are
|
|
468
|
+
* shown: `GET /mail/messages/conversations` takes no sort parameters at all, so those rows are ordered
|
|
469
|
+
* in the browser (`sortConversations()`) and rearranging them must not refetch the identical page -
|
|
470
|
+
* which would also collapse whichever conversations the reader had expanded. */
|
|
471
|
+
const serverSortKey = preferences.showAsConversations ? "" : `${preferences.sortBy}:${preferences.sortOrder}`;
|
|
472
|
+
/** The conversation list's own equivalent of `listParams` - a function because the page differs. */
|
|
473
|
+
function conversationParams(page) {
|
|
474
|
+
return { folderUid, filter: effectiveFilter, ...labelFilter, page, limit: MESSAGE_PAGE_SIZE };
|
|
475
|
+
}
|
|
379
476
|
// How far into the folder's *current* server-side listing the rows fetched so far reach. Offset
|
|
380
477
|
// paging, not a cursor (`listMessages()` has none): a row removed locally (archived, scheduled send
|
|
381
478
|
// cancelled) also left the folder server-side, shifting every later message back by one - so each
|
|
@@ -401,13 +498,8 @@ function InboxContent({ userUid }) {
|
|
|
401
498
|
const [loadMoreStalled, setLoadMoreStalled] = useState(false);
|
|
402
499
|
const messagesRef = useRef(messages);
|
|
403
500
|
messagesRef.current = messages;
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
// aggregate-view row from a *different*, not-yet-unlocked mailbox stays locked until that mailbox's
|
|
407
|
-
// own folder view is opened directly - an accepted limitation, not a bug (see `MailShell`'s own doc
|
|
408
|
-
// comment on the same tradeoff for its `LocalIndexLifecycle`/`KeyEnrollmentGate` wiring).
|
|
409
|
-
const activeMailboxUid = mailboxUid ?? mailboxes.find((mb) => mb.ownerUserUid === userUid)?.uid ?? mailboxes[0]?.uid;
|
|
410
|
-
const mailboxKeys = mailboxes.find((mb) => mb.uid === activeMailboxUid)?.keys ?? [];
|
|
501
|
+
const conversationsRef = useRef(conversations);
|
|
502
|
+
conversationsRef.current = conversations;
|
|
411
503
|
// Reset to a fresh Map at the start of every new search pass (see the search effect below) - see
|
|
412
504
|
// resolveHitsToMessages()'s own doc comment on why this needs to persist *within* one pass but not
|
|
413
505
|
// across passes (a stale `null` for a uid that's since become resolvable elsewhere must not stick).
|
|
@@ -512,35 +604,61 @@ function InboxContent({ userUid }) {
|
|
|
512
604
|
// `activeMailboxUid`, and offering that mailbox's labels would let a label from the wrong mailbox be
|
|
513
605
|
// applied. The conversation view stays scoped to `activeMailboxUid`, the mailbox its threads come from.
|
|
514
606
|
const selectedMessageMailboxUid = messages.find((m) => m.uid === selectedUid)?.mailboxUid;
|
|
515
|
-
const labelsMailboxUid =
|
|
607
|
+
const labelsMailboxUid = preferences.showAsConversations ? activeMailboxUid : (selectedMessageMailboxUid ?? activeMailboxUid);
|
|
608
|
+
// Almost always the open mailbox's own labels, already fetched below - so `labels` reuses that list
|
|
609
|
+
// rather than asking for the identical one a second time (which is what this page did on every single
|
|
610
|
+
// view). Only a selected message from *another* mailbox - a search hit or an aggregate-view row - needs
|
|
611
|
+
// its own fetch, and only then is this state used at all.
|
|
612
|
+
const otherMailbox = labelsMailboxUid !== activeMailboxUid;
|
|
613
|
+
const labels = otherMailbox ? otherMailboxLabels : mailboxLabels;
|
|
516
614
|
// `labelsMailboxUid` is always set: `MailShell` only renders this component once at least one mailbox
|
|
517
615
|
// exists, so `activeMailboxUid` (its fallback) always resolves.
|
|
518
616
|
useEffect(() => {
|
|
617
|
+
if (!otherMailbox) {
|
|
618
|
+
setOtherMailboxLabels([]);
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
519
621
|
let cancelled = false;
|
|
520
|
-
|
|
622
|
+
setOtherMailboxLabels([]);
|
|
521
623
|
listLabels(labelsMailboxUid, { limit: 200 })
|
|
522
624
|
.then((result) => {
|
|
523
625
|
if (!cancelled) {
|
|
524
|
-
|
|
626
|
+
setOtherMailboxLabels(result);
|
|
627
|
+
}
|
|
628
|
+
})
|
|
629
|
+
.catch(() => undefined);
|
|
630
|
+
return () => {
|
|
631
|
+
cancelled = true;
|
|
632
|
+
};
|
|
633
|
+
}, [labelsMailboxUid, otherMailbox]);
|
|
634
|
+
useEffect(() => {
|
|
635
|
+
let cancelled = false;
|
|
636
|
+
setMailboxLabels([]);
|
|
637
|
+
listLabels(activeMailboxUid, { limit: 200 })
|
|
638
|
+
.then((result) => {
|
|
639
|
+
if (!cancelled) {
|
|
640
|
+
setMailboxLabels(result);
|
|
525
641
|
}
|
|
526
642
|
})
|
|
527
643
|
.catch(() => undefined);
|
|
528
644
|
return () => {
|
|
529
645
|
cancelled = true;
|
|
530
646
|
};
|
|
531
|
-
}, [
|
|
647
|
+
}, [activeMailboxUid]);
|
|
532
648
|
// Debounce the raw input into the query actually searched, so every keystroke doesn't fire a request.
|
|
533
649
|
useEffect(() => {
|
|
534
650
|
const handle = setTimeout(() => setSearchQuery(searchInput.trim()), SEARCH_DEBOUNCE_MS);
|
|
535
651
|
return () => clearTimeout(handle);
|
|
536
652
|
}, [searchInput]);
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
// folder-tree sidebar's own selection becomes purely informational until switching back to "By date".
|
|
653
|
+
// Loads whichever list the current folder, arrangement and search state call for, and resets every
|
|
654
|
+
// piece of per-listing state (selection, paging, select mode) that a previous listing left behind.
|
|
540
655
|
useEffect(() => {
|
|
541
656
|
setSelectedUid(null);
|
|
542
|
-
|
|
543
|
-
|
|
657
|
+
setOpenThread(null);
|
|
658
|
+
setConversationPatches({});
|
|
659
|
+
setSelectedUids(new Set());
|
|
660
|
+
setSelectedConversationIds(new Set());
|
|
661
|
+
setConversationMessagesById({});
|
|
544
662
|
listedOffsetRef.current = 0;
|
|
545
663
|
compositeCursorRef.current = undefined;
|
|
546
664
|
setHasMore(false);
|
|
@@ -558,16 +676,38 @@ function InboxContent({ userUid }) {
|
|
|
558
676
|
const invalidate = () => {
|
|
559
677
|
searchRunIdRef.current += 1;
|
|
560
678
|
};
|
|
561
|
-
if (
|
|
679
|
+
if (!folderUid && !aggregateFolderType) {
|
|
680
|
+
// The shell hasn't resolved this mailbox's folders yet (the render below says so). Listing
|
|
681
|
+
// anything now means a request whose answer is thrown away the moment the Inbox arrives - and
|
|
682
|
+
// for conversations a mailbox-wide grouping pass, the most expensive listing there is.
|
|
683
|
+
setMessages([]);
|
|
684
|
+
setConversations([]);
|
|
685
|
+
setLoading(false);
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
if (aggregateFolderType && mailboxFolders.length === 0) {
|
|
689
|
+
// The same, for a merged view: it has no folder of its own, but it is built from every
|
|
690
|
+
// mailbox's folders, and this effect re-runs the moment they arrive. Listing twice is what
|
|
691
|
+
// that cost before.
|
|
692
|
+
setMessages([]);
|
|
693
|
+
setConversations([]);
|
|
694
|
+
setLoading(true);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
if (preferences.showAsConversations) {
|
|
562
698
|
// Conversations stay single-mailbox (not aggregated across mailboxes in this pass) - in
|
|
563
699
|
// aggregate mode this falls back to `activeMailboxUid`, the same mailbox unlock/labels use
|
|
564
700
|
// (always set - `MailShell` only renders this component once at least one mailbox exists).
|
|
701
|
+
// They're scoped to the selected folder (`folderUid`, absent only in aggregate mode), so the
|
|
702
|
+
// conversation list matches the folder the sidebar has selected rather than the whole mailbox.
|
|
565
703
|
setLoading(true);
|
|
566
704
|
setError(null);
|
|
567
|
-
listConversations(activeMailboxUid)
|
|
705
|
+
listConversations(activeMailboxUid, conversationParams(0))
|
|
568
706
|
.then((result) => {
|
|
569
707
|
if (isCurrentRun()) {
|
|
570
708
|
setConversations(result);
|
|
709
|
+
listedOffsetRef.current = result.length;
|
|
710
|
+
setHasMore(result.length === MESSAGE_PAGE_SIZE);
|
|
571
711
|
}
|
|
572
712
|
})
|
|
573
713
|
.catch((err) => {
|
|
@@ -586,7 +726,7 @@ function InboxContent({ userUid }) {
|
|
|
586
726
|
setLoading(true);
|
|
587
727
|
setError(null);
|
|
588
728
|
// hasMore stays false (set above) - see fetchAggregateMessages()'s own pagination scope trim.
|
|
589
|
-
void fetchAggregateMessages(mailboxFolders, aggregateFolderType)
|
|
729
|
+
void fetchAggregateMessages(mailboxFolders, aggregateFolderType, effectiveFilter)
|
|
590
730
|
.then((results) => {
|
|
591
731
|
if (isCurrentRun()) {
|
|
592
732
|
setMessages(results);
|
|
@@ -599,11 +739,6 @@ function InboxContent({ userUid }) {
|
|
|
599
739
|
});
|
|
600
740
|
return invalidate;
|
|
601
741
|
}
|
|
602
|
-
if (!folderUid) {
|
|
603
|
-
setMessages([]);
|
|
604
|
-
setLoading(false);
|
|
605
|
-
return;
|
|
606
|
-
}
|
|
607
742
|
setLoading(true);
|
|
608
743
|
setError(null);
|
|
609
744
|
if (isSearching) {
|
|
@@ -690,7 +825,9 @@ function InboxContent({ userUid }) {
|
|
|
690
825
|
})();
|
|
691
826
|
return invalidate;
|
|
692
827
|
}
|
|
693
|
-
|
|
828
|
+
// Set: the guard at the top of this effect returned for a view with neither a folder nor an
|
|
829
|
+
// aggregate type, and the aggregate branch above returned for the one with an aggregate type.
|
|
830
|
+
listMessages(folderUid, listParams)
|
|
694
831
|
.then((results) => {
|
|
695
832
|
if (isCurrentRun()) {
|
|
696
833
|
setMessages(results);
|
|
@@ -715,7 +852,24 @@ function InboxContent({ userUid }) {
|
|
|
715
852
|
// user unlocks, and what makes "Search all mail" actually remove Tier 3's coverage bound. Neither
|
|
716
853
|
// has any effect on the non-search branch below; re-running it with identical inputs just
|
|
717
854
|
// re-fetches the same page.
|
|
718
|
-
|
|
855
|
+
// `refreshKey` is a dependency for the same reason: a failed bulk action bumps it to refetch,
|
|
856
|
+
// since a bulk update applies until its first rejection rather than all-or-nothing.
|
|
857
|
+
}, [
|
|
858
|
+
preferences.showAsConversations,
|
|
859
|
+
serverSortKey,
|
|
860
|
+
effectiveFilter,
|
|
861
|
+
labelFilterKey,
|
|
862
|
+
refreshKey,
|
|
863
|
+
folderUid,
|
|
864
|
+
mailboxUid,
|
|
865
|
+
isSearching,
|
|
866
|
+
searchQuery,
|
|
867
|
+
unlockRefresh,
|
|
868
|
+
searchAllMail,
|
|
869
|
+
aggregateFolderType,
|
|
870
|
+
mailboxFolders,
|
|
871
|
+
activeMailboxUid,
|
|
872
|
+
]);
|
|
719
873
|
function handleSearchAllMail() {
|
|
720
874
|
setSearchAllMailKey(searchAllMailScope);
|
|
721
875
|
}
|
|
@@ -729,8 +883,7 @@ function InboxContent({ userUid }) {
|
|
|
729
883
|
if (loadMoreInFlightRef.current ||
|
|
730
884
|
!hasMore ||
|
|
731
885
|
loading ||
|
|
732
|
-
|
|
733
|
-
!folderUid ||
|
|
886
|
+
(!preferences.showAsConversations && !folderUid) ||
|
|
734
887
|
(isSearching && !searchCursor)) {
|
|
735
888
|
return;
|
|
736
889
|
}
|
|
@@ -762,7 +915,19 @@ function InboxContent({ userUid }) {
|
|
|
762
915
|
const myRunId = searchRunIdRef.current;
|
|
763
916
|
const isCurrentRun = () => searchRunIdRef.current === myRunId;
|
|
764
917
|
try {
|
|
765
|
-
if (
|
|
918
|
+
if (preferences.showAsConversations) {
|
|
919
|
+
const page = Math.floor(listedOffsetRef.current / MESSAGE_PAGE_SIZE);
|
|
920
|
+
const more = await listConversations(activeMailboxUid, conversationParams(page));
|
|
921
|
+
if (!isCurrentRun()) {
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
const addedRows = hasUnseenRows(conversationsRef.current, more, conversationKey);
|
|
925
|
+
setConversations((prev) => appendUnseenRows(prev, more, conversationKey));
|
|
926
|
+
setHasMore(more.length === MESSAGE_PAGE_SIZE);
|
|
927
|
+
listedOffsetRef.current = page * MESSAGE_PAGE_SIZE + more.length;
|
|
928
|
+
notePageLanded(addedRows, more.length === MESSAGE_PAGE_SIZE);
|
|
929
|
+
}
|
|
930
|
+
else if (isSearching) {
|
|
766
931
|
const unlocked = getUnlockedKeys(mailboxUid);
|
|
767
932
|
const cursor = searchCursor;
|
|
768
933
|
const [tier1Page, tier2Page] = await Promise.all([
|
|
@@ -784,8 +949,8 @@ function InboxContent({ userUid }) {
|
|
|
784
949
|
if (!isCurrentRun()) {
|
|
785
950
|
return;
|
|
786
951
|
}
|
|
787
|
-
const addedRows =
|
|
788
|
-
setMessages((prev) =>
|
|
952
|
+
const addedRows = hasUnseenRows(messagesRef.current, more, messageUid);
|
|
953
|
+
setMessages((prev) => appendUnseenRows(prev, more, messageUid));
|
|
789
954
|
setSnippets((prev) => ({ ...prev, ...moreSnippets }));
|
|
790
955
|
const nextTier3Offset = tier3Offset + tier3Page.length;
|
|
791
956
|
compositeCursorRef.current = {
|
|
@@ -801,15 +966,15 @@ function InboxContent({ userUid }) {
|
|
|
801
966
|
}
|
|
802
967
|
else {
|
|
803
968
|
// The page containing the first message not fetched yet. After a local removal that offset is
|
|
804
|
-
// no longer a page boundary, so this page overlaps rows already shown - `
|
|
969
|
+
// no longer a page boundary, so this page overlaps rows already shown - `appendUnseenRows()`
|
|
805
970
|
// drops those - rather than skipping the message that shifted back across the boundary.
|
|
806
971
|
const page = Math.floor(listedOffsetRef.current / MESSAGE_PAGE_SIZE);
|
|
807
|
-
const more = await listMessages(folderUid, {
|
|
972
|
+
const more = await listMessages(folderUid, { ...listParams, page });
|
|
808
973
|
if (!isCurrentRun()) {
|
|
809
974
|
return;
|
|
810
975
|
}
|
|
811
|
-
const addedRows =
|
|
812
|
-
setMessages((prev) =>
|
|
976
|
+
const addedRows = hasUnseenRows(messagesRef.current, more, messageUid);
|
|
977
|
+
setMessages((prev) => appendUnseenRows(prev, more, messageUid));
|
|
813
978
|
setHasMore(more.length === MESSAGE_PAGE_SIZE);
|
|
814
979
|
listedOffsetRef.current = page * MESSAGE_PAGE_SIZE + more.length;
|
|
815
980
|
notePageLanded(addedRows, more.length === MESSAGE_PAGE_SIZE);
|
|
@@ -826,7 +991,19 @@ function InboxContent({ userUid }) {
|
|
|
826
991
|
loadMoreInFlightRef.current = false;
|
|
827
992
|
setLoadingMore(false);
|
|
828
993
|
}
|
|
829
|
-
}, [
|
|
994
|
+
}, [
|
|
995
|
+
hasMore,
|
|
996
|
+
loading,
|
|
997
|
+
preferences.showAsConversations,
|
|
998
|
+
serverSortKey,
|
|
999
|
+
effectiveFilter,
|
|
1000
|
+
labelFilterKey,
|
|
1001
|
+
folderUid,
|
|
1002
|
+
isSearching,
|
|
1003
|
+
searchQuery,
|
|
1004
|
+
mailboxUid,
|
|
1005
|
+
activeMailboxUid,
|
|
1006
|
+
]);
|
|
830
1007
|
// Always calls the latest `loadMore` closure so the effect below doesn't need `loadMore` itself in its
|
|
831
1008
|
// dependency array (it changes on every keystroke/page load, which would otherwise mean nothing here).
|
|
832
1009
|
const loadMoreRef = useRef(loadMore);
|
|
@@ -860,57 +1037,261 @@ function InboxContent({ userUid }) {
|
|
|
860
1037
|
void loadMoreRef.current();
|
|
861
1038
|
}
|
|
862
1039
|
}, [appendedPageCount]);
|
|
1040
|
+
/**
|
|
1041
|
+
* Replaces one listed row - and, in the conversation list, the opened message and the child row
|
|
1042
|
+
* standing for it - with a newer copy the reading pane just produced.
|
|
1043
|
+
*
|
|
1044
|
+
* A conversation's *parent* row has no copy to replace: it is a summary of the whole thread, and its
|
|
1045
|
+
* "2 unread" chip and bold styling come from a count the server worked out when the list was fetched.
|
|
1046
|
+
* So when the reading pane reports a message it has just read (`previous` unread, `updated` read), that
|
|
1047
|
+
* count is decremented here - otherwise a conversation kept claiming unread mail the reader had just
|
|
1048
|
+
* read, until the whole list was reloaded.
|
|
1049
|
+
*/
|
|
1050
|
+
function patchListedMessage(updated, previous) {
|
|
1051
|
+
setMessages((prev) => prev.map((m) => (m.uid === updated.uid ? updated : m)));
|
|
1052
|
+
// `ConversationList` fetched its own copy of this message when the thread was expanded; hand it the
|
|
1053
|
+
// newer one so the child row doesn't keep showing a stale read/flag state.
|
|
1054
|
+
setConversationPatches((prev) => ({ ...prev, [updated.uid]: updated }));
|
|
1055
|
+
if (previous && !previous.flags.read && updated.flags.read) {
|
|
1056
|
+
setConversations((prev) => prev.map((conversation) => conversation.messageUids.includes(updated.uid)
|
|
1057
|
+
? { ...conversation, unreadCount: Math.max(0, conversation.unreadCount - 1) }
|
|
1058
|
+
: conversation));
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
function removeListedMessages(uids) {
|
|
1062
|
+
setMessages((prev) => prev.filter((m) => !uids.has(m.uid)));
|
|
1063
|
+
listedOffsetRef.current = Math.max(0, listedOffsetRef.current - uids.size);
|
|
1064
|
+
setSelectedUid((prev) => (prev && uids.has(prev) ? null : prev));
|
|
1065
|
+
}
|
|
863
1066
|
function removeListedMessage(uid) {
|
|
864
|
-
|
|
865
|
-
listedOffsetRef.current = Math.max(0, listedOffsetRef.current - 1);
|
|
866
|
-
setSelectedUid(null);
|
|
1067
|
+
removeListedMessages(new Set([uid]));
|
|
867
1068
|
}
|
|
868
|
-
|
|
869
|
-
|
|
1069
|
+
// The conversation list opens a whole thread in `ConversationThreadPane`, which loads and marks read
|
|
1070
|
+
// its own messages; only the flat list feeds the single-message pane below.
|
|
1071
|
+
const selected = preferences.showAsConversations ? null : (messages.find((m) => m.uid === selectedUid) ?? null);
|
|
870
1072
|
const attachments = useMessageAttachments(selected);
|
|
871
|
-
useMarkMessageRead(selected,
|
|
1073
|
+
useMarkMessageRead(selected, patchListedMessage);
|
|
872
1074
|
// Search results can span every folder in the mailbox, not just the one selected in the sidebar - a
|
|
873
1075
|
// selected message's own folderUid is the only reliable source for its actual folder type once
|
|
874
1076
|
// searching (outside search, every message in `messages` already comes from `folderUid` itself, so
|
|
875
1077
|
// this falls back to the sidebar selection unchanged). Aggregate views span every *mailbox* too, so
|
|
876
|
-
// the folder list consulted is the selected message's own mailbox's, not the shell's ambient one.
|
|
877
|
-
|
|
1078
|
+
// the folder list consulted is the selected message's own mailbox's, not the shell's ambient one. A
|
|
1079
|
+
// conversation spans folders for the same reason (an Inbox message and the Sent Items copy of its reply).
|
|
1080
|
+
const spansFolders = isSearching || !!aggregateFolderType || preferences.showAsConversations;
|
|
878
1081
|
const selectedFolderUid = spansFolders ? (selected?.folderUid ?? folderUid) : folderUid;
|
|
879
1082
|
const selectedMailboxUid = spansFolders ? (selected?.mailboxUid ?? activeMailboxUid) : mailboxUid;
|
|
880
1083
|
const folders = mailboxFolders.find((mf) => mf.mailbox.uid === selectedMailboxUid)?.folders ?? [];
|
|
881
1084
|
const isSentItems = folders.find((f) => f.uid === selectedFolderUid)?.type === "sent_items";
|
|
882
1085
|
const isOutbox = folders.find((f) => f.uid === selectedFolderUid)?.type === "outbox";
|
|
883
|
-
const isInbox = folders.find((f) => f.uid === selectedFolderUid)?.type === "inbox";
|
|
884
1086
|
const draftsFolderUid = folders.find((f) => f.type === "drafts")?.uid;
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
1087
|
+
/** What a row actually shows as its subject - the decrypted one where this device recovered it, a
|
|
1088
|
+
* readable stand-in for an encrypted one it hasn't, and a placeholder for a message with no subject. */
|
|
1089
|
+
function rowSubject(message) {
|
|
1090
|
+
return (decryptedRows[message.uid]?.subject ||
|
|
1091
|
+
(message.subject === ENCRYPTED_SUBJECT_PLACEHOLDER ? "Encrypted message" : message.subject) ||
|
|
1092
|
+
"(no subject)");
|
|
1093
|
+
}
|
|
1094
|
+
/** The conversation list's selection as plain messages: every message of every ticked conversation
|
|
1095
|
+
* that is in the folder being listed. A conversation spans folders (an Inbox message and the Sent
|
|
1096
|
+
* Items copy of its reply), and the list only ever showed this folder's half of it, so a bulk action
|
|
1097
|
+
* from here must not reach into the other folders' copies either. */
|
|
1098
|
+
const selectedConversationMessages = [...selectedConversationIds].flatMap((id) => (conversationMessagesById[id] ?? []).filter((m) => !folderUid || m.folderUid === folderUid));
|
|
1099
|
+
const selectedMessages = preferences.showAsConversations
|
|
1100
|
+
? selectedConversationMessages
|
|
1101
|
+
: messages.filter((m) => selectedUids.has(m.uid));
|
|
1102
|
+
/** How many rows the list is actually showing - conversations or messages, whichever it lists. What the
|
|
1103
|
+
* Select toggle is enabled by: there is nothing to select in a list with no rows. */
|
|
1104
|
+
const listedRowCount = preferences.showAsConversations ? conversations.length : messages.length;
|
|
1105
|
+
/** The conversation rows in the order the reader arranged them. The endpoint takes no sort parameters
|
|
1106
|
+
* of its own (it pages by latest activity), so the arrangement is applied here, to the rows fetched so
|
|
1107
|
+
* far - which the Sort menu says on screen. `conversations` itself stays in the order the pages
|
|
1108
|
+
* arrived, so paging keeps appending to the same accumulated set. */
|
|
1109
|
+
const listedConversations = sortConversations(conversations, preferences.sortBy, preferences.sortOrder);
|
|
1110
|
+
function leaveSelectMode() {
|
|
1111
|
+
setSelectMode(false);
|
|
1112
|
+
setSelectedUids(new Set());
|
|
1113
|
+
setSelectedConversationIds(new Set());
|
|
1114
|
+
setBulkError(null);
|
|
1115
|
+
}
|
|
1116
|
+
function toggleSelected(uid) {
|
|
1117
|
+
setSelectedUids((prev) => {
|
|
1118
|
+
const next = new Set(prev);
|
|
1119
|
+
if (next.has(uid)) {
|
|
1120
|
+
next.delete(uid);
|
|
1121
|
+
}
|
|
1122
|
+
else {
|
|
1123
|
+
next.add(uid);
|
|
1124
|
+
}
|
|
1125
|
+
return next;
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
/** Loads (once) the messages behind each of `ids`, so a ticked conversation resolves to the messages
|
|
1129
|
+
* every bulk action below acts on. If any of them fails to load, none of that batch stays ticked and
|
|
1130
|
+
* the bar says why - better than acting on the part of a selection that happened to arrive. */
|
|
1131
|
+
async function resolveConversations(ids) {
|
|
1132
|
+
const missing = ids.filter((id) => !conversationMessagesById[id]);
|
|
1133
|
+
if (missing.length === 0) {
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
setResolvingSelection((n) => n + 1);
|
|
1137
|
+
try {
|
|
1138
|
+
const loaded = await Promise.all(missing.map(async (id) => [id, await listConversationMessages(activeMailboxUid, id)]));
|
|
1139
|
+
setConversationMessagesById((prev) => ({ ...prev, ...Object.fromEntries(loaded) }));
|
|
1140
|
+
}
|
|
1141
|
+
catch (err) {
|
|
1142
|
+
setBulkError(err instanceof ApiRequestError ? err.message : "Could not load the messages in one of those conversations.");
|
|
1143
|
+
setSelectedConversationIds((prev) => {
|
|
1144
|
+
const next = new Set(prev);
|
|
1145
|
+
for (const id of missing) {
|
|
1146
|
+
next.delete(id);
|
|
1147
|
+
}
|
|
1148
|
+
return next;
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
finally {
|
|
1152
|
+
setResolvingSelection((n) => n - 1);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
function toggleConversationSelected(conversation) {
|
|
1156
|
+
const id = conversation.conversationId;
|
|
1157
|
+
const ticking = !selectedConversationIds.has(id);
|
|
1158
|
+
setSelectedConversationIds((prev) => {
|
|
1159
|
+
const next = new Set(prev);
|
|
1160
|
+
if (ticking) {
|
|
1161
|
+
next.add(id);
|
|
1162
|
+
}
|
|
1163
|
+
else {
|
|
1164
|
+
next.delete(id);
|
|
1165
|
+
}
|
|
1166
|
+
return next;
|
|
1167
|
+
});
|
|
1168
|
+
if (ticking) {
|
|
1169
|
+
void resolveConversations([id]);
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
function selectAllConversations() {
|
|
1173
|
+
const ids = conversations.map(conversationKey);
|
|
1174
|
+
setSelectedConversationIds(new Set(ids));
|
|
1175
|
+
void resolveConversations(ids);
|
|
1176
|
+
}
|
|
1177
|
+
/**
|
|
1178
|
+
* Runs one bulk action over the current selection, then either patches the affected rows in place or
|
|
1179
|
+
* drops them (a move takes them out of the folder being listed).
|
|
1180
|
+
*
|
|
1181
|
+
* A bulk update is applied element by element server-side and stops at its first rejection, so a
|
|
1182
|
+
* failure leaves an unknown prefix of the selection already changed (see `bulkUpdateMessages()`) -
|
|
1183
|
+
* which is why a failure reloads the list rather than trying to reconcile it, and says so.
|
|
1184
|
+
*/
|
|
1185
|
+
async function runBulkAction(action, removesRows) {
|
|
1186
|
+
const chosen = selectedMessages;
|
|
1187
|
+
setBulkBusy(true);
|
|
1188
|
+
setBulkError(null);
|
|
1189
|
+
try {
|
|
1190
|
+
const updated = await action(chosen);
|
|
1191
|
+
if (preferences.showAsConversations) {
|
|
1192
|
+
// A conversation row is a summary of its messages - its count, unread count, participants
|
|
1193
|
+
// and preview all move when a bulk action changes or empties part of it - so the list is
|
|
1194
|
+
// reloaded rather than patched row by row.
|
|
1195
|
+
setSelectedConversationIds(new Set());
|
|
1196
|
+
setConversationMessagesById({});
|
|
1197
|
+
setRefreshKey((n) => n + 1);
|
|
1198
|
+
}
|
|
1199
|
+
else if (removesRows) {
|
|
1200
|
+
removeListedMessages(new Set(chosen.map((m) => m.uid)));
|
|
1201
|
+
}
|
|
1202
|
+
else {
|
|
1203
|
+
const byUid = new Map(updated.map((m) => [m.uid, m]));
|
|
1204
|
+
setMessages((prev) => prev.map((m) => byUid.get(m.uid) ?? m));
|
|
1205
|
+
}
|
|
1206
|
+
setSelectedUids(new Set());
|
|
1207
|
+
}
|
|
1208
|
+
catch (err) {
|
|
1209
|
+
setBulkError(`${err instanceof ApiRequestError ? err.message : "Those messages couldn't all be updated."} Some of them may already have changed, so the list has been reloaded.`);
|
|
1210
|
+
setSelectedUids(new Set());
|
|
1211
|
+
setSelectedConversationIds(new Set());
|
|
1212
|
+
setConversationMessagesById({});
|
|
1213
|
+
setRefreshKey((n) => n + 1);
|
|
1214
|
+
}
|
|
1215
|
+
finally {
|
|
1216
|
+
setBulkBusy(false);
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
/** Archive has no folder to move into until the mailbox has one: the server creates it lazily on the
|
|
1220
|
+
* first single-message archive, so that call both creates the folder and archives the first message,
|
|
1221
|
+
* and the rest of the selection is then moved into the folder it reports. */
|
|
1222
|
+
async function bulkArchive(chosen) {
|
|
1223
|
+
const archiveFolderUid = currentFolders.find((f) => f.type === "archive")?.uid;
|
|
1224
|
+
if (archiveFolderUid) {
|
|
1225
|
+
return moveMessages(chosen, archiveFolderUid);
|
|
1226
|
+
}
|
|
1227
|
+
const first = await archiveMessage(chosen[0].uid);
|
|
1228
|
+
const rest = chosen.slice(1);
|
|
1229
|
+
return rest.length === 0 ? [first] : [first, ...(await moveMessages(rest, first.folderUid))];
|
|
1230
|
+
}
|
|
1231
|
+
/**
|
|
1232
|
+
* This mailbox's folder of `type`, created on demand. A mailbox is provisioned with only the folders it
|
|
1233
|
+
* has needed so far, so Deleted Items and Junk may genuinely not exist the first time a selection is
|
|
1234
|
+
* deleted or reported - and unlike Archive, neither has a server-side lazy-create route to go through.
|
|
1235
|
+
*
|
|
1236
|
+
* A folder created here isn't in `mailboxFolders` (the shell fetched that once), so it's remembered per
|
|
1237
|
+
* mailbox and type until the page reloads; otherwise a second Delete would create a second folder.
|
|
1238
|
+
*/
|
|
1239
|
+
async function resolveFolderOfType(type, name) {
|
|
1240
|
+
const key = `${activeMailboxUid}:${type}`;
|
|
1241
|
+
const known = currentFolders.find((f) => f.type === type)?.uid ?? lazyFoldersRef.current.get(key);
|
|
1242
|
+
if (known) {
|
|
1243
|
+
return known;
|
|
1244
|
+
}
|
|
1245
|
+
const created = await createFolder({ mailboxUid: activeMailboxUid, name, type });
|
|
1246
|
+
lazyFoldersRef.current.set(key, created.uid);
|
|
1247
|
+
return created.uid;
|
|
1248
|
+
}
|
|
1249
|
+
/**
|
|
1250
|
+
* Sets every selected message's labels in one bulk update: each ends up with `labelUids`, plus the
|
|
1251
|
+
* ones left partially applied (`keepPartial`) that it already had, plus any label this mailbox no
|
|
1252
|
+
* longer defines - a label the menu couldn't show isn't one the reader chose to remove.
|
|
1253
|
+
*
|
|
1254
|
+
* `bulkUpdateMessages()` rather than `setMessagesLabels()`, which is its one-list-for-everyone special
|
|
1255
|
+
* case: with a partially-applied row each message keeps a *different* list.
|
|
1256
|
+
*/
|
|
1257
|
+
function applyLabelsToSelection(labelUids, keepPartial) {
|
|
1258
|
+
void runBulkAction((chosen) => bulkUpdateMessages(chosen.map((message) => {
|
|
1259
|
+
const kept = (message.labelUids ?? []).filter((uid) => keepPartial.includes(uid) || !mailboxLabels.some((label) => label.uid === uid));
|
|
1260
|
+
return { uid: message.uid, version: message.version, labelUids: [...new Set([...labelUids, ...kept])] };
|
|
1261
|
+
})), false);
|
|
1262
|
+
}
|
|
1263
|
+
function moveSelectionToType(type, name) {
|
|
1264
|
+
void runBulkAction(async (chosen) => moveMessages(chosen, await resolveFolderOfType(type, name)), true);
|
|
1265
|
+
}
|
|
1266
|
+
const loadMoreStatus = loadingMore ? ("Loading more\u2026") : loadMoreError ? (_jsxs("span", { className: "inline-flex items-center gap-2", children: [_jsx("span", { role: "alert", className: "text-danger", children: loadMoreError }), _jsx("button", { type: "button", onClick: () => void loadMoreRef.current(), className: "text-primary-dark hover:underline font-medium", children: "Retry" })] })) : loadMoreStalled ? (_jsx("button", { type: "button", onClick: () => void loadMoreRef.current(), className: "text-primary-dark hover:underline font-medium", children: "Load more" })) : null;
|
|
895
1267
|
function handleSelect(message) {
|
|
1268
|
+
if (selectMode) {
|
|
1269
|
+
toggleSelected(message.uid);
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
896
1272
|
if (isMobile) {
|
|
897
1273
|
window.location.href = `/messages/${encodeURIComponent(message.uid)}`;
|
|
898
1274
|
return;
|
|
899
1275
|
}
|
|
900
1276
|
setSelectedUid(message.uid);
|
|
901
1277
|
}
|
|
902
|
-
|
|
1278
|
+
/** Opens a conversation in the reading pane, positioned at one of its messages: the one a child row
|
|
1279
|
+
* stands for, or the latest for a parent row. The thread pane loads the thread itself. */
|
|
1280
|
+
function handleOpenConversation(conversation, uid) {
|
|
1281
|
+
if (selectMode) {
|
|
1282
|
+
// Same rule as `handleSelect()` for a message row: while selecting, a row's own button ticks
|
|
1283
|
+
// the row rather than opening it.
|
|
1284
|
+
toggleConversationSelected(conversation);
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
903
1287
|
if (isMobile) {
|
|
904
|
-
// No dedicated mobile thread route yet
|
|
905
|
-
// handles any message uid regardless of conversation grouping
|
|
906
|
-
|
|
907
|
-
// `messageUids` always has at least one entry — a `ConversationSummary` only ever exists
|
|
908
|
-
// because it was grouped from real messages (see `BaseMessageRoute.conversations()`).
|
|
909
|
-
const latestUid = conversation.messageUids[conversation.messageUids.length - 1];
|
|
910
|
-
window.location.href = `/messages/${encodeURIComponent(latestUid)}`;
|
|
1288
|
+
// No dedicated mobile thread route yet - the existing single-message detail route already
|
|
1289
|
+
// handles any message uid regardless of conversation grouping.
|
|
1290
|
+
window.location.href = `/messages/${encodeURIComponent(uid)}`;
|
|
911
1291
|
return;
|
|
912
1292
|
}
|
|
913
|
-
|
|
1293
|
+
setSelectedUid(uid);
|
|
1294
|
+
setOpenThread({ conversation, uid });
|
|
914
1295
|
}
|
|
915
1296
|
if (!folderUid && !aggregateFolderType) {
|
|
916
1297
|
// `MailShell` never renders this component at all until a mailbox is resolved (see its own
|
|
@@ -920,42 +1301,55 @@ function InboxContent({ userUid }) {
|
|
|
920
1301
|
// indistinguishable to anything (a test, a user re-reading the screen) that catches this one.
|
|
921
1302
|
return _jsx("p", { className: "p-8 text-sm text-text-muted", children: "Loading your mailbox\u2026" });
|
|
922
1303
|
}
|
|
923
|
-
return (_jsxs("div", { className: "flex h-full min-h-0", children: [_jsxs("div", { ref: scrollContainerRef, className: "w-full md:w-96 shrink-0 md:border-r border-border overflow-y-auto", children: [
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
1304
|
+
return (_jsxs("div", { className: "flex h-full min-h-0", children: [_jsxs("div", { ref: scrollContainerRef, className: "w-full md:w-96 shrink-0 md:border-r border-border overflow-y-auto", children: [selectMode ? (_jsx(MailSelectionBar, { selected: selectedMessages, listed: messages, totals: preferences.showAsConversations
|
|
1305
|
+
? { selected: selectedConversationIds.size, listed: conversations.length, noun: "conversation" }
|
|
1306
|
+
: undefined, onSelectAll: () => preferences.showAsConversations
|
|
1307
|
+
? selectAllConversations()
|
|
1308
|
+
: setSelectedUids(new Set(messages.map((m) => m.uid))), onClearSelection: () => preferences.showAsConversations ? setSelectedConversationIds(new Set()) : setSelectedUids(new Set()), onCancel: leaveSelectMode, folders: currentFolders, currentFolderUid: folderUid, labels: mailboxLabels, mailboxUid: activeMailboxUid, onLabelCreated: (label) => setMailboxLabels((prev) => [...prev, label]), onFolderCreated: onFolderCreated, onApplyLabels: applyLabelsToSelection, onSetRead: (read) => void runBulkAction((chosen) => setMessagesRead(chosen, read), false), onSetFlagged: (flagged) => void runBulkAction((chosen) => setMessagesFlagged(chosen, flagged), false), onArchive: () => void runBulkAction(bulkArchive, true), onMoveTo: (targetFolderUid) => runBulkAction((chosen) => moveMessages(chosen, targetFolderUid), true), onReportJunk: () => moveSelectionToType("junk", "Junk Email"), onDelete: () => moveSelectionToType("deleted_items", "Deleted Items"), busy: bulkBusy || resolvingSelection > 0, error: bulkError })) : (_jsx(MailListToolbar, { sortBy: preferences.sortBy, sortOrder: preferences.sortOrder, filter: preferences.filter, labelUids: preferences.labelUids, labels: mailboxLabels, mailboxUid: activeMailboxUid, onLabelCreated: (label) => setMailboxLabels((prev) => [...prev, label]), onLabelUidsChange: (nextLabelUids) => updatePreferences({ labelUids: nextLabelUids }), showAsConversations: preferences.showAsConversations, onSortChange: (sortBy, sortOrder) => updatePreferences({ sortBy, sortOrder }), onFilterChange: (filter) => updatePreferences({ filter }), onShowAsConversationsChange: (showAsConversations) => updatePreferences({ showAsConversations }), selectMode: selectMode, onSelectModeChange: setSelectMode, offerClassificationFilters: offerClassificationFilters, filterDisabled: isSearching, filterDisabledReason: "Filters don't apply to search results", sortKeysDisabled: isSearching || !!aggregateFolderType,
|
|
1309
|
+
// A conversation row is a thread summary, so only some of the keys have anything to
|
|
1310
|
+
// order by - the rest stay pickable and are applied to the rows already fetched.
|
|
1311
|
+
unavailableSortKeys: preferences.showAsConversations ? CONVERSATION_SORT_UNAVAILABLE : undefined, sortKeysNote: isSearching
|
|
1312
|
+
? "Search results are ranked by relevance rather than sorted."
|
|
1313
|
+
: aggregateFolderType
|
|
1314
|
+
? "This view merges the newest mail from every mailbox and is always listed by date."
|
|
1315
|
+
: preferences.showAsConversations
|
|
1316
|
+
? CONVERSATION_SORT_NOTE
|
|
1317
|
+
: undefined, selectDisabled: !!aggregateFolderType || loading || listedRowCount === 0, selectDisabledReason: aggregateFolderType
|
|
1318
|
+
? "Open a mailbox's own folder to select messages"
|
|
1319
|
+
: loading
|
|
1320
|
+
? "Wait for this folder to finish loading"
|
|
1321
|
+
: "There is nothing here to select" })), !preferences.showAsConversations && (_jsx("div", { className: "p-2 border-b border-border", children: _jsx("input", { type: "search", value: searchInput, onChange: (e) => setSearchInput(e.target.value), placeholder: aggregateFolderType ? "Open a mailbox's own folder to search" : "Search all mail…", "aria-label": "Search all mail", disabled: !!aggregateFolderType, className: "w-full text-sm px-3 py-1.5 rounded-md border border-border bg-surface disabled:opacity-55" }) })), offerClassificationFilters && (_jsx("div", { className: "flex border-b border-border text-xs", children: MAIL_LIST_CLASSIFICATION_FILTERS.map(({ value, label }) => (_jsx("button", { type: "button", "aria-pressed": preferences.filter === value, onClick: () => updatePreferences({ filter: value }), className: [
|
|
930
1322
|
"flex-1 py-1.5 font-semibold",
|
|
931
|
-
|
|
1323
|
+
preferences.filter === value
|
|
932
1324
|
? "text-primary-dark border-b-2 border-primary-dark"
|
|
933
1325
|
: "text-text-muted",
|
|
934
|
-
].join(" "), children:
|
|
935
|
-
? `${
|
|
936
|
-
: `${
|
|
1326
|
+
].join(" "), children: label }, value))) })), isSearching && (_jsxs("div", { className: "px-4 py-1.5 text-xs text-text-muted border-b border-border flex items-center justify-between gap-2", children: [_jsx("span", { children: tier1Done && tier2Done && tier3Done
|
|
1327
|
+
? `${messages.length} result${messages.length === 1 ? "" : "s"}`
|
|
1328
|
+
: `${messages.length} of ??` }), tier2Done && !searchAllMail && (_jsx("button", { type: "button", onClick: handleSearchAllMail, className: "text-primary-dark hover:underline font-medium shrink-0", children: "Search all mail" }))] })), isSearching && coverage?.indexedFrom && (_jsxs("div", { className: "px-4 py-1.5 text-xs text-text-muted border-b border-border", children: ["Local search covers messages back to ", new Date(coverage.indexedFrom).toLocaleDateString(), coverage.building ? " (still building)" : "", searchAllMail
|
|
937
1329
|
? " - searching everything, not just recent mail."
|
|
938
|
-
: " - older encrypted mail is still searched, just slower."] })), isSearching && !getUnlockedKeys(mailboxUid) && (_jsx("div", { className: "px-4 py-2 border-b border-border bg-surface-alt", children: _jsxs("button", { type: "button", onClick: handleUnlockSearch, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to include encrypted messages in these results"] }) })), !isSearching &&
|
|
1330
|
+
: " - older encrypted mail is still searched, just slower."] })), isSearching && !getUnlockedKeys(mailboxUid) && (_jsx("div", { className: "px-4 py-2 border-b border-border bg-surface-alt", children: _jsxs("button", { type: "button", onClick: handleUnlockSearch, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to include encrypted messages in these results"] }) })), !isSearching && !preferences.showAsConversations && undecryptedEncryptedUids.length > 0 && !getUnlockedKeys(activeMailboxUid) && (_jsx("div", { className: "px-4 py-2 border-b border-border bg-surface-alt", children: _jsxs("button", { type: "button", onClick: handleUnlockList, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to show ", undecryptedEncryptedUids.length === 1 ? "an encrypted message's" : "encrypted messages'", " subject"] }) })), error && (_jsx("div", { className: "p-4", children: _jsx(Alert, { children: error }) })), loading ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "Loading\u2026" })) : preferences.showAsConversations ? (_jsxs(_Fragment, { children: [_jsx(ConversationList, { conversations: listedConversations, newestFirst: preferences.sortBy === "date" && preferences.sortOrder === "desc", mailboxUid: activeMailboxUid, selectedUid: selectedUid, messageOverrides: conversationPatches, onOpenMessage: handleOpenConversation, selectMode: selectMode, selectedConversationIds: selectedConversationIds, onToggleSelected: toggleConversationSelected }), hasMore && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus }))] })) : messages.length === 0 ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "p-4 text-sm text-text-muted", children: isSearching
|
|
939
1331
|
? `No messages match "${searchQuery}".`
|
|
940
|
-
:
|
|
1332
|
+
: effectiveFilter === "all"
|
|
941
1333
|
? "No messages in this folder."
|
|
942
|
-
: "No messages here." }), hasMore && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus }))] })) : (_jsxs(_Fragment, { children: [_jsx("ul", { children:
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
1334
|
+
: "No messages here." }), hasMore && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus }))] })) : (_jsxs(_Fragment, { children: [_jsx("ul", { children: messages.map((message) => (_jsxs("li", { className: "flex items-stretch border-b border-border", children: [selectMode && (_jsx("span", { className: "shrink-0 flex items-center pl-3", children: _jsx("input", { type: "checkbox", checked: selectedUids.has(message.uid), onChange: () => toggleSelected(message.uid), "aria-label": `Select ${rowSubject(message)}`, className: "w-4 h-4 accent-primary" }) })), _jsxs("button", { type: "button", onClick: () => handleSelect(message), className: [
|
|
1335
|
+
"flex-1 min-w-0 text-left px-4 py-3",
|
|
1336
|
+
message.uid === selectedUid || selectedUids.has(message.uid)
|
|
1337
|
+
? "bg-primary/10"
|
|
1338
|
+
: "hover:bg-surface-alt",
|
|
1339
|
+
message.flags.read ? "" : "font-semibold",
|
|
1340
|
+
].join(" "), children: [_jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx("span", { className: "truncate", children: message.from.displayName || message.from.address }), _jsx("span", { className: "text-xs text-text-muted shrink-0", children: new Date(message.receivedDate).toLocaleDateString() })] }), aggregateFolderType && (_jsx("div", { className: "text-xs text-text-muted truncate font-normal", children: mailboxes.find((mb) => mb.uid === message.mailboxUid)?.displayName })), isSearching && pendingUids.has(message.uid) ? (_jsxs("div", { className: "flex flex-col gap-1.5 py-0.5", children: [_jsx(Skeleton, { height: "h-3.5", className: "w-2/3 rounded-sm" }), _jsx(Skeleton, { height: "h-3", className: "w-full rounded-sm" })] })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: "text-sm truncate", children: rowSubject(message) }), _jsxs("div", { className: "flex items-center gap-2 text-xs text-text-muted font-normal", children: [_jsx("span", { className: "truncate", children: snippets[message.uid] || decryptedRows[message.uid]?.preview || message.bodyPreview }), message.hasAttachments && _jsx(HiOutlinePaperClip, { size: 12, "aria-label": "Has attachments" }), message.flags.flagged && (_jsx(HiOutlineFlag, { size: 12, "aria-label": "Flagged", className: "text-danger" }))] })] }))] })] }, message.uid))) }), hasMore && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus })), aggregateFolderType && (_jsx("p", { className: "p-4 text-center text-xs text-text-muted", children: "Showing the most recent mail from each mailbox. Open a specific mailbox\u2019s folder to see older mail." }))] }))] }), _jsx("div", { className: "hidden md:flex flex-1 min-w-0 min-h-0", children: preferences.showAsConversations ? (_jsx(ConversationThreadPane, { conversation: openThread?.conversation ?? null, selectedUid: openThread?.uid ?? null, mailboxUid: activeMailboxUid, folders: currentFolders, labels: mailboxLabels, onMessagePatched: patchListedMessage, onMessageRemoved: (updated) => removeListedMessage(updated.uid), onLabelCreated: (label) => setMailboxLabels((prev) => [...prev, label]), onFolderCreated: onFolderCreated })) : (_jsx(MessageDetailPane, { message: selected, attachments: attachments, isSentItems: isSentItems, onRecalled: patchListedMessage, isOutbox: isOutbox, onReceiptHandled: patchListedMessage, draftsFolderUid: draftsFolderUid, folders: folders, onMoved: (updated) => {
|
|
1341
|
+
// Same reasoning as onArchived below - a move takes the message out of the folder
|
|
1342
|
+
// being listed, so it leaves the list rather than being patched in place.
|
|
1343
|
+
removeListedMessage(updated.uid);
|
|
1344
|
+
}, onFolderCreated: onFolderCreated, onScheduledSendCanceled: (updated) => {
|
|
951
1345
|
// The message moved out of the currently-viewed Outbox folder (into Drafts)
|
|
952
|
-
//
|
|
1346
|
+
// - unlike a recall, which patches a message in place, this removes it from
|
|
953
1347
|
// the list entirely, matching what a real folder switch would show.
|
|
954
1348
|
removeListedMessage(updated.uid);
|
|
955
1349
|
}, onArchived: (updated) => {
|
|
956
|
-
// Same reasoning as onScheduledSendCanceled above
|
|
1350
|
+
// Same reasoning as onScheduledSendCanceled above - the message moved out of
|
|
957
1351
|
// whichever folder is currently being viewed (into Archive), so it's removed
|
|
958
1352
|
// from the list rather than patched in place.
|
|
959
1353
|
removeListedMessage(updated.uid);
|
|
960
|
-
}, labels: labels, onLabelsChanged:
|
|
1354
|
+
}, labels: labels, onLabelsChanged: patchListedMessage, onLabelCreated: (label) => (otherMailbox ? setOtherMailboxLabels : setMailboxLabels)((prev) => [...prev, label]) })) })] }));
|
|
961
1355
|
}
|