@rapidmx/web-client 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/apps/shared/components/admin/settings/BrandingForm.tsx +398 -385
  2. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +119 -112
  3. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +159 -152
  4. package/apps/shared/components/admin/settings/PluginsManager.tsx +1288 -1235
  5. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +9 -1
  6. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +288 -290
  7. package/apps/shared/components/admin/setup/SetupWizard.tsx +443 -416
  8. package/apps/shared/components/layout/AppShell.tsx +288 -288
  9. package/apps/shared/components/mail/ConversationList.tsx +225 -33
  10. package/apps/shared/components/mail/ConversationThreadPane.tsx +434 -216
  11. package/apps/shared/components/mail/MailListToolbar.tsx +265 -0
  12. package/apps/shared/components/mail/MailSelectionBar.tsx +222 -0
  13. package/apps/shared/components/mail/MenuButton.tsx +384 -0
  14. package/apps/shared/components/mail/MessageDetailPane.tsx +280 -215
  15. package/apps/shared/components/mail/MoveToFolderDialog.tsx +263 -0
  16. package/apps/shared/components/mail/compose/ComposeContext.tsx +29 -2
  17. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1700 -1652
  18. package/apps/shared/components/mail/compose/RecipientInput.tsx +356 -0
  19. package/apps/shared/components/mail/compose/RichTextEditor.tsx +38 -3
  20. package/apps/shared/components/mail/compose/quotedBody.ts +100 -0
  21. package/apps/shared/components/mail/compose/recipients.ts +104 -0
  22. package/apps/shared/components/mail/labelMenu.tsx +278 -0
  23. package/apps/shared/components/mail/layout/MailShell.tsx +445 -420
  24. package/apps/shared/components/mail/listPreferences.ts +228 -0
  25. package/apps/shared/components/settings/layout/SettingsShell.tsx +0 -1
  26. package/apps/shared/styles/app.css +28 -5
  27. package/apps/www/index.tsx +1854 -1314
  28. package/apps/www/messages/[uid].tsx +5 -4
  29. package/dist/apps/shared/components/admin/settings/BrandingForm.d.ts +7 -3
  30. package/dist/apps/shared/components/admin/settings/BrandingForm.js +6 -5
  31. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.d.ts +4 -1
  32. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +2 -2
  33. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.d.ts +4 -1
  34. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +2 -2
  35. package/dist/apps/shared/components/admin/settings/PluginsManager.d.ts +8 -1
  36. package/dist/apps/shared/components/admin/settings/PluginsManager.js +33 -14
  37. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.d.ts +4 -1
  38. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +2 -2
  39. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +1 -1
  40. package/dist/apps/shared/components/admin/setup/SetupWizard.js +14 -10
  41. package/dist/apps/shared/components/mail/ConversationList.d.ts +42 -6
  42. package/dist/apps/shared/components/mail/ConversationList.js +84 -10
  43. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +52 -13
  44. package/dist/apps/shared/components/mail/ConversationThreadPane.js +217 -60
  45. package/dist/apps/shared/components/mail/MailListToolbar.d.ts +55 -0
  46. package/dist/apps/shared/components/mail/MailListToolbar.js +149 -0
  47. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +59 -0
  48. package/dist/apps/shared/components/mail/MailSelectionBar.js +48 -0
  49. package/dist/apps/shared/components/mail/MenuButton.d.ts +71 -0
  50. package/dist/apps/shared/components/mail/MenuButton.js +211 -0
  51. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +27 -19
  52. package/dist/apps/shared/components/mail/MessageDetailPane.js +126 -129
  53. package/dist/apps/shared/components/mail/MoveToFolderDialog.d.ts +57 -0
  54. package/dist/apps/shared/components/mail/MoveToFolderDialog.js +138 -0
  55. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +16 -1
  56. package/dist/apps/shared/components/mail/compose/ComposeContext.js +3 -1
  57. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +52 -20
  58. package/dist/apps/shared/components/mail/compose/RecipientInput.d.ts +36 -0
  59. package/dist/apps/shared/components/mail/compose/RecipientInput.js +203 -0
  60. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +10 -1
  61. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +21 -3
  62. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +37 -0
  63. package/dist/apps/shared/components/mail/compose/quotedBody.js +85 -0
  64. package/dist/apps/shared/components/mail/compose/recipients.d.ts +24 -0
  65. package/dist/apps/shared/components/mail/compose/recipients.js +87 -0
  66. package/dist/apps/shared/components/mail/labelMenu.d.ts +97 -0
  67. package/dist/apps/shared/components/mail/labelMenu.js +134 -0
  68. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +8 -0
  69. package/dist/apps/shared/components/mail/layout/MailShell.js +15 -3
  70. package/dist/apps/shared/components/mail/listPreferences.d.ts +101 -0
  71. package/dist/apps/shared/components/mail/listPreferences.js +186 -0
  72. package/dist/apps/shared/components/settings/layout/SettingsShell.js +0 -1
  73. package/dist/apps/shared/styles/app.css +28 -5
  74. package/dist/apps/www/index.js +506 -112
  75. package/dist/apps/www/messages/[uid].js +2 -3
  76. package/package.json +2 -2
  77. package/apps/www/settings/focused-inbox/index.tsx +0 -150
  78. package/dist/apps/www/settings/focused-inbox/index.d.ts +0 -4
  79. package/dist/apps/www/settings/focused-inbox/index.js +0 -71
@@ -1,11 +1,47 @@
1
1
  import React from "react";
2
+ import { Message } from "@rapidmx/react-shared/mail/mailApi.js";
2
3
  import { ConversationSummary } from "@rapidmx/react-shared/mail/conversationsApi.js";
3
4
  export interface ConversationListProps {
4
5
  conversations: ConversationSummary[];
5
- selectedId: string | null;
6
- onSelect: (conversation: ConversationSummary) => void;
6
+ /** The mailbox the conversations were listed from - `listConversationMessages()` is mailbox-scoped. */
7
+ mailboxUid: string;
8
+ /** The message currently open in the reading pane, so the row standing for it can be marked. */
9
+ selectedUid: string | null;
10
+ /**
11
+ * Opens a conversation in the reading pane, positioned at one of its messages: the one a child row
12
+ * stands for, or the conversation's own `latestMessageUid` for a parent row. The conversation goes
13
+ * with the uid because the pane shows the whole thread, not just that message.
14
+ */
15
+ onOpenMessage: (conversation: ConversationSummary, uid: string) => void;
16
+ /** Newer copies of messages this list already fetched - the reading pane marks the message it opens as
17
+ * read, which this list would otherwise keep showing as unread until the thread is collapsed and
18
+ * expanded again. */
19
+ messageOverrides?: Record<string, Message>;
20
+ /** Turns each parent row into a checkbox row: select mode here ticks whole conversations, since a
21
+ * conversation is what this list's rows are. A child row stays a plain "open this message" button - a
22
+ * mixed conversation/message selection has no sensible bulk semantics (see `MailListToolbar`). */
23
+ selectMode?: boolean;
24
+ /** The `conversationId`s currently ticked. */
25
+ selectedConversationIds?: Set<string>;
26
+ onToggleSelected?: (conversation: ConversationSummary) => void;
27
+ /**
28
+ * Whether a conversation's own messages read newest first, matching the order sense the rows themselves
29
+ * are in ("Newest on top"). `listConversationMessages()` always answers oldest first - the order a
30
+ * thread is read in - so this reverses that copy for display rather than asking for it differently.
31
+ */
32
+ newestFirst?: boolean;
7
33
  }
8
- /** The "By conversation" counterpart to the per-message list `apps/www/index.tsx` renders inline for
9
- * "By date" — conversations are mailbox-wide (see `conversationsApi.ts`), so there's no per-folder
10
- * empty/loading state to distinguish here beyond what the page itself already handles. */
11
- export default function ConversationList({ conversations, selectedId, onSelect }: ConversationListProps): React.JSX.Element;
34
+ /**
35
+ * The nested, Outlook-style conversation list: one parent row per conversation - its participants, subject,
36
+ * message count, unread count, flag/attachment hints and the latest message's preview - expanding via its
37
+ * own chevron into the conversation's individual messages as child rows, fetched on first expand with
38
+ * `listConversationMessages()` (one request for the whole thread, across folders, oldest first).
39
+ *
40
+ * Opening a row opens the whole conversation in `ConversationThreadPane`, positioned at the message the row
41
+ * stands for: the latest one for a parent row, that one for a child row.
42
+ *
43
+ * The chevron is a button of its own beside the row's own button rather than inside it (a button can't
44
+ * nest inside a button), so expanding and opening are separately reachable by keyboard and each carries
45
+ * its own name.
46
+ */
47
+ export default function ConversationList({ conversations, mailboxUid, selectedUid, onOpenMessage, messageOverrides, selectMode, selectedConversationIds, onToggleSelected, newestFirst, }: ConversationListProps): React.JSX.Element;
@@ -1,17 +1,91 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- /** The "By conversation" counterpart to the per-message list `apps/www/index.tsx` renders inline for
3
- * "By date" — conversations are mailbox-wide (see `conversationsApi.ts`), so there's no per-folder
4
- * empty/loading state to distinguish here beyond what the page itself already handles. */
5
- export default function ConversationList({ conversations, selectedId, onSelect }) {
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useState } from "react";
7
+ import { HiChevronDown, HiChevronRight, HiOutlineFlag, HiOutlinePaperClip } from "react-icons/hi2";
8
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
9
+ import { listConversationMessages } from "@rapidmx/react-shared/mail/conversationsApi.js";
10
+ function participantNames(conversation) {
11
+ return conversation.participants.map((participant) => participant.displayName || participant.address).join(", ");
12
+ }
13
+ /**
14
+ * The nested, Outlook-style conversation list: one parent row per conversation - its participants, subject,
15
+ * message count, unread count, flag/attachment hints and the latest message's preview - expanding via its
16
+ * own chevron into the conversation's individual messages as child rows, fetched on first expand with
17
+ * `listConversationMessages()` (one request for the whole thread, across folders, oldest first).
18
+ *
19
+ * Opening a row opens the whole conversation in `ConversationThreadPane`, positioned at the message the row
20
+ * stands for: the latest one for a parent row, that one for a child row.
21
+ *
22
+ * The chevron is a button of its own beside the row's own button rather than inside it (a button can't
23
+ * nest inside a button), so expanding and opening are separately reachable by keyboard and each carries
24
+ * its own name.
25
+ */
26
+ export default function ConversationList({ conversations, mailboxUid, selectedUid, onOpenMessage, messageOverrides, selectMode, selectedConversationIds, onToggleSelected, newestFirst, }) {
27
+ const [expanded, setExpanded] = useState(new Set());
28
+ const [messagesById, setMessagesById] = useState({});
29
+ const [loadingIds, setLoadingIds] = useState(new Set());
30
+ const [errorsById, setErrorsById] = useState({});
31
+ function toggle(conversation) {
32
+ const id = conversation.conversationId;
33
+ setExpanded((prev) => {
34
+ const next = new Set(prev);
35
+ if (next.has(id)) {
36
+ next.delete(id);
37
+ }
38
+ else {
39
+ next.add(id);
40
+ }
41
+ return next;
42
+ });
43
+ if (expanded.has(id) || messagesById[id] || loadingIds.has(id)) {
44
+ return;
45
+ }
46
+ setLoadingIds((prev) => new Set(prev).add(id));
47
+ setErrorsById((prev) => {
48
+ const next = { ...prev };
49
+ delete next[id];
50
+ return next;
51
+ });
52
+ listConversationMessages(mailboxUid, id)
53
+ .then((loaded) => setMessagesById((prev) => ({ ...prev, [id]: loaded })))
54
+ .catch((err) => setErrorsById((prev) => ({
55
+ ...prev,
56
+ [id]: err instanceof ApiRequestError ? err.message : "Could not load this conversation's messages.",
57
+ })))
58
+ .finally(() => setLoadingIds((prev) => {
59
+ const next = new Set(prev);
60
+ next.delete(id);
61
+ return next;
62
+ }));
63
+ }
6
64
  if (conversations.length === 0) {
7
- return _jsx("p", { className: "p-4 text-sm text-text-muted", children: "No conversations in this mailbox." });
65
+ return _jsx("p", { className: "p-4 text-sm text-text-muted", children: "No conversations in this folder." });
8
66
  }
9
67
  return (_jsx("ul", { children: conversations.map((conversation) => {
68
+ const id = conversation.conversationId;
69
+ const isExpanded = expanded.has(id);
10
70
  const unread = conversation.unreadCount > 0;
11
- return (_jsx("li", { children: _jsxs("button", { type: "button", onClick: () => onSelect(conversation), className: [
12
- "w-full text-left px-4 py-3 border-b border-border",
13
- conversation.conversationId === selectedId ? "bg-primary/10" : "hover:bg-surface-alt",
14
- unread ? "font-semibold" : "",
15
- ].join(" "), children: [_jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx("span", { className: "truncate", children: conversation.participants.map((p) => p.displayName || p.address).join(", ") }), _jsx("span", { className: "text-xs text-text-muted shrink-0", children: new Date(conversation.latestDate).toLocaleDateString() })] }), _jsx("div", { className: "text-sm truncate", children: conversation.subject || "(no subject)" }), _jsxs("div", { className: "flex items-center gap-2 mt-1 text-xs text-text-muted font-normal", children: [conversation.messageCount > 1 && _jsxs("span", { children: [conversation.messageCount, " messages"] }), unread && (_jsxs("span", { className: "py-0.5 px-2 rounded-pill bg-primary/10 text-primary-dark font-semibold", children: [conversation.unreadCount, " unread"] })), conversation.hasAttachments && _jsx("span", { "aria-label": "Has attachments", children: "\uD83D\uDCCE" })] })] }) }, conversation.conversationId));
71
+ const loaded = messagesById[id];
72
+ // `listConversationMessages()` answers oldest first; the rows read in whichever sense the
73
+ // list itself is arranged in. Copied before reversing - the fetched array is cached.
74
+ const children = loaded && newestFirst ? [...loaded].reverse() : loaded;
75
+ const panelId = `conversation-messages-${id}`;
76
+ const ticked = selectedConversationIds?.has(id) ?? false;
77
+ return (_jsxs("li", { children: [_jsxs("div", { className: [
78
+ "flex items-stretch border-b border-border",
79
+ conversation.latestMessageUid === selectedUid || ticked
80
+ ? "bg-primary/10"
81
+ : "hover:bg-surface-alt",
82
+ ].join(" "), children: [selectMode && (_jsx("span", { className: "shrink-0 flex items-center pl-3", children: _jsx("input", { type: "checkbox", checked: ticked, onChange: () => onToggleSelected?.(conversation), "aria-label": `Select conversation: ${conversation.subject || "(no subject)"}`, className: "w-4 h-4 accent-primary" }) })), _jsx("button", { type: "button", onClick: () => toggle(conversation), "aria-expanded": isExpanded, "aria-controls": panelId, "aria-label": `${isExpanded ? "Collapse" : "Expand"} conversation: ${conversation.subject || "(no subject)"}`, className: "shrink-0 px-2 text-text-muted hover:text-text", children: isExpanded ? (_jsx(HiChevronDown, { size: 16, "aria-hidden": "true" })) : (_jsx(HiChevronRight, { size: 16, "aria-hidden": "true" })) }), _jsxs("button", { type: "button", onClick: () => onOpenMessage(conversation, conversation.latestMessageUid), className: ["flex-1 min-w-0 text-left pr-4 py-3", unread ? "font-semibold" : ""].join(" "), children: [_jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx("span", { className: "truncate", children: participantNames(conversation) }), _jsx("span", { className: "text-xs text-text-muted shrink-0", children: new Date(conversation.latestDate).toLocaleDateString() })] }), _jsx("div", { className: "text-sm truncate", children: conversation.subject || "(no subject)" }), _jsx("div", { className: "text-xs text-text-muted truncate font-normal", children: conversation.latestPreview }), _jsxs("div", { className: "flex items-center gap-2 mt-1 text-xs text-text-muted font-normal", children: [conversation.messageCount > 1 && _jsxs("span", { children: [conversation.messageCount, " messages"] }), unread && conversation.messageCount > 1 && (_jsxs("span", { className: "py-0.5 px-2 rounded-pill bg-primary/10 text-primary-dark font-semibold", children: [conversation.unreadCount, " unread"] })), conversation.hasAttachments && (_jsx(HiOutlinePaperClip, { size: 13, "aria-label": "Has attachments" })), conversation.flagged && (_jsx(HiOutlineFlag, { size: 13, "aria-label": "Flagged", className: "text-danger" }))] })] })] }), _jsxs("ul", { id: panelId, hidden: !isExpanded, children: [errorsById[id] && (_jsx("li", { role: "alert", className: "pl-8 pr-4 py-2 text-xs text-danger border-b border-border", children: errorsById[id] })), loadingIds.has(id) && (_jsx("li", { className: "pl-8 pr-4 py-2 text-xs text-text-muted border-b border-border", children: "Loading messages\u2026" })), children?.map((fetched) => {
83
+ const message = messageOverrides?.[fetched.uid] ?? fetched;
84
+ return (_jsx("li", { children: _jsxs("button", { type: "button", onClick: () => onOpenMessage(conversation, message.uid), className: [
85
+ "w-full text-left pl-8 pr-4 py-2 border-b border-border",
86
+ message.uid === selectedUid ? "bg-primary/10" : "hover:bg-surface-alt",
87
+ message.flags.read ? "" : "font-semibold",
88
+ ].join(" "), children: [_jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx("span", { className: "truncate", children: message.from.displayName || message.from.address }), _jsx("span", { className: "text-xs text-text-muted shrink-0", children: new Date(message.receivedDate).toLocaleDateString() })] }), _jsxs("div", { className: "flex items-center gap-2 text-xs text-text-muted font-normal", children: [_jsx("span", { className: "truncate", children: message.bodyPreview }), message.hasAttachments && (_jsx(HiOutlinePaperClip, { size: 12, "aria-label": "Has attachments" })), message.flags.flagged && (_jsx(HiOutlineFlag, { size: 12, "aria-label": "Flagged", className: "text-danger" }))] })] }) }, message.uid));
89
+ })] })] }, id));
16
90
  }) }));
17
91
  }
@@ -1,23 +1,62 @@
1
1
  import React from "react";
2
- import { Folder } from "@rapidmx/react-shared/mail/mailApi.js";
2
+ import { Folder, Message } from "@rapidmx/react-shared/mail/mailApi.js";
3
3
  import { ConversationSummary } from "@rapidmx/react-shared/mail/conversationsApi.js";
4
4
  import { Label } from "@rapidmx/react-shared/mail/labelsApi.js";
5
+ /** One request's worth of the thread. The server's own default for `listConversationMessages()`. */
6
+ export declare const THREAD_PAGE_SIZE = 100;
7
+ /**
8
+ * How many of a conversation's messages this pane will load. The server reads at most
9
+ * `CONVERSATION_SCAN_LIMIT` (500) messages when it groups conversations, so a `ConversationSummary` never
10
+ * describes more than this many anyway; the cap is here so a thread that somehow reports more can't turn
11
+ * into an unbounded run of requests. Past it the pane says so and the rest stay readable from the list.
12
+ */
13
+ export declare const THREAD_MESSAGE_LIMIT = 500;
5
14
  export interface ConversationThreadPaneProps {
6
15
  conversation: ConversationSummary | null;
7
- /** The mailbox's full folder list — a conversation can span folders (e.g. an Inbox message and the
8
- * Sent Items copy of its reply), so each message's own recall eligibility is looked up individually
9
- * against its own `folderUid` rather than assuming one folder for the whole thread. */
16
+ /** The mailbox the conversation was listed from - `listConversationMessages()` is mailbox-scoped. */
17
+ mailboxUid: string;
18
+ /**
19
+ * The message the reader opened: the thread is scrolled to it, it takes focus, and it is the *oldest*
20
+ * message left expanded (see `expandedFrom()`). `null`, or a uid this thread doesn't hold, falls back
21
+ * to the newest message - which is also what a parent row means by "open the conversation".
22
+ */
23
+ selectedUid: string | null;
24
+ /** The mailbox's full folder list. A conversation spans folders (an Inbox message and the Sent Items
25
+ * copy of its reply), so each message's own folder type is looked up against its own `folderUid`. */
10
26
  folders: Folder[];
11
- /** The mailbox's labels, fetched once by the parent (`apps/www/index.tsx`) and passed through to
12
- * every expanded message's own `MessageDetailPane` — see that component's own `labels` prop. */
27
+ /** The mailbox's labels, passed through to every expanded message's own `MessageDetailPane`. */
13
28
  labels?: Label[];
29
+ /**
30
+ * A newer copy of one of the thread's messages - read, flagged, labelled, classified, recalled - for
31
+ * the caller's own list to stay in step with what was done in here.
32
+ *
33
+ * `previous` is the copy this pane held before the change, where it has one: the conversation row this
34
+ * thread came from is a *summary* (a message count, an unread count), so a list showing those rows has
35
+ * no way to tell "this message has just been read" from "this already-read message was relabelled"
36
+ * without it - which is what left a row reporting "2 unread" after both had been read.
37
+ */
38
+ onMessagePatched: (updated: Message, previous?: Message) => void;
39
+ /** A message that left the folder being listed (archived, or a scheduled send sent back to Drafts). */
40
+ onMessageRemoved: (updated: Message) => void;
41
+ onLabelCreated?: (label: Label) => void;
42
+ /** A folder created from a message's own Move to prompt, for the folder sidebar to pick up. */
43
+ onFolderCreated?: (folder: Folder) => void;
14
44
  }
15
45
  /**
16
- * The merged, Gmail-style reading pane for "By conversation" mode — every message in the conversation
17
- * fetched and stacked oldest-to-newest, all but the most recent collapsed to a one-line summary
18
- * (sender + subject) until clicked. Reuses `MessageDetailPane` for each *expanded* message's own
19
- * header/attachments/body-iframe rendering rather than re-implementing it; a collapsed row is its own
20
- * lightweight summary, since mounting a full `MessageDetailPane` (and its iframe) for every message in
21
- * a long thread up front would be wasteful.
46
+ * The reading pane for the conversation list: the whole thread, **newest at the top**, opened at the message
47
+ * the reader picked. Every message from that one through to the newest is expanded - which in this order is
48
+ * the opened message and the entries above it - and the older ones, below it, are collapsed to a one-line
49
+ * summary (sender, date, preview) that expands on click or Enter. So opening the newest message shows just
50
+ * the top entry expanded, and opening 5 of 10 expands 10 down to 5.
51
+ *
52
+ * Newest first regardless of how the *list* is sorted: the list's own order arranges rows to pick from,
53
+ * while this is one conversation being read, and the entry a conversation row stands for - its latest
54
+ * message - is the one that should be at the top of the pane every time it is opened.
55
+ *
56
+ * Each *expanded* message is a `MessageDetailPane` of its own rather than a reimplementation of it, so the
57
+ * signature and verification badges, the verification-seal and decryption behaviour, the labels chips and
58
+ * menu, the attachments and Reply/Reply All/Forward/Archive all behave exactly as they do in the
59
+ * single-message pane, and each acts on the message it belongs to. A collapsed message mounts none of
60
+ * that - mounting a body iframe per message up front would be wasteful in a long thread.
22
61
  */
23
- export default function ConversationThreadPane({ conversation, folders, labels }: ConversationThreadPaneProps): React.JSX.Element;
62
+ export default function ConversationThreadPane({ conversation, mailboxUid, selectedUid, folders, labels, onMessagePatched, onMessageRemoved, onLabelCreated, onFolderCreated, }: ConversationThreadPaneProps): React.JSX.Element;
@@ -3,56 +3,145 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // Copyright (C) 2026 Jean-Philippe Steinmetz
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
- import { useEffect, useRef, useState } from "react";
6
+ import { useEffect, useLayoutEffect, useRef, useState } from "react";
7
7
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
- import { getMessage, listAttachments, setMessageRead } from "@rapidmx/react-shared/mail/mailApi.js";
9
- import MessageDetailPane from "./MessageDetailPane.js";
8
+ import { listAttachments, setMessageRead } from "@rapidmx/react-shared/mail/mailApi.js";
9
+ import { listConversationMessages } from "@rapidmx/react-shared/mail/conversationsApi.js";
10
10
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
11
+ import MessageDetailPane from "./MessageDetailPane.js";
12
+ /** One request's worth of the thread. The server's own default for `listConversationMessages()`. */
13
+ export const THREAD_PAGE_SIZE = 100;
14
+ /**
15
+ * How many of a conversation's messages this pane will load. The server reads at most
16
+ * `CONVERSATION_SCAN_LIMIT` (500) messages when it groups conversations, so a `ConversationSummary` never
17
+ * describes more than this many anyway; the cap is here so a thread that somehow reports more can't turn
18
+ * into an unbounded run of requests. Past it the pane says so and the rest stay readable from the list.
19
+ */
20
+ export const THREAD_MESSAGE_LIMIT = 500;
11
21
  /**
12
- * The merged, Gmail-style reading pane for "By conversation" mode — every message in the conversation
13
- * fetched and stacked oldest-to-newest, all but the most recent collapsed to a one-line summary
14
- * (sender + subject) until clicked. Reuses `MessageDetailPane` for each *expanded* message's own
15
- * header/attachments/body-iframe rendering rather than re-implementing it; a collapsed row is its own
16
- * lightweight summary, since mounting a full `MessageDetailPane` (and its iframe) for every message in
17
- * a long thread up front would be wasteful.
22
+ * Every message from `selectedUid` through to the newest - the run the reader is reading. Anything older
23
+ * stays collapsed to its one-line summary. Selecting the newest message therefore expands just that one.
24
+ *
25
+ * The pane lists newest first (see `loadThread()`), so that run is the opened message together with
26
+ * everything *above* it, and the opened message is the set's **last** entry rather than its first.
18
27
  */
19
- export default function ConversationThreadPane({ conversation, folders, labels }) {
20
- const [messages, setMessages] = useState({});
28
+ function expandedFrom(messages, selectedUid) {
29
+ const index = messages.findIndex((message) => message.uid === selectedUid);
30
+ // `messages` is never empty here (the callers below check), so -1 means "not in this thread" and the
31
+ // newest message - the first entry - is the anchor, exactly as a parent row's own click means.
32
+ const anchor = index === -1 ? 0 : index;
33
+ return new Set(messages.slice(0, anchor + 1).map((message) => message.uid));
34
+ }
35
+ /**
36
+ * The element a message of this thread actually scrolls inside. The pane is not itself the scroll
37
+ * container in the mail shell - `MailShell`'s own `<main>` is - so an adjustment has to be applied where
38
+ * the scrolling really happens, which is whichever ancestor is both scrollable and overflowing.
39
+ */
40
+ function scrollingAncestor(node) {
41
+ for (let el = node.parentElement; el; el = el.parentElement) {
42
+ const overflowY = getComputedStyle(el).overflowY;
43
+ if ((overflowY === "auto" || overflowY === "scroll") && el.scrollHeight > el.clientHeight) {
44
+ return el;
45
+ }
46
+ }
47
+ // Nothing between the row and the root scrolls, so the page itself does - which in standards mode is
48
+ // `documentElement`, the same element `document.scrollingElement` names there.
49
+ return document.documentElement;
50
+ }
51
+ /**
52
+ * The thread's messages, **newest first**, in pages of `THREAD_PAGE_SIZE` up to `THREAD_MESSAGE_LIMIT`.
53
+ *
54
+ * `listConversationMessages()` pages oldest first and has no order of its own to ask for, so the pages are
55
+ * collected in that order - which is also the order the `THREAD_MESSAGE_LIMIT` cap has to apply in, since it
56
+ * is the oldest messages that are dropped when a thread is too long to load - and reversed once at the end.
57
+ * The pane reads newest first always, whatever the *list* is sorted by: it is the reading order for mail,
58
+ * and it means the message a conversation row stands for is the entry at the top.
59
+ */
60
+ async function loadThread(mailboxUid, conversationId) {
61
+ const messages = [];
62
+ let more = true;
63
+ while (more && messages.length < THREAD_MESSAGE_LIMIT) {
64
+ const page = await listConversationMessages(mailboxUid, conversationId, {
65
+ page: messages.length / THREAD_PAGE_SIZE,
66
+ limit: THREAD_PAGE_SIZE,
67
+ });
68
+ messages.push(...page);
69
+ // A short page is the last one; a full page means asking for another.
70
+ more = page.length === THREAD_PAGE_SIZE;
71
+ }
72
+ messages.reverse();
73
+ return { messages, truncated: more };
74
+ }
75
+ /**
76
+ * The reading pane for the conversation list: the whole thread, **newest at the top**, opened at the message
77
+ * the reader picked. Every message from that one through to the newest is expanded - which in this order is
78
+ * the opened message and the entries above it - and the older ones, below it, are collapsed to a one-line
79
+ * summary (sender, date, preview) that expands on click or Enter. So opening the newest message shows just
80
+ * the top entry expanded, and opening 5 of 10 expands 10 down to 5.
81
+ *
82
+ * Newest first regardless of how the *list* is sorted: the list's own order arranges rows to pick from,
83
+ * while this is one conversation being read, and the entry a conversation row stands for - its latest
84
+ * message - is the one that should be at the top of the pane every time it is opened.
85
+ *
86
+ * Each *expanded* message is a `MessageDetailPane` of its own rather than a reimplementation of it, so the
87
+ * signature and verification badges, the verification-seal and decryption behaviour, the labels chips and
88
+ * menu, the attachments and Reply/Reply All/Forward/Archive all behave exactly as they do in the
89
+ * single-message pane, and each acts on the message it belongs to. A collapsed message mounts none of
90
+ * that - mounting a body iframe per message up front would be wasteful in a long thread.
91
+ */
92
+ export default function ConversationThreadPane({ conversation, mailboxUid, selectedUid, folders, labels, onMessagePatched, onMessageRemoved, onLabelCreated, onFolderCreated, }) {
93
+ const [messages, setMessages] = useState([]);
21
94
  const [attachmentsByUid, setAttachmentsByUid] = useState({});
22
95
  const [expandedUids, setExpandedUids] = useState(new Set());
96
+ const [truncated, setTruncated] = useState(false);
23
97
  const [loading, setLoading] = useState(false);
24
98
  const [error, setError] = useState(null);
25
- // Bumped on every conversation switch - any in-flight load/attachments/mark-read response carrying an
99
+ /** The message to scroll to and focus once it has rendered, or `null` once that has happened. */
100
+ const [pendingFocusUid, setPendingFocusUid] = useState(null);
101
+ // Bumped on every conversation switch - an in-flight load/attachments/mark-read response carrying an
26
102
  // older generation belongs to a superseded conversation and is dropped rather than applied.
27
103
  const generationRef = useRef(0);
28
- // Message uids a mark-read / attachments request has already been issued for in the current
29
- // generation, so re-running the effect below (every `messages`/`expandedUids` change) while one is
30
- // still in flight never fires a duplicate - which for mark-read would also carry a stale `version`.
31
104
  const markReadRequestedRef = useRef(new Set());
32
105
  const attachmentsRequestedRef = useRef(new Set());
106
+ /** The `conversationId:selectedUid` the expansion run below has already been applied for, so patching
107
+ * a message (which changes `messages`) doesn't re-expand what the reader has since collapsed. */
108
+ const appliedSelectionRef = useRef(null);
109
+ /** Which conversation the messages currently in state belong to. A render with a new conversation and
110
+ * the previous one's messages still in state happens before the load effect has cleared them, and the
111
+ * expansion run below must sit that render out rather than anchor on a message from another thread. */
112
+ const loadedIdRef = useRef(null);
113
+ const rowRefs = useRef({});
114
+ const headerRefs = useRef({});
115
+ /** Where the toggled message's header sat in the viewport before it expanded, so the run below can put
116
+ * it back there - expanding a message above the one being read must not shove that one off-screen. */
117
+ const anchorRef = useRef(null);
118
+ const conversationId = conversation?.conversationId;
33
119
  useEffect(() => {
34
120
  const generation = ++generationRef.current;
35
121
  markReadRequestedRef.current = new Set();
36
122
  attachmentsRequestedRef.current = new Set();
37
- setMessages({});
123
+ appliedSelectionRef.current = null;
124
+ loadedIdRef.current = null;
125
+ setMessages([]);
38
126
  setAttachmentsByUid({});
39
127
  setExpandedUids(new Set());
40
- if (!conversation) {
128
+ setTruncated(false);
129
+ setError(null);
130
+ // The message the previous conversation was to be scrolled to went with it; leaving it set would
131
+ // point the run below at a row that is no longer rendered.
132
+ setPendingFocusUid(null);
133
+ if (!conversationId) {
41
134
  setLoading(false);
42
135
  return;
43
136
  }
44
137
  setLoading(true);
45
- setError(null);
46
- Promise.all(conversation.messageUids.map((uid) => getMessage(uid)))
138
+ loadThread(mailboxUid, conversationId)
47
139
  .then((loaded) => {
48
140
  if (generation !== generationRef.current)
49
141
  return;
50
- setMessages(Object.fromEntries(loaded.map((m) => [m.uid, m])));
51
- // `messageUids` always has at least one entry — a `ConversationSummary` only ever
52
- // exists because it was grouped from real messages (see
53
- // `BaseMessageRoute.conversations()`).
54
- const latestUid = conversation.messageUids[conversation.messageUids.length - 1];
55
- setExpandedUids(new Set([latestUid]));
142
+ loadedIdRef.current = conversationId;
143
+ setMessages(loaded.messages);
144
+ setTruncated(loaded.truncated);
56
145
  })
57
146
  .catch((err) => {
58
147
  if (generation !== generationRef.current)
@@ -63,16 +152,68 @@ export default function ConversationThreadPane({ conversation, folders, labels }
63
152
  if (generation === generationRef.current)
64
153
  setLoading(false);
65
154
  });
66
- }, [conversation?.conversationId]);
67
- // Lazily loads attachments and marks-as-read only for messages the reader has actually expanded —
68
- // mirrors `mailDetailHooks.ts`'s `useMessageAttachments`/`useMarkMessageRead`, reimplemented here
69
- // (rather than called in a loop, which the rules of hooks don't allow) since a thread can expand
70
- // more than one message at once, unlike the single-selected-message case those hooks were built for.
155
+ }, [conversationId, mailboxUid]);
156
+ // Opening the thread, and opening a different message of the same thread, both set the run of expanded
157
+ // messages and ask for that message to be scrolled to. `messages` is a dependency because the thread's
158
+ // messages arrive after the click that selected one of them; the ref guard keeps a later change to
159
+ // `messages` (a patched copy) from re-running it.
160
+ useEffect(() => {
161
+ if (messages.length === 0 || loadedIdRef.current !== conversationId)
162
+ return;
163
+ const key = `${conversationId}:${selectedUid}`;
164
+ if (appliedSelectionRef.current === key)
165
+ return;
166
+ appliedSelectionRef.current = key;
167
+ const expanded = expandedFrom(messages, selectedUid);
168
+ setExpandedUids(expanded);
169
+ // The oldest expanded message is the one that was opened - `expandedFrom()`'s own anchor - which in
170
+ // this newest-first order is the *last* of the run, not the first.
171
+ setPendingFocusUid([...expanded][expanded.size - 1]);
172
+ }, [conversationId, selectedUid, messages]);
173
+ useLayoutEffect(() => {
174
+ if (!pendingFocusUid)
175
+ return;
176
+ // The row is always rendered by now: this runs after the DOM update that added the message it
177
+ // names, and that message came out of `messages` in the first place.
178
+ //
179
+ // Scrolled inside the thread's own list and nowhere else. `scrollIntoView()` scrolls *every*
180
+ // scrollable ancestor, the window included, which with a run of full-height messages expanded
181
+ // took the app header and the folder rail off the screen - so the adjustment goes on whichever
182
+ // ancestor actually scrolls, exactly as the toggle below already does it. When that is the page
183
+ // itself, nothing inside the pane scrolls and the row is already in view, so it is left alone.
184
+ const row = rowRefs.current[pendingFocusUid];
185
+ const scroller = scrollingAncestor(row);
186
+ if (scroller !== document.documentElement) {
187
+ const rect = row.getBoundingClientRect();
188
+ const top = rect.top - scroller.getBoundingClientRect().top;
189
+ // "nearest": the least that brings it into view, and nothing at all when it is already there.
190
+ if (top < 0 || top + rect.height > scroller.clientHeight) {
191
+ scroller.scrollTop += top;
192
+ }
193
+ }
194
+ // `preventScroll` so focusing doesn't scroll it somewhere else again.
195
+ headerRefs.current[pendingFocusUid].focus({ preventScroll: true });
196
+ setPendingFocusUid(null);
197
+ }, [pendingFocusUid, expandedUids]);
198
+ // Keeps the message whose header was just clicked where it was on screen. Expanding one above the
199
+ // message being read otherwise pushes everything below it down by however tall the new body is.
200
+ useLayoutEffect(() => {
201
+ const anchor = anchorRef.current;
202
+ if (!anchor)
203
+ return;
204
+ anchorRef.current = null;
205
+ // The row is still there: the anchor was taken from a rendered row, and toggling never removes one.
206
+ const row = rowRefs.current[anchor.uid];
207
+ scrollingAncestor(row).scrollTop += row.getBoundingClientRect().top - anchor.top;
208
+ }, [expandedUids]);
209
+ // Attachments and mark-as-read, for expanded messages only - mirrors `mailDetailHooks.ts`'s
210
+ // `useMessageAttachments`/`useMarkMessageRead`, reimplemented here (rather than called in a loop, which
211
+ // the rules of hooks don't allow) because a thread expands several messages at once.
71
212
  useEffect(() => {
72
213
  const generation = generationRef.current;
73
- for (const uid of expandedUids) {
74
- const message = messages[uid];
75
- if (!message)
214
+ for (const message of messages) {
215
+ const uid = message.uid;
216
+ if (!expandedUids.has(uid))
76
217
  continue;
77
218
  if (message.hasAttachments && !attachmentsRequestedRef.current.has(uid)) {
78
219
  attachmentsRequestedRef.current.add(uid);
@@ -83,31 +224,43 @@ export default function ConversationThreadPane({ conversation, folders, labels }
83
224
  }
84
225
  })
85
226
  .catch(() => {
86
- // Best-effort, same as the mark-as-read catch below — `attachmentsByUid[uid] ??
87
- // []` already renders no attachments while this stays unset; forgetting the
88
- // request just lets a later re-expand retry it.
227
+ // Best-effort, as in `useMessageAttachments`: no attachments render meanwhile, and
228
+ // forgetting the request lets a later re-expand retry it.
89
229
  attachmentsRequestedRef.current.delete(uid);
90
230
  });
91
231
  }
92
- // `message` is this render's latest copy, so the request carries its current `version`.
93
232
  if (!message.flags.read && !markReadRequestedRef.current.has(uid)) {
94
233
  markReadRequestedRef.current.add(uid);
234
+ // `message` is this render's copy, so the request carries its current `version`.
95
235
  setMessageRead(message, true)
96
236
  .then((updated) => {
97
237
  if (generation !== generationRef.current)
98
238
  return;
99
- // Never clobber a newer copy another action (label/classify/...) already patched in.
100
- setMessages((prev) => (prev[uid].version > updated.version ? prev : { ...prev, [uid]: updated }));
239
+ // `message` is the unread copy this pane just replaced - what tells the list's own
240
+ // conversation row that its unread count has gone down by one.
241
+ patchMessage(updated, message);
101
242
  })
102
243
  .catch(() => {
103
- // Best-effort — matches `useMarkMessageRead`'s own doc comment. Forgetting the
104
- // request lets a later effect run (e.g. a re-expand) retry with a fresh copy.
244
+ // Best-effort, as in `useMarkMessageRead`.
105
245
  markReadRequestedRef.current.delete(uid);
106
246
  });
107
247
  }
108
248
  }
109
249
  }, [expandedUids, messages]);
250
+ /** A newer copy of one of the thread's messages, kept here and handed to the list - with the copy it
251
+ * replaces where the caller was given one, so a conversation row can tell what actually changed. */
252
+ function patchMessage(updated, previous) {
253
+ setMessages((prev) => prev.map((message) => (message.uid === updated.uid ? updated : message)));
254
+ onMessagePatched(updated, previous);
255
+ }
256
+ /** A message that left the folder being listed - it leaves the thread too, as it left the list. */
257
+ function removeMessage(updated) {
258
+ setMessages((prev) => prev.filter((message) => message.uid !== updated.uid));
259
+ onMessageRemoved(updated);
260
+ }
110
261
  function toggleExpanded(uid) {
262
+ // The row this button lives in has rendered, so its ref is set.
263
+ anchorRef.current = { uid, top: rowRefs.current[uid].getBoundingClientRect().top };
111
264
  setExpandedUids((prev) => {
112
265
  const next = new Set(prev);
113
266
  if (next.has(uid)) {
@@ -119,6 +272,9 @@ export default function ConversationThreadPane({ conversation, folders, labels }
119
272
  return next;
120
273
  });
121
274
  }
275
+ function folderTypeOf(message) {
276
+ return folders.find((folder) => folder.uid === message.folderUid)?.type;
277
+ }
122
278
  if (!conversation) {
123
279
  return _jsx("p", { className: "p-8 text-sm text-text-muted", children: "Select a conversation to read it." });
124
280
  }
@@ -128,23 +284,24 @@ export default function ConversationThreadPane({ conversation, folders, labels }
128
284
  if (error) {
129
285
  return (_jsx("div", { className: "p-4 flex-1", children: _jsx(Alert, { children: error }) }));
130
286
  }
131
- return (_jsxs("div", { className: "flex-1 min-w-0 flex flex-col overflow-y-auto", children: [_jsxs("div", { className: "border-b border-border p-4", children: [_jsx("h1", { className: "text-lg font-bold tracking-tight", children: conversation.subject || "(no subject)" }), _jsxs("p", { className: "text-sm text-text-muted mt-1", children: [conversation.messageCount, " message", conversation.messageCount === 1 ? "" : "s"] })] }), conversation.messageUids.map((uid) => {
132
- const message = messages[uid];
133
- if (!message) {
134
- return null;
135
- }
136
- const expanded = expandedUids.has(uid);
137
- if (!expanded) {
138
- return (_jsxs("button", { type: "button", onClick: () => toggleExpanded(uid), className: [
139
- "w-full text-left px-4 py-3 border-b border-border hover:bg-surface-alt",
140
- message.flags.read ? "" : "font-semibold",
141
- ].join(" "), children: [_jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx("span", { className: "truncate", children: message.from.displayName || message.from.address }), _jsx("span", { className: "text-xs text-text-muted shrink-0", children: new Date(message.receivedDate).toLocaleString() })] }), _jsx("div", { className: "text-xs text-text-muted truncate font-normal", children: message.bodyPreview })] }, uid));
142
- }
143
- return (_jsxs("div", { className: "border-b border-border", children: [_jsx("button", { type: "button", onClick: () => toggleExpanded(uid), className: "w-full text-left px-4 pt-2 text-xs text-text-muted hover:text-text", children: "Collapse" }), _jsx(MessageDetailPane, { message: message, attachments: attachmentsByUid[uid] ?? [], isSentItems: folders.find((f) => f.uid === message.folderUid)?.type === "sent_items", onRecalled: (updated) => setMessages((prev) => ({ ...prev, [uid]: updated })), isOutbox: folders.find((f) => f.uid === message.folderUid)?.type === "outbox", draftsFolderUid: folders.find((f) => f.type === "drafts")?.uid,
144
- // Unlike `apps/www/index.tsx`'s list-removal behavior, this patches the
145
- // message in place (same as `onRecalled` above) rather than removing it from
146
- // the conversation — `conversation.messageUids` is owned by the parent, not
147
- // this component, so there's no membership list here to remove it from.
148
- onScheduledSendCanceled: (updated) => setMessages((prev) => ({ ...prev, [uid]: updated })), isInbox: folders.find((f) => f.uid === message.folderUid)?.type === "inbox", onClassified: (updated) => setMessages((prev) => ({ ...prev, [uid]: updated })), onReceiptHandled: (updated) => setMessages((prev) => ({ ...prev, [uid]: updated })), onArchived: (updated) => setMessages((prev) => ({ ...prev, [uid]: updated })), labels: labels, onLabelsChanged: (updated) => setMessages((prev) => ({ ...prev, [uid]: updated })) })] }, uid));
149
- })] }));
287
+ return (_jsxs("div", { className: "flex-1 min-w-0 min-h-0 flex flex-col", children: [_jsxs("div", { className: "shrink-0 border-b border-border p-4", children: [_jsx("h1", { className: "text-lg font-bold tracking-tight", children: conversation.subject || "(no subject)" }), _jsxs("p", { className: "text-sm text-text-muted mt-1", children: [messages.length, " message", messages.length === 1 ? "" : "s"] }), truncated && (_jsxs("p", { className: "text-xs text-text-muted mt-1", children: ["Only the oldest ", THREAD_MESSAGE_LIMIT, " messages of this conversation are shown here. The rest are still in the message list."] }))] }), _jsx("ul", { className: "flex-1 min-h-0 overflow-y-auto", children: messages.map((message) => {
288
+ const uid = message.uid;
289
+ const expanded = expandedUids.has(uid);
290
+ const bodyId = `thread-message-${uid}`;
291
+ const sender = message.from.displayName || message.from.address;
292
+ return (_jsxs("li", { ref: (node) => {
293
+ rowRefs.current[uid] = node;
294
+ },
295
+ // An expanded message is as tall as the list it scrolls in - `min-h-full`
296
+ // against the `<ul>`'s own resolved height - so its `MessageDetailPane` below
297
+ // has a full pane of height to fill and its body reaches the bottom of the
298
+ // window whatever that window's size is. `min-` rather than `h-`: a message
299
+ // whose header alone is taller than the pane still gets the room it needs.
300
+ className: ["border-b border-border", expanded ? "flex flex-col min-h-full" : ""].join(" "), children: [_jsx("h2", { className: "shrink-0", children: _jsxs("button", { type: "button", ref: (node) => {
301
+ headerRefs.current[uid] = node;
302
+ }, onClick: () => toggleExpanded(uid), "aria-expanded": expanded, "aria-controls": bodyId, className: [
303
+ "w-full text-left px-4 py-3 hover:bg-surface-alt",
304
+ message.flags.read ? "" : "font-semibold",
305
+ ].join(" "), children: [_jsxs("span", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx("span", { className: "truncate", children: sender }), _jsx("span", { className: "text-xs text-text-muted shrink-0", children: new Date(message.receivedDate).toLocaleString() })] }), !expanded && (_jsx("span", { className: "block text-xs text-text-muted truncate font-normal", children: message.bodyPreview }))] }) }), _jsx("div", { id: bodyId, hidden: !expanded, className: expanded ? "flex-1 min-h-0 flex" : undefined, children: expanded && (_jsx(MessageDetailPane, { inThread: true, message: message, attachments: attachmentsByUid[uid] ?? [], isSentItems: folderTypeOf(message) === "sent_items", isOutbox: folderTypeOf(message) === "outbox", draftsFolderUid: folders.find((folder) => folder.type === "drafts")?.uid, folders: folders, onMoved: removeMessage, onFolderCreated: onFolderCreated, onRecalled: patchMessage, onReceiptHandled: patchMessage, onScheduledSendCanceled: removeMessage, onArchived: removeMessage, labels: labels, onLabelsChanged: patchMessage, onLabelCreated: onLabelCreated })) })] }, uid));
306
+ }) })] }));
150
307
  }