@rapidmx/web-client 0.28.0 → 0.29.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 (119) hide show
  1. package/README.md +3 -0
  2. package/apps/shared/components/admin/diagnostics/DiagnosticsManager.tsx +22 -10
  3. package/apps/shared/components/admin/diagnostics/InformationPanel.tsx +60 -0
  4. package/apps/shared/components/admin/diagnostics/SettingsTable.tsx +108 -0
  5. package/apps/shared/components/admin/diagnostics/diagnosticsApi.ts +25 -1
  6. package/apps/shared/components/calendar/CalendarSearchBox.tsx +83 -0
  7. package/apps/shared/components/calendar/EventMatchNav.tsx +72 -0
  8. package/apps/shared/components/calendar/EventModal.tsx +33 -12
  9. package/apps/shared/components/calendar/ListView.tsx +207 -0
  10. package/apps/shared/components/calendar/MonthView.tsx +14 -2
  11. package/apps/shared/components/calendar/SlideIn.tsx +47 -0
  12. package/apps/shared/components/calendar/SplitDayView.tsx +5 -0
  13. package/apps/shared/components/calendar/TimeGridView.tsx +23 -12
  14. package/apps/shared/components/calendar/activeOccurrence.ts +28 -0
  15. package/apps/shared/components/calendar/allDay.ts +16 -1
  16. package/apps/shared/components/calendar/calendarView.ts +41 -0
  17. package/apps/shared/components/layout/AppShell.tsx +13 -6
  18. package/apps/shared/components/mail/ConversationList.tsx +29 -3
  19. package/apps/shared/components/mail/ConversationThreadPane.tsx +15 -0
  20. package/apps/shared/components/mail/MailSelectionBar.tsx +117 -55
  21. package/apps/shared/components/mail/MessageDetailPane.tsx +3 -0
  22. package/apps/shared/components/mail/compose/ComposeContext.tsx +193 -35
  23. package/apps/shared/components/mail/compose/ComposeWindow.tsx +62 -11
  24. package/apps/shared/components/mail/compose/ComposeWindowPlaceholder.tsx +21 -10
  25. package/apps/shared/components/mail/compose/RichTextEditor.tsx +2 -1
  26. package/apps/shared/components/mail/compose/quotedBody.ts +45 -2
  27. package/apps/shared/components/mail/labelMenu.tsx +4 -0
  28. package/apps/shared/components/mail/layout/MailShell.tsx +9 -2
  29. package/apps/shared/components/mail/reading/MessageBody.tsx +1 -1
  30. package/apps/shared/components/mail/reading/bodyContent.ts +15 -13
  31. package/apps/shared/components/mail/reading/bodyHtml.ts +12 -7
  32. package/apps/shared/gestures/useEnterSlide.ts +1 -1
  33. package/apps/shared/keyboard/keymap.ts +2 -0
  34. package/apps/shared/mail/rangeSelection.ts +45 -0
  35. package/apps/shared/mail/rowExit.ts +39 -0
  36. package/apps/www/calendar/index.tsx +251 -53
  37. package/apps/www/index.tsx +522 -343
  38. package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.d.ts +3 -3
  39. package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.js +15 -9
  40. package/dist/apps/shared/components/admin/diagnostics/InformationPanel.d.ts +15 -0
  41. package/dist/apps/shared/components/admin/diagnostics/InformationPanel.js +16 -0
  42. package/dist/apps/shared/components/admin/diagnostics/SettingsTable.d.ts +20 -0
  43. package/dist/apps/shared/components/admin/diagnostics/SettingsTable.js +31 -0
  44. package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.d.ts +20 -1
  45. package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.js +7 -0
  46. package/dist/apps/shared/components/calendar/CalendarSearchBox.d.ts +22 -0
  47. package/dist/apps/shared/components/calendar/CalendarSearchBox.js +24 -0
  48. package/dist/apps/shared/components/calendar/EventMatchNav.d.ts +17 -0
  49. package/dist/apps/shared/components/calendar/EventMatchNav.js +37 -0
  50. package/dist/apps/shared/components/calendar/EventModal.d.ts +17 -1
  51. package/dist/apps/shared/components/calendar/EventModal.js +13 -11
  52. package/dist/apps/shared/components/calendar/ListView.d.ts +30 -0
  53. package/dist/apps/shared/components/calendar/ListView.js +111 -0
  54. package/dist/apps/shared/components/calendar/MonthView.js +12 -3
  55. package/dist/apps/shared/components/calendar/SlideIn.d.ts +16 -0
  56. package/dist/apps/shared/components/calendar/SlideIn.js +32 -0
  57. package/dist/apps/shared/components/calendar/SplitDayView.js +5 -1
  58. package/dist/apps/shared/components/calendar/TimeGridView.js +11 -3
  59. package/dist/apps/shared/components/calendar/activeOccurrence.d.ts +15 -0
  60. package/dist/apps/shared/components/calendar/activeOccurrence.js +21 -0
  61. package/dist/apps/shared/components/calendar/allDay.d.ts +4 -0
  62. package/dist/apps/shared/components/calendar/allDay.js +14 -1
  63. package/dist/apps/shared/components/calendar/calendarView.d.ts +16 -0
  64. package/dist/apps/shared/components/calendar/calendarView.js +36 -0
  65. package/dist/apps/shared/components/layout/AppShell.js +8 -4
  66. package/dist/apps/shared/components/mail/ConversationList.d.ts +9 -1
  67. package/dist/apps/shared/components/mail/ConversationList.js +10 -2
  68. package/dist/apps/shared/components/mail/ConversationThreadPane.js +9 -1
  69. package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -4
  70. package/dist/apps/shared/components/mail/MessageDetailPane.js +3 -0
  71. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +31 -0
  72. package/dist/apps/shared/components/mail/compose/ComposeContext.js +123 -11
  73. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +6 -1
  74. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +37 -11
  75. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.d.ts +5 -1
  76. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.js +10 -6
  77. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +2 -1
  78. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +3 -0
  79. package/dist/apps/shared/components/mail/compose/quotedBody.js +43 -1
  80. package/dist/apps/shared/components/mail/labelMenu.d.ts +3 -1
  81. package/dist/apps/shared/components/mail/labelMenu.js +2 -2
  82. package/dist/apps/shared/components/mail/layout/MailShell.js +7 -1
  83. package/dist/apps/shared/components/mail/reading/MessageBody.js +1 -1
  84. package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +5 -1
  85. package/dist/apps/shared/components/mail/reading/bodyContent.js +11 -11
  86. package/dist/apps/shared/components/mail/reading/bodyHtml.d.ts +3 -2
  87. package/dist/apps/shared/components/mail/reading/bodyHtml.js +8 -4
  88. package/dist/apps/shared/gestures/useEnterSlide.d.ts +2 -0
  89. package/dist/apps/shared/gestures/useEnterSlide.js +1 -1
  90. package/dist/apps/shared/keyboard/keymap.d.ts +12 -0
  91. package/dist/apps/shared/keyboard/keymap.js +2 -0
  92. package/dist/apps/shared/mail/rangeSelection.d.ts +20 -0
  93. package/dist/apps/shared/mail/rangeSelection.js +34 -0
  94. package/dist/apps/shared/mail/rowExit.d.ts +12 -0
  95. package/dist/apps/shared/mail/rowExit.js +33 -0
  96. package/dist/apps/www/calendar/index.js +177 -18
  97. package/dist/apps/www/index.js +220 -65
  98. package/dist/lib/calendar/eventSearch.d.ts +42 -0
  99. package/dist/lib/calendar/eventSearch.js +152 -0
  100. package/dist/lib/components/navigation/BottomTabBar.d.ts +15 -3
  101. package/dist/lib/components/navigation/BottomTabBar.js +52 -15
  102. package/dist/lib/components/navigation/NavBadge.d.ts +13 -0
  103. package/dist/lib/components/navigation/NavBadge.js +18 -0
  104. package/dist/lib/mail/compose/quotedImages.d.ts +21 -0
  105. package/dist/lib/mail/compose/quotedImages.js +41 -0
  106. package/dist/lib/mail/inlineImages.d.ts +38 -0
  107. package/dist/lib/mail/inlineImages.js +90 -0
  108. package/dist/lib/mail/mailApi.d.ts +8 -1
  109. package/dist/lib/mail/mailApi.js +57 -5
  110. package/lib/calendar/eventSearch.ts +172 -0
  111. package/lib/components/navigation/BottomTabBar.tsx +107 -22
  112. package/lib/components/navigation/NavBadge.tsx +33 -0
  113. package/lib/mail/compose/quotedImages.ts +49 -0
  114. package/lib/mail/inlineImages.ts +104 -0
  115. package/lib/mail/mailApi.ts +64 -5
  116. package/package.json +1 -1
  117. package/apps/shared/components/admin/diagnostics/VersionsPanel.tsx +0 -36
  118. package/dist/apps/shared/components/admin/diagnostics/VersionsPanel.d.ts +0 -10
  119. package/dist/apps/shared/components/admin/diagnostics/VersionsPanel.js +0 -11
@@ -15,6 +15,7 @@ import { useApiClient } from "../../../../lib/util/apiClientContext.js";
15
15
  import { ROW_FOCUS_CLASS, UnreadBar, UnreadLabel, dateClass, isUnread, rowClass, senderClass, subjectClass } from "./unreadStyle.js";
16
16
  import SwipeRow from "./SwipeRow.js";
17
17
  import InviteRowChip from "./invite/InviteRowChip.js";
18
+ import { ClickModifiers, isSelectionClick } from "../../mail/rangeSelection.js";
18
19
 
19
20
  /** A conversation row of the list. `mailboxUid` is set only by a view that merges several mailboxes ("All Mailboxes"), whose rows are
20
21
  * each from a different one; a list of one mailbox's own leaves it out and every row is that mailbox's. */
@@ -50,6 +51,13 @@ export interface ConversationListProps {
50
51
  /** The `conversationRowKey()`s currently ticked. */
51
52
  selectedConversationIds?: Set<string>;
52
53
  onToggleSelected?: (conversation: ListedConversation) => void;
54
+ /**
55
+ * A Shift, Ctrl or Cmd click on a parent row (or on its checkbox): the caller extends or toggles the selection instead of this list opening or
56
+ * ticking the row itself (`onOpenMessage` / `onToggleSelected` are not called for it). Absent, those clicks behave as plain ones.
57
+ */
58
+ onSelectWithModifier?: (conversation: ListedConversation, modifiers: ClickModifiers) => void;
59
+ /** The `conversationRowKey()`s of rows on their way out of the list (moved, deleted): drawn non-interactive and hidden from assistive technology while the page collapses them. */
60
+ exitingKeys?: Set<string>;
53
61
  /**
54
62
  * What swiping a parent row does on a phone: right to left archives the conversation (resolving whether it went through), left
55
63
  * to right asks where to move it. Absent, or `enabled: false` (a desktop), rows don't follow a finger.
@@ -95,6 +103,8 @@ export default function ConversationList({
95
103
  selectMode,
96
104
  selectedConversationIds,
97
105
  onToggleSelected,
106
+ onSelectWithModifier,
107
+ exitingKeys,
98
108
  swipe,
99
109
  newestFirst,
100
110
  }: ConversationListProps) {
@@ -176,8 +186,15 @@ export default function ConversationList({
176
186
  const children = loaded && newestFirst ? [...loaded].reverse() : loaded;
177
187
  const panelId = `conversation-messages-${conversation.mailboxUid ? `${conversation.mailboxUid}-` : ""}${conversation.conversationId}`;
178
188
  const ticked = selectedConversationIds?.has(id) ?? false;
189
+ const exiting = exitingKeys?.has(id) ?? false;
179
190
  return (
180
- <li key={id}>
191
+ <li
192
+ key={id}
193
+ data-exiting={exiting ? "true" : undefined}
194
+ aria-hidden={exiting ? "true" : undefined}
195
+ inert={exiting}
196
+ className={exiting ? "pointer-events-none" : undefined}
197
+ >
181
198
  <SwipeRow
182
199
  as="div"
183
200
  enabled={!!swipe?.enabled && !selectMode}
@@ -193,7 +210,12 @@ export default function ConversationList({
193
210
  <input
194
211
  type="checkbox"
195
212
  checked={ticked}
196
- onChange={() => onToggleSelected?.(conversation)}
213
+ onChange={(event) =>
214
+ // The change of a checkbox is caused by a click (or the Space key, which makes one): its modifiers are that event's.
215
+ onSelectWithModifier && isSelectionClick(event.nativeEvent as MouseEvent)
216
+ ? onSelectWithModifier(conversation, event.nativeEvent as MouseEvent)
217
+ : onToggleSelected?.(conversation)
218
+ }
197
219
  aria-label={`Select conversation: ${conversation.subject || "(no subject)"}`}
198
220
  className="w-4 h-4 accent-primary"
199
221
  />
@@ -218,7 +240,11 @@ export default function ConversationList({
218
240
  <button
219
241
  type="button"
220
242
  data-row-open
221
- onClick={() => onOpenMessage(conversation, conversation.latestMessageUid)}
243
+ onClick={(event) =>
244
+ onSelectWithModifier && isSelectionClick(event)
245
+ ? onSelectWithModifier(conversation, event)
246
+ : onOpenMessage(conversation, conversation.latestMessageUid)
247
+ }
222
248
  className={["w-full text-left pr-4 py-3", ROW_FOCUS_CLASS].join(" ")}
223
249
  >
224
250
  <UnreadLabel unread={unread} />
@@ -15,6 +15,7 @@ import { ENCRYPTED_SUBJECT_PLACEHOLDER, EncryptedPreview, displaySubject } from
15
15
  import { useDecryptedMessages } from "../../mail/decryptedMessages.js";
16
16
  import { CollapsedCard, SkeletonCards, SubjectCard } from "./reading/MessageCard.js";
17
17
  import PendingMessageCard from "./reading/PendingMessageCard.js";
18
+ import { InlineComposeSlot, useInlineCompose } from "./compose/ComposeContext.js";
18
19
  import { useMailShell } from "./layout/MailShell.js";
19
20
  import { setReadState } from "../../mail/messageReadState.js";
20
21
  import { adoptedOutgoing, belongsToThread, settleOutgoing, useOutgoingReplies } from "../../mail/outbox/outgoingReplies.js";
@@ -202,6 +203,9 @@ export default function ConversationThreadPane({
202
203
  const [error, setError] = useState<string | null>(null);
203
204
  /** The message to scroll to and focus once it has rendered, or `null` once that has happened. */
204
205
  const [pendingFocusUid, setPendingFocusUid] = useState<string | null>(null);
206
+ // The Reply / Reply all / Forward compose windows of this thread's messages, drawn as cards at the top of the list; the thread's messages are
207
+ // what makes the compose provider open a reply here rather than floating at the bottom right.
208
+ const inlineSessions = useInlineCompose(messages.map((message) => message.uid));
205
209
 
206
210
  // Bumped on every conversation switch - an in-flight load/attachments/mark-read response carrying an
207
211
  // older generation belongs to a superseded conversation and is dropped rather than applied.
@@ -538,6 +542,17 @@ export default function ConversationThreadPane({
538
542
  </div>
539
543
  ) : (
540
544
  <ul className="flex-1 min-h-0 overflow-y-auto p-2 sm:p-3 flex flex-col gap-3">
545
+ {inlineSessions.map((session) => (
546
+ <InlineComposeSlot
547
+ key={session.id}
548
+ id={session.id}
549
+ onPlaced={(slot) => {
550
+ // Brought into view like a message that has just been sent: the same scroll, inside the list.
551
+ rowRefs.current[session.id] = slot;
552
+ setPendingFocusUid(session.id);
553
+ }}
554
+ />
555
+ ))}
541
556
  {pendingCards.map((reply) => (
542
557
  <li
543
558
  key={reply.uid}
@@ -3,6 +3,18 @@
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import React, { useState } from "react";
6
+ import {
7
+ HiOutlineArchiveBox,
8
+ HiOutlineCheckCircle,
9
+ HiOutlineEnvelope,
10
+ HiOutlineEnvelopeOpen,
11
+ HiOutlineFlag,
12
+ HiOutlineFolderArrowDown,
13
+ HiOutlineMinusCircle,
14
+ HiOutlineNoSymbol,
15
+ HiOutlineTrash,
16
+ HiOutlineXMark,
17
+ } from "react-icons/hi2";
6
18
  import { Folder, Message } from "../../../../lib/mail/mailApi.js";
7
19
  import { Label } from "../../../../lib/mail/labelsApi.js";
8
20
  import LabelMenuButton from "./labelMenu.js";
@@ -65,8 +77,43 @@ export interface MailSelectionBarProps {
65
77
  labelsDisabledReason?: string;
66
78
  }
67
79
 
68
- function actionClassName(destructive = false): string {
69
- return `px-2 py-1 rounded-md text-sm ${destructive ? "text-danger font-semibold" : "text-text"} hover:bg-surface-alt disabled:opacity-50 disabled:hover:bg-transparent`;
80
+ /** The look of the bar's icon buttons - the reading pane's own command row (`ICON_BUTTON_CLASS` in `MessageDetailPane`), without its border, which nine of them in a row don't need. */
81
+ function iconClassName(destructive = false): string {
82
+ return `inline-flex items-center justify-center p-1.5 rounded-md text-sm ${destructive ? "text-danger" : "text-text"} hover:bg-surface-alt disabled:opacity-50 disabled:hover:bg-transparent`;
83
+ }
84
+
85
+ /** One of the bar's actions as an icon button: the action's name is its `aria-label` (so it has the accessible name its text button had) and its tooltip,
86
+ * unless there is a `reason` it is unavailable, or a keyboard shortcut to name (`title` and `aria-keyshortcuts` from `hint()`). */
87
+ function BarAction({
88
+ icon,
89
+ label,
90
+ onClick,
91
+ disabled,
92
+ reason,
93
+ destructive,
94
+ hint,
95
+ }: {
96
+ icon: React.ReactNode;
97
+ label: string;
98
+ onClick: () => void;
99
+ disabled?: boolean;
100
+ reason?: string;
101
+ destructive?: boolean;
102
+ hint?: { title?: string; "aria-keyshortcuts"?: string };
103
+ }) {
104
+ return (
105
+ <button
106
+ type="button"
107
+ aria-label={label}
108
+ onClick={onClick}
109
+ disabled={disabled}
110
+ className={iconClassName(destructive)}
111
+ {...hint}
112
+ title={reason ?? hint?.title ?? label}
113
+ >
114
+ {icon}
115
+ </button>
116
+ );
70
117
  }
71
118
 
72
119
  /**
@@ -148,47 +195,67 @@ export default function MailSelectionBar({
148
195
  onMove={onMoveTo}
149
196
  onFolderCreated={onFolderCreated}
150
197
  />
151
- <div className="flex items-center gap-2 px-3 py-1.5 bg-surface-alt">
152
- <span aria-live="polite" className="text-sm font-semibold text-text">
198
+ <div className="flex items-center gap-1 px-3 py-1.5 bg-surface-alt">
199
+ <span aria-live="polite" className="text-sm font-semibold text-text mr-auto">
153
200
  {selectedCount}
154
201
  {totals ? ` ${totals.noun}${selectedCount === 1 ? "" : "s"}` : ""} selected
155
202
  </span>
156
- <button type="button" onClick={onSelectAll} disabled={allSelected || listedCount === 0} className={actionClassName()}>
157
- Select all
158
- </button>
159
- <button type="button" onClick={onClearSelection} disabled={selectedCount === 0} className={actionClassName()}>
160
- Clear
161
- </button>
162
- <button type="button" onClick={onCancel} className={`${actionClassName()} ml-auto`}>
163
- Cancel
164
- </button>
203
+ <BarAction
204
+ icon={<HiOutlineCheckCircle size={16} aria-hidden="true" />}
205
+ label="Select all"
206
+ onClick={onSelectAll}
207
+ disabled={allSelected || listedCount === 0}
208
+ />
209
+ <BarAction icon={<HiOutlineMinusCircle size={16} aria-hidden="true" />} label="Clear" onClick={onClearSelection} disabled={selectedCount === 0} />
210
+ <BarAction icon={<HiOutlineXMark size={16} aria-hidden="true" />} label="Cancel" onClick={onCancel} />
165
211
  </div>
212
+ {/* Icon-only, so all nine fit one row of the 24rem list column; wraps if the column is ever narrower. */}
166
213
  <div className="flex flex-wrap items-center gap-0.5 px-1.5 py-1">
167
- <button type="button" onClick={() => onSetRead(true)} disabled={none || busy} className={actionClassName()} {...hint("Mark read", SHORTCUTS.mail.markRead)}>
168
- Mark read
169
- </button>
170
- <button type="button" onClick={() => onSetRead(false)} disabled={none || busy} className={actionClassName()} {...hint("Mark unread", SHORTCUTS.mail.markUnread)}>
171
- Mark unread
172
- </button>
173
- <button type="button" onClick={() => onSetFlagged(true)} disabled={none || busy} className={actionClassName()} {...hint("Flag", SHORTCUTS.mail.flag)}>
174
- Flag
175
- </button>
176
- <button type="button" onClick={() => onSetFlagged(false)} disabled={none || busy} className={actionClassName()}>
177
- Unflag
178
- </button>
179
- <button
180
- type="button"
214
+ <BarAction
215
+ icon={<HiOutlineEnvelopeOpen size={16} aria-hidden="true" />}
216
+ label="Mark read"
217
+ onClick={() => onSetRead(true)}
218
+ disabled={none || busy}
219
+ hint={hint("Mark read", SHORTCUTS.mail.markRead)}
220
+ />
221
+ <BarAction
222
+ icon={<HiOutlineEnvelope size={16} aria-hidden="true" />}
223
+ label="Mark unread"
224
+ onClick={() => onSetRead(false)}
225
+ disabled={none || busy}
226
+ hint={hint("Mark unread", SHORTCUTS.mail.markUnread)}
227
+ />
228
+ <BarAction
229
+ icon={<HiOutlineFlag size={16} aria-hidden="true" />}
230
+ label="Flag"
231
+ onClick={() => onSetFlagged(true)}
232
+ disabled={none || busy}
233
+ hint={hint("Flag", SHORTCUTS.mail.flag)}
234
+ />
235
+ <BarAction
236
+ icon={
237
+ // `hi2` has no flag-with-a-line-through glyph: the flag, struck through.
238
+ <span className="relative inline-flex" aria-hidden="true">
239
+ <HiOutlineFlag size={16} />
240
+ <span className="absolute left-1/2 top-[-1px] h-[18px] w-[1.5px] -translate-x-1/2 rotate-45 rounded bg-current" />
241
+ </span>
242
+ }
243
+ label="Unflag"
244
+ onClick={() => onSetFlagged(false)}
245
+ disabled={none || busy}
246
+ />
247
+ <BarAction
248
+ icon={<HiOutlineArchiveBox size={16} aria-hidden="true" />}
249
+ label="Archive"
181
250
  onClick={onArchive}
182
251
  disabled={none || busy || !!archiveReason}
183
- title={archiveReason}
184
- className={actionClassName()}
185
- >
186
- Archive
187
- </button>
252
+ reason={archiveReason}
253
+ />
188
254
  <LabelMenuButton
189
255
  aria-label="Apply label"
190
256
  label="Apply label"
191
- className="text-sm"
257
+ iconOnly
258
+ className={iconClassName()}
192
259
  labels={labels}
193
260
  mailboxUid={mailboxUid}
194
261
  onLabelCreated={onLabelCreated}
@@ -197,7 +264,7 @@ export default function MailSelectionBar({
197
264
  onCommit={onApplyLabels}
198
265
  busy={busy}
199
266
  disabled={none || busy || !!labelsDisabledReason}
200
- title={labelsDisabledReason}
267
+ title={labelsDisabledReason ?? "Apply label"}
201
268
  note={
202
269
  selected.length === 1
203
270
  ? "Ticked labels are applied, unticked ones removed."
@@ -207,33 +274,28 @@ export default function MailSelectionBar({
207
274
  commit={{ label: "Apply" }}
208
275
  clear={{ label: "Remove all labels" }}
209
276
  />
210
- <button
211
- type="button"
277
+ <BarAction
278
+ icon={<HiOutlineFolderArrowDown size={16} aria-hidden="true" />}
279
+ label="Move to"
212
280
  onClick={() => setMovePrompt(true)}
213
281
  disabled={none || busy || !!moveDisabledReason}
214
- title={moveDisabledReason}
215
- className={actionClassName()}
216
- >
217
- Move to
218
- </button>
219
- <button
220
- type="button"
282
+ reason={moveDisabledReason}
283
+ />
284
+ <BarAction
285
+ icon={<HiOutlineNoSymbol size={16} aria-hidden="true" />}
286
+ label="Report junk"
221
287
  onClick={onReportJunk}
222
288
  disabled={none || busy || !!junkReason}
223
- title={junkReason}
224
- className={actionClassName()}
225
- >
226
- Report junk
227
- </button>
228
- <button
229
- type="button"
289
+ reason={junkReason}
290
+ />
291
+ <BarAction
292
+ icon={<HiOutlineTrash size={16} aria-hidden="true" />}
293
+ label={deleteLabel}
230
294
  onClick={onDelete}
231
295
  disabled={none || busy}
232
- className={actionClassName(deletesPermanently)}
233
- {...hint(deleteLabel, SHORTCUTS.mail.delete)}
234
- >
235
- {deleteLabel}
236
- </button>
296
+ destructive={deletesPermanently}
297
+ hint={hint(deleteLabel, SHORTCUTS.mail.delete)}
298
+ />
237
299
  </div>
238
300
  </div>
239
301
  );
@@ -830,6 +830,7 @@ function MessageDetailContent({
830
830
  mailboxUid: message.mailboxUid,
831
831
  subject: forwardSubject(message.subject),
832
832
  signatureContext: "reply_forward",
833
+ inlineFor: message.uid,
833
834
  encrypt,
834
835
  threading,
835
836
  pending: late,
@@ -843,6 +844,8 @@ function MessageDetailContent({
843
844
  subject: replySubject(message.subject),
844
845
  signatureContext: "reply_forward",
845
846
  suppressSigning: isLikelyMailingList({ listUnsubscribe: message.listUnsubscribeHeader }),
847
+ // A card above this message in the reading pane when it is in one, else the floating window (see `OpenComposeInput.inlineFor`).
848
+ inlineFor: message.uid,
846
849
  encrypt,
847
850
  threading,
848
851
  pending: late,
@@ -2,7 +2,8 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- import React, { PropsWithChildren, createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
5
+ import React, { PropsWithChildren, createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
6
+ import { createPortal } from "react-dom";
6
7
  import { DraftThreading } from "../../../../../lib/mail/mailApi.js";
7
8
  import { ApiClient } from "../../../../../lib/util/api.js";
8
9
  import { useApiClient } from "../../../../../lib/util/apiClientContext.js";
@@ -110,6 +111,8 @@ export interface ComposeSession {
110
111
  late?: ComposeLateInput;
111
112
  /** See `OpenComposeInput.resume`. */
112
113
  resume?: ResumeCompose;
114
+ /** See `OpenComposeInput.inlineFor`. Absent once the window was opened without a host for it. */
115
+ inlineFor?: string;
113
116
  minimized: boolean;
114
117
  }
115
118
 
@@ -166,6 +169,13 @@ export interface OpenComposeInput {
166
169
  /** Re-opens a message that was already composed - a failed send's "Open draft": the window continues that server draft, with every field
167
170
  * exactly as it was typed, instead of starting a new one. */
168
171
  resume?: ResumeCompose;
172
+ /**
173
+ * The uid of the message this reply or forward answers. When the reading pane is showing a thread that holds that message (see
174
+ * `useInlineCompose()`), the compose window opens as a card above the thread instead of floating at the bottom right; the
175
+ * user can still pop it out to the floating stack, and it goes there by itself when the thread is left. Ignored - the window
176
+ * floats - when no pane is showing that message. Absent for a new message, which always floats.
177
+ */
178
+ inlineFor?: string;
169
179
  }
170
180
 
171
181
  export interface ComposeContextValue {
@@ -183,6 +193,57 @@ export interface ComposeContextValue {
183
193
 
184
194
  const ComposeContext = createContext<ComposeContextValue>({ openCompose: () => undefined });
185
195
 
196
+ /** The part of a session a reading pane needs to draw its slot. */
197
+ export interface InlineComposeSession {
198
+ id: string;
199
+ /** The uid of the message the window answers. */
200
+ inlineFor: string;
201
+ }
202
+
203
+ interface InlineComposeValue {
204
+ /** The sessions currently drawn inline, in the order they were opened. */
205
+ sessions: InlineComposeSession[];
206
+ /** Announces the messages a reading pane holds; the returned function withdraws it. */
207
+ register: (uids: string[]) => () => void;
208
+ /** The element a session's window is rendered into, for a slot to hold. */
209
+ containerFor: (id: string) => HTMLElement | undefined;
210
+ }
211
+
212
+ const InlineComposeContext = createContext<InlineComposeValue>({ sessions: [], register: () => () => undefined, containerFor: () => undefined });
213
+
214
+ /**
215
+ * For a reading pane: announces that it is showing the messages `uids`, so a Reply or Forward of one of them opens its compose
216
+ * window as a card in that pane (drawn with `InlineComposeSlot`) rather than a floating window, and returns the sessions to
217
+ * draw a slot for. When the pane stops holding a message - it unmounts, or moves to another conversation - the window answering
218
+ * it moves out to the floating stack, with everything typed into it.
219
+ */
220
+ export function useInlineCompose(uids: string[]): InlineComposeSession[] {
221
+ const { sessions, register } = useContext(InlineComposeContext);
222
+ const key = uids.join(",");
223
+ useEffect(() => register(uids), [key, register]);
224
+ return useMemo(() => sessions.filter((session) => uids.includes(session.inlineFor)), [sessions, key]);
225
+ }
226
+
227
+ /**
228
+ * The list item a reading pane holds an inline compose window in. The window itself is owned (and kept alive) by
229
+ * `ComposeProvider`; this only moves the element it renders into into place. `onPlaced` is called once, as it arrives, for the pane
230
+ * to bring it into view.
231
+ */
232
+ export function InlineComposeSlot({ id, onPlaced }: { id: string; onPlaced?: (slot: HTMLLIElement) => void }) {
233
+ const { containerFor } = useContext(InlineComposeContext);
234
+ const slotRef = useRef<HTMLLIElement>(null);
235
+ useLayoutEffect(() => {
236
+ const slot = slotRef.current!;
237
+ // Nothing to hold outside a `ComposeProvider`.
238
+ const container = containerFor(id);
239
+ if (container) {
240
+ slot.appendChild(container);
241
+ }
242
+ onPlaced?.(slot);
243
+ }, [id]);
244
+ return <li ref={slotRef} data-inline-compose={id} />;
245
+ }
246
+
186
247
  /** Opens the floating Compose window from anywhere inside `AppShell` (any of the four webmail apps). */
187
248
  export function useCompose(): ComposeContextValue {
188
249
  return useContext(ComposeContext);
@@ -201,9 +262,67 @@ export default function ComposeProvider({ children, userUid, trusted }: PropsWit
201
262
  const isMobile = useIsMobile();
202
263
  const { Component: ComposeWindow, failed, retry: retryLoad } = useComposeWindowComponent(sessions.length > 0);
203
264
 
204
- // The top edge of the compose windows, published as `--rr-compose-top` so the pop-up stack (`NotificationCenter`) can keep clear of them.
265
+ // Every session's window is rendered (through a portal) into an element of its own that is *moved* between the floating stack
266
+ // and a reading pane's slot, never re-created: a window that changed parents in the React tree would remount and lose what was typed.
267
+ const containersRef = useRef(new Map<string, HTMLElement>());
268
+ // The messages the reading panes currently showing a thread hold, one entry per pane (a ref too, for `openCompose()` to read at the click).
269
+ const hostsRef = useRef(new Map<symbol, string[]>());
270
+ const [hostedUids, setHostedUids] = useState<string[]>([]);
271
+ // The inline sessions that were popped out or whose pane went away: floating for good, even if a pane shows their message again.
272
+ const [parked, setParked] = useState<Set<string>>(new Set());
273
+ const register = useCallback((uids: string[]) => {
274
+ const token = Symbol();
275
+ hostsRef.current.set(token, uids);
276
+ setHostedUids([...hostsRef.current.values()].flat());
277
+ return () => {
278
+ hostsRef.current.delete(token);
279
+ setHostedUids([...hostsRef.current.values()].flat());
280
+ };
281
+ }, []);
282
+ const hosted = new Set(hostedUids);
283
+ const isInline = (session: ComposeSession) => !!session.inlineFor && !parked.has(session.id) && hosted.has(session.inlineFor);
284
+ const inlineKey = sessions.filter(isInline).map((session) => `${session.id}:${session.inlineFor}`).join("|");
285
+ const inlineSessions = useMemo<InlineComposeSession[]>(
286
+ () => sessions.filter(isInline).map((session) => ({ id: session.id, inlineFor: session.inlineFor! })),
287
+ [inlineKey],
288
+ );
289
+ const inlineValue = useMemo<InlineComposeValue>(
290
+ () => ({ sessions: inlineSessions, register, containerFor: (id) => containersRef.current.get(id) }),
291
+ [inlineSessions, register],
292
+ );
293
+ const floatingSessions = sessions.filter((session) => !inlineSessions.some((inline) => inline.id === session.id));
294
+
295
+ // A session whose message is no longer in any pane (the reader went to another conversation) is parked in the stack.
296
+ useEffect(() => {
297
+ const orphans = sessions.filter((session) => session.inlineFor && !parked.has(session.id) && !hosted.has(session.inlineFor));
298
+ if (orphans.length > 0) {
299
+ setParked((prev) => new Set([...prev, ...orphans.map((session) => session.id)]));
300
+ }
301
+ }, [sessions, hostedUids, parked]);
302
+
303
+ // The elements of closed sessions go away with them.
304
+ useEffect(() => {
305
+ const open = new Set(sessions.map((session) => session.id));
306
+ for (const [id, element] of containersRef.current) {
307
+ if (!open.has(id)) {
308
+ element.remove();
309
+ containersRef.current.delete(id);
310
+ }
311
+ }
312
+ }, [sessions]);
313
+ useEffect(
314
+ () => () => {
315
+ for (const element of containersRef.current.values()) {
316
+ element.remove();
317
+ }
318
+ containersRef.current.clear();
319
+ },
320
+ [],
321
+ );
322
+
323
+ // The top edge of the floating compose windows, published as `--rr-compose-top` so the pop-up stack (`NotificationCenter`) can keep clear of them.
205
324
  const windowsRef = useRef<HTMLDivElement>(null);
206
- const hasSessions = sessions.length > 0;
325
+ const hasSessions = floatingSessions.length > 0;
207
326
  useEffect(() => {
208
327
  const element = windowsRef.current;
209
328
  if (!element) {
@@ -233,9 +352,15 @@ export default function ComposeProvider({ children, userUid, trusted }: PropsWit
233
352
  threading,
234
353
  pending,
235
354
  resume,
355
+ inlineFor,
236
356
  }: OpenComposeInput) {
237
357
  const id = crypto.randomUUID();
238
358
  markComposePhase(id, "click");
359
+ // The window's own element, attached to the page from the start (it is moved to its slot or the stack as soon as that is drawn).
360
+ const container = document.createElement("div");
361
+ container.className = "contents";
362
+ document.body.appendChild(container);
363
+ containersRef.current.set(id, container);
239
364
  setSessions((prev) => [
240
365
  ...prev,
241
366
  {
@@ -251,6 +376,8 @@ export default function ComposeProvider({ children, userUid, trusted }: PropsWit
251
376
  suppressSigning,
252
377
  quotePending: !!pending,
253
378
  resume,
379
+ // Only a pane that is showing the message can hold the window.
380
+ inlineFor: inlineFor && [...hostsRef.current.values()].some((uids) => uids.includes(inlineFor)) ? inlineFor : undefined,
254
381
  minimized: false,
255
382
  },
256
383
  ]);
@@ -268,6 +395,18 @@ export default function ComposeProvider({ children, userUid, trusted }: PropsWit
268
395
 
269
396
  function closeCompose(id: string) {
270
397
  setSessions((prev) => prev.filter((s) => s.id !== id));
398
+ setParked((prev) => {
399
+ if (!prev.has(id)) {
400
+ return prev;
401
+ }
402
+ const next = new Set(prev);
403
+ next.delete(id);
404
+ return next;
405
+ });
406
+ }
407
+
408
+ function popOut(id: string) {
409
+ setParked((prev) => new Set(prev).add(id));
271
410
  }
272
411
 
273
412
  function toggleMinimize(id: string) {
@@ -278,46 +417,65 @@ export default function ComposeProvider({ children, userUid, trusted }: PropsWit
278
417
  // Code with no React context - a failed send's "Open draft" pop-up - opens windows through this.
279
418
  useEffect(() => registerComposeOpener((input) => value.openCompose(input)), [value]);
280
419
 
281
- // On mobile, a non-minimized `ComposeWindow` renders full-screen (see that component's own doc
420
+ // On mobile, a non-minimized floating `ComposeWindow` renders full-screen (see that component's own doc
282
421
  // comment) — Gmail-style stacking of several full-screen overlays at once makes no sense there, so
283
422
  // at most one non-minimized session is ever shown: the most recently opened one. Minimized
284
423
  // sessions are small chips regardless of device, so every one of those still shows — an earlier
285
424
  // session becomes visible again (as its own chip, or full-screen if it's the new most-recent
286
425
  // non-minimized one) once whatever's currently "on top" is closed or minimized. The others stay
287
- // mounted, just hidden: unmounting them would throw away everything typed into them.
288
- const lastNonMinimizedId = isMobile ? [...sessions].reverse().find((s) => !s.minimized)?.id : undefined;
426
+ // mounted, just hidden: unmounting them would throw away everything typed into them. An inline card
427
+ // is part of the reading pane and is never hidden.
428
+ const lastNonMinimizedId = isMobile ? [...floatingSessions].reverse().find((s) => !s.minimized)?.id : undefined;
429
+
430
+ // Puts every floating session's element in the stack (an inline one is put in its slot by `InlineComposeSlot`), and hides those
431
+ // the mobile rule above hides.
432
+ useLayoutEffect(() => {
433
+ for (const session of sessions) {
434
+ const element = containersRef.current.get(session.id)!;
435
+ const floating = floatingSessions.some((s) => s.id === session.id);
436
+ const hidden = floating && isMobile && !session.minimized && session.id !== lastNonMinimizedId;
437
+ element.hidden = hidden;
438
+ element.className = hidden ? "hidden" : "contents";
439
+ if (floating && element.parentElement !== windowsRef.current) {
440
+ windowsRef.current!.appendChild(element);
441
+ }
442
+ }
443
+ });
289
444
 
290
445
  return (
291
446
  <ComposeContext.Provider value={value}>
292
- {children}
293
- {sessions.length > 0 && (
294
- <div ref={windowsRef} className="fixed bottom-0 right-6 flex items-end gap-3 z-50">
295
- {sessions.map((session) => {
296
- const hidden = isMobile && !session.minimized && session.id !== lastNonMinimizedId;
297
- return (
298
- <div key={session.id} hidden={hidden} className={hidden ? "hidden" : "contents"}>
299
- {ComposeWindow ? (
300
- <ComposeWindow
301
- session={session}
302
- userUid={userUid}
303
- trusted={trusted}
304
- onClose={() => closeCompose(session.id)}
305
- onToggleMinimize={() => toggleMinimize(session.id)}
306
- />
307
- ) : (
308
- <ComposeWindowPlaceholder
309
- session={session}
310
- failed={failed}
311
- onRetry={retryLoad}
312
- onClose={() => closeCompose(session.id)}
313
- onToggleMinimize={() => toggleMinimize(session.id)}
314
- />
315
- )}
316
- </div>
317
- );
318
- })}
319
- </div>
320
- )}
447
+ <InlineComposeContext.Provider value={inlineValue}>
448
+ {children}
449
+ {sessions.map((session) => {
450
+ const inline = inlineSessions.some((s) => s.id === session.id);
451
+ return createPortal(
452
+ ComposeWindow ? (
453
+ <ComposeWindow
454
+ session={session}
455
+ userUid={userUid}
456
+ trusted={trusted}
457
+ inline={inline}
458
+ onPopOut={() => popOut(session.id)}
459
+ onClose={() => closeCompose(session.id)}
460
+ onToggleMinimize={() => toggleMinimize(session.id)}
461
+ />
462
+ ) : (
463
+ <ComposeWindowPlaceholder
464
+ session={session}
465
+ failed={failed}
466
+ inline={inline}
467
+ onPopOut={() => popOut(session.id)}
468
+ onRetry={retryLoad}
469
+ onClose={() => closeCompose(session.id)}
470
+ onToggleMinimize={() => toggleMinimize(session.id)}
471
+ />
472
+ ),
473
+ containersRef.current.get(session.id)!,
474
+ session.id,
475
+ );
476
+ })}
477
+ {hasSessions && <div ref={windowsRef} className="fixed bottom-0 right-6 flex items-end gap-3 z-50" />}
478
+ </InlineComposeContext.Provider>
321
479
  </ComposeContext.Provider>
322
480
  );
323
481
  }