@rapidmx/web-client 0.14.1 → 0.15.1
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/admin/domains/[uid].tsx +3 -2
- package/apps/shared/appearance/AppearanceForm.tsx +9 -1
- package/apps/shared/calendar/bookingPlugin.ts +100 -0
- package/apps/shared/calendar/calendarLiveUpdates.ts +27 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +37 -5
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +3 -1
- package/apps/shared/components/admin/settings/PluginsManager.tsx +1545 -1491
- package/apps/shared/components/calendar/AppointmentQuickForm.tsx +327 -0
- package/apps/shared/components/calendar/DescriptionEditor.tsx +274 -0
- package/apps/shared/components/calendar/EventDescriptionView.tsx +70 -0
- package/apps/shared/components/calendar/EventDetails.tsx +401 -0
- package/apps/shared/components/calendar/EventEditor.tsx +640 -0
- package/apps/shared/components/calendar/EventExpandedForm.tsx +210 -0
- package/apps/shared/components/calendar/EventFormParts.tsx +622 -0
- package/apps/shared/components/calendar/EventModal.tsx +126 -866
- package/apps/shared/components/calendar/EventQuickForm.tsx +116 -0
- package/apps/shared/components/calendar/EventShell.tsx +202 -0
- package/apps/shared/components/calendar/FindATime.tsx +379 -0
- package/apps/shared/components/calendar/LazyDescriptionEditor.tsx +70 -0
- package/apps/shared/components/calendar/MonthView.tsx +15 -3
- package/apps/shared/components/calendar/QuickCreateFaces.tsx +87 -0
- package/apps/shared/components/calendar/QuickCreateTabs.tsx +81 -0
- package/apps/shared/components/calendar/QuickFaceFrame.tsx +81 -0
- package/apps/shared/components/calendar/RecurrenceEditor.tsx +10 -5
- package/apps/shared/components/calendar/RequestChangeForm.tsx +271 -0
- package/apps/shared/components/calendar/SplitDayView.tsx +6 -5
- package/apps/shared/components/calendar/TaskQuickForm.tsx +289 -0
- package/apps/shared/components/calendar/TimeGridView.tsx +8 -6
- package/apps/shared/components/calendar/eventDialogFields.ts +86 -0
- package/apps/shared/components/calendar/eventForm.ts +108 -0
- package/apps/shared/components/calendar/eventFormat.ts +183 -0
- package/apps/shared/components/calendar/findTimeGrid.ts +90 -0
- package/apps/shared/components/calendar/swipeNavigation.ts +14 -0
- package/apps/shared/components/layout/UserMenu.tsx +80 -28
- package/apps/shared/components/mail/ConversationList.tsx +100 -48
- package/apps/shared/components/mail/ConversationThreadPane.tsx +490 -486
- package/apps/shared/components/mail/InviteCard.tsx +387 -0
- package/apps/shared/components/mail/MessageDetailPane.tsx +24 -2
- package/apps/shared/components/mail/SwipeRow.tsx +120 -0
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +7 -3
- package/apps/shared/components/mail/invite/InviteRowChip.tsx +104 -0
- package/apps/shared/components/mail/invite/InviteRsvpPopover.tsx +271 -0
- package/apps/shared/components/mail/invite/InviteTimeline.tsx +200 -0
- package/apps/shared/components/mail/invite/ProposeTimeForm.tsx +109 -0
- package/apps/shared/components/mail/invite/inviteFormat.ts +110 -0
- package/apps/shared/components/mail/invite/inviteStore.ts +114 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +522 -490
- package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
- package/apps/shared/gestures/useEnterSlide.ts +54 -0
- package/apps/shared/gestures/useSwipe.ts +114 -0
- package/apps/shared/gestures/useSwipeSlide.ts +113 -0
- package/apps/shared/gestures/useWheelPaging.ts +107 -0
- package/apps/shared/mail/newMailNotifications.ts +171 -183
- package/apps/shared/mail/useNewMailNotifications.ts +178 -178
- package/apps/shared/notifications/preferences.ts +36 -0
- package/apps/shared/notifications/store.ts +560 -551
- package/apps/www/_routes.ts +1 -0
- package/apps/www/calendar/index.tsx +139 -37
- package/apps/www/index.tsx +273 -126
- package/apps/www/messages/[uid].tsx +31 -4
- package/apps/www/settings/profile/index.tsx +248 -0
- package/dist/apps/admin/domains/[uid].js +4 -2
- package/dist/apps/shared/appearance/AppearanceForm.js +9 -1
- package/dist/apps/shared/calendar/bookingPlugin.d.ts +50 -0
- package/dist/apps/shared/calendar/bookingPlugin.js +57 -0
- package/dist/apps/shared/calendar/calendarLiveUpdates.d.ts +11 -0
- package/dist/apps/shared/calendar/calendarLiveUpdates.js +23 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
- package/dist/apps/shared/components/admin/layout/AdminShell.js +7 -3
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +3 -1
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +40 -6
- package/dist/apps/shared/components/calendar/AppointmentQuickForm.d.ts +47 -0
- package/dist/apps/shared/components/calendar/AppointmentQuickForm.js +135 -0
- package/dist/apps/shared/components/calendar/DescriptionEditor.d.ts +26 -0
- package/dist/apps/shared/components/calendar/DescriptionEditor.js +192 -0
- package/dist/apps/shared/components/calendar/EventDescriptionView.d.ts +14 -0
- package/dist/apps/shared/components/calendar/EventDescriptionView.js +39 -0
- package/dist/apps/shared/components/calendar/EventDetails.d.ts +33 -0
- package/dist/apps/shared/components/calendar/EventDetails.js +137 -0
- package/dist/apps/shared/components/calendar/EventEditor.d.ts +59 -0
- package/dist/apps/shared/components/calendar/EventEditor.js +549 -0
- package/dist/apps/shared/components/calendar/EventExpandedForm.d.ts +14 -0
- package/dist/apps/shared/components/calendar/EventExpandedForm.js +44 -0
- package/dist/apps/shared/components/calendar/EventFormParts.d.ts +69 -0
- package/dist/apps/shared/components/calendar/EventFormParts.js +202 -0
- package/dist/apps/shared/components/calendar/EventModal.d.ts +26 -13
- package/dist/apps/shared/components/calendar/EventModal.js +57 -459
- package/dist/apps/shared/components/calendar/EventQuickForm.d.ts +18 -0
- package/dist/apps/shared/components/calendar/EventQuickForm.js +25 -0
- package/dist/apps/shared/components/calendar/EventShell.d.ts +64 -0
- package/dist/apps/shared/components/calendar/EventShell.js +134 -0
- package/dist/apps/shared/components/calendar/FindATime.d.ts +18 -0
- package/dist/apps/shared/components/calendar/FindATime.js +190 -0
- package/dist/apps/shared/components/calendar/LazyDescriptionEditor.d.ts +8 -0
- package/dist/apps/shared/components/calendar/LazyDescriptionEditor.js +44 -0
- package/dist/apps/shared/components/calendar/MonthView.d.ts +5 -1
- package/dist/apps/shared/components/calendar/MonthView.js +11 -5
- package/dist/apps/shared/components/calendar/QuickCreateFaces.d.ts +30 -0
- package/dist/apps/shared/components/calendar/QuickCreateFaces.js +44 -0
- package/dist/apps/shared/components/calendar/QuickCreateTabs.d.ts +24 -0
- package/dist/apps/shared/components/calendar/QuickCreateTabs.js +46 -0
- package/dist/apps/shared/components/calendar/QuickFaceFrame.d.ts +25 -0
- package/dist/apps/shared/components/calendar/QuickFaceFrame.js +19 -0
- package/dist/apps/shared/components/calendar/RecurrenceEditor.d.ts +4 -1
- package/dist/apps/shared/components/calendar/RecurrenceEditor.js +2 -2
- package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +36 -0
- package/dist/apps/shared/components/calendar/RequestChangeForm.js +138 -0
- package/dist/apps/shared/components/calendar/SplitDayView.d.ts +3 -2
- package/dist/apps/shared/components/calendar/SplitDayView.js +3 -2
- package/dist/apps/shared/components/calendar/TaskQuickForm.d.ts +40 -0
- package/dist/apps/shared/components/calendar/TaskQuickForm.js +101 -0
- package/dist/apps/shared/components/calendar/TimeGridView.d.ts +4 -2
- package/dist/apps/shared/components/calendar/TimeGridView.js +3 -2
- package/dist/apps/shared/components/calendar/eventDialogFields.d.ts +28 -0
- package/dist/apps/shared/components/calendar/eventDialogFields.js +59 -0
- package/dist/apps/shared/components/calendar/eventForm.d.ts +98 -0
- package/dist/apps/shared/components/calendar/eventForm.js +1 -0
- package/dist/apps/shared/components/calendar/eventFormat.d.ts +67 -0
- package/dist/apps/shared/components/calendar/eventFormat.js +163 -0
- package/dist/apps/shared/components/calendar/findTimeGrid.d.ts +30 -0
- package/dist/apps/shared/components/calendar/findTimeGrid.js +83 -0
- package/dist/apps/shared/components/calendar/swipeNavigation.d.ts +9 -0
- package/dist/apps/shared/components/calendar/swipeNavigation.js +8 -0
- package/dist/apps/shared/components/layout/UserMenu.d.ts +10 -8
- package/dist/apps/shared/components/layout/UserMenu.js +70 -23
- package/dist/apps/shared/components/mail/ConversationList.d.ts +12 -1
- package/dist/apps/shared/components/mail/ConversationList.js +21 -4
- package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
- package/dist/apps/shared/components/mail/InviteCard.d.ts +20 -0
- package/dist/apps/shared/components/mail/InviteCard.js +150 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.js +13 -2
- package/dist/apps/shared/components/mail/SwipeRow.d.ts +22 -0
- package/dist/apps/shared/components/mail/SwipeRow.js +71 -0
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +8 -3
- package/dist/apps/shared/components/mail/invite/InviteRowChip.d.ts +23 -0
- package/dist/apps/shared/components/mail/invite/InviteRowChip.js +46 -0
- package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.d.ts +20 -0
- package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.js +141 -0
- package/dist/apps/shared/components/mail/invite/InviteTimeline.d.ts +27 -0
- package/dist/apps/shared/components/mail/invite/InviteTimeline.js +120 -0
- package/dist/apps/shared/components/mail/invite/ProposeTimeForm.d.ts +17 -0
- package/dist/apps/shared/components/mail/invite/ProposeTimeForm.js +45 -0
- package/dist/apps/shared/components/mail/invite/inviteFormat.d.ts +28 -0
- package/dist/apps/shared/components/mail/invite/inviteFormat.js +94 -0
- package/dist/apps/shared/components/mail/invite/inviteStore.d.ts +28 -0
- package/dist/apps/shared/components/mail/invite/inviteStore.js +95 -0
- package/dist/apps/shared/components/mail/layout/MailShell.d.ts +5 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +14 -3
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
- package/dist/apps/shared/gestures/useEnterSlide.d.ts +14 -0
- package/dist/apps/shared/gestures/useEnterSlide.js +47 -0
- package/dist/apps/shared/gestures/useSwipe.d.ts +45 -0
- package/dist/apps/shared/gestures/useSwipe.js +90 -0
- package/dist/apps/shared/gestures/useSwipeSlide.d.ts +40 -0
- package/dist/apps/shared/gestures/useSwipeSlide.js +95 -0
- package/dist/apps/shared/gestures/useWheelPaging.d.ts +32 -0
- package/dist/apps/shared/gestures/useWheelPaging.js +85 -0
- package/dist/apps/shared/mail/newMailNotifications.d.ts +3 -8
- package/dist/apps/shared/mail/newMailNotifications.js +3 -12
- package/dist/apps/shared/mail/useNewMailNotifications.js +3 -2
- package/dist/apps/shared/notifications/preferences.d.ts +13 -0
- package/dist/apps/shared/notifications/preferences.js +36 -0
- package/dist/apps/shared/notifications/store.d.ts +1 -0
- package/dist/apps/shared/notifications/store.js +7 -23
- package/dist/apps/www/_routes.js +1 -0
- package/dist/apps/www/calendar/index.js +76 -6
- package/dist/apps/www/index.js +140 -39
- package/dist/apps/www/messages/[uid].js +13 -5
- package/dist/apps/www/settings/profile/index.d.ts +4 -0
- package/dist/apps/www/settings/profile/index.js +124 -0
- package/package.json +2 -2
package/dist/apps/www/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { useNavigate } from "../shared/navigation/AppRouter.js";
|
|
|
8
8
|
import { whenIdle } from "../shared/navigation/idle.js";
|
|
9
9
|
import { prefetchComposeWindow } from "../shared/components/mail/compose/ComposeContext.js";
|
|
10
10
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
11
|
+
import { createPortal } from "react-dom";
|
|
11
12
|
import { HiOutlineFlag, HiOutlineLockClosed, HiOutlinePaperClip } from "react-icons/hi2";
|
|
12
13
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
13
14
|
import { archiveMessage, bulkUpdateMessages, createFolder, getMessage, getMessageRawContent, listFolders, listMessages, moveMessages, setMessagesFlagged, } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
@@ -30,6 +31,9 @@ import { useMarkMessageRead } from "../shared/mail/useMarkMessageRead.js";
|
|
|
30
31
|
import { ROW_FOCUS_CLASS, UnreadBar, UnreadLabel, dateClass, isUnread, rowClass, senderClass, subjectClass } from "../shared/components/mail/unreadStyle.js";
|
|
31
32
|
import { LazyConversationThreadPane, LazyMessageDetailPane, prefetchReadingPane } from "../shared/components/mail/LazyReadingPane.js";
|
|
32
33
|
import ConversationList from "../shared/components/mail/ConversationList.js";
|
|
34
|
+
import SwipeRow from "../shared/components/mail/SwipeRow.js";
|
|
35
|
+
import InviteRowChip from "../shared/components/mail/invite/InviteRowChip.js";
|
|
36
|
+
import MoveToFolderDialog from "../shared/components/mail/MoveToFolderDialog.js";
|
|
33
37
|
import { EncryptedPreview } from "../shared/components/mail/reading/EncryptedPreview.js";
|
|
34
38
|
import MailListToolbar from "../shared/components/mail/MailListToolbar.js";
|
|
35
39
|
import MailSelectionBar from "../shared/components/mail/MailSelectionBar.js";
|
|
@@ -366,11 +370,26 @@ async function fetchAggregateMessages(mailboxFolders, type, filter) {
|
|
|
366
370
|
}));
|
|
367
371
|
return mergeInboxMessages(perMailbox);
|
|
368
372
|
}
|
|
373
|
+
/** Groups search results by the conversation each message belongs to, the groups in the order of their first result. */
|
|
374
|
+
function groupByConversation(results) {
|
|
375
|
+
const groups = new Map();
|
|
376
|
+
for (const message of results) {
|
|
377
|
+
const id = message.conversationId ?? message.uid;
|
|
378
|
+
const group = groups.get(id);
|
|
379
|
+
if (group) {
|
|
380
|
+
group.messages.push(message);
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
groups.set(id, { id, messages: [message] });
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return [...groups.values()];
|
|
387
|
+
}
|
|
369
388
|
function InboxPage(props) {
|
|
370
389
|
return (_jsx(MailShell, { ...props, children: _jsx(InboxContent, { userUid: props.userUid }) }));
|
|
371
390
|
}
|
|
372
391
|
function InboxContent({ userUid }) {
|
|
373
|
-
const { folderUid, mailboxUid, mailboxes, mailboxFolders, aggregateFolderType, onFolderCreated, noteFolderUids, live, trackMessageChange } = useMailShell();
|
|
392
|
+
const { folderUid, mailboxUid, mailboxes, mailboxFolders, aggregateFolderType, onFolderCreated, noteFolderUids, live, trackMessageChange, mobileSearchSlot } = useMailShell();
|
|
374
393
|
const isMobile = useIsMobile();
|
|
375
394
|
const navigate = useNavigate();
|
|
376
395
|
const { requestUnlock } = useUnlockPrompt();
|
|
@@ -480,7 +499,14 @@ function InboxContent({ userUid }) {
|
|
|
480
499
|
// Search stays real-folder-only - Tier 1/2/3 are all deeply mailbox/folder-scoped, and extending them
|
|
481
500
|
// to span an arbitrary number of mailboxes is out of scope for this pass (see the aggregate-fetch
|
|
482
501
|
// branch below, which the search effect never reaches while `folderUid` is unset).
|
|
483
|
-
|
|
502
|
+
// Search works whichever way the list is arranged. Its results are messages, so while a query is held the list is fetched as
|
|
503
|
+
// messages (`asConversations` is about the list's data and is false then), and when the reader arranges by conversation they are
|
|
504
|
+
// shown grouped under their conversation - only the messages that match - with the whole thread in the reading pane
|
|
505
|
+
// (`threadPane`, which follows the arrangement the reader chose and not the query).
|
|
506
|
+
const isSearching = searchQuery.length > 0 && !aggregateFolderType;
|
|
507
|
+
const asConversations = preferences.showAsConversations && !isSearching;
|
|
508
|
+
const threadPane = preferences.showAsConversations;
|
|
509
|
+
const groupedResults = isSearching && preferences.showAsConversations;
|
|
484
510
|
// Focused/Other is an Inbox-only concept (`FocusedInboxUtils.classifyMessage()` short-circuits to
|
|
485
511
|
// Focused for every other folder), and
|
|
486
512
|
// search results are ranked across folders rather than listed from one, so neither tab is offered
|
|
@@ -513,7 +539,7 @@ function InboxContent({ userUid }) {
|
|
|
513
539
|
* shown: `GET /mail/messages/conversations` takes no sort parameters at all, so those rows are ordered
|
|
514
540
|
* in the browser (`sortConversations()`) and rearranging them must not refetch the identical page -
|
|
515
541
|
* which would also collapse whichever conversations the reader had expanded. */
|
|
516
|
-
const serverSortKey =
|
|
542
|
+
const serverSortKey = asConversations ? "" : `${preferences.sortBy}:${preferences.sortOrder}`;
|
|
517
543
|
/** The conversation list's own equivalent of `listParams` - a function because the page differs. */
|
|
518
544
|
function conversationParams(page) {
|
|
519
545
|
return { folderUid, filter: effectiveFilter, ...labelFilter, page, limit: MESSAGE_PAGE_SIZE };
|
|
@@ -653,7 +679,7 @@ function InboxContent({ userUid }) {
|
|
|
653
679
|
// `activeMailboxUid`, and offering that mailbox's labels would let a label from the wrong mailbox be
|
|
654
680
|
// applied. The conversation view stays scoped to `activeMailboxUid`, the mailbox its threads come from.
|
|
655
681
|
const selectedMessageMailboxUid = messages.find((m) => m.uid === selectedUid)?.mailboxUid;
|
|
656
|
-
const labelsMailboxUid =
|
|
682
|
+
const labelsMailboxUid = threadPane ? activeMailboxUid : (selectedMessageMailboxUid ?? activeMailboxUid);
|
|
657
683
|
// Almost always the open mailbox's own labels, already fetched below - so `labels` reuses that list
|
|
658
684
|
// rather than asking for the identical one a second time (which is what this page did on every single
|
|
659
685
|
// view). Only a selected message from *another* mailbox - a search hit or an aggregate-view row - needs
|
|
@@ -719,7 +745,7 @@ function InboxContent({ userUid }) {
|
|
|
719
745
|
const listKey = listSnapshotKey({
|
|
720
746
|
mailboxUid: activeMailboxUid,
|
|
721
747
|
folderUid,
|
|
722
|
-
conversations:
|
|
748
|
+
conversations: asConversations,
|
|
723
749
|
filter: effectiveFilter,
|
|
724
750
|
labels: labelFilterKey,
|
|
725
751
|
sort: serverSortKey,
|
|
@@ -787,7 +813,7 @@ function InboxContent({ userUid }) {
|
|
|
787
813
|
setLoading(true);
|
|
788
814
|
return;
|
|
789
815
|
}
|
|
790
|
-
if (
|
|
816
|
+
if (asConversations) {
|
|
791
817
|
// Conversations stay single-mailbox (not aggregated across mailboxes in this pass) - in
|
|
792
818
|
// aggregate mode this falls back to `activeMailboxUid`, the same mailbox unlock/labels use
|
|
793
819
|
// (always set - `MailShell` only renders this component once at least one mailbox exists).
|
|
@@ -986,7 +1012,7 @@ function InboxContent({ userUid }) {
|
|
|
986
1012
|
// `refreshKey` is a dependency for the same reason: a failed bulk action bumps it to refetch,
|
|
987
1013
|
// since a bulk update applies until its first rejection rather than all-or-nothing.
|
|
988
1014
|
}, [
|
|
989
|
-
|
|
1015
|
+
asConversations,
|
|
990
1016
|
serverSortKey,
|
|
991
1017
|
effectiveFilter,
|
|
992
1018
|
labelFilterKey,
|
|
@@ -1019,7 +1045,7 @@ function InboxContent({ userUid }) {
|
|
|
1019
1045
|
if (live.tick === 0 || isSearching || loading || loadMoreInFlightRef.current || (!folderUid && !aggregateFolderType)) {
|
|
1020
1046
|
return;
|
|
1021
1047
|
}
|
|
1022
|
-
if (!
|
|
1048
|
+
if (!asConversations && live.folderUids) {
|
|
1023
1049
|
const shown = new Set(aggregateFolderType
|
|
1024
1050
|
? mailboxFolders.flatMap((entry) => entry.folders.filter((f) => f.type === aggregateFolderType).map((f) => f.uid))
|
|
1025
1051
|
: [folderUid]);
|
|
@@ -1033,7 +1059,7 @@ function InboxContent({ userUid }) {
|
|
|
1033
1059
|
const isCurrent = () => searchRunIdRef.current === listRun && liveRunRef.current === myRun;
|
|
1034
1060
|
void (async () => {
|
|
1035
1061
|
try {
|
|
1036
|
-
if (
|
|
1062
|
+
if (asConversations) {
|
|
1037
1063
|
const fresh = await listConversations(activeMailboxUid, conversationParams(0));
|
|
1038
1064
|
if (!isCurrent()) {
|
|
1039
1065
|
return;
|
|
@@ -1082,12 +1108,12 @@ function InboxContent({ userUid }) {
|
|
|
1082
1108
|
// Reads the ref, not `conversations`/`messages` state, so this callback (and the observer effect
|
|
1083
1109
|
// watching it) don't need to be recreated every time a page lands - the same reason every other
|
|
1084
1110
|
// check below reads a ref rather than closing over state.
|
|
1085
|
-
const atRowCap = (
|
|
1111
|
+
const atRowCap = (asConversations ? conversationsRef.current.length : messagesRef.current.length) >= MAX_LOADED_ROWS;
|
|
1086
1112
|
if (loadMoreInFlightRef.current ||
|
|
1087
1113
|
!hasMore ||
|
|
1088
1114
|
loading ||
|
|
1089
1115
|
atRowCap ||
|
|
1090
|
-
(!
|
|
1116
|
+
(!asConversations && !folderUid) ||
|
|
1091
1117
|
(isSearching && !searchCursor)) {
|
|
1092
1118
|
return;
|
|
1093
1119
|
}
|
|
@@ -1119,7 +1145,7 @@ function InboxContent({ userUid }) {
|
|
|
1119
1145
|
const myRunId = searchRunIdRef.current;
|
|
1120
1146
|
const isCurrentRun = () => searchRunIdRef.current === myRunId;
|
|
1121
1147
|
try {
|
|
1122
|
-
if (
|
|
1148
|
+
if (asConversations) {
|
|
1123
1149
|
const page = Math.floor(listedOffsetRef.current / MESSAGE_PAGE_SIZE);
|
|
1124
1150
|
const more = await listConversations(activeMailboxUid, conversationParams(page));
|
|
1125
1151
|
if (!isCurrentRun()) {
|
|
@@ -1198,7 +1224,7 @@ function InboxContent({ userUid }) {
|
|
|
1198
1224
|
}, [
|
|
1199
1225
|
hasMore,
|
|
1200
1226
|
loading,
|
|
1201
|
-
|
|
1227
|
+
asConversations,
|
|
1202
1228
|
serverSortKey,
|
|
1203
1229
|
effectiveFilter,
|
|
1204
1230
|
labelFilterKey,
|
|
@@ -1277,7 +1303,7 @@ function InboxContent({ userUid }) {
|
|
|
1277
1303
|
}
|
|
1278
1304
|
// The conversation list opens a whole thread in `ConversationThreadPane`, which loads and marks read
|
|
1279
1305
|
// its own messages; only the flat list feeds the single-message pane below.
|
|
1280
|
-
const selected =
|
|
1306
|
+
const selected = threadPane ? null : (messages.find((m) => m.uid === selectedUid) ?? null);
|
|
1281
1307
|
const attachments = useMessageAttachments(selected);
|
|
1282
1308
|
useMarkMessageRead(selected, patchListedMessage);
|
|
1283
1309
|
// Search results can span every folder in the mailbox, not just the one selected in the sidebar - a
|
|
@@ -1286,7 +1312,7 @@ function InboxContent({ userUid }) {
|
|
|
1286
1312
|
// this falls back to the sidebar selection unchanged). Aggregate views span every *mailbox* too, so
|
|
1287
1313
|
// the folder list consulted is the selected message's own mailbox's, not the shell's ambient one. A
|
|
1288
1314
|
// conversation spans folders for the same reason (an Inbox message and the Sent Items copy of its reply).
|
|
1289
|
-
const spansFolders = isSearching || !!aggregateFolderType ||
|
|
1315
|
+
const spansFolders = isSearching || !!aggregateFolderType || threadPane;
|
|
1290
1316
|
const selectedFolderUid = spansFolders ? (selected?.folderUid ?? folderUid) : folderUid;
|
|
1291
1317
|
const selectedMailboxUid = spansFolders ? (selected?.mailboxUid ?? activeMailboxUid) : mailboxUid;
|
|
1292
1318
|
const folders = mailboxFolders.find((mf) => mf.mailbox.uid === selectedMailboxUid)?.folders ?? [];
|
|
@@ -1305,12 +1331,12 @@ function InboxContent({ userUid }) {
|
|
|
1305
1331
|
* Items copy of its reply), and the list only ever showed this folder's half of it, so a bulk action
|
|
1306
1332
|
* from here must not reach into the other folders' copies either. */
|
|
1307
1333
|
const selectedConversationMessages = [...selectedConversationIds].flatMap((id) => (conversationMessagesById[id] ?? []).filter((m) => !folderUid || m.folderUid === folderUid));
|
|
1308
|
-
const selectedMessages =
|
|
1334
|
+
const selectedMessages = asConversations
|
|
1309
1335
|
? selectedConversationMessages
|
|
1310
1336
|
: messages.filter((m) => selectedUids.has(m.uid));
|
|
1311
1337
|
/** How many rows the list is actually showing - conversations or messages, whichever it lists. What the
|
|
1312
1338
|
* Select toggle is enabled by: there is nothing to select in a list with no rows. */
|
|
1313
|
-
const listedRowCount =
|
|
1339
|
+
const listedRowCount = asConversations ? conversations.length : messages.length;
|
|
1314
1340
|
/** The conversation rows in the order the reader arranged them. The endpoint takes no sort parameters
|
|
1315
1341
|
* of its own (it pages by latest activity), so the arrangement is applied here, to the rows fetched so
|
|
1316
1342
|
* far - which the Sort menu says on screen. `conversations` itself stays in the order the pages
|
|
@@ -1323,7 +1349,7 @@ function InboxContent({ userUid }) {
|
|
|
1323
1349
|
* mailbox/search whose true size lands at or near the cap can have its very last page be a partial one,
|
|
1324
1350
|
* which correctly turns `hasMore` false - without checking it here, the banner would still claim rows
|
|
1325
1351
|
* are being hidden even though every one of them is already on screen. */
|
|
1326
|
-
const rowCapReached = hasMore && (
|
|
1352
|
+
const rowCapReached = hasMore && (asConversations ? conversations.length : messages.length) >= MAX_LOADED_ROWS;
|
|
1327
1353
|
function leaveSelectMode() {
|
|
1328
1354
|
setSelectMode(false);
|
|
1329
1355
|
setSelectedUids(new Set());
|
|
@@ -1417,7 +1443,7 @@ function InboxContent({ userUid }) {
|
|
|
1417
1443
|
}
|
|
1418
1444
|
}
|
|
1419
1445
|
}
|
|
1420
|
-
if (
|
|
1446
|
+
if (asConversations) {
|
|
1421
1447
|
// A conversation row is a summary of its messages - its count, unread count, participants
|
|
1422
1448
|
// and preview all move when a bulk action changes or empties part of it - so the list is
|
|
1423
1449
|
// reloaded rather than patched row by row.
|
|
@@ -1501,12 +1527,69 @@ function InboxContent({ userUid }) {
|
|
|
1501
1527
|
function moveSelectionToType(type, name, chosen) {
|
|
1502
1528
|
return runBulkAction(async (moving) => moveMessages(moving, await resolveFolderOfType(type, name)), true, chosen);
|
|
1503
1529
|
}
|
|
1530
|
+
// ---- Swiping a row on a phone (see `SwipeRow`): right to left archives, left to right asks for a folder. Both go through the same
|
|
1531
|
+
// ---- bulk path as the selection bar's Archive and Move to, so the badges, the optimistic bookkeeping and the failure pop-up are one
|
|
1532
|
+
// ---- implementation. Not offered where the actions aren't (an aggregate view spans mailboxes, search results are a mixed bag, the
|
|
1533
|
+
// ---- Outbox is the server's send queue) or while rows are being ticked.
|
|
1534
|
+
const listingOutbox = currentFolders.find((f) => f.uid === folderUid)?.type === "outbox";
|
|
1535
|
+
const swipeEnabled = isMobile && !selectMode && !aggregateFolderType && !isSearching && !listingOutbox;
|
|
1536
|
+
/** What a swipe's Move to is about, while its folder prompt is open: one message, or every message of a conversation in this folder. */
|
|
1537
|
+
const [moveDialog, setMoveDialog] = useState(null);
|
|
1538
|
+
/** The messages a swiped conversation stands for (the ones in the folder being listed), or `null` after saying why they couldn't be loaded. */
|
|
1539
|
+
async function swipedConversationMessages(conversation) {
|
|
1540
|
+
try {
|
|
1541
|
+
return await loadOpenConversation(conversation);
|
|
1542
|
+
}
|
|
1543
|
+
catch (err) {
|
|
1544
|
+
notifyApiError(err, "Couldn't load the messages in that conversation");
|
|
1545
|
+
return null;
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
/** Archives what a swipe took away. Resolves whether it went through, so the row comes back when it didn't. */
|
|
1549
|
+
async function swipeArchive(target) {
|
|
1550
|
+
if (bulkBusy || resolvingSelection > 0) {
|
|
1551
|
+
return false;
|
|
1552
|
+
}
|
|
1553
|
+
const chosen = Array.isArray(target) ? target : await swipedConversationMessages(target);
|
|
1554
|
+
if (!chosen || chosen.length === 0) {
|
|
1555
|
+
return false;
|
|
1556
|
+
}
|
|
1557
|
+
return runBulkAction(bulkArchive, true, chosen);
|
|
1558
|
+
}
|
|
1559
|
+
async function swipeMove(folderUidToMoveTo) {
|
|
1560
|
+
// Only ever asked by the folder prompt, which is only open while `moveDialog` is set.
|
|
1561
|
+
const target = moveDialog;
|
|
1562
|
+
const chosen = "messages" in target ? target.messages : await swipedConversationMessages(target.conversation);
|
|
1563
|
+
if (!chosen || chosen.length === 0) {
|
|
1564
|
+
setMoveDialog(null);
|
|
1565
|
+
return;
|
|
1566
|
+
}
|
|
1567
|
+
await runBulkAction((moving) => moveMessages(moving, folderUidToMoveTo), true, chosen);
|
|
1568
|
+
setMoveDialog(null);
|
|
1569
|
+
}
|
|
1504
1570
|
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;
|
|
1571
|
+
/** One message's row of the flat list - and, in search results grouped by conversation, of a group. */
|
|
1572
|
+
function renderMessageRow(message) {
|
|
1573
|
+
return (_jsxs(SwipeRow, { as: "li", enabled: swipeEnabled, onArchive: () => swipeArchive([message]), onMove: () => setMoveDialog({ messages: [message] }), "data-message-uid": message.uid, "data-unread": isUnread(message) ? "true" : undefined, className: rowClass({ unread: isUnread(message), selected: message.uid === selectedUid || selectedUids.has(message.uid) }, "flex items-stretch"), children: [_jsx(UnreadBar, { unread: isUnread(message) }), 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("div", { className: "flex-1 min-w-0 flex flex-col", children: [_jsxs("button", { type: "button", "data-row-open": true, onClick: () => handleSelect(message), className: ["w-full text-left px-4 py-3", ROW_FOCUS_CLASS].join(" "), children: [_jsx(UnreadLabel, { unread: isUnread(message) }), _jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(MailAddress, { recipient: message.from, className: senderClass(isUnread(message)) }), _jsx("span", { className: ["text-xs shrink-0", dateClass(isUnread(message))].join(" "), 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", subjectClass(isUnread(message))].join(" "), 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] ||
|
|
1574
|
+
decryptedRows[message.uid]?.preview ||
|
|
1575
|
+
message.bodyPreview ||
|
|
1576
|
+
(message.encrypted ? _jsx(EncryptedPreview, {}) : null) }), message.hasAttachments && _jsx(HiOutlinePaperClip, { size: 12, "aria-label": "Has attachments" }), message.flags.flagged && (_jsx(HiOutlineFlag, { size: 12, "aria-label": "Flagged", className: "text-danger" }))] }), isOutbox && _jsx(OutboxRowStatus, { message: message })] }))] }), _jsx(InviteRowChip, { message: message, onResponded: (updated) => patchListedMessage(updated) })] })] }, message.uid));
|
|
1577
|
+
}
|
|
1578
|
+
/** Search results arranged by conversation: the messages that matched, grouped under the conversation each belongs to, in the order of the
|
|
1579
|
+
* first match of each - the order the results are ranked in. `null` when the list is not showing grouped results. */
|
|
1580
|
+
const resultGroups = groupedResults ? groupByConversation(messages) : null;
|
|
1581
|
+
/** The search box: in the list on a desktop (flat lists only), in the shell's header beside the folders button on a phone. */
|
|
1582
|
+
const searchField = (_jsx("input", { ref: searchInputRef, 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 rounded-md border border-border bg-surface disabled:opacity-55", isMobile ? "py-2" : "py-1.5"].join(" ") }));
|
|
1505
1583
|
function handleSelect(message) {
|
|
1506
1584
|
if (selectMode) {
|
|
1507
1585
|
toggleSelected(message.uid);
|
|
1508
1586
|
return;
|
|
1509
1587
|
}
|
|
1588
|
+
if (preferences.showAsConversations) {
|
|
1589
|
+
// A search result of a list arranged by conversation: the reading pane is the whole thread, opened at this message.
|
|
1590
|
+
openThreadOf(message);
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1510
1593
|
if (isMobile) {
|
|
1511
1594
|
navigate(`/messages/${encodeURIComponent(message.uid)}`);
|
|
1512
1595
|
return;
|
|
@@ -1514,6 +1597,22 @@ function InboxContent({ userUid }) {
|
|
|
1514
1597
|
removedAnchorRef.current = null;
|
|
1515
1598
|
setSelectedUid(message.uid);
|
|
1516
1599
|
}
|
|
1600
|
+
/** A message of a conversation, as the thread pane names it: the conversation's id (a message with no thread is its own) and the
|
|
1601
|
+
* subject to head it with while the thread loads. */
|
|
1602
|
+
function threadHeadOf(message) {
|
|
1603
|
+
return { conversationId: message.conversationId ?? message.uid, subject: message.subject ?? "", messageCount: 1 };
|
|
1604
|
+
}
|
|
1605
|
+
/** Opens the whole thread a message belongs to, positioned at it - the phone's message page, or the reading pane. */
|
|
1606
|
+
function openThreadOf(message) {
|
|
1607
|
+
const conversation = threadHeadOf(message);
|
|
1608
|
+
if (isMobile) {
|
|
1609
|
+
navigate(`/messages/${encodeURIComponent(message.uid)}?conversation=${encodeURIComponent(conversation.conversationId)}`);
|
|
1610
|
+
return;
|
|
1611
|
+
}
|
|
1612
|
+
removedAnchorRef.current = null;
|
|
1613
|
+
setSelectedUid(message.uid);
|
|
1614
|
+
setOpenThread({ conversation, uid: message.uid });
|
|
1615
|
+
}
|
|
1517
1616
|
/** Opens a conversation in the reading pane, positioned at one of its messages: the one a child row
|
|
1518
1617
|
* stands for, or the latest for a parent row. The thread pane loads the thread itself. */
|
|
1519
1618
|
function handleOpenConversation(conversation, uid) {
|
|
@@ -1524,9 +1623,8 @@ function InboxContent({ userUid }) {
|
|
|
1524
1623
|
return;
|
|
1525
1624
|
}
|
|
1526
1625
|
if (isMobile) {
|
|
1527
|
-
//
|
|
1528
|
-
|
|
1529
|
-
navigate(`/messages/${encodeURIComponent(uid)}`);
|
|
1626
|
+
// The message page shows the whole conversation, positioned at this message, when it is given the conversation.
|
|
1627
|
+
navigate(`/messages/${encodeURIComponent(uid)}?conversation=${encodeURIComponent(conversation.conversationId)}`);
|
|
1530
1628
|
return;
|
|
1531
1629
|
}
|
|
1532
1630
|
removedAnchorRef.current = null;
|
|
@@ -1537,11 +1635,11 @@ function InboxContent({ userUid }) {
|
|
|
1537
1635
|
// ---- the selection bar call: the same bulk-action path (`runBulkAction()` - so the same optimistic badge tracking, the same rollback,
|
|
1538
1636
|
// ---- the same lazily created Deleted Items folder), never a second implementation. Reply, Reply all, Forward, Archive and Move to are
|
|
1539
1637
|
// ---- registered by the reading pane itself (`MessageDetailPane`'s `shortcuts`), which owns those handlers.
|
|
1540
|
-
const inConversations =
|
|
1638
|
+
const inConversations = asConversations;
|
|
1541
1639
|
/** The selection bar isn't offered in the aggregate view (its rows belong to several mailboxes), and neither are these. */
|
|
1542
1640
|
const keyboardActions = !aggregateFolderType;
|
|
1543
1641
|
/** What the keyboard acts on exists: the ticked rows in select mode, else the message (or conversation) open in the reading pane. */
|
|
1544
|
-
const keyboardTargetExists = selectMode ? selectedMessages.length > 0 :
|
|
1642
|
+
const keyboardTargetExists = selectMode ? selectedMessages.length > 0 : threadPane ? openThread !== null : selected !== null;
|
|
1545
1643
|
const rowCount = inConversations ? listedConversations.length : messages.length;
|
|
1546
1644
|
const canMoveSelection = !selectMode && !isMobile && !loading && rowCount > 0;
|
|
1547
1645
|
function folderTypeOf(folderUidOfMessage) {
|
|
@@ -1611,7 +1709,7 @@ function InboxContent({ userUid }) {
|
|
|
1611
1709
|
}
|
|
1612
1710
|
let chosen;
|
|
1613
1711
|
try {
|
|
1614
|
-
chosen = selectMode ? selectedMessages :
|
|
1712
|
+
chosen = selectMode ? selectedMessages : threadPane ? await loadOpenConversation(openThread.conversation) : [selected];
|
|
1615
1713
|
}
|
|
1616
1714
|
catch (err) {
|
|
1617
1715
|
notifyApiError(err, "Couldn't load the messages in that conversation");
|
|
@@ -1622,7 +1720,7 @@ function InboxContent({ userUid }) {
|
|
|
1622
1720
|
return;
|
|
1623
1721
|
}
|
|
1624
1722
|
const succeeded = await runBulkAction(action, removesRows, chosen);
|
|
1625
|
-
if (succeeded && removesRows &&
|
|
1723
|
+
if (succeeded && removesRows && threadPane && !selectMode) {
|
|
1626
1724
|
setOpenThread(null);
|
|
1627
1725
|
setSelectedUid(null);
|
|
1628
1726
|
}
|
|
@@ -1661,7 +1759,7 @@ function InboxContent({ userUid }) {
|
|
|
1661
1759
|
useShortcut(SHORTCUTS.mail.search, () => {
|
|
1662
1760
|
searchInputRef.current?.focus();
|
|
1663
1761
|
searchInputRef.current?.select();
|
|
1664
|
-
}, { enabled: !
|
|
1762
|
+
}, { enabled: !aggregateFolderType });
|
|
1665
1763
|
if (!folderUid && !aggregateFolderType) {
|
|
1666
1764
|
// `MailShell` never renders this component at all until a mailbox is resolved (see its own
|
|
1667
1765
|
// full-screen `MailboxProvisioning` takeover otherwise) — this is purely the brief gap before
|
|
@@ -1673,28 +1771,28 @@ function InboxContent({ userUid }) {
|
|
|
1673
1771
|
return (_jsxs("div", { className: "flex h-full min-h-0", children: [_jsxs("div", { ref: scrollContainerRef,
|
|
1674
1772
|
// Remembered as it moves (not when the list is left: by then React has already detached the element), so
|
|
1675
1773
|
// the folder - or Mail, after another app - comes back where it was.
|
|
1676
|
-
onScroll: (event) => saveListScroll(listKey, event.currentTarget.scrollTop), 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:
|
|
1774
|
+
onScroll: (event) => saveListScroll(listKey, event.currentTarget.scrollTop), 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: asConversations
|
|
1677
1775
|
? { selected: selectedConversationIds.size, listed: conversations.length, noun: "conversation" }
|
|
1678
|
-
: undefined, onSelectAll: () =>
|
|
1776
|
+
: undefined, onSelectAll: () => asConversations
|
|
1679
1777
|
? selectAllConversations()
|
|
1680
|
-
: setSelectedUids(new Set(messages.map((m) => m.uid))), onClearSelection: () =>
|
|
1778
|
+
: setSelectedUids(new Set(messages.map((m) => m.uid))), onClearSelection: () => asConversations ? 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(
|
|
1681
1779
|
// Optimistic: the rows and the folder badges change now, and change back if the server refuses.
|
|
1682
1780
|
(chosen) => setReadStateMany(chosen, read, { patch: patchListedMessage, track: trackMessageChange }), false), onSetFlagged: (flagged) => void runBulkAction((chosen) => setMessagesFlagged(chosen, flagged), false), shortcuts: keyboardActions, onArchive: () => void runBulkAction(bulkArchive, true), onMoveTo: async (targetFolderUid) => {
|
|
1683
1781
|
await runBulkAction((chosen) => moveMessages(chosen, targetFolderUid), true);
|
|
1684
1782
|
}, onReportJunk: () => void moveSelectionToType("junk", "Junk Email"), onDelete: () => void moveSelectionToType("deleted_items", "Deleted Items"), busy: bulkBusy || resolvingSelection > 0 })) : (_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,
|
|
1685
1783
|
// A conversation row is a thread summary, so only some of the keys have anything to
|
|
1686
1784
|
// order by - the rest stay pickable and are applied to the rows already fetched.
|
|
1687
|
-
unavailableSortKeys:
|
|
1785
|
+
unavailableSortKeys: asConversations ? CONVERSATION_SORT_UNAVAILABLE : undefined, sortKeysNote: isSearching
|
|
1688
1786
|
? "Search results are ranked by relevance rather than sorted."
|
|
1689
1787
|
: aggregateFolderType
|
|
1690
1788
|
? "This view merges the newest mail from every mailbox and is always listed by date."
|
|
1691
|
-
:
|
|
1789
|
+
: asConversations
|
|
1692
1790
|
? CONVERSATION_SORT_NOTE
|
|
1693
1791
|
: undefined, selectDisabled: !!aggregateFolderType || loading || listedRowCount === 0, selectDisabledReason: aggregateFolderType
|
|
1694
1792
|
? "Open a mailbox's own folder to select messages"
|
|
1695
1793
|
: loading
|
|
1696
1794
|
? "Wait for this folder to finish loading"
|
|
1697
|
-
: "There is nothing here to select" })), !
|
|
1795
|
+
: "There is nothing here to select" })), !isMobile && _jsx("div", { className: "p-2 border-b border-border", children: searchField }), isMobile && mobileSearchSlot && createPortal(searchField, mobileSearchSlot), 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: [
|
|
1698
1796
|
"flex-1 py-1.5 font-semibold",
|
|
1699
1797
|
preferences.filter === value
|
|
1700
1798
|
? "text-primary-dark border-b-2 border-primary-dark"
|
|
@@ -1703,14 +1801,17 @@ function InboxContent({ userUid }) {
|
|
|
1703
1801
|
? `${messages.length} result${messages.length === 1 ? "" : "s"}`
|
|
1704
1802
|
: `${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
|
|
1705
1803
|
? " - searching everything, not just recent mail."
|
|
1706
|
-
: " - 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 && !
|
|
1804
|
+
: " - 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 && !asConversations && 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 ? (_jsxs("div", { role: "status", "aria-busy": "true", className: "p-4", children: [_jsx("span", { className: "sr-only", children: "Loading\u2026" }), _jsx(SkeletonList, { count: 8 })] })) : asConversations ? (_jsxs(_Fragment, { children: [_jsx(ConversationList, { conversations: listedConversations, newestFirst: preferences.sortBy === "date" && preferences.sortOrder === "desc", mailboxUid: activeMailboxUid, selectedUid: selectedUid, messageOverrides: conversationPatches, onMeetingResponded: patchListedMessage, onOpenMessage: handleOpenConversation, selectMode: selectMode, selectedConversationIds: selectedConversationIds, onToggleSelected: toggleConversationSelected, swipe: {
|
|
1805
|
+
enabled: swipeEnabled,
|
|
1806
|
+
onArchive: swipeArchive,
|
|
1807
|
+
onMove: (conversation) => setMoveDialog({ conversation }),
|
|
1808
|
+
} }), hasMore && !rowCapReached && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus })), rowCapReached && (_jsxs("p", { className: "p-4 text-center text-xs text-text-muted", children: ["Showing the most recent ", MAX_LOADED_ROWS, " conversations \u2014 refine your search or filters to see more."] }))] })) : messages.length === 0 ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "p-4 text-sm text-text-muted", children: isSearching
|
|
1707
1809
|
? `No messages match "${searchQuery}".`
|
|
1708
1810
|
: effectiveFilter === "all"
|
|
1709
1811
|
? "No messages in this folder."
|
|
1710
|
-
: "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", {
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
(message.encrypted ? _jsx(EncryptedPreview, {}) : null) }), message.hasAttachments && _jsx(HiOutlinePaperClip, { size: 12, "aria-label": "Has attachments" }), message.flags.flagged && (_jsx(HiOutlineFlag, { size: 12, "aria-label": "Flagged", className: "text-danger" }))] }), isOutbox && _jsx(OutboxRowStatus, { message: message })] }))] })] }, message.uid))) }), hasMore && !rowCapReached && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus })), rowCapReached && (_jsxs("p", { className: "p-4 text-center text-xs text-text-muted", children: ["Showing the most recent ", MAX_LOADED_ROWS, " messages \u2014 refine your search or filters to see more."] })), 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(LazyConversationThreadPane, { conversation: openThread?.conversation ?? null, selectedUid: openThread?.uid ?? null, mailboxUid: activeMailboxUid, folders: currentFolders, labels: mailboxLabels, shortcuts: !selectMode, onMessagePatched: patchListedMessage, onMessageRemoved: (updated) => removeListedMessage(updated.uid), onLabelCreated: (label) => setMailboxLabels((prev) => [...prev, label]), onFolderCreated: onFolderCreated })) : (_jsx(LazyMessageDetailPane, { shortcuts: !selectMode, message: selected, attachments: attachments, isSentItems: isSentItems, onRecalled: patchListedMessage, isOutbox: isOutbox, onReceiptHandled: patchListedMessage, draftsFolderUid: draftsFolderUid, folders: folders, onMoved: (updated) => {
|
|
1812
|
+
: "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", { className: swipeEnabled ? "overflow-x-clip" : undefined, children: resultGroups
|
|
1813
|
+
? resultGroups.map((group) => (_jsxs("li", { "data-search-group": group.id, children: [_jsxs("div", { className: "px-4 py-1.5 bg-surface-alt border-b border-border text-xs text-text-muted flex items-center justify-between gap-2", children: [_jsx("span", { className: "truncate font-semibold", children: rowSubject(group.messages[0]) }), _jsxs("span", { className: "shrink-0", children: [group.messages.length, " matching message", group.messages.length === 1 ? "" : "s"] })] }), _jsx("ul", { children: group.messages.map(renderMessageRow) })] }, group.id)))
|
|
1814
|
+
: messages.map(renderMessageRow) }), hasMore && !rowCapReached && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus })), rowCapReached && (_jsxs("p", { className: "p-4 text-center text-xs text-text-muted", children: ["Showing the most recent ", MAX_LOADED_ROWS, " messages \u2014 refine your search or filters to see more."] })), 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: threadPane ? (_jsx(LazyConversationThreadPane, { conversation: openThread?.conversation ?? null, selectedUid: openThread?.uid ?? null, mailboxUid: activeMailboxUid, folders: currentFolders, labels: mailboxLabels, shortcuts: !selectMode, onMessagePatched: patchListedMessage, onMessageRemoved: (updated) => removeListedMessage(updated.uid), onLabelCreated: (label) => setMailboxLabels((prev) => [...prev, label]), onFolderCreated: onFolderCreated })) : (_jsx(LazyMessageDetailPane, { shortcuts: !selectMode, message: selected, attachments: attachments, isSentItems: isSentItems, onRecalled: patchListedMessage, isOutbox: isOutbox, onReceiptHandled: patchListedMessage, draftsFolderUid: draftsFolderUid, folders: folders, onMoved: (updated) => {
|
|
1714
1815
|
// Same reasoning as onArchived below - a move takes the message out of the folder
|
|
1715
1816
|
// being listed, so it leaves the list rather than being patched in place.
|
|
1716
1817
|
removeListedMessage(updated.uid);
|
|
@@ -1724,6 +1825,6 @@ function InboxContent({ userUid }) {
|
|
|
1724
1825
|
// whichever folder is currently being viewed (into Archive), so it's removed
|
|
1725
1826
|
// from the list rather than patched in place.
|
|
1726
1827
|
removeListedMessage(updated.uid);
|
|
1727
|
-
}, labels: labels, onLabelsChanged: patchListedMessage, onLabelCreated: (label) => (otherMailbox ? setOtherMailboxLabels : setMailboxLabels)((prev) => [...prev, label]) })) })] }));
|
|
1828
|
+
}, labels: labels, onLabelsChanged: patchListedMessage, onLabelCreated: (label) => (otherMailbox ? setOtherMailboxLabels : setMailboxLabels)((prev) => [...prev, label]) })) }), _jsx(MoveToFolderDialog, { open: moveDialog !== null, onClose: () => setMoveDialog(null), mailboxUid: activeMailboxUid, folders: currentFolders, currentFolderUid: folderUid, count: moveDialog === null ? 1 : "messages" in moveDialog ? moveDialog.messages.length : moveDialog.conversation.messageCount, onMove: swipeMove, onFolderCreated: onFolderCreated })] }));
|
|
1728
1829
|
}
|
|
1729
1830
|
export default routedPage("/", InboxPage);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
///////////////////////////////////////////////////////////////////////////////
|
|
3
3
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
@@ -11,12 +11,15 @@ import { listLabels } from "@rapidmx/react-shared/mail/labelsApi.js";
|
|
|
11
11
|
import { useMessageAttachments } from "@rapidmx/react-shared/mail/mailDetailHooks.js";
|
|
12
12
|
import { useMarkMessageRead } from "../../shared/mail/useMarkMessageRead.js";
|
|
13
13
|
import MailShell, { useMailShell } from "../../shared/components/mail/layout/MailShell.js";
|
|
14
|
-
import { LazyMessageDetailPane } from "../../shared/components/mail/LazyReadingPane.js";
|
|
14
|
+
import { LazyConversationThreadPane, LazyMessageDetailPane } from "../../shared/components/mail/LazyReadingPane.js";
|
|
15
|
+
import { useLocationSearch } from "../../shared/navigation/AppRouter.js";
|
|
15
16
|
import { ReadingPaneSkeleton } from "../../shared/components/mail/reading/MessageCard.js";
|
|
16
17
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
17
18
|
/**
|
|
18
19
|
* Only reached on mobile (below the `md` breakpoint) — desktop's `apps/www/index.tsx` keeps its existing
|
|
19
|
-
* inline reading pane and never navigates here; see that file's `handleSelect`.
|
|
20
|
+
* inline reading pane and never navigates here; see that file's `handleSelect`. A row of the conversation list
|
|
21
|
+
* comes with `?conversation=<id>` (`handleOpenConversation`), and then this page is the whole thread, opened at this
|
|
22
|
+
* message, as the desktop's reading pane is; without it (a flat list, a link to a message) it is that one message.
|
|
20
23
|
*/
|
|
21
24
|
function MessageDetailPage(props) {
|
|
22
25
|
return (_jsx(MailShell, { ...props, children: _jsx(MessageDetailContent, { uid: props.params.uid }) }));
|
|
@@ -27,6 +30,7 @@ function MessageDetailContent({ uid }) {
|
|
|
27
30
|
const [loading, setLoading] = useState(true);
|
|
28
31
|
const [error, setError] = useState(null);
|
|
29
32
|
const [labels, setLabels] = useState([]);
|
|
33
|
+
const conversationId = new URLSearchParams(useLocationSearch()).get("conversation");
|
|
30
34
|
useEffect(() => {
|
|
31
35
|
setLoading(true);
|
|
32
36
|
setError(null);
|
|
@@ -55,8 +59,9 @@ function MessageDetailContent({ uid }) {
|
|
|
55
59
|
cancelled = true;
|
|
56
60
|
};
|
|
57
61
|
}, [messageMailboxUid]);
|
|
58
|
-
|
|
59
|
-
|
|
62
|
+
// A thread loads, marks read and fetches the attachments of each message it opens itself.
|
|
63
|
+
const attachments = useMessageAttachments(conversationId ? null : message);
|
|
64
|
+
useMarkMessageRead(conversationId ? null : message, setMessage);
|
|
60
65
|
if (loading) {
|
|
61
66
|
// The pane's own frame - a header card and a message card - rather than a line of text: what is coming is known, only not yet its contents.
|
|
62
67
|
return _jsx(ReadingPaneSkeleton, {});
|
|
@@ -72,6 +77,9 @@ function MessageDetailContent({ uid }) {
|
|
|
72
77
|
const isSentItems = folders.find((f) => f.uid === message.folderUid)?.type === "sent_items";
|
|
73
78
|
const isOutbox = folders.find((f) => f.uid === message.folderUid)?.type === "outbox";
|
|
74
79
|
const draftsFolderUid = folders.find((f) => f.type === "drafts")?.uid;
|
|
80
|
+
if (conversationId) {
|
|
81
|
+
return (_jsxs("div", { className: "flex flex-col h-full min-h-0", children: [_jsx("a", { href: backHref, className: "text-sm text-primary-dark hover:underline block px-4 pt-3", children: "\u2190 Back to messages" }), _jsx(LazyConversationThreadPane, { conversation: { conversationId, subject: message.subject, messageCount: 1 }, selectedUid: message.uid, mailboxUid: message.mailboxUid, folders: folders, labels: labels, shortcuts: true, onMessagePatched: () => undefined, onMessageRemoved: () => undefined, onLabelCreated: (label) => setLabels((prev) => [...prev, label]), onFolderCreated: onFolderCreated })] }));
|
|
82
|
+
}
|
|
75
83
|
return (_jsx(LazyMessageDetailPane, { shortcuts: true, message: message, attachments: attachments, backHref: backHref, isSentItems: isSentItems, onRecalled: setMessage, isOutbox: isOutbox, onReceiptHandled: setMessage, draftsFolderUid: draftsFolderUid, folders: folders, onMoved: setMessage, onFolderCreated: onFolderCreated, onScheduledSendCanceled: setMessage, onArchived: setMessage, labels: labels, onLabelsChanged: setMessage, onLabelCreated: (label) => setLabels((prev) => [...prev, label]) }));
|
|
76
84
|
}
|
|
77
85
|
export default routedPage("/messages/:uid", MessageDetailPage);
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { SettingsShellProps } from "../../../shared/components/settings/layout/SettingsShell.js";
|
|
2
|
+
export type SettingsProfilePageProps = Omit<SettingsShellProps, "active">;
|
|
3
|
+
declare const _default: import("../../../shared/navigation/AppRouter.js").RoutedPageComponent<SettingsProfilePageProps>;
|
|
4
|
+
export default _default;
|