@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/apps/www/index.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import { useNavigate } from "../shared/navigation/AppRouter.js";
|
|
|
7
7
|
import { whenIdle } from "../shared/navigation/idle.js";
|
|
8
8
|
import { prefetchComposeWindow } from "../shared/components/mail/compose/ComposeContext.js";
|
|
9
9
|
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
10
|
+
import { createPortal } from "react-dom";
|
|
10
11
|
import { HiOutlineFlag, HiOutlineLockClosed, HiOutlinePaperClip } from "react-icons/hi2";
|
|
11
12
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
12
13
|
import {
|
|
@@ -55,6 +56,10 @@ import { useMarkMessageRead } from "../shared/mail/useMarkMessageRead.js";
|
|
|
55
56
|
import { ROW_FOCUS_CLASS, UnreadBar, UnreadLabel, dateClass, isUnread, rowClass, senderClass, subjectClass } from "../shared/components/mail/unreadStyle.js";
|
|
56
57
|
import { LazyConversationThreadPane, LazyMessageDetailPane, prefetchReadingPane } from "../shared/components/mail/LazyReadingPane.js";
|
|
57
58
|
import ConversationList from "../shared/components/mail/ConversationList.js";
|
|
59
|
+
import type { ConversationThreadHead } from "../shared/components/mail/ConversationThreadPane.js";
|
|
60
|
+
import SwipeRow from "../shared/components/mail/SwipeRow.js";
|
|
61
|
+
import InviteRowChip from "../shared/components/mail/invite/InviteRowChip.js";
|
|
62
|
+
import MoveToFolderDialog from "../shared/components/mail/MoveToFolderDialog.js";
|
|
58
63
|
import { EncryptedPreview } from "../shared/components/mail/reading/EncryptedPreview.js";
|
|
59
64
|
import MailListToolbar from "../shared/components/mail/MailListToolbar.js";
|
|
60
65
|
import MailSelectionBar from "../shared/components/mail/MailSelectionBar.js";
|
|
@@ -473,6 +478,29 @@ async function fetchAggregateMessages(
|
|
|
473
478
|
return mergeInboxMessages(perMailbox);
|
|
474
479
|
}
|
|
475
480
|
|
|
481
|
+
/** One conversation's part of a set of search results. */
|
|
482
|
+
interface ResultGroup {
|
|
483
|
+
/** The conversation's id - a message that belongs to no thread is its own conversation. */
|
|
484
|
+
id: string;
|
|
485
|
+
/** Only the messages of the conversation that are among the results, in the order they were ranked. */
|
|
486
|
+
messages: Message[];
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Groups search results by the conversation each message belongs to, the groups in the order of their first result. */
|
|
490
|
+
function groupByConversation(results: Message[]): ResultGroup[] {
|
|
491
|
+
const groups = new Map<string, ResultGroup>();
|
|
492
|
+
for (const message of results) {
|
|
493
|
+
const id = message.conversationId ?? message.uid;
|
|
494
|
+
const group = groups.get(id);
|
|
495
|
+
if (group) {
|
|
496
|
+
group.messages.push(message);
|
|
497
|
+
} else {
|
|
498
|
+
groups.set(id, { id, messages: [message] });
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return [...groups.values()];
|
|
502
|
+
}
|
|
503
|
+
|
|
476
504
|
function InboxPage(props: MailShellProps) {
|
|
477
505
|
return (
|
|
478
506
|
<MailShell {...props}>
|
|
@@ -482,7 +510,7 @@ function InboxPage(props: MailShellProps) {
|
|
|
482
510
|
}
|
|
483
511
|
|
|
484
512
|
function InboxContent({ userUid }: { userUid?: string }) {
|
|
485
|
-
const { folderUid, mailboxUid, mailboxes, mailboxFolders, aggregateFolderType, onFolderCreated, noteFolderUids, live, trackMessageChange } = useMailShell();
|
|
513
|
+
const { folderUid, mailboxUid, mailboxes, mailboxFolders, aggregateFolderType, onFolderCreated, noteFolderUids, live, trackMessageChange, mobileSearchSlot } = useMailShell();
|
|
486
514
|
const isMobile = useIsMobile();
|
|
487
515
|
const navigate = useNavigate();
|
|
488
516
|
const { requestUnlock } = useUnlockPrompt();
|
|
@@ -506,7 +534,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
506
534
|
const [selectedUid, setSelectedUid] = useState<string | null>(null);
|
|
507
535
|
// The thread the conversation list opened, and which of its messages was picked - the reading pane
|
|
508
536
|
// shows the whole conversation, positioned at that message (see `ConversationThreadPane`).
|
|
509
|
-
const [openThread, setOpenThread] = useState<{ conversation:
|
|
537
|
+
const [openThread, setOpenThread] = useState<{ conversation: ConversationThreadHead; uid: string } | null>(null);
|
|
510
538
|
// Messages the page has a newer copy of than `ConversationList` fetched (so far only the one the reading
|
|
511
539
|
// pane just marked read), applied over its own child rows so they don't stay bold after being read.
|
|
512
540
|
const [conversationPatches, setConversationPatches] = useState<Record<string, Message>>({});
|
|
@@ -592,7 +620,14 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
592
620
|
// Search stays real-folder-only - Tier 1/2/3 are all deeply mailbox/folder-scoped, and extending them
|
|
593
621
|
// to span an arbitrary number of mailboxes is out of scope for this pass (see the aggregate-fetch
|
|
594
622
|
// branch below, which the search effect never reaches while `folderUid` is unset).
|
|
595
|
-
|
|
623
|
+
// Search works whichever way the list is arranged. Its results are messages, so while a query is held the list is fetched as
|
|
624
|
+
// messages (`asConversations` is about the list's data and is false then), and when the reader arranges by conversation they are
|
|
625
|
+
// shown grouped under their conversation - only the messages that match - with the whole thread in the reading pane
|
|
626
|
+
// (`threadPane`, which follows the arrangement the reader chose and not the query).
|
|
627
|
+
const isSearching = searchQuery.length > 0 && !aggregateFolderType;
|
|
628
|
+
const asConversations = preferences.showAsConversations && !isSearching;
|
|
629
|
+
const threadPane = preferences.showAsConversations;
|
|
630
|
+
const groupedResults = isSearching && preferences.showAsConversations;
|
|
596
631
|
// Focused/Other is an Inbox-only concept (`FocusedInboxUtils.classifyMessage()` short-circuits to
|
|
597
632
|
// Focused for every other folder), and
|
|
598
633
|
// search results are ranked across folders rather than listed from one, so neither tab is offered
|
|
@@ -626,7 +661,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
626
661
|
* shown: `GET /mail/messages/conversations` takes no sort parameters at all, so those rows are ordered
|
|
627
662
|
* in the browser (`sortConversations()`) and rearranging them must not refetch the identical page -
|
|
628
663
|
* which would also collapse whichever conversations the reader had expanded. */
|
|
629
|
-
const serverSortKey =
|
|
664
|
+
const serverSortKey = asConversations ? "" : `${preferences.sortBy}:${preferences.sortOrder}`;
|
|
630
665
|
/** The conversation list's own equivalent of `listParams` - a function because the page differs. */
|
|
631
666
|
function conversationParams(page: number): ConversationListParams {
|
|
632
667
|
return { folderUid, filter: effectiveFilter, ...labelFilter, page, limit: MESSAGE_PAGE_SIZE };
|
|
@@ -779,7 +814,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
779
814
|
// `activeMailboxUid`, and offering that mailbox's labels would let a label from the wrong mailbox be
|
|
780
815
|
// applied. The conversation view stays scoped to `activeMailboxUid`, the mailbox its threads come from.
|
|
781
816
|
const selectedMessageMailboxUid = messages.find((m) => m.uid === selectedUid)?.mailboxUid;
|
|
782
|
-
const labelsMailboxUid =
|
|
817
|
+
const labelsMailboxUid = threadPane ? activeMailboxUid : (selectedMessageMailboxUid ?? activeMailboxUid);
|
|
783
818
|
// Almost always the open mailbox's own labels, already fetched below - so `labels` reuses that list
|
|
784
819
|
// rather than asking for the identical one a second time (which is what this page did on every single
|
|
785
820
|
// view). Only a selected message from *another* mailbox - a search hit or an aggregate-view row - needs
|
|
@@ -849,7 +884,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
849
884
|
const listKey = listSnapshotKey({
|
|
850
885
|
mailboxUid: activeMailboxUid,
|
|
851
886
|
folderUid,
|
|
852
|
-
conversations:
|
|
887
|
+
conversations: asConversations,
|
|
853
888
|
filter: effectiveFilter,
|
|
854
889
|
labels: labelFilterKey,
|
|
855
890
|
sort: serverSortKey,
|
|
@@ -920,7 +955,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
920
955
|
return;
|
|
921
956
|
}
|
|
922
957
|
|
|
923
|
-
if (
|
|
958
|
+
if (asConversations) {
|
|
924
959
|
// Conversations stay single-mailbox (not aggregated across mailboxes in this pass) - in
|
|
925
960
|
// aggregate mode this falls back to `activeMailboxUid`, the same mailbox unlock/labels use
|
|
926
961
|
// (always set - `MailShell` only renders this component once at least one mailbox exists).
|
|
@@ -1128,7 +1163,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1128
1163
|
// `refreshKey` is a dependency for the same reason: a failed bulk action bumps it to refetch,
|
|
1129
1164
|
// since a bulk update applies until its first rejection rather than all-or-nothing.
|
|
1130
1165
|
}, [
|
|
1131
|
-
|
|
1166
|
+
asConversations,
|
|
1132
1167
|
serverSortKey,
|
|
1133
1168
|
effectiveFilter,
|
|
1134
1169
|
labelFilterKey,
|
|
@@ -1163,7 +1198,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1163
1198
|
if (live.tick === 0 || isSearching || loading || loadMoreInFlightRef.current || (!folderUid && !aggregateFolderType)) {
|
|
1164
1199
|
return;
|
|
1165
1200
|
}
|
|
1166
|
-
if (!
|
|
1201
|
+
if (!asConversations && live.folderUids) {
|
|
1167
1202
|
const shown = new Set(
|
|
1168
1203
|
aggregateFolderType
|
|
1169
1204
|
? mailboxFolders.flatMap((entry) => entry.folders.filter((f) => f.type === aggregateFolderType).map((f) => f.uid))
|
|
@@ -1179,7 +1214,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1179
1214
|
const isCurrent = () => searchRunIdRef.current === listRun && liveRunRef.current === myRun;
|
|
1180
1215
|
void (async () => {
|
|
1181
1216
|
try {
|
|
1182
|
-
if (
|
|
1217
|
+
if (asConversations) {
|
|
1183
1218
|
const fresh = await listConversations(activeMailboxUid, conversationParams(0));
|
|
1184
1219
|
if (!isCurrent()) {
|
|
1185
1220
|
return;
|
|
@@ -1229,13 +1264,13 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1229
1264
|
// Reads the ref, not `conversations`/`messages` state, so this callback (and the observer effect
|
|
1230
1265
|
// watching it) don't need to be recreated every time a page lands - the same reason every other
|
|
1231
1266
|
// check below reads a ref rather than closing over state.
|
|
1232
|
-
const atRowCap = (
|
|
1267
|
+
const atRowCap = (asConversations ? conversationsRef.current.length : messagesRef.current.length) >= MAX_LOADED_ROWS;
|
|
1233
1268
|
if (
|
|
1234
1269
|
loadMoreInFlightRef.current ||
|
|
1235
1270
|
!hasMore ||
|
|
1236
1271
|
loading ||
|
|
1237
1272
|
atRowCap ||
|
|
1238
|
-
(!
|
|
1273
|
+
(!asConversations && !folderUid) ||
|
|
1239
1274
|
(isSearching && !searchCursor)
|
|
1240
1275
|
) {
|
|
1241
1276
|
return;
|
|
@@ -1266,7 +1301,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1266
1301
|
const myRunId = searchRunIdRef.current;
|
|
1267
1302
|
const isCurrentRun = () => searchRunIdRef.current === myRunId;
|
|
1268
1303
|
try {
|
|
1269
|
-
if (
|
|
1304
|
+
if (asConversations) {
|
|
1270
1305
|
const page = Math.floor(listedOffsetRef.current / MESSAGE_PAGE_SIZE);
|
|
1271
1306
|
const more = await listConversations(activeMailboxUid, conversationParams(page));
|
|
1272
1307
|
if (!isCurrentRun()) {
|
|
@@ -1344,7 +1379,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1344
1379
|
}, [
|
|
1345
1380
|
hasMore,
|
|
1346
1381
|
loading,
|
|
1347
|
-
|
|
1382
|
+
asConversations,
|
|
1348
1383
|
serverSortKey,
|
|
1349
1384
|
effectiveFilter,
|
|
1350
1385
|
labelFilterKey,
|
|
@@ -1437,7 +1472,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1437
1472
|
|
|
1438
1473
|
// The conversation list opens a whole thread in `ConversationThreadPane`, which loads and marks read
|
|
1439
1474
|
// its own messages; only the flat list feeds the single-message pane below.
|
|
1440
|
-
const selected =
|
|
1475
|
+
const selected = threadPane ? null : (messages.find((m) => m.uid === selectedUid) ?? null);
|
|
1441
1476
|
const attachments = useMessageAttachments(selected);
|
|
1442
1477
|
useMarkMessageRead(selected, patchListedMessage);
|
|
1443
1478
|
// Search results can span every folder in the mailbox, not just the one selected in the sidebar - a
|
|
@@ -1446,7 +1481,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1446
1481
|
// this falls back to the sidebar selection unchanged). Aggregate views span every *mailbox* too, so
|
|
1447
1482
|
// the folder list consulted is the selected message's own mailbox's, not the shell's ambient one. A
|
|
1448
1483
|
// conversation spans folders for the same reason (an Inbox message and the Sent Items copy of its reply).
|
|
1449
|
-
const spansFolders = isSearching || !!aggregateFolderType ||
|
|
1484
|
+
const spansFolders = isSearching || !!aggregateFolderType || threadPane;
|
|
1450
1485
|
const selectedFolderUid = spansFolders ? (selected?.folderUid ?? folderUid) : folderUid;
|
|
1451
1486
|
const selectedMailboxUid = spansFolders ? (selected?.mailboxUid ?? activeMailboxUid) : mailboxUid;
|
|
1452
1487
|
const folders = mailboxFolders.find((mf) => mf.mailbox.uid === selectedMailboxUid)?.folders ?? [];
|
|
@@ -1471,12 +1506,12 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1471
1506
|
const selectedConversationMessages = [...selectedConversationIds].flatMap((id) =>
|
|
1472
1507
|
(conversationMessagesById[id] ?? []).filter((m) => !folderUid || m.folderUid === folderUid),
|
|
1473
1508
|
);
|
|
1474
|
-
const selectedMessages =
|
|
1509
|
+
const selectedMessages = asConversations
|
|
1475
1510
|
? selectedConversationMessages
|
|
1476
1511
|
: messages.filter((m) => selectedUids.has(m.uid));
|
|
1477
1512
|
/** How many rows the list is actually showing - conversations or messages, whichever it lists. What the
|
|
1478
1513
|
* Select toggle is enabled by: there is nothing to select in a list with no rows. */
|
|
1479
|
-
const listedRowCount =
|
|
1514
|
+
const listedRowCount = asConversations ? conversations.length : messages.length;
|
|
1480
1515
|
/** The conversation rows in the order the reader arranged them. The endpoint takes no sort parameters
|
|
1481
1516
|
* of its own (it pages by latest activity), so the arrangement is applied here, to the rows fetched so
|
|
1482
1517
|
* far - which the Sort menu says on screen. `conversations` itself stays in the order the pages
|
|
@@ -1490,7 +1525,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1490
1525
|
* mailbox/search whose true size lands at or near the cap can have its very last page be a partial one,
|
|
1491
1526
|
* which correctly turns `hasMore` false - without checking it here, the banner would still claim rows
|
|
1492
1527
|
* are being hidden even though every one of them is already on screen. */
|
|
1493
|
-
const rowCapReached = hasMore && (
|
|
1528
|
+
const rowCapReached = hasMore && (asConversations ? conversations.length : messages.length) >= MAX_LOADED_ROWS;
|
|
1494
1529
|
|
|
1495
1530
|
function leaveSelectMode() {
|
|
1496
1531
|
setSelectMode(false);
|
|
@@ -1592,7 +1627,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1592
1627
|
}
|
|
1593
1628
|
}
|
|
1594
1629
|
}
|
|
1595
|
-
if (
|
|
1630
|
+
if (asConversations) {
|
|
1596
1631
|
// A conversation row is a summary of its messages - its count, unread count, participants
|
|
1597
1632
|
// and preview all move when a bulk action changes or empties part of it - so the list is
|
|
1598
1633
|
// reloaded rather than patched row by row.
|
|
@@ -1685,6 +1720,49 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1685
1720
|
return runBulkAction(async (moving) => moveMessages(moving, await resolveFolderOfType(type, name)), true, chosen);
|
|
1686
1721
|
}
|
|
1687
1722
|
|
|
1723
|
+
// ---- Swiping a row on a phone (see `SwipeRow`): right to left archives, left to right asks for a folder. Both go through the same
|
|
1724
|
+
// ---- bulk path as the selection bar's Archive and Move to, so the badges, the optimistic bookkeeping and the failure pop-up are one
|
|
1725
|
+
// ---- implementation. Not offered where the actions aren't (an aggregate view spans mailboxes, search results are a mixed bag, the
|
|
1726
|
+
// ---- Outbox is the server's send queue) or while rows are being ticked.
|
|
1727
|
+
const listingOutbox = currentFolders.find((f) => f.uid === folderUid)?.type === "outbox";
|
|
1728
|
+
const swipeEnabled = isMobile && !selectMode && !aggregateFolderType && !isSearching && !listingOutbox;
|
|
1729
|
+
/** 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. */
|
|
1730
|
+
const [moveDialog, setMoveDialog] = useState<{ messages: Message[] } | { conversation: ConversationSummary } | null>(null);
|
|
1731
|
+
|
|
1732
|
+
/** The messages a swiped conversation stands for (the ones in the folder being listed), or `null` after saying why they couldn't be loaded. */
|
|
1733
|
+
async function swipedConversationMessages(conversation: ConversationThreadHead): Promise<Message[] | null> {
|
|
1734
|
+
try {
|
|
1735
|
+
return await loadOpenConversation(conversation);
|
|
1736
|
+
} catch (err) {
|
|
1737
|
+
notifyApiError(err, "Couldn't load the messages in that conversation");
|
|
1738
|
+
return null;
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
/** Archives what a swipe took away. Resolves whether it went through, so the row comes back when it didn't. */
|
|
1743
|
+
async function swipeArchive(target: Message[] | ConversationSummary): Promise<boolean> {
|
|
1744
|
+
if (bulkBusy || resolvingSelection > 0) {
|
|
1745
|
+
return false;
|
|
1746
|
+
}
|
|
1747
|
+
const chosen = Array.isArray(target) ? target : await swipedConversationMessages(target);
|
|
1748
|
+
if (!chosen || chosen.length === 0) {
|
|
1749
|
+
return false;
|
|
1750
|
+
}
|
|
1751
|
+
return runBulkAction(bulkArchive, true, chosen);
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
async function swipeMove(folderUidToMoveTo: string): Promise<void> {
|
|
1755
|
+
// Only ever asked by the folder prompt, which is only open while `moveDialog` is set.
|
|
1756
|
+
const target = moveDialog!;
|
|
1757
|
+
const chosen = "messages" in target ? target.messages : await swipedConversationMessages(target.conversation);
|
|
1758
|
+
if (!chosen || chosen.length === 0) {
|
|
1759
|
+
setMoveDialog(null);
|
|
1760
|
+
return;
|
|
1761
|
+
}
|
|
1762
|
+
await runBulkAction((moving) => moveMessages(moving, folderUidToMoveTo), true, chosen);
|
|
1763
|
+
setMoveDialog(null);
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1688
1766
|
const loadMoreStatus = loadingMore ? (
|
|
1689
1767
|
"Loading more\u2026"
|
|
1690
1768
|
) : loadMoreError ? (
|
|
@@ -1702,11 +1780,120 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1702
1780
|
</button>
|
|
1703
1781
|
) : null;
|
|
1704
1782
|
|
|
1783
|
+
/** One message's row of the flat list - and, in search results grouped by conversation, of a group. */
|
|
1784
|
+
function renderMessageRow(message: Message) {
|
|
1785
|
+
return (
|
|
1786
|
+
<SwipeRow
|
|
1787
|
+
as="li"
|
|
1788
|
+
enabled={swipeEnabled}
|
|
1789
|
+
onArchive={() => swipeArchive([message])}
|
|
1790
|
+
onMove={() => setMoveDialog({ messages: [message] })}
|
|
1791
|
+
key={message.uid}
|
|
1792
|
+
data-message-uid={message.uid}
|
|
1793
|
+
data-unread={isUnread(message) ? "true" : undefined}
|
|
1794
|
+
className={rowClass(
|
|
1795
|
+
{ unread: isUnread(message), selected: message.uid === selectedUid || selectedUids.has(message.uid) },
|
|
1796
|
+
"flex items-stretch",
|
|
1797
|
+
)}
|
|
1798
|
+
>
|
|
1799
|
+
<UnreadBar unread={isUnread(message)} />
|
|
1800
|
+
{selectMode && (
|
|
1801
|
+
<span className="shrink-0 flex items-center pl-3">
|
|
1802
|
+
<input
|
|
1803
|
+
type="checkbox"
|
|
1804
|
+
checked={selectedUids.has(message.uid)}
|
|
1805
|
+
onChange={() => toggleSelected(message.uid)}
|
|
1806
|
+
aria-label={`Select ${rowSubject(message)}`}
|
|
1807
|
+
className="w-4 h-4 accent-primary"
|
|
1808
|
+
/>
|
|
1809
|
+
</span>
|
|
1810
|
+
)}
|
|
1811
|
+
{/* The open button and, for a meeting request, its RSVP chip: a button can't hold a button, so the chip is the button's sibling. */}
|
|
1812
|
+
<div className="flex-1 min-w-0 flex flex-col">
|
|
1813
|
+
<button
|
|
1814
|
+
type="button"
|
|
1815
|
+
data-row-open
|
|
1816
|
+
onClick={() => handleSelect(message)}
|
|
1817
|
+
className={["w-full text-left px-4 py-3", ROW_FOCUS_CLASS].join(" ")}
|
|
1818
|
+
>
|
|
1819
|
+
<UnreadLabel unread={isUnread(message)} />
|
|
1820
|
+
<div className="flex items-center justify-between gap-2 text-sm">
|
|
1821
|
+
<MailAddress recipient={message.from} className={senderClass(isUnread(message))} />
|
|
1822
|
+
<span className={["text-xs shrink-0", dateClass(isUnread(message))].join(" ")}>
|
|
1823
|
+
{new Date(message.receivedDate).toLocaleDateString()}
|
|
1824
|
+
</span>
|
|
1825
|
+
</div>
|
|
1826
|
+
{aggregateFolderType && (
|
|
1827
|
+
// The one view where a row needs to say which mailbox it came from.
|
|
1828
|
+
<div className="text-xs text-text-muted truncate font-normal">
|
|
1829
|
+
{mailboxes.find((mb) => mb.uid === message.mailboxUid)?.displayName}
|
|
1830
|
+
</div>
|
|
1831
|
+
)}
|
|
1832
|
+
{isSearching && pendingUids.has(message.uid) ? (
|
|
1833
|
+
// §_Progressive Results_: "Unresolved encrypted results MUST
|
|
1834
|
+
// be rendered as skeleton entries in place, not appended on
|
|
1835
|
+
// arrival." This uid is a Tier 1 metadataOnly guess Tier 2/3
|
|
1836
|
+
// haven't confirmed (or ruled out) yet.
|
|
1837
|
+
<div className="flex flex-col gap-1.5 py-0.5">
|
|
1838
|
+
<Skeleton height="h-3.5" className="w-2/3 rounded-sm" />
|
|
1839
|
+
<Skeleton height="h-3" className="w-full rounded-sm" />
|
|
1840
|
+
</div>
|
|
1841
|
+
) : (
|
|
1842
|
+
<>
|
|
1843
|
+
<div className={["text-sm truncate", subjectClass(isUnread(message))].join(" ")}>
|
|
1844
|
+
{rowSubject(message)}
|
|
1845
|
+
</div>
|
|
1846
|
+
<div className="flex items-center gap-2 text-xs text-text-muted font-normal">
|
|
1847
|
+
<span className="truncate">
|
|
1848
|
+
{snippets[message.uid] ||
|
|
1849
|
+
decryptedRows[message.uid]?.preview ||
|
|
1850
|
+
message.bodyPreview ||
|
|
1851
|
+
(message.encrypted ? <EncryptedPreview /> : null)}
|
|
1852
|
+
</span>
|
|
1853
|
+
{message.hasAttachments && <HiOutlinePaperClip size={12} aria-label="Has attachments" />}
|
|
1854
|
+
{message.flags.flagged && (
|
|
1855
|
+
<HiOutlineFlag size={12} aria-label="Flagged" className="text-danger" />
|
|
1856
|
+
)}
|
|
1857
|
+
</div>
|
|
1858
|
+
{/* What the server is doing with a message in Outbox: sending, retrying, or why it wasn't sent. */}
|
|
1859
|
+
{isOutbox && <OutboxRowStatus message={message} />}
|
|
1860
|
+
</>
|
|
1861
|
+
)}
|
|
1862
|
+
</button>
|
|
1863
|
+
<InviteRowChip message={message} onResponded={(updated) => patchListedMessage(updated)} />
|
|
1864
|
+
</div>
|
|
1865
|
+
</SwipeRow>
|
|
1866
|
+
);
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
/** Search results arranged by conversation: the messages that matched, grouped under the conversation each belongs to, in the order of the
|
|
1870
|
+
* first match of each - the order the results are ranked in. `null` when the list is not showing grouped results. */
|
|
1871
|
+
const resultGroups = groupedResults ? groupByConversation(messages) : null;
|
|
1872
|
+
|
|
1873
|
+
/** The search box: in the list on a desktop (flat lists only), in the shell's header beside the folders button on a phone. */
|
|
1874
|
+
const searchField = (
|
|
1875
|
+
<input
|
|
1876
|
+
ref={searchInputRef}
|
|
1877
|
+
type="search"
|
|
1878
|
+
value={searchInput}
|
|
1879
|
+
onChange={(e) => setSearchInput(e.target.value)}
|
|
1880
|
+
placeholder={aggregateFolderType ? "Open a mailbox's own folder to search" : "Search all mail…"}
|
|
1881
|
+
aria-label="Search all mail"
|
|
1882
|
+
disabled={!!aggregateFolderType}
|
|
1883
|
+
className={["w-full text-sm px-3 rounded-md border border-border bg-surface disabled:opacity-55", isMobile ? "py-2" : "py-1.5"].join(" ")}
|
|
1884
|
+
/>
|
|
1885
|
+
);
|
|
1886
|
+
|
|
1705
1887
|
function handleSelect(message: Message) {
|
|
1706
1888
|
if (selectMode) {
|
|
1707
1889
|
toggleSelected(message.uid);
|
|
1708
1890
|
return;
|
|
1709
1891
|
}
|
|
1892
|
+
if (preferences.showAsConversations) {
|
|
1893
|
+
// A search result of a list arranged by conversation: the reading pane is the whole thread, opened at this message.
|
|
1894
|
+
openThreadOf(message);
|
|
1895
|
+
return;
|
|
1896
|
+
}
|
|
1710
1897
|
if (isMobile) {
|
|
1711
1898
|
navigate(`/messages/${encodeURIComponent(message.uid)}`);
|
|
1712
1899
|
return;
|
|
@@ -1715,6 +1902,24 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1715
1902
|
setSelectedUid(message.uid);
|
|
1716
1903
|
}
|
|
1717
1904
|
|
|
1905
|
+
/** 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
|
|
1906
|
+
* subject to head it with while the thread loads. */
|
|
1907
|
+
function threadHeadOf(message: Message): ConversationThreadHead {
|
|
1908
|
+
return { conversationId: message.conversationId ?? message.uid, subject: message.subject ?? "", messageCount: 1 };
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
/** Opens the whole thread a message belongs to, positioned at it - the phone's message page, or the reading pane. */
|
|
1912
|
+
function openThreadOf(message: Message) {
|
|
1913
|
+
const conversation = threadHeadOf(message);
|
|
1914
|
+
if (isMobile) {
|
|
1915
|
+
navigate(`/messages/${encodeURIComponent(message.uid)}?conversation=${encodeURIComponent(conversation.conversationId)}`);
|
|
1916
|
+
return;
|
|
1917
|
+
}
|
|
1918
|
+
removedAnchorRef.current = null;
|
|
1919
|
+
setSelectedUid(message.uid);
|
|
1920
|
+
setOpenThread({ conversation, uid: message.uid });
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1718
1923
|
/** Opens a conversation in the reading pane, positioned at one of its messages: the one a child row
|
|
1719
1924
|
* stands for, or the latest for a parent row. The thread pane loads the thread itself. */
|
|
1720
1925
|
function handleOpenConversation(conversation: ConversationSummary, uid: string) {
|
|
@@ -1725,9 +1930,8 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1725
1930
|
return;
|
|
1726
1931
|
}
|
|
1727
1932
|
if (isMobile) {
|
|
1728
|
-
//
|
|
1729
|
-
|
|
1730
|
-
navigate(`/messages/${encodeURIComponent(uid)}`);
|
|
1933
|
+
// The message page shows the whole conversation, positioned at this message, when it is given the conversation.
|
|
1934
|
+
navigate(`/messages/${encodeURIComponent(uid)}?conversation=${encodeURIComponent(conversation.conversationId)}`);
|
|
1731
1935
|
return;
|
|
1732
1936
|
}
|
|
1733
1937
|
removedAnchorRef.current = null;
|
|
@@ -1739,11 +1943,11 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1739
1943
|
// ---- the selection bar call: the same bulk-action path (`runBulkAction()` - so the same optimistic badge tracking, the same rollback,
|
|
1740
1944
|
// ---- the same lazily created Deleted Items folder), never a second implementation. Reply, Reply all, Forward, Archive and Move to are
|
|
1741
1945
|
// ---- registered by the reading pane itself (`MessageDetailPane`'s `shortcuts`), which owns those handlers.
|
|
1742
|
-
const inConversations =
|
|
1946
|
+
const inConversations = asConversations;
|
|
1743
1947
|
/** The selection bar isn't offered in the aggregate view (its rows belong to several mailboxes), and neither are these. */
|
|
1744
1948
|
const keyboardActions = !aggregateFolderType;
|
|
1745
1949
|
/** What the keyboard acts on exists: the ticked rows in select mode, else the message (or conversation) open in the reading pane. */
|
|
1746
|
-
const keyboardTargetExists = selectMode ? selectedMessages.length > 0 :
|
|
1950
|
+
const keyboardTargetExists = selectMode ? selectedMessages.length > 0 : threadPane ? openThread !== null : selected !== null;
|
|
1747
1951
|
const rowCount = inConversations ? listedConversations.length : messages.length;
|
|
1748
1952
|
const canMoveSelection = !selectMode && !isMobile && !loading && rowCount > 0;
|
|
1749
1953
|
|
|
@@ -1800,7 +2004,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1800
2004
|
}
|
|
1801
2005
|
|
|
1802
2006
|
/** The messages of the open conversation that are in the folder being listed - fetched (once) like a ticked conversation's. */
|
|
1803
|
-
async function loadOpenConversation(conversation:
|
|
2007
|
+
async function loadOpenConversation(conversation: ConversationThreadHead): Promise<Message[]> {
|
|
1804
2008
|
const id = conversation.conversationId;
|
|
1805
2009
|
let all = conversationMessagesById[id];
|
|
1806
2010
|
if (!all) {
|
|
@@ -1826,7 +2030,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1826
2030
|
}
|
|
1827
2031
|
let chosen: Message[];
|
|
1828
2032
|
try {
|
|
1829
|
-
chosen = selectMode ? selectedMessages :
|
|
2033
|
+
chosen = selectMode ? selectedMessages : threadPane ? await loadOpenConversation(openThread!.conversation) : [selected!];
|
|
1830
2034
|
} catch (err) {
|
|
1831
2035
|
notifyApiError(err, "Couldn't load the messages in that conversation");
|
|
1832
2036
|
return;
|
|
@@ -1836,7 +2040,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1836
2040
|
return;
|
|
1837
2041
|
}
|
|
1838
2042
|
const succeeded = await runBulkAction(action, removesRows, chosen);
|
|
1839
|
-
if (succeeded && removesRows &&
|
|
2043
|
+
if (succeeded && removesRows && threadPane && !selectMode) {
|
|
1840
2044
|
setOpenThread(null);
|
|
1841
2045
|
setSelectedUid(null);
|
|
1842
2046
|
}
|
|
@@ -1916,7 +2120,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1916
2120
|
searchInputRef.current?.focus();
|
|
1917
2121
|
searchInputRef.current?.select();
|
|
1918
2122
|
},
|
|
1919
|
-
{ enabled: !
|
|
2123
|
+
{ enabled: !aggregateFolderType },
|
|
1920
2124
|
);
|
|
1921
2125
|
|
|
1922
2126
|
if (!folderUid && !aggregateFolderType) {
|
|
@@ -1942,17 +2146,17 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1942
2146
|
selected={selectedMessages}
|
|
1943
2147
|
listed={messages}
|
|
1944
2148
|
totals={
|
|
1945
|
-
|
|
2149
|
+
asConversations
|
|
1946
2150
|
? { selected: selectedConversationIds.size, listed: conversations.length, noun: "conversation" }
|
|
1947
2151
|
: undefined
|
|
1948
2152
|
}
|
|
1949
2153
|
onSelectAll={() =>
|
|
1950
|
-
|
|
2154
|
+
asConversations
|
|
1951
2155
|
? selectAllConversations()
|
|
1952
2156
|
: setSelectedUids(new Set(messages.map((m) => m.uid)))
|
|
1953
2157
|
}
|
|
1954
2158
|
onClearSelection={() =>
|
|
1955
|
-
|
|
2159
|
+
asConversations ? setSelectedConversationIds(new Set()) : setSelectedUids(new Set())
|
|
1956
2160
|
}
|
|
1957
2161
|
onCancel={leaveSelectMode}
|
|
1958
2162
|
folders={currentFolders}
|
|
@@ -2001,13 +2205,13 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2001
2205
|
sortKeysDisabled={isSearching || !!aggregateFolderType}
|
|
2002
2206
|
// A conversation row is a thread summary, so only some of the keys have anything to
|
|
2003
2207
|
// order by - the rest stay pickable and are applied to the rows already fetched.
|
|
2004
|
-
unavailableSortKeys={
|
|
2208
|
+
unavailableSortKeys={asConversations ? CONVERSATION_SORT_UNAVAILABLE : undefined}
|
|
2005
2209
|
sortKeysNote={
|
|
2006
2210
|
isSearching
|
|
2007
2211
|
? "Search results are ranked by relevance rather than sorted."
|
|
2008
2212
|
: aggregateFolderType
|
|
2009
2213
|
? "This view merges the newest mail from every mailbox and is always listed by date."
|
|
2010
|
-
:
|
|
2214
|
+
: asConversations
|
|
2011
2215
|
? CONVERSATION_SORT_NOTE
|
|
2012
2216
|
: undefined
|
|
2013
2217
|
}
|
|
@@ -2021,20 +2225,8 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2021
2225
|
}
|
|
2022
2226
|
/>
|
|
2023
2227
|
)}
|
|
2024
|
-
{!
|
|
2025
|
-
|
|
2026
|
-
<input
|
|
2027
|
-
ref={searchInputRef}
|
|
2028
|
-
type="search"
|
|
2029
|
-
value={searchInput}
|
|
2030
|
-
onChange={(e) => setSearchInput(e.target.value)}
|
|
2031
|
-
placeholder={aggregateFolderType ? "Open a mailbox's own folder to search" : "Search all mail…"}
|
|
2032
|
-
aria-label="Search all mail"
|
|
2033
|
-
disabled={!!aggregateFolderType}
|
|
2034
|
-
className="w-full text-sm px-3 py-1.5 rounded-md border border-border bg-surface disabled:opacity-55"
|
|
2035
|
-
/>
|
|
2036
|
-
</div>
|
|
2037
|
-
)}
|
|
2228
|
+
{!isMobile && <div className="p-2 border-b border-border">{searchField}</div>}
|
|
2229
|
+
{isMobile && mobileSearchSlot && createPortal(searchField, mobileSearchSlot)}
|
|
2038
2230
|
{/* Two tabs, as Outlook has: Focused and Other. There is no "All" tab - the whole Inbox is
|
|
2039
2231
|
still one pick away, in the Filter menu, which is where every other named filter lives
|
|
2040
2232
|
and the only place that can show which of them is really in force. A stored `all` (or
|
|
@@ -2104,7 +2296,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2104
2296
|
</button>
|
|
2105
2297
|
</div>
|
|
2106
2298
|
)}
|
|
2107
|
-
{!isSearching && !
|
|
2299
|
+
{!isSearching && !asConversations && undecryptedEncryptedUids.length > 0 && !getUnlockedKeys(activeMailboxUid) && (
|
|
2108
2300
|
<div className="px-4 py-2 border-b border-border bg-surface-alt">
|
|
2109
2301
|
<button
|
|
2110
2302
|
type="button"
|
|
@@ -2130,7 +2322,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2130
2322
|
<span className="sr-only">Loading…</span>
|
|
2131
2323
|
<SkeletonList count={8} />
|
|
2132
2324
|
</div>
|
|
2133
|
-
) :
|
|
2325
|
+
) : asConversations ? (
|
|
2134
2326
|
<>
|
|
2135
2327
|
<ConversationList
|
|
2136
2328
|
conversations={listedConversations}
|
|
@@ -2138,10 +2330,16 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2138
2330
|
mailboxUid={activeMailboxUid}
|
|
2139
2331
|
selectedUid={selectedUid}
|
|
2140
2332
|
messageOverrides={conversationPatches}
|
|
2333
|
+
onMeetingResponded={patchListedMessage}
|
|
2141
2334
|
onOpenMessage={handleOpenConversation}
|
|
2142
2335
|
selectMode={selectMode}
|
|
2143
2336
|
selectedConversationIds={selectedConversationIds}
|
|
2144
2337
|
onToggleSelected={toggleConversationSelected}
|
|
2338
|
+
swipe={{
|
|
2339
|
+
enabled: swipeEnabled,
|
|
2340
|
+
onArchive: swipeArchive,
|
|
2341
|
+
onMove: (conversation) => setMoveDialog({ conversation }),
|
|
2342
|
+
}}
|
|
2145
2343
|
/>
|
|
2146
2344
|
{hasMore && !rowCapReached && (
|
|
2147
2345
|
<div ref={setSentinel} data-testid="load-more-sentinel" className="p-4 text-center text-xs text-text-muted">
|
|
@@ -2173,81 +2371,20 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2173
2371
|
</>
|
|
2174
2372
|
) : (
|
|
2175
2373
|
<>
|
|
2176
|
-
<ul>
|
|
2177
|
-
{
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
<input
|
|
2191
|
-
type="checkbox"
|
|
2192
|
-
checked={selectedUids.has(message.uid)}
|
|
2193
|
-
onChange={() => toggleSelected(message.uid)}
|
|
2194
|
-
aria-label={`Select ${rowSubject(message)}`}
|
|
2195
|
-
className="w-4 h-4 accent-primary"
|
|
2196
|
-
/>
|
|
2197
|
-
</span>
|
|
2198
|
-
)}
|
|
2199
|
-
<button
|
|
2200
|
-
type="button"
|
|
2201
|
-
data-row-open
|
|
2202
|
-
onClick={() => handleSelect(message)}
|
|
2203
|
-
className={["flex-1 min-w-0 text-left px-4 py-3", ROW_FOCUS_CLASS].join(" ")}
|
|
2204
|
-
>
|
|
2205
|
-
<UnreadLabel unread={isUnread(message)} />
|
|
2206
|
-
<div className="flex items-center justify-between gap-2 text-sm">
|
|
2207
|
-
<MailAddress recipient={message.from} className={senderClass(isUnread(message))} />
|
|
2208
|
-
<span className={["text-xs shrink-0", dateClass(isUnread(message))].join(" ")}>
|
|
2209
|
-
{new Date(message.receivedDate).toLocaleDateString()}
|
|
2210
|
-
</span>
|
|
2211
|
-
</div>
|
|
2212
|
-
{aggregateFolderType && (
|
|
2213
|
-
// The one view where a row needs to say which mailbox it came from.
|
|
2214
|
-
<div className="text-xs text-text-muted truncate font-normal">
|
|
2215
|
-
{mailboxes.find((mb) => mb.uid === message.mailboxUid)?.displayName}
|
|
2216
|
-
</div>
|
|
2217
|
-
)}
|
|
2218
|
-
{isSearching && pendingUids.has(message.uid) ? (
|
|
2219
|
-
// §_Progressive Results_: "Unresolved encrypted results MUST
|
|
2220
|
-
// be rendered as skeleton entries in place, not appended on
|
|
2221
|
-
// arrival." This uid is a Tier 1 metadataOnly guess Tier 2/3
|
|
2222
|
-
// haven't confirmed (or ruled out) yet.
|
|
2223
|
-
<div className="flex flex-col gap-1.5 py-0.5">
|
|
2224
|
-
<Skeleton height="h-3.5" className="w-2/3 rounded-sm" />
|
|
2225
|
-
<Skeleton height="h-3" className="w-full rounded-sm" />
|
|
2226
|
-
</div>
|
|
2227
|
-
) : (
|
|
2228
|
-
<>
|
|
2229
|
-
<div className={["text-sm truncate", subjectClass(isUnread(message))].join(" ")}>
|
|
2230
|
-
{rowSubject(message)}
|
|
2231
|
-
</div>
|
|
2232
|
-
<div className="flex items-center gap-2 text-xs text-text-muted font-normal">
|
|
2233
|
-
<span className="truncate">
|
|
2234
|
-
{snippets[message.uid] ||
|
|
2235
|
-
decryptedRows[message.uid]?.preview ||
|
|
2236
|
-
message.bodyPreview ||
|
|
2237
|
-
(message.encrypted ? <EncryptedPreview /> : null)}
|
|
2238
|
-
</span>
|
|
2239
|
-
{message.hasAttachments && <HiOutlinePaperClip size={12} aria-label="Has attachments" />}
|
|
2240
|
-
{message.flags.flagged && (
|
|
2241
|
-
<HiOutlineFlag size={12} aria-label="Flagged" className="text-danger" />
|
|
2242
|
-
)}
|
|
2243
|
-
</div>
|
|
2244
|
-
{/* What the server is doing with a message in Outbox: sending, retrying, or why it wasn't sent. */}
|
|
2245
|
-
{isOutbox && <OutboxRowStatus message={message} />}
|
|
2246
|
-
</>
|
|
2247
|
-
)}
|
|
2248
|
-
</button>
|
|
2249
|
-
</li>
|
|
2250
|
-
))}
|
|
2374
|
+
<ul className={swipeEnabled ? "overflow-x-clip" : undefined}>
|
|
2375
|
+
{resultGroups
|
|
2376
|
+
? resultGroups.map((group) => (
|
|
2377
|
+
<li key={group.id} data-search-group={group.id}>
|
|
2378
|
+
<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">
|
|
2379
|
+
<span className="truncate font-semibold">{rowSubject(group.messages[0])}</span>
|
|
2380
|
+
<span className="shrink-0">
|
|
2381
|
+
{group.messages.length} matching message{group.messages.length === 1 ? "" : "s"}
|
|
2382
|
+
</span>
|
|
2383
|
+
</div>
|
|
2384
|
+
<ul>{group.messages.map(renderMessageRow)}</ul>
|
|
2385
|
+
</li>
|
|
2386
|
+
))
|
|
2387
|
+
: messages.map(renderMessageRow)}
|
|
2251
2388
|
</ul>
|
|
2252
2389
|
{hasMore && !rowCapReached && (
|
|
2253
2390
|
<div ref={setSentinel} data-testid="load-more-sentinel" className="p-4 text-center text-xs text-text-muted">
|
|
@@ -2275,7 +2412,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2275
2412
|
body iframe that cannot measure itself - takes its height from here, never from a `vh`
|
|
2276
2413
|
number of its own. */}
|
|
2277
2414
|
<div className="hidden md:flex flex-1 min-w-0 min-h-0">
|
|
2278
|
-
{
|
|
2415
|
+
{threadPane ? (
|
|
2279
2416
|
<LazyConversationThreadPane
|
|
2280
2417
|
conversation={openThread?.conversation ?? null}
|
|
2281
2418
|
selectedUid={openThread?.uid ?? null}
|
|
@@ -2325,6 +2462,16 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2325
2462
|
/>
|
|
2326
2463
|
)}
|
|
2327
2464
|
</div>
|
|
2465
|
+
<MoveToFolderDialog
|
|
2466
|
+
open={moveDialog !== null}
|
|
2467
|
+
onClose={() => setMoveDialog(null)}
|
|
2468
|
+
mailboxUid={activeMailboxUid}
|
|
2469
|
+
folders={currentFolders}
|
|
2470
|
+
currentFolderUid={folderUid}
|
|
2471
|
+
count={moveDialog === null ? 1 : "messages" in moveDialog ? moveDialog.messages.length : moveDialog.conversation.messageCount}
|
|
2472
|
+
onMove={swipeMove}
|
|
2473
|
+
onFolderCreated={onFolderCreated}
|
|
2474
|
+
/>
|
|
2328
2475
|
</div>
|
|
2329
2476
|
);
|
|
2330
2477
|
}
|