@rapidmx/web-client 0.9.0 → 0.10.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 (226) hide show
  1. package/README.md +192 -88
  2. package/apps/shared/auth/accountUrl.ts +9 -0
  3. package/apps/shared/auth/adminAccess.ts +99 -0
  4. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +2 -1
  5. package/apps/shared/components/calendar/layout/CalendarShell.tsx +8 -2
  6. package/apps/shared/components/contacts/ContactsToolbar.tsx +125 -115
  7. package/apps/shared/components/contacts/layout/ContactsShell.tsx +15 -3
  8. package/apps/shared/components/layout/AppShell.tsx +411 -288
  9. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -389
  10. package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -164
  11. package/apps/shared/components/layout/UserMenu.tsx +303 -185
  12. package/apps/shared/components/mail/ConversationList.tsx +269 -262
  13. package/apps/shared/components/mail/ConversationThreadPane.tsx +475 -434
  14. package/apps/shared/components/mail/LazyReadingPane.tsx +115 -0
  15. package/apps/shared/components/mail/MailSelectionBar.tsx +235 -222
  16. package/apps/shared/components/mail/MessageDetailPane.tsx +1458 -1387
  17. package/apps/shared/components/mail/NewMailToasts.tsx +141 -0
  18. package/apps/shared/components/mail/compose/ComposeContext.tsx +279 -160
  19. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -418
  20. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1842 -1736
  21. package/apps/shared/components/mail/compose/ComposeWindowPlaceholder.tsx +111 -0
  22. package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -118
  23. package/apps/shared/components/mail/compose/composePerf.ts +46 -0
  24. package/apps/shared/components/mail/compose/quotedBody.ts +161 -100
  25. package/apps/shared/components/mail/layout/MailShell.tsx +476 -472
  26. package/apps/shared/components/mail/unreadStyle.tsx +75 -0
  27. package/apps/shared/components/settings/layout/SettingsShell.tsx +252 -240
  28. package/apps/shared/components/tasks/layout/TasksShell.tsx +15 -3
  29. package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -0
  30. package/apps/shared/keyboard/ShortcutProvider.tsx +62 -0
  31. package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -0
  32. package/apps/shared/keyboard/dispatch.ts +124 -0
  33. package/apps/shared/keyboard/format.ts +89 -0
  34. package/apps/shared/keyboard/keymap.ts +114 -0
  35. package/apps/shared/keyboard/match.ts +44 -0
  36. package/apps/shared/keyboard/parse.ts +136 -0
  37. package/apps/shared/keyboard/platform.ts +34 -0
  38. package/apps/shared/keyboard/registry.ts +65 -0
  39. package/apps/shared/keyboard/targets.ts +79 -0
  40. package/apps/shared/keyboard/useShortcut.ts +50 -0
  41. package/apps/shared/keyboard/useShortcutProps.ts +17 -0
  42. package/apps/shared/mail/folderCounts.ts +302 -0
  43. package/apps/shared/mail/listSnapshots.ts +87 -0
  44. package/apps/shared/mail/messageReadState.ts +85 -0
  45. package/apps/shared/mail/newMailNotifications.ts +183 -0
  46. package/apps/shared/mail/useMailConnection.ts +150 -0
  47. package/apps/shared/mail/useMailLiveUpdates.ts +232 -224
  48. package/apps/shared/mail/useMarkMessageRead.ts +47 -0
  49. package/apps/shared/mail/useNewMailNotifications.ts +163 -0
  50. package/apps/shared/mail/useUnreadTitle.ts +42 -0
  51. package/apps/shared/navigation/AppRouter.tsx +300 -0
  52. package/apps/shared/navigation/appHrefs.ts +23 -0
  53. package/apps/shared/navigation/frameContext.tsx +35 -0
  54. package/apps/shared/navigation/idle.ts +45 -0
  55. package/apps/shared/navigation/routerContext.tsx +83 -0
  56. package/apps/shared/navigation/routes.ts +72 -0
  57. package/apps/shared/search/LocalIndexLifecycle.tsx +114 -98
  58. package/apps/shared/styles/app.css +28 -10
  59. package/apps/www/_routedPage.tsx +24 -0
  60. package/apps/www/_routes.ts +35 -0
  61. package/apps/www/calendar/index.tsx +55 -19
  62. package/apps/www/contacts/[uid].tsx +112 -107
  63. package/apps/www/contacts/index.tsx +584 -567
  64. package/apps/www/index.tsx +2280 -1917
  65. package/apps/www/messages/[uid].tsx +106 -101
  66. package/apps/www/settings/auto-reply/index.tsx +4 -1
  67. package/apps/www/settings/encryption/index.tsx +1252 -1249
  68. package/apps/www/settings/filters/[uid].tsx +4 -1
  69. package/apps/www/settings/filters/index.tsx +102 -99
  70. package/apps/www/settings/filters/new/index.tsx +138 -133
  71. package/apps/www/settings/labels/index.tsx +204 -201
  72. package/apps/www/settings/privacy/index.tsx +4 -1
  73. package/apps/www/settings/read-receipts/index.tsx +4 -1
  74. package/apps/www/settings/sharing/index.tsx +277 -274
  75. package/apps/www/settings/signatures/[uid].tsx +170 -167
  76. package/apps/www/settings/signatures/index.tsx +88 -85
  77. package/apps/www/settings/signatures/new/index.tsx +134 -129
  78. package/apps/www/tasks/index.tsx +18 -2
  79. package/dist/apps/shared/auth/accountUrl.d.ts +2 -0
  80. package/dist/apps/shared/auth/accountUrl.js +8 -0
  81. package/dist/apps/shared/auth/adminAccess.d.ts +30 -0
  82. package/dist/apps/shared/auth/adminAccess.js +89 -0
  83. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +1 -1
  84. package/dist/apps/shared/components/calendar/layout/CalendarShell.d.ts +1 -1
  85. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +8 -4
  86. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
  87. package/dist/apps/shared/components/contacts/ContactsToolbar.js +7 -4
  88. package/dist/apps/shared/components/contacts/layout/ContactsShell.d.ts +1 -1
  89. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +15 -5
  90. package/dist/apps/shared/components/layout/AppShell.d.ts +29 -4
  91. package/dist/apps/shared/components/layout/AppShell.js +73 -14
  92. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -9
  93. package/dist/apps/shared/components/layout/MailboxProvisioning.js +3 -1
  94. package/dist/apps/shared/components/layout/UserMenu.d.ts +19 -3
  95. package/dist/apps/shared/components/layout/UserMenu.js +52 -5
  96. package/dist/apps/shared/components/mail/ConversationList.js +4 -11
  97. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
  98. package/dist/apps/shared/components/mail/ConversationThreadPane.js +52 -27
  99. package/dist/apps/shared/components/mail/LazyReadingPane.d.ts +9 -0
  100. package/dist/apps/shared/components/mail/LazyReadingPane.js +82 -0
  101. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +4 -1
  102. package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -3
  103. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +7 -1
  104. package/dist/apps/shared/components/mail/MessageDetailPane.js +83 -34
  105. package/dist/apps/shared/components/mail/NewMailToasts.d.ts +19 -0
  106. package/dist/apps/shared/components/mail/NewMailToasts.js +55 -0
  107. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +30 -0
  108. package/dist/apps/shared/components/mail/compose/ComposeContext.js +71 -5
  109. package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +4 -3
  110. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +1 -1
  111. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +98 -22
  112. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.d.ts +19 -0
  113. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.js +33 -0
  114. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +9 -1
  115. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +20 -2
  116. package/dist/apps/shared/components/mail/compose/composePerf.d.ts +16 -0
  117. package/dist/apps/shared/components/mail/compose/composePerf.js +41 -0
  118. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +13 -0
  119. package/dist/apps/shared/components/mail/compose/quotedBody.js +67 -11
  120. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +12 -4
  121. package/dist/apps/shared/components/mail/layout/MailShell.js +76 -96
  122. package/dist/apps/shared/components/mail/unreadStyle.d.ts +45 -0
  123. package/dist/apps/shared/components/mail/unreadStyle.js +61 -0
  124. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +1 -1
  125. package/dist/apps/shared/components/settings/layout/SettingsShell.js +15 -5
  126. package/dist/apps/shared/components/tasks/layout/TasksShell.d.ts +1 -1
  127. package/dist/apps/shared/components/tasks/layout/TasksShell.js +15 -5
  128. package/dist/apps/shared/keyboard/GlobalShortcuts.d.ts +14 -0
  129. package/dist/apps/shared/keyboard/GlobalShortcuts.js +37 -0
  130. package/dist/apps/shared/keyboard/ShortcutProvider.d.ts +20 -0
  131. package/dist/apps/shared/keyboard/ShortcutProvider.js +52 -0
  132. package/dist/apps/shared/keyboard/ShortcutsDialog.d.ts +14 -0
  133. package/dist/apps/shared/keyboard/ShortcutsDialog.js +42 -0
  134. package/dist/apps/shared/keyboard/dispatch.d.ts +16 -0
  135. package/dist/apps/shared/keyboard/dispatch.js +109 -0
  136. package/dist/apps/shared/keyboard/format.d.ts +12 -0
  137. package/dist/apps/shared/keyboard/format.js +74 -0
  138. package/dist/apps/shared/keyboard/keymap.d.ts +294 -0
  139. package/dist/apps/shared/keyboard/keymap.js +84 -0
  140. package/dist/apps/shared/keyboard/match.d.ts +20 -0
  141. package/dist/apps/shared/keyboard/match.js +31 -0
  142. package/dist/apps/shared/keyboard/parse.d.ts +31 -0
  143. package/dist/apps/shared/keyboard/parse.js +107 -0
  144. package/dist/apps/shared/keyboard/platform.d.ts +15 -0
  145. package/dist/apps/shared/keyboard/platform.js +22 -0
  146. package/dist/apps/shared/keyboard/registry.d.ts +39 -0
  147. package/dist/apps/shared/keyboard/registry.js +33 -0
  148. package/dist/apps/shared/keyboard/targets.d.ts +14 -0
  149. package/dist/apps/shared/keyboard/targets.js +67 -0
  150. package/dist/apps/shared/keyboard/useShortcut.d.ts +19 -0
  151. package/dist/apps/shared/keyboard/useShortcut.js +35 -0
  152. package/dist/apps/shared/keyboard/useShortcutProps.d.ts +10 -0
  153. package/dist/apps/shared/keyboard/useShortcutProps.js +15 -0
  154. package/dist/apps/shared/mail/folderCounts.d.ts +78 -0
  155. package/dist/apps/shared/mail/folderCounts.js +212 -0
  156. package/dist/apps/shared/mail/listSnapshots.d.ts +46 -0
  157. package/dist/apps/shared/mail/listSnapshots.js +43 -0
  158. package/dist/apps/shared/mail/messageReadState.d.ts +32 -0
  159. package/dist/apps/shared/mail/messageReadState.js +63 -0
  160. package/dist/apps/shared/mail/newMailNotifications.d.ts +62 -0
  161. package/dist/apps/shared/mail/newMailNotifications.js +138 -0
  162. package/dist/apps/shared/mail/useMailConnection.d.ts +55 -0
  163. package/dist/apps/shared/mail/useMailConnection.js +96 -0
  164. package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +13 -6
  165. package/dist/apps/shared/mail/useMailLiveUpdates.js +28 -26
  166. package/dist/apps/shared/mail/useMarkMessageRead.d.ts +12 -0
  167. package/dist/apps/shared/mail/useMarkMessageRead.js +44 -0
  168. package/dist/apps/shared/mail/useNewMailNotifications.d.ts +41 -0
  169. package/dist/apps/shared/mail/useNewMailNotifications.js +110 -0
  170. package/dist/apps/shared/mail/useUnreadTitle.d.ts +18 -0
  171. package/dist/apps/shared/mail/useUnreadTitle.js +31 -0
  172. package/dist/apps/shared/navigation/AppRouter.d.ts +52 -0
  173. package/dist/apps/shared/navigation/AppRouter.js +242 -0
  174. package/dist/apps/shared/navigation/appHrefs.d.ts +14 -0
  175. package/dist/apps/shared/navigation/appHrefs.js +20 -0
  176. package/dist/apps/shared/navigation/frameContext.d.ts +19 -0
  177. package/dist/apps/shared/navigation/frameContext.js +24 -0
  178. package/dist/apps/shared/navigation/idle.d.ts +14 -0
  179. package/dist/apps/shared/navigation/idle.js +43 -0
  180. package/dist/apps/shared/navigation/routerContext.d.ts +37 -0
  181. package/dist/apps/shared/navigation/routerContext.js +56 -0
  182. package/dist/apps/shared/navigation/routes.d.ts +32 -0
  183. package/dist/apps/shared/navigation/routes.js +37 -0
  184. package/dist/apps/shared/search/LocalIndexLifecycle.js +17 -3
  185. package/dist/apps/shared/styles/app.css +28 -10
  186. package/dist/apps/www/_routedPage.d.ts +12 -0
  187. package/dist/apps/www/_routedPage.js +19 -0
  188. package/dist/apps/www/_routes.d.ts +11 -0
  189. package/dist/apps/www/_routes.js +29 -0
  190. package/dist/apps/www/calendar/index.d.ts +2 -2
  191. package/dist/apps/www/calendar/index.js +39 -8
  192. package/dist/apps/www/contacts/[uid].d.ts +3 -9
  193. package/dist/apps/www/contacts/[uid].js +6 -2
  194. package/dist/apps/www/contacts/index.d.ts +2 -2
  195. package/dist/apps/www/contacts/index.js +17 -4
  196. package/dist/apps/www/index.d.ts +2 -2
  197. package/dist/apps/www/index.js +301 -35
  198. package/dist/apps/www/messages/[uid].d.ts +3 -7
  199. package/dist/apps/www/messages/[uid].js +7 -4
  200. package/dist/apps/www/settings/auto-reply/index.d.ts +2 -2
  201. package/dist/apps/www/settings/auto-reply/index.js +3 -1
  202. package/dist/apps/www/settings/encryption/index.d.ts +2 -2
  203. package/dist/apps/www/settings/encryption/index.js +3 -1
  204. package/dist/apps/www/settings/filters/[uid].d.ts +2 -2
  205. package/dist/apps/www/settings/filters/[uid].js +3 -1
  206. package/dist/apps/www/settings/filters/index.d.ts +2 -2
  207. package/dist/apps/www/settings/filters/index.js +3 -1
  208. package/dist/apps/www/settings/filters/new/index.d.ts +2 -2
  209. package/dist/apps/www/settings/filters/new/index.js +6 -2
  210. package/dist/apps/www/settings/labels/index.d.ts +2 -2
  211. package/dist/apps/www/settings/labels/index.js +3 -1
  212. package/dist/apps/www/settings/privacy/index.d.ts +2 -2
  213. package/dist/apps/www/settings/privacy/index.js +3 -1
  214. package/dist/apps/www/settings/read-receipts/index.d.ts +2 -2
  215. package/dist/apps/www/settings/read-receipts/index.js +3 -1
  216. package/dist/apps/www/settings/sharing/index.d.ts +2 -2
  217. package/dist/apps/www/settings/sharing/index.js +3 -1
  218. package/dist/apps/www/settings/signatures/[uid].d.ts +2 -2
  219. package/dist/apps/www/settings/signatures/[uid].js +3 -1
  220. package/dist/apps/www/settings/signatures/index.d.ts +2 -2
  221. package/dist/apps/www/settings/signatures/index.js +3 -1
  222. package/dist/apps/www/settings/signatures/new/index.d.ts +2 -2
  223. package/dist/apps/www/settings/signatures/new/index.js +6 -2
  224. package/dist/apps/www/tasks/index.d.ts +2 -2
  225. package/dist/apps/www/tasks/index.js +15 -3
  226. package/package.json +2 -2
@@ -1,434 +1,475 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
6
- import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
- import { Attachment, Folder, Message, listAttachments, setMessageRead } from "@rapidmx/react-shared/mail/mailApi.js";
8
- import { ConversationSummary, listConversationMessages } from "@rapidmx/react-shared/mail/conversationsApi.js";
9
- import { Label } from "@rapidmx/react-shared/mail/labelsApi.js";
10
- import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
11
- import MailAddress from "./MailAddress.js";
12
- import MessageDetailPane from "./MessageDetailPane.js";
13
-
14
- /** One request's worth of the thread. The server's own default for `listConversationMessages()`. */
15
- export const THREAD_PAGE_SIZE = 100;
16
- /**
17
- * How many of a conversation's messages this pane will load. The server reads at most
18
- * `CONVERSATION_SCAN_LIMIT` (500) messages when it groups conversations, so a `ConversationSummary` never
19
- * describes more than this many anyway; the cap is here so a thread that somehow reports more can't turn
20
- * into an unbounded run of requests. Past it the pane says so and the rest stay readable from the list.
21
- */
22
- export const THREAD_MESSAGE_LIMIT = 500;
23
-
24
- export interface ConversationThreadPaneProps {
25
- conversation: ConversationSummary | null;
26
- /** The mailbox the conversation was listed from - `listConversationMessages()` is mailbox-scoped. */
27
- mailboxUid: string;
28
- /**
29
- * The message the reader opened: the thread is scrolled to it, it takes focus, and it is the *oldest*
30
- * message left expanded (see `expandedFrom()`). `null`, or a uid this thread doesn't hold, falls back
31
- * to the newest message - which is also what a parent row means by "open the conversation".
32
- */
33
- selectedUid: string | null;
34
- /** The mailbox's full folder list. A conversation spans folders (an Inbox message and the Sent Items
35
- * copy of its reply), so each message's own folder type is looked up against its own `folderUid`. */
36
- folders: Folder[];
37
- /** The mailbox's labels, passed through to every expanded message's own `MessageDetailPane`. */
38
- labels?: Label[];
39
- /**
40
- * A newer copy of one of the thread's messages - read, flagged, labelled, classified, recalled - for
41
- * the caller's own list to stay in step with what was done in here.
42
- *
43
- * `previous` is the copy this pane held before the change, where it has one: the conversation row this
44
- * thread came from is a *summary* (a message count, an unread count), so a list showing those rows has
45
- * no way to tell "this message has just been read" from "this already-read message was relabelled"
46
- * without it - which is what left a row reporting "2 unread" after both had been read.
47
- */
48
- onMessagePatched: (updated: Message, previous?: Message) => void;
49
- /** A message that left the folder being listed (archived, or a scheduled send sent back to Drafts). */
50
- onMessageRemoved: (updated: Message) => void;
51
- onLabelCreated?: (label: Label) => void;
52
- /** A folder created from a message's own Move to prompt, for the folder sidebar to pick up. */
53
- onFolderCreated?: (folder: Folder) => void;
54
- }
55
-
56
- /**
57
- * Every message from `selectedUid` through to the newest - the run the reader is reading. Anything older
58
- * stays collapsed to its one-line summary. Selecting the newest message therefore expands just that one.
59
- *
60
- * The pane lists newest first (see `loadThread()`), so that run is the opened message together with
61
- * everything *above* it, and the opened message is the set's **last** entry rather than its first.
62
- */
63
- function expandedFrom(messages: Message[], selectedUid: string | null): Set<string> {
64
- const index = messages.findIndex((message) => message.uid === selectedUid);
65
- // `messages` is never empty here (the callers below check), so -1 means "not in this thread" and the
66
- // newest message - the first entry - is the anchor, exactly as a parent row's own click means.
67
- const anchor = index === -1 ? 0 : index;
68
- return new Set(messages.slice(0, anchor + 1).map((message) => message.uid));
69
- }
70
-
71
- /**
72
- * The element a message of this thread actually scrolls inside. The pane is not itself the scroll
73
- * container in the mail shell - `MailShell`'s own `<main>` is - so an adjustment has to be applied where
74
- * the scrolling really happens, which is whichever ancestor is both scrollable and overflowing.
75
- */
76
- function scrollingAncestor(node: HTMLElement): HTMLElement {
77
- for (let el = node.parentElement; el; el = el.parentElement) {
78
- const overflowY = getComputedStyle(el).overflowY;
79
- if ((overflowY === "auto" || overflowY === "scroll") && el.scrollHeight > el.clientHeight) {
80
- return el;
81
- }
82
- }
83
- // Nothing between the row and the root scrolls, so the page itself does - which in standards mode is
84
- // `documentElement`, the same element `document.scrollingElement` names there.
85
- return document.documentElement;
86
- }
87
-
88
- /**
89
- * The thread's messages, **newest first**, in pages of `THREAD_PAGE_SIZE` up to `THREAD_MESSAGE_LIMIT`.
90
- *
91
- * `listConversationMessages()` pages oldest first and has no order of its own to ask for, so the pages are
92
- * collected in that order - which is also the order the `THREAD_MESSAGE_LIMIT` cap has to apply in, since it
93
- * is the oldest messages that are dropped when a thread is too long to load - and reversed once at the end.
94
- * The pane reads newest first always, whatever the *list* is sorted by: it is the reading order for mail,
95
- * and it means the message a conversation row stands for is the entry at the top.
96
- */
97
- async function loadThread(mailboxUid: string, conversationId: string): Promise<{ messages: Message[]; truncated: boolean }> {
98
- const messages: Message[] = [];
99
- let more = true;
100
- while (more && messages.length < THREAD_MESSAGE_LIMIT) {
101
- const page = await listConversationMessages(mailboxUid, conversationId, {
102
- page: messages.length / THREAD_PAGE_SIZE,
103
- limit: THREAD_PAGE_SIZE,
104
- });
105
- messages.push(...page);
106
- // A short page is the last one; a full page means asking for another.
107
- more = page.length === THREAD_PAGE_SIZE;
108
- }
109
- messages.reverse();
110
- return { messages, truncated: more };
111
- }
112
-
113
- /**
114
- * The reading pane for the conversation list: the whole thread, **newest at the top**, opened at the message
115
- * the reader picked. Every message from that one through to the newest is expanded - which in this order is
116
- * the opened message and the entries above it - and the older ones, below it, are collapsed to a one-line
117
- * summary (sender, date, preview) that expands on click or Enter. So opening the newest message shows just
118
- * the top entry expanded, and opening 5 of 10 expands 10 down to 5.
119
- *
120
- * Newest first regardless of how the *list* is sorted: the list's own order arranges rows to pick from,
121
- * while this is one conversation being read, and the entry a conversation row stands for - its latest
122
- * message - is the one that should be at the top of the pane every time it is opened.
123
- *
124
- * Each *expanded* message is a `MessageDetailPane` of its own rather than a reimplementation of it, so the
125
- * signature and verification badges, the verification-seal and decryption behaviour, the labels chips and
126
- * menu, the attachments and Reply/Reply All/Forward/Archive all behave exactly as they do in the
127
- * single-message pane, and each acts on the message it belongs to. A collapsed message mounts none of
128
- * that - mounting a body iframe per message up front would be wasteful in a long thread.
129
- */
130
- export default function ConversationThreadPane({
131
- conversation,
132
- mailboxUid,
133
- selectedUid,
134
- folders,
135
- labels,
136
- onMessagePatched,
137
- onMessageRemoved,
138
- onLabelCreated,
139
- onFolderCreated,
140
- }: ConversationThreadPaneProps) {
141
- const [messages, setMessages] = useState<Message[]>([]);
142
- const [attachmentsByUid, setAttachmentsByUid] = useState<Record<string, Attachment[]>>({});
143
- const [expandedUids, setExpandedUids] = useState<Set<string>>(new Set());
144
- const [truncated, setTruncated] = useState(false);
145
- const [loading, setLoading] = useState(false);
146
- const [error, setError] = useState<string | null>(null);
147
- /** The message to scroll to and focus once it has rendered, or `null` once that has happened. */
148
- const [pendingFocusUid, setPendingFocusUid] = useState<string | null>(null);
149
-
150
- // Bumped on every conversation switch - an in-flight load/attachments/mark-read response carrying an
151
- // older generation belongs to a superseded conversation and is dropped rather than applied.
152
- const generationRef = useRef(0);
153
- const markReadRequestedRef = useRef<Set<string>>(new Set());
154
- const attachmentsRequestedRef = useRef<Set<string>>(new Set());
155
- /** The `conversationId:selectedUid` the expansion run below has already been applied for, so patching
156
- * a message (which changes `messages`) doesn't re-expand what the reader has since collapsed. */
157
- const appliedSelectionRef = useRef<string | null>(null);
158
- /** Which conversation the messages currently in state belong to. A render with a new conversation and
159
- * the previous one's messages still in state happens before the load effect has cleared them, and the
160
- * expansion run below must sit that render out rather than anchor on a message from another thread. */
161
- const loadedIdRef = useRef<string | null>(null);
162
- const rowRefs = useRef<Record<string, HTMLLIElement | null>>({});
163
- const headerRefs = useRef<Record<string, HTMLButtonElement | null>>({});
164
- /** Where the toggled message's header sat in the viewport before it expanded, so the run below can put
165
- * it back there - expanding a message above the one being read must not shove that one off-screen. */
166
- const anchorRef = useRef<{ uid: string; top: number } | null>(null);
167
-
168
- const conversationId = conversation?.conversationId;
169
-
170
- useEffect(() => {
171
- const generation = ++generationRef.current;
172
- markReadRequestedRef.current = new Set();
173
- attachmentsRequestedRef.current = new Set();
174
- appliedSelectionRef.current = null;
175
- loadedIdRef.current = null;
176
- setMessages([]);
177
- setAttachmentsByUid({});
178
- setExpandedUids(new Set());
179
- setTruncated(false);
180
- setError(null);
181
- // The message the previous conversation was to be scrolled to went with it; leaving it set would
182
- // point the run below at a row that is no longer rendered.
183
- setPendingFocusUid(null);
184
- if (!conversationId) {
185
- setLoading(false);
186
- return;
187
- }
188
- setLoading(true);
189
- loadThread(mailboxUid, conversationId)
190
- .then((loaded) => {
191
- if (generation !== generationRef.current) return;
192
- loadedIdRef.current = conversationId;
193
- setMessages(loaded.messages);
194
- setTruncated(loaded.truncated);
195
- })
196
- .catch((err) => {
197
- if (generation !== generationRef.current) return;
198
- setError(err instanceof ApiRequestError ? err.message : "Could not load this conversation.");
199
- })
200
- .finally(() => {
201
- if (generation === generationRef.current) setLoading(false);
202
- });
203
- }, [conversationId, mailboxUid]);
204
-
205
- // Opening the thread, and opening a different message of the same thread, both set the run of expanded
206
- // messages and ask for that message to be scrolled to. `messages` is a dependency because the thread's
207
- // messages arrive after the click that selected one of them; the ref guard keeps a later change to
208
- // `messages` (a patched copy) from re-running it.
209
- useEffect(() => {
210
- if (messages.length === 0 || loadedIdRef.current !== conversationId) return;
211
- const key = `${conversationId}:${selectedUid}`;
212
- if (appliedSelectionRef.current === key) return;
213
- appliedSelectionRef.current = key;
214
- const expanded = expandedFrom(messages, selectedUid);
215
- setExpandedUids(expanded);
216
- // The oldest expanded message is the one that was opened - `expandedFrom()`'s own anchor - which in
217
- // this newest-first order is the *last* of the run, not the first.
218
- setPendingFocusUid([...expanded][expanded.size - 1]);
219
- }, [conversationId, selectedUid, messages]);
220
-
221
- useLayoutEffect(() => {
222
- if (!pendingFocusUid) return;
223
- // The row is always rendered by now: this runs after the DOM update that added the message it
224
- // names, and that message came out of `messages` in the first place.
225
- //
226
- // Scrolled inside the thread's own list and nowhere else. `scrollIntoView()` scrolls *every*
227
- // scrollable ancestor, the window included, which with a run of full-height messages expanded
228
- // took the app header and the folder rail off the screen - so the adjustment goes on whichever
229
- // ancestor actually scrolls, exactly as the toggle below already does it. When that is the page
230
- // itself, nothing inside the pane scrolls and the row is already in view, so it is left alone.
231
- const row = rowRefs.current[pendingFocusUid]!;
232
- const scroller = scrollingAncestor(row);
233
- if (scroller !== document.documentElement) {
234
- const rect = row.getBoundingClientRect();
235
- const top = rect.top - scroller.getBoundingClientRect().top;
236
- // "nearest": the least that brings it into view, and nothing at all when it is already there.
237
- if (top < 0 || top + rect.height > scroller.clientHeight) {
238
- scroller.scrollTop += top;
239
- }
240
- }
241
- // `preventScroll` so focusing doesn't scroll it somewhere else again.
242
- headerRefs.current[pendingFocusUid]!.focus({ preventScroll: true });
243
- setPendingFocusUid(null);
244
- }, [pendingFocusUid, expandedUids]);
245
-
246
- // Keeps the message whose header was just clicked where it was on screen. Expanding one above the
247
- // message being read otherwise pushes everything below it down by however tall the new body is.
248
- useLayoutEffect(() => {
249
- const anchor = anchorRef.current;
250
- if (!anchor) return;
251
- anchorRef.current = null;
252
- // The row is still there: the anchor was taken from a rendered row, and toggling never removes one.
253
- const row = rowRefs.current[anchor.uid]!;
254
- scrollingAncestor(row).scrollTop += row.getBoundingClientRect().top - anchor.top;
255
- }, [expandedUids]);
256
-
257
- // Attachments and mark-as-read, for expanded messages only - mirrors `mailDetailHooks.ts`'s
258
- // `useMessageAttachments`/`useMarkMessageRead`, reimplemented here (rather than called in a loop, which
259
- // the rules of hooks don't allow) because a thread expands several messages at once.
260
- useEffect(() => {
261
- const generation = generationRef.current;
262
- for (const message of messages) {
263
- const uid = message.uid;
264
- if (!expandedUids.has(uid)) continue;
265
- if (message.hasAttachments && !attachmentsRequestedRef.current.has(uid)) {
266
- attachmentsRequestedRef.current.add(uid);
267
- listAttachments(message.folderUid, uid)
268
- .then((loaded) => {
269
- if (generation === generationRef.current) {
270
- setAttachmentsByUid((prev) => ({ ...prev, [uid]: loaded }));
271
- }
272
- })
273
- .catch(() => {
274
- // Best-effort, as in `useMessageAttachments`: no attachments render meanwhile, and
275
- // forgetting the request lets a later re-expand retry it.
276
- attachmentsRequestedRef.current.delete(uid);
277
- });
278
- }
279
- if (!message.flags.read && !markReadRequestedRef.current.has(uid)) {
280
- markReadRequestedRef.current.add(uid);
281
- // `message` is this render's copy, so the request carries its current `version`.
282
- setMessageRead(message, true)
283
- .then((updated) => {
284
- if (generation !== generationRef.current) return;
285
- // `message` is the unread copy this pane just replaced - what tells the list's own
286
- // conversation row that its unread count has gone down by one.
287
- patchMessage(updated, message);
288
- })
289
- .catch(() => {
290
- // Best-effort, as in `useMarkMessageRead`.
291
- markReadRequestedRef.current.delete(uid);
292
- });
293
- }
294
- }
295
- }, [expandedUids, messages]);
296
-
297
- /** A newer copy of one of the thread's messages, kept here and handed to the list - with the copy it
298
- * replaces where the caller was given one, so a conversation row can tell what actually changed. */
299
- function patchMessage(updated: Message, previous?: Message) {
300
- setMessages((prev) => prev.map((message) => (message.uid === updated.uid ? updated : message)));
301
- onMessagePatched(updated, previous);
302
- }
303
-
304
- /** A message that left the folder being listed - it leaves the thread too, as it left the list. */
305
- function removeMessage(updated: Message) {
306
- setMessages((prev) => prev.filter((message) => message.uid !== updated.uid));
307
- onMessageRemoved(updated);
308
- }
309
-
310
- function toggleExpanded(uid: string) {
311
- // The row this button lives in has rendered, so its ref is set.
312
- anchorRef.current = { uid, top: rowRefs.current[uid]!.getBoundingClientRect().top };
313
- setExpandedUids((prev) => {
314
- const next = new Set(prev);
315
- if (next.has(uid)) {
316
- next.delete(uid);
317
- } else {
318
- next.add(uid);
319
- }
320
- return next;
321
- });
322
- }
323
-
324
- function folderTypeOf(message: Message): string | undefined {
325
- return folders.find((folder) => folder.uid === message.folderUid)?.type;
326
- }
327
-
328
- if (!conversation) {
329
- return <p className="p-8 text-sm text-text-muted">Select a conversation to read it.</p>;
330
- }
331
- if (loading) {
332
- return <p className="p-8 text-sm text-text-muted">Loading&hellip;</p>;
333
- }
334
- if (error) {
335
- return (
336
- <div className="p-4 flex-1">
337
- <Alert>{error}</Alert>
338
- </div>
339
- );
340
- }
341
-
342
- return (
343
- // The pane is the window's height, not the thread's: a full-height flex column whose heading is
344
- // fixed and whose list of messages is the one scrolling, growing child (`min-h-0`, or the list
345
- // would stretch the column past the pane instead of scrolling inside it). That is what gives an
346
- // expanded message's own `min-h-full` row - and therefore its body iframe, which can't measure
347
- // itself - a real height to resolve against at any window size.
348
- <div className="flex-1 min-w-0 min-h-0 flex flex-col">
349
- <div className="shrink-0 border-b border-border p-4">
350
- <h1 className="text-lg font-bold tracking-tight">{conversation.subject || "(no subject)"}</h1>
351
- <p className="text-sm text-text-muted mt-1">
352
- {messages.length} message{messages.length === 1 ? "" : "s"}
353
- </p>
354
- {truncated && (
355
- <p className="text-xs text-text-muted mt-1">
356
- Only the oldest {THREAD_MESSAGE_LIMIT} messages of this conversation are shown here. The rest
357
- are still in the message list.
358
- </p>
359
- )}
360
- </div>
361
- <ul className="flex-1 min-h-0 overflow-y-auto">
362
- {messages.map((message) => {
363
- const uid = message.uid;
364
- const expanded = expandedUids.has(uid);
365
- const bodyId = `thread-message-${uid}`;
366
- return (
367
- <li
368
- key={uid}
369
- ref={(node) => {
370
- rowRefs.current[uid] = node;
371
- }}
372
- // An expanded message is as tall as the list it scrolls in - `min-h-full`
373
- // against the `<ul>`'s own resolved height - so its `MessageDetailPane` below
374
- // has a full pane of height to fill and its body reaches the bottom of the
375
- // window whatever that window's size is. `min-` rather than `h-`: a message
376
- // whose header alone is taller than the pane still gets the room it needs.
377
- className={["border-b border-border", expanded ? "flex flex-col min-h-full" : ""].join(" ")}
378
- >
379
- <h2 className="shrink-0">
380
- <button
381
- type="button"
382
- ref={(node) => {
383
- headerRefs.current[uid] = node;
384
- }}
385
- onClick={() => toggleExpanded(uid)}
386
- aria-expanded={expanded}
387
- aria-controls={bodyId}
388
- className={[
389
- "w-full text-left px-4 py-3 hover:bg-surface-alt",
390
- message.flags.read ? "" : "font-semibold",
391
- ].join(" ")}
392
- >
393
- <span className="flex items-center justify-between gap-2 text-sm">
394
- <MailAddress recipient={message.from} />
395
- <span className="text-xs text-text-muted shrink-0">
396
- {new Date(message.receivedDate).toLocaleString()}
397
- </span>
398
- </span>
399
- {!expanded && (
400
- <span className="block text-xs text-text-muted truncate font-normal">
401
- {message.bodyPreview}
402
- </span>
403
- )}
404
- </button>
405
- </h2>
406
- <div id={bodyId} hidden={!expanded} className={expanded ? "flex-1 min-h-0 flex" : undefined}>
407
- {expanded && (
408
- <MessageDetailPane
409
- inThread
410
- message={message}
411
- attachments={attachmentsByUid[uid] ?? []}
412
- isSentItems={folderTypeOf(message) === "sent_items"}
413
- isOutbox={folderTypeOf(message) === "outbox"}
414
- draftsFolderUid={folders.find((folder) => folder.type === "drafts")?.uid}
415
- folders={folders}
416
- onMoved={removeMessage}
417
- onFolderCreated={onFolderCreated}
418
- onRecalled={patchMessage}
419
- onReceiptHandled={patchMessage}
420
- onScheduledSendCanceled={removeMessage}
421
- onArchived={removeMessage}
422
- labels={labels}
423
- onLabelsChanged={patchMessage}
424
- onLabelCreated={onLabelCreated}
425
- />
426
- )}
427
- </div>
428
- </li>
429
- );
430
- })}
431
- </ul>
432
- </div>
433
- );
434
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
6
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
+ import { Attachment, Folder, Message, listAttachments } from "@rapidmx/react-shared/mail/mailApi.js";
8
+ import { ConversationSummary, listConversationMessages } from "@rapidmx/react-shared/mail/conversationsApi.js";
9
+ import { Label } from "@rapidmx/react-shared/mail/labelsApi.js";
10
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
11
+ import MailAddress from "./MailAddress.js";
12
+ import MessageDetailPane from "./MessageDetailPane.js";
13
+ import { useMailShell } from "./layout/MailShell.js";
14
+ import { setReadState } from "../../mail/messageReadState.js";
15
+ import { ROW_FOCUS_CLASS, UnreadBar, UnreadLabel, dateClass, isUnread, senderClass } from "./unreadStyle.js";
16
+
17
+ /** One request's worth of the thread. The server's own default for `listConversationMessages()`. */
18
+ export const THREAD_PAGE_SIZE = 100;
19
+ /**
20
+ * How many of a conversation's messages this pane will load. The server reads at most
21
+ * `CONVERSATION_SCAN_LIMIT` (500) messages when it groups conversations, so a `ConversationSummary` never
22
+ * describes more than this many anyway; the cap is here so a thread that somehow reports more can't turn
23
+ * into an unbounded run of requests. Past it the pane says so and the rest stay readable from the list.
24
+ */
25
+ export const THREAD_MESSAGE_LIMIT = 500;
26
+
27
+ export interface ConversationThreadPaneProps {
28
+ conversation: ConversationSummary | null;
29
+ /** The mailbox the conversation was listed from - `listConversationMessages()` is mailbox-scoped. */
30
+ mailboxUid: string;
31
+ /**
32
+ * The message the reader opened: the thread is scrolled to it, it takes focus, and it is the *oldest*
33
+ * message left expanded (see `expandedFrom()`). `null`, or a uid this thread doesn't hold, falls back
34
+ * to the newest message - which is also what a parent row means by "open the conversation".
35
+ */
36
+ selectedUid: string | null;
37
+ /** The mailbox's full folder list. A conversation spans folders (an Inbox message and the Sent Items
38
+ * copy of its reply), so each message's own folder type is looked up against its own `folderUid`. */
39
+ folders: Folder[];
40
+ /** The mailbox's labels, passed through to every expanded message's own `MessageDetailPane`. */
41
+ labels?: Label[];
42
+ /**
43
+ * A newer copy of one of the thread's messages - read, flagged, labelled, classified, recalled - for
44
+ * the caller's own list to stay in step with what was done in here.
45
+ *
46
+ * `previous` is the copy this pane held before the change, where it has one: the conversation row this
47
+ * thread came from is a *summary* (a message count, an unread count), so a list showing those rows has
48
+ * no way to tell "this message has just been read" from "this already-read message was relabelled"
49
+ * without it - which is what left a row reporting "2 unread" after both had been read.
50
+ */
51
+ onMessagePatched: (updated: Message, previous?: Message) => void;
52
+ /** A message that left the folder being listed (archived, or a scheduled send sent back to Drafts). */
53
+ onMessageRemoved: (updated: Message) => void;
54
+ onLabelCreated?: (label: Label) => void;
55
+ /** A folder created from a message's own Move to prompt, for the folder sidebar to pick up. */
56
+ onFolderCreated?: (folder: Folder) => void;
57
+ /** Registers the keyboard shortcuts (Reply, Reply all, Forward, Archive, Move to) of the message that was opened - see `MessageDetailPane`'s
58
+ * `shortcuts`. The caller turns it off while it is doing something else with the keyboard (select mode). */
59
+ shortcuts?: boolean;
60
+ }
61
+
62
+ /**
63
+ * Every message from `selectedUid` through to the newest - the run the reader is reading. Anything older
64
+ * stays collapsed to its one-line summary. Selecting the newest message therefore expands just that one.
65
+ *
66
+ * The pane lists newest first (see `loadThread()`), so that run is the opened message together with
67
+ * everything *above* it, and the opened message is the set's **last** entry rather than its first.
68
+ */
69
+ function expandedFrom(messages: Message[], selectedUid: string | null): Set<string> {
70
+ const index = messages.findIndex((message) => message.uid === selectedUid);
71
+ // `messages` is never empty here (the callers below check), so -1 means "not in this thread" and the
72
+ // newest message - the first entry - is the anchor, exactly as a parent row's own click means.
73
+ const anchor = index === -1 ? 0 : index;
74
+ return new Set(messages.slice(0, anchor + 1).map((message) => message.uid));
75
+ }
76
+
77
+ /**
78
+ * The element a message of this thread actually scrolls inside. The pane is not itself the scroll
79
+ * container in the mail shell - `MailShell`'s own `<main>` is - so an adjustment has to be applied where
80
+ * the scrolling really happens, which is whichever ancestor is both scrollable and overflowing.
81
+ */
82
+ function scrollingAncestor(node: HTMLElement): HTMLElement {
83
+ for (let el = node.parentElement; el; el = el.parentElement) {
84
+ const overflowY = getComputedStyle(el).overflowY;
85
+ if ((overflowY === "auto" || overflowY === "scroll") && el.scrollHeight > el.clientHeight) {
86
+ return el;
87
+ }
88
+ }
89
+ // Nothing between the row and the root scrolls, so the page itself does - which in standards mode is
90
+ // `documentElement`, the same element `document.scrollingElement` names there.
91
+ return document.documentElement;
92
+ }
93
+
94
+ /**
95
+ * The thread's messages, **newest first**, in pages of `THREAD_PAGE_SIZE` up to `THREAD_MESSAGE_LIMIT`.
96
+ *
97
+ * `listConversationMessages()` pages oldest first and has no order of its own to ask for, so the pages are
98
+ * collected in that order - which is also the order the `THREAD_MESSAGE_LIMIT` cap has to apply in, since it
99
+ * is the oldest messages that are dropped when a thread is too long to load - and reversed once at the end.
100
+ * The pane reads newest first always, whatever the *list* is sorted by: it is the reading order for mail,
101
+ * and it means the message a conversation row stands for is the entry at the top.
102
+ */
103
+ async function loadThread(mailboxUid: string, conversationId: string): Promise<{ messages: Message[]; truncated: boolean }> {
104
+ const messages: Message[] = [];
105
+ let more = true;
106
+ while (more && messages.length < THREAD_MESSAGE_LIMIT) {
107
+ const page = await listConversationMessages(mailboxUid, conversationId, {
108
+ page: messages.length / THREAD_PAGE_SIZE,
109
+ limit: THREAD_PAGE_SIZE,
110
+ });
111
+ messages.push(...page);
112
+ // A short page is the last one; a full page means asking for another.
113
+ more = page.length === THREAD_PAGE_SIZE;
114
+ }
115
+ messages.reverse();
116
+ return { messages, truncated: more };
117
+ }
118
+
119
+ /** Whether the browser is showing a focus ring on `element` - it was focused from the keyboard (or by a key press's handler), not by a click. */
120
+ function hasFocusRing(element: HTMLElement): boolean {
121
+ try {
122
+ return element.matches(":focus-visible");
123
+ } catch {
124
+ return false;
125
+ }
126
+ }
127
+
128
+ /**
129
+ * The reading pane for the conversation list: the whole thread, **newest at the top**, opened at the message
130
+ * the reader picked. Every message from that one through to the newest is expanded - which in this order is
131
+ * the opened message and the entries above it - and the older ones, below it, are collapsed to a one-line
132
+ * summary (sender, date, preview) that expands on click or Enter. So opening the newest message shows just
133
+ * the top entry expanded, and opening 5 of 10 expands 10 down to 5.
134
+ *
135
+ * Newest first regardless of how the *list* is sorted: the list's own order arranges rows to pick from,
136
+ * while this is one conversation being read, and the entry a conversation row stands for - its latest
137
+ * message - is the one that should be at the top of the pane every time it is opened.
138
+ *
139
+ * Each *expanded* message is a `MessageDetailPane` of its own rather than a reimplementation of it, so the
140
+ * signature and verification badges, the verification-seal and decryption behaviour, the labels chips and
141
+ * menu, the attachments and Reply/Reply All/Forward/Archive all behave exactly as they do in the
142
+ * single-message pane, and each acts on the message it belongs to. A collapsed message mounts none of
143
+ * that - mounting a body iframe per message up front would be wasteful in a long thread.
144
+ */
145
+ export default function ConversationThreadPane({
146
+ conversation,
147
+ mailboxUid,
148
+ selectedUid,
149
+ folders,
150
+ labels,
151
+ onMessagePatched,
152
+ onMessageRemoved,
153
+ onLabelCreated,
154
+ onFolderCreated,
155
+ shortcuts,
156
+ }: ConversationThreadPaneProps) {
157
+ const { trackMessageChange } = useMailShell();
158
+ const [messages, setMessages] = useState<Message[]>([]);
159
+ const [attachmentsByUid, setAttachmentsByUid] = useState<Record<string, Attachment[]>>({});
160
+ const [expandedUids, setExpandedUids] = useState<Set<string>>(new Set());
161
+ const [truncated, setTruncated] = useState(false);
162
+ const [loading, setLoading] = useState(false);
163
+ const [error, setError] = useState<string | null>(null);
164
+ /** The message to scroll to and focus once it has rendered, or `null` once that has happened. */
165
+ const [pendingFocusUid, setPendingFocusUid] = useState<string | null>(null);
166
+
167
+ // Bumped on every conversation switch - an in-flight load/attachments/mark-read response carrying an
168
+ // older generation belongs to a superseded conversation and is dropped rather than applied.
169
+ const generationRef = useRef(0);
170
+ const markReadRequestedRef = useRef<Set<string>>(new Set());
171
+ const attachmentsRequestedRef = useRef<Set<string>>(new Set());
172
+ /** The `conversationId:selectedUid` the expansion run below has already been applied for, so patching
173
+ * a message (which changes `messages`) doesn't re-expand what the reader has since collapsed. */
174
+ const appliedSelectionRef = useRef<string | null>(null);
175
+ /** Which conversation the messages currently in state belong to. A render with a new conversation and
176
+ * the previous one's messages still in state happens before the load effect has cleared them, and the
177
+ * expansion run below must sit that render out rather than anchor on a message from another thread. */
178
+ const loadedIdRef = useRef<string | null>(null);
179
+ const rowRefs = useRef<Record<string, HTMLLIElement | null>>({});
180
+ const headerRefs = useRef<Record<string, HTMLButtonElement | null>>({});
181
+ /** Where the toggled message's header sat in the viewport before it expanded, so the run below can put
182
+ * it back there - expanding a message above the one being read must not shove that one off-screen. */
183
+ const anchorRef = useRef<{ uid: string; top: number } | null>(null);
184
+
185
+ const conversationId = conversation?.conversationId;
186
+
187
+ useEffect(() => {
188
+ const generation = ++generationRef.current;
189
+ markReadRequestedRef.current = new Set();
190
+ attachmentsRequestedRef.current = new Set();
191
+ appliedSelectionRef.current = null;
192
+ loadedIdRef.current = null;
193
+ setMessages([]);
194
+ setAttachmentsByUid({});
195
+ setExpandedUids(new Set());
196
+ setTruncated(false);
197
+ setError(null);
198
+ // The message the previous conversation was to be scrolled to went with it; leaving it set would
199
+ // point the run below at a row that is no longer rendered.
200
+ setPendingFocusUid(null);
201
+ if (!conversationId) {
202
+ setLoading(false);
203
+ return;
204
+ }
205
+ setLoading(true);
206
+ loadThread(mailboxUid, conversationId)
207
+ .then((loaded) => {
208
+ if (generation !== generationRef.current) return;
209
+ loadedIdRef.current = conversationId;
210
+ setMessages(loaded.messages);
211
+ setTruncated(loaded.truncated);
212
+ })
213
+ .catch((err) => {
214
+ if (generation !== generationRef.current) return;
215
+ setError(err instanceof ApiRequestError ? err.message : "Could not load this conversation.");
216
+ })
217
+ .finally(() => {
218
+ if (generation === generationRef.current) setLoading(false);
219
+ });
220
+ }, [conversationId, mailboxUid]);
221
+
222
+ // Opening the thread, and opening a different message of the same thread, both set the run of expanded
223
+ // messages and ask for that message to be scrolled to. `messages` is a dependency because the thread's
224
+ // messages arrive after the click that selected one of them; the ref guard keeps a later change to
225
+ // `messages` (a patched copy) from re-running it.
226
+ useEffect(() => {
227
+ if (messages.length === 0 || loadedIdRef.current !== conversationId) return;
228
+ const key = `${conversationId}:${selectedUid}`;
229
+ if (appliedSelectionRef.current === key) return;
230
+ appliedSelectionRef.current = key;
231
+ const expanded = expandedFrom(messages, selectedUid);
232
+ setExpandedUids(expanded);
233
+ // The oldest expanded message is the one that was opened - `expandedFrom()`'s own anchor - which in
234
+ // this newest-first order is the *last* of the run, not the first.
235
+ setPendingFocusUid([...expanded][expanded.size - 1]);
236
+ }, [conversationId, selectedUid, messages]);
237
+
238
+ useLayoutEffect(() => {
239
+ if (!pendingFocusUid) return;
240
+ // The row is always rendered by now: this runs after the DOM update that added the message it
241
+ // names, and that message came out of `messages` in the first place.
242
+ //
243
+ // Scrolled inside the thread's own list and nowhere else. `scrollIntoView()` scrolls *every*
244
+ // scrollable ancestor, the window included, which with a run of full-height messages expanded
245
+ // took the app header and the folder rail off the screen - so the adjustment goes on whichever
246
+ // ancestor actually scrolls, exactly as the toggle below already does it. When that is the page
247
+ // itself, nothing inside the pane scrolls and the row is already in view, so it is left alone.
248
+ const row = rowRefs.current[pendingFocusUid]!;
249
+ const scroller = scrollingAncestor(row);
250
+ if (scroller !== document.documentElement) {
251
+ const rect = row.getBoundingClientRect();
252
+ const top = rect.top - scroller.getBoundingClientRect().top;
253
+ // "nearest": the least that brings it into view, and nothing at all when it is already there.
254
+ if (top < 0 || top + rect.height > scroller.clientHeight) {
255
+ scroller.scrollTop += top;
256
+ }
257
+ }
258
+ // A key press (j, k, the arrows) that opened this thread left the focus on its list row on purpose, so Enter goes on to open the
259
+ // message's page; taking the focus here would make Enter toggle this header instead. A click leaves no focus ring, and hands
260
+ // the focus to the thread as before.
261
+ const active = document.activeElement;
262
+ if (!(active instanceof HTMLElement && active.hasAttribute("data-row-open") && hasFocusRing(active))) {
263
+ // `preventScroll` so focusing doesn't scroll it somewhere else again.
264
+ headerRefs.current[pendingFocusUid]!.focus({ preventScroll: true });
265
+ }
266
+ setPendingFocusUid(null);
267
+ }, [pendingFocusUid, expandedUids]);
268
+
269
+ // Keeps the message whose header was just clicked where it was on screen. Expanding one above the
270
+ // message being read otherwise pushes everything below it down by however tall the new body is.
271
+ useLayoutEffect(() => {
272
+ const anchor = anchorRef.current;
273
+ if (!anchor) return;
274
+ anchorRef.current = null;
275
+ // The row is still there: the anchor was taken from a rendered row, and toggling never removes one.
276
+ const row = rowRefs.current[anchor.uid]!;
277
+ scrollingAncestor(row).scrollTop += row.getBoundingClientRect().top - anchor.top;
278
+ }, [expandedUids]);
279
+
280
+ // Attachments and mark-as-read, for expanded messages only - mirrors `mailDetailHooks.ts`'s
281
+ // `useMessageAttachments`/`useMarkMessageRead`, reimplemented here (rather than called in a loop, which
282
+ // the rules of hooks don't allow) because a thread expands several messages at once.
283
+ useEffect(() => {
284
+ const generation = generationRef.current;
285
+ for (const message of messages) {
286
+ const uid = message.uid;
287
+ if (!expandedUids.has(uid)) continue;
288
+ if (message.hasAttachments && !attachmentsRequestedRef.current.has(uid)) {
289
+ attachmentsRequestedRef.current.add(uid);
290
+ listAttachments(message.folderUid, uid)
291
+ .then((loaded) => {
292
+ if (generation === generationRef.current) {
293
+ setAttachmentsByUid((prev) => ({ ...prev, [uid]: loaded }));
294
+ }
295
+ })
296
+ .catch(() => {
297
+ // Best-effort, as in `useMessageAttachments`: no attachments render meanwhile, and
298
+ // forgetting the request lets a later re-expand retry it.
299
+ attachmentsRequestedRef.current.delete(uid);
300
+ });
301
+ }
302
+ if (!markReadRequestedRef.current.has(uid)) {
303
+ // Asked once per opened conversation and per expanding of the message: a failure is not retried from here, because
304
+ // undoing the optimistic change changes `messages`, which would run this effect again and ask again, for ever. A message
305
+ // that is already read is asked about too (there is nothing to send), so marking it unread while it is open - the
306
+ // keyboard's Ctrl+U - doesn't make this run again and read it straight back.
307
+ markReadRequestedRef.current.add(uid);
308
+ if (message.flags.read === true) {
309
+ continue;
310
+ }
311
+ // `message` is this render's copy, so the request carries its current `version`. The row, the conversation row's
312
+ // unread count and the folder badge all change at once; `previous` is what tells the list's conversation row
313
+ // that its count moved (see `setReadState()`).
314
+ void setReadState(message, true, {
315
+ patch: (updated, previous) => {
316
+ if (generation === generationRef.current) {
317
+ patchMessage(updated, previous);
318
+ }
319
+ },
320
+ track: trackMessageChange,
321
+ });
322
+ }
323
+ }
324
+ }, [expandedUids, messages]);
325
+
326
+ /** A newer copy of one of the thread's messages, kept here and handed to the list - with the copy it
327
+ * replaces where the caller was given one, so a conversation row can tell what actually changed. */
328
+ function patchMessage(updated: Message, previous?: Message) {
329
+ setMessages((prev) => prev.map((message) => (message.uid === updated.uid ? updated : message)));
330
+ onMessagePatched(updated, previous);
331
+ }
332
+
333
+ /** A message that left the folder being listed - it leaves the thread too, as it left the list. */
334
+ function removeMessage(updated: Message) {
335
+ setMessages((prev) => prev.filter((message) => message.uid !== updated.uid));
336
+ onMessageRemoved(updated);
337
+ }
338
+
339
+ function toggleExpanded(uid: string) {
340
+ // Opening a message again asks to mark it read again - that is how one whose request failed is retried.
341
+ markReadRequestedRef.current.delete(uid);
342
+ // The row this button lives in has rendered, so its ref is set.
343
+ anchorRef.current = { uid, top: rowRefs.current[uid]!.getBoundingClientRect().top };
344
+ setExpandedUids((prev) => {
345
+ const next = new Set(prev);
346
+ if (next.has(uid)) {
347
+ next.delete(uid);
348
+ } else {
349
+ next.add(uid);
350
+ }
351
+ return next;
352
+ });
353
+ }
354
+
355
+ // The keyboard acts on the message that was opened - or the newest, when the opened one isn't in this thread - never on all the expanded
356
+ // ones at once (each is a `MessageDetailPane`, and two of them must not both answer Ctrl+R).
357
+ const keyboardUid = messages.some((message) => message.uid === selectedUid) ? selectedUid : messages[0]?.uid;
358
+
359
+ function folderTypeOf(message: Message): string | undefined {
360
+ return folders.find((folder) => folder.uid === message.folderUid)?.type;
361
+ }
362
+
363
+ if (!conversation) {
364
+ return <p className="p-8 text-sm text-text-muted">Select a conversation to read it.</p>;
365
+ }
366
+ if (loading) {
367
+ return <p className="p-8 text-sm text-text-muted">Loading&hellip;</p>;
368
+ }
369
+ if (error) {
370
+ return (
371
+ <div className="p-4 flex-1">
372
+ <Alert>{error}</Alert>
373
+ </div>
374
+ );
375
+ }
376
+
377
+ return (
378
+ // The pane is the window's height, not the thread's: a full-height flex column whose heading is
379
+ // fixed and whose list of messages is the one scrolling, growing child (`min-h-0`, or the list
380
+ // would stretch the column past the pane instead of scrolling inside it). That is what gives an
381
+ // expanded message's own `min-h-full` row - and therefore its body iframe, which can't measure
382
+ // itself - a real height to resolve against at any window size.
383
+ <div className="flex-1 min-w-0 min-h-0 flex flex-col">
384
+ <div className="shrink-0 border-b border-border p-4">
385
+ <h1 className="text-lg font-bold tracking-tight">{conversation.subject || "(no subject)"}</h1>
386
+ <p className="text-sm text-text-muted mt-1">
387
+ {messages.length} message{messages.length === 1 ? "" : "s"}
388
+ </p>
389
+ {truncated && (
390
+ <p className="text-xs text-text-muted mt-1">
391
+ Only the oldest {THREAD_MESSAGE_LIMIT} messages of this conversation are shown here. The rest
392
+ are still in the message list.
393
+ </p>
394
+ )}
395
+ </div>
396
+ <ul className="flex-1 min-h-0 overflow-y-auto">
397
+ {messages.map((message) => {
398
+ const uid = message.uid;
399
+ const expanded = expandedUids.has(uid);
400
+ const bodyId = `thread-message-${uid}`;
401
+ const messageUnread = isUnread(message);
402
+ return (
403
+ <li
404
+ key={uid}
405
+ ref={(node) => {
406
+ rowRefs.current[uid] = node;
407
+ }}
408
+ // An expanded message is as tall as the list it scrolls in - `min-h-full`
409
+ // against the `<ul>`'s own resolved height - so its `MessageDetailPane` below
410
+ // has a full pane of height to fill and its body reaches the bottom of the
411
+ // window whatever that window's size is. `min-` rather than `h-`: a message
412
+ // whose header alone is taller than the pane still gets the room it needs.
413
+ data-unread={messageUnread ? "true" : undefined}
414
+ className={["relative border-b border-border", expanded ? "flex flex-col min-h-full" : ""].join(" ")}
415
+ >
416
+ <UnreadBar unread={messageUnread} />
417
+ <h2 className="shrink-0">
418
+ <button
419
+ type="button"
420
+ ref={(node) => {
421
+ headerRefs.current[uid] = node;
422
+ }}
423
+ onClick={() => toggleExpanded(uid)}
424
+ aria-expanded={expanded}
425
+ aria-controls={bodyId}
426
+ className={[
427
+ "w-full text-left px-4 py-3",
428
+ messageUnread ? "bg-primary/[0.07] hover:bg-primary/10" : "hover:bg-surface-alt",
429
+ ROW_FOCUS_CLASS,
430
+ ].join(" ")}
431
+ >
432
+ <UnreadLabel unread={messageUnread} />
433
+ <span className="flex items-center justify-between gap-2 text-sm">
434
+ <MailAddress recipient={message.from} className={senderClass(messageUnread)} />
435
+ <span className={["text-xs shrink-0", dateClass(messageUnread)].join(" ")}>
436
+ {new Date(message.receivedDate).toLocaleString()}
437
+ </span>
438
+ </span>
439
+ {!expanded && (
440
+ <span className="block text-xs text-text-muted truncate font-normal">
441
+ {message.bodyPreview}
442
+ </span>
443
+ )}
444
+ </button>
445
+ </h2>
446
+ <div id={bodyId} hidden={!expanded} className={expanded ? "flex-1 min-h-0 flex" : undefined}>
447
+ {expanded && (
448
+ <MessageDetailPane
449
+ inThread
450
+ shortcuts={!!shortcuts && uid === keyboardUid}
451
+ message={message}
452
+ attachments={attachmentsByUid[uid] ?? []}
453
+ isSentItems={folderTypeOf(message) === "sent_items"}
454
+ isOutbox={folderTypeOf(message) === "outbox"}
455
+ draftsFolderUid={folders.find((folder) => folder.type === "drafts")?.uid}
456
+ folders={folders}
457
+ onMoved={removeMessage}
458
+ onFolderCreated={onFolderCreated}
459
+ onRecalled={patchMessage}
460
+ onReceiptHandled={patchMessage}
461
+ onScheduledSendCanceled={removeMessage}
462
+ onArchived={removeMessage}
463
+ labels={labels}
464
+ onLabelsChanged={patchMessage}
465
+ onLabelCreated={onLabelCreated}
466
+ />
467
+ )}
468
+ </div>
469
+ </li>
470
+ );
471
+ })}
472
+ </ul>
473
+ </div>
474
+ );
475
+ }