@rapidmx/web-client 0.28.0 → 0.30.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 (125) 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 +38 -5
  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/SwipeRow.tsx +26 -4
  23. package/apps/shared/components/mail/compose/ComposeContext.tsx +193 -35
  24. package/apps/shared/components/mail/compose/ComposeWindow.tsx +62 -11
  25. package/apps/shared/components/mail/compose/ComposeWindowPlaceholder.tsx +21 -10
  26. package/apps/shared/components/mail/compose/RichTextEditor.tsx +2 -1
  27. package/apps/shared/components/mail/compose/quotedBody.ts +45 -2
  28. package/apps/shared/components/mail/labelMenu.tsx +4 -0
  29. package/apps/shared/components/mail/layout/MailShell.tsx +9 -2
  30. package/apps/shared/components/mail/reading/MessageBody.tsx +1 -1
  31. package/apps/shared/components/mail/reading/bodyContent.ts +15 -13
  32. package/apps/shared/components/mail/reading/bodyHtml.ts +12 -7
  33. package/apps/shared/gestures/useEnterSlide.ts +1 -1
  34. package/apps/shared/gestures/useLongPress.ts +138 -0
  35. package/apps/shared/keyboard/keymap.ts +2 -0
  36. package/apps/shared/mail/rangeSelection.ts +45 -0
  37. package/apps/shared/mail/rowExit.ts +39 -0
  38. package/apps/www/calendar/index.tsx +251 -53
  39. package/apps/www/index.tsx +570 -358
  40. package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.d.ts +3 -3
  41. package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.js +15 -9
  42. package/dist/apps/shared/components/admin/diagnostics/InformationPanel.d.ts +15 -0
  43. package/dist/apps/shared/components/admin/diagnostics/InformationPanel.js +16 -0
  44. package/dist/apps/shared/components/admin/diagnostics/SettingsTable.d.ts +20 -0
  45. package/dist/apps/shared/components/admin/diagnostics/SettingsTable.js +31 -0
  46. package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.d.ts +20 -1
  47. package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.js +7 -0
  48. package/dist/apps/shared/components/calendar/CalendarSearchBox.d.ts +22 -0
  49. package/dist/apps/shared/components/calendar/CalendarSearchBox.js +24 -0
  50. package/dist/apps/shared/components/calendar/EventMatchNav.d.ts +17 -0
  51. package/dist/apps/shared/components/calendar/EventMatchNav.js +37 -0
  52. package/dist/apps/shared/components/calendar/EventModal.d.ts +17 -1
  53. package/dist/apps/shared/components/calendar/EventModal.js +13 -11
  54. package/dist/apps/shared/components/calendar/ListView.d.ts +30 -0
  55. package/dist/apps/shared/components/calendar/ListView.js +111 -0
  56. package/dist/apps/shared/components/calendar/MonthView.js +12 -3
  57. package/dist/apps/shared/components/calendar/SlideIn.d.ts +16 -0
  58. package/dist/apps/shared/components/calendar/SlideIn.js +32 -0
  59. package/dist/apps/shared/components/calendar/SplitDayView.js +5 -1
  60. package/dist/apps/shared/components/calendar/TimeGridView.js +11 -3
  61. package/dist/apps/shared/components/calendar/activeOccurrence.d.ts +15 -0
  62. package/dist/apps/shared/components/calendar/activeOccurrence.js +21 -0
  63. package/dist/apps/shared/components/calendar/allDay.d.ts +4 -0
  64. package/dist/apps/shared/components/calendar/allDay.js +14 -1
  65. package/dist/apps/shared/components/calendar/calendarView.d.ts +16 -0
  66. package/dist/apps/shared/components/calendar/calendarView.js +36 -0
  67. package/dist/apps/shared/components/layout/AppShell.js +8 -4
  68. package/dist/apps/shared/components/mail/ConversationList.d.ts +16 -3
  69. package/dist/apps/shared/components/mail/ConversationList.js +10 -2
  70. package/dist/apps/shared/components/mail/ConversationThreadPane.js +9 -1
  71. package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -4
  72. package/dist/apps/shared/components/mail/MessageDetailPane.js +3 -0
  73. package/dist/apps/shared/components/mail/SwipeRow.d.ts +5 -1
  74. package/dist/apps/shared/components/mail/SwipeRow.js +17 -3
  75. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +31 -0
  76. package/dist/apps/shared/components/mail/compose/ComposeContext.js +123 -11
  77. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +6 -1
  78. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +37 -11
  79. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.d.ts +5 -1
  80. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.js +10 -6
  81. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +2 -1
  82. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +3 -0
  83. package/dist/apps/shared/components/mail/compose/quotedBody.js +43 -1
  84. package/dist/apps/shared/components/mail/labelMenu.d.ts +3 -1
  85. package/dist/apps/shared/components/mail/labelMenu.js +2 -2
  86. package/dist/apps/shared/components/mail/layout/MailShell.js +7 -1
  87. package/dist/apps/shared/components/mail/reading/MessageBody.js +1 -1
  88. package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +5 -1
  89. package/dist/apps/shared/components/mail/reading/bodyContent.js +11 -11
  90. package/dist/apps/shared/components/mail/reading/bodyHtml.d.ts +3 -2
  91. package/dist/apps/shared/components/mail/reading/bodyHtml.js +8 -4
  92. package/dist/apps/shared/gestures/useEnterSlide.d.ts +2 -0
  93. package/dist/apps/shared/gestures/useEnterSlide.js +1 -1
  94. package/dist/apps/shared/gestures/useLongPress.d.ts +41 -0
  95. package/dist/apps/shared/gestures/useLongPress.js +112 -0
  96. package/dist/apps/shared/keyboard/keymap.d.ts +12 -0
  97. package/dist/apps/shared/keyboard/keymap.js +2 -0
  98. package/dist/apps/shared/mail/rangeSelection.d.ts +20 -0
  99. package/dist/apps/shared/mail/rangeSelection.js +34 -0
  100. package/dist/apps/shared/mail/rowExit.d.ts +12 -0
  101. package/dist/apps/shared/mail/rowExit.js +33 -0
  102. package/dist/apps/www/calendar/index.js +177 -18
  103. package/dist/apps/www/index.js +257 -73
  104. package/dist/lib/calendar/eventSearch.d.ts +42 -0
  105. package/dist/lib/calendar/eventSearch.js +152 -0
  106. package/dist/lib/components/navigation/BottomTabBar.d.ts +15 -3
  107. package/dist/lib/components/navigation/BottomTabBar.js +52 -15
  108. package/dist/lib/components/navigation/NavBadge.d.ts +13 -0
  109. package/dist/lib/components/navigation/NavBadge.js +18 -0
  110. package/dist/lib/mail/compose/quotedImages.d.ts +21 -0
  111. package/dist/lib/mail/compose/quotedImages.js +41 -0
  112. package/dist/lib/mail/inlineImages.d.ts +38 -0
  113. package/dist/lib/mail/inlineImages.js +90 -0
  114. package/dist/lib/mail/mailApi.d.ts +8 -1
  115. package/dist/lib/mail/mailApi.js +57 -5
  116. package/lib/calendar/eventSearch.ts +172 -0
  117. package/lib/components/navigation/BottomTabBar.tsx +107 -22
  118. package/lib/components/navigation/NavBadge.tsx +33 -0
  119. package/lib/mail/compose/quotedImages.ts +49 -0
  120. package/lib/mail/inlineImages.ts +104 -0
  121. package/lib/mail/mailApi.ts +64 -5
  122. package/package.json +1 -1
  123. package/apps/shared/components/admin/diagnostics/VersionsPanel.tsx +0 -36
  124. package/dist/apps/shared/components/admin/diagnostics/VersionsPanel.d.ts +0 -10
  125. 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,15 +51,27 @@ 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.
56
64
  */
57
65
  swipe?: {
58
66
  enabled: boolean;
59
- onArchive: (conversation: ConversationSummary) => Promise<boolean>;
60
- onMove: (conversation: ConversationSummary) => void;
67
+ onArchive: (conversation: ListedConversation) => Promise<boolean>;
68
+ onMove: (conversation: ListedConversation) => void;
61
69
  };
70
+ /**
71
+ * A finger held on a parent row on a phone: the caller starts select mode with that conversation ticked. Absent, rows have no long press; it is not
72
+ * heard in select mode either, where a tap ticks a row already.
73
+ */
74
+ onLongPress?: (conversation: ListedConversation) => void;
62
75
  /**
63
76
  * Whether a conversation's own messages read newest first, matching the order sense the rows themselves
64
77
  * are in ("Newest on top"). `listConversationMessages()` always answers oldest first - the order a
@@ -95,7 +108,10 @@ export default function ConversationList({
95
108
  selectMode,
96
109
  selectedConversationIds,
97
110
  onToggleSelected,
111
+ onSelectWithModifier,
112
+ exitingKeys,
98
113
  swipe,
114
+ onLongPress,
99
115
  newestFirst,
100
116
  }: ConversationListProps) {
101
117
  const client = useApiClient();
@@ -176,13 +192,21 @@ export default function ConversationList({
176
192
  const children = loaded && newestFirst ? [...loaded].reverse() : loaded;
177
193
  const panelId = `conversation-messages-${conversation.mailboxUid ? `${conversation.mailboxUid}-` : ""}${conversation.conversationId}`;
178
194
  const ticked = selectedConversationIds?.has(id) ?? false;
195
+ const exiting = exitingKeys?.has(id) ?? false;
179
196
  return (
180
- <li key={id}>
197
+ <li
198
+ key={id}
199
+ data-exiting={exiting ? "true" : undefined}
200
+ aria-hidden={exiting ? "true" : undefined}
201
+ inert={exiting}
202
+ className={exiting ? "pointer-events-none" : undefined}
203
+ >
181
204
  <SwipeRow
182
205
  as="div"
183
206
  enabled={!!swipe?.enabled && !selectMode}
184
207
  onArchive={() => swipe!.onArchive(conversation)}
185
208
  onMove={() => swipe!.onMove(conversation)}
209
+ onLongPress={onLongPress && !selectMode ? () => onLongPress(conversation) : undefined}
186
210
  data-message-uid={conversation.latestMessageUid}
187
211
  data-unread={unread ? "true" : undefined}
188
212
  className={rowClass({ unread, selected: conversation.latestMessageUid === selectedUid || ticked }, "flex items-stretch")}
@@ -193,7 +217,12 @@ export default function ConversationList({
193
217
  <input
194
218
  type="checkbox"
195
219
  checked={ticked}
196
- onChange={() => onToggleSelected?.(conversation)}
220
+ onChange={(event) =>
221
+ // The change of a checkbox is caused by a click (or the Space key, which makes one): its modifiers are that event's.
222
+ onSelectWithModifier && isSelectionClick(event.nativeEvent as MouseEvent)
223
+ ? onSelectWithModifier(conversation, event.nativeEvent as MouseEvent)
224
+ : onToggleSelected?.(conversation)
225
+ }
197
226
  aria-label={`Select conversation: ${conversation.subject || "(no subject)"}`}
198
227
  className="w-4 h-4 accent-primary"
199
228
  />
@@ -218,7 +247,11 @@ export default function ConversationList({
218
247
  <button
219
248
  type="button"
220
249
  data-row-open
221
- onClick={() => onOpenMessage(conversation, conversation.latestMessageUid)}
250
+ onClick={(event) =>
251
+ onSelectWithModifier && isSelectionClick(event)
252
+ ? onSelectWithModifier(conversation, event)
253
+ : onOpenMessage(conversation, conversation.latestMessageUid)
254
+ }
222
255
  className={["w-full text-left pr-4 py-3", ROW_FOCUS_CLASS].join(" ")}
223
256
  >
224
257
  <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,8 +2,9 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- import React, { HTMLAttributes, ReactNode, useEffect, useRef, useState } from "react";
5
+ import React, { HTMLAttributes, ReactNode, TouchEvent, useEffect, useRef, useState } from "react";
6
6
  import { HiOutlineArchiveBox, HiOutlineFolder } from "react-icons/hi2";
7
+ import { useLongPress } from "../../gestures/useLongPress.js";
7
8
  import { useSwipe } from "../../gestures/useSwipe.js";
8
9
 
9
10
  export interface SwipeRowProps extends HTMLAttributes<HTMLElement> {
@@ -15,12 +16,22 @@ export interface SwipeRowProps extends HTMLAttributes<HTMLElement> {
15
16
  onArchive: () => Promise<boolean>;
16
17
  /** Left to right. The row comes back at once, and this asks where to (a prompt for the folder). */
17
18
  onMove: () => void;
19
+ /** A finger held on the row (see `useLongPress()`) - the phone's way into select mode. Heard whether or not swipes are `enabled`; absent, the row has none. */
20
+ onLongPress?: () => void;
18
21
  children: ReactNode;
19
22
  }
20
23
 
21
24
  /** How long the row takes to leave, or come back - `motion-reduce` skips it. */
22
25
  const SLIDE = "transform 150ms ease-out";
23
26
 
27
+ /** Both hooks' handlers for one touch event; `useSwipe()` has none while swipes are off. */
28
+ function both(first: (event: TouchEvent<HTMLElement>) => void, second?: (event: TouchEvent<HTMLElement>) => void) {
29
+ return (event: TouchEvent<HTMLElement>) => {
30
+ first(event);
31
+ second?.(event);
32
+ };
33
+ }
34
+
24
35
  /**
25
36
  * A row of a list of mail that slides with a finger: right to left it takes the row away to Archive (a green panel with the
26
37
  * archive icon follows the row in), left to right it comes back and asks for a folder to move it to (a blue panel with a
@@ -29,8 +40,10 @@ const SLIDE = "transform 150ms ease-out";
29
40
  *
30
41
  * Only a swipe that starts out horizontal counts (see `useSwipe()`), so scrolling the list is untouched, and it is an addition
31
42
  * to, never the only way of, archiving and moving: the selection bar and the reading pane do the same.
43
+ *
44
+ * The row is also where a press and hold is heard (`onLongPress`), which shares the finger with the swipe: a hold gives way to the first drag.
32
45
  */
33
- export default function SwipeRow({ as: Tag = "div", enabled, onArchive, onMove, children, className, style, ...rest }: SwipeRowProps) {
46
+ export default function SwipeRow({ as: Tag = "div", enabled, onArchive, onMove, onLongPress, children, className, style, ...rest }: SwipeRowProps) {
34
47
  const [offset, setOffset] = useState(0);
35
48
  const [width, setWidth] = useState(0);
36
49
  /** The row is on its way out (or waiting for the server to confirm it), so a finger can't drag it. */
@@ -71,9 +84,13 @@ export default function SwipeRow({ as: Tag = "div", enabled, onArchive, onMove,
71
84
  },
72
85
  });
73
86
 
87
+ // What `useSwipe()` hands over is `{}` while swipes are off.
88
+ const swipeTouch: Partial<ReturnType<typeof useLongPress>["handlers"]> = swipe.handlers;
89
+ const press = useLongPress({ enabled: !!onLongPress, onLongPress: onLongPress! });
90
+
74
91
  if (!enabled) {
75
92
  return (
76
- <Tag className={className} style={style} {...rest}>
93
+ <Tag className={className} style={{ ...style, ...press.style }} {...rest} {...press.handlers}>
77
94
  {children}
78
95
  </Tag>
79
96
  );
@@ -82,7 +99,11 @@ export default function SwipeRow({ as: Tag = "div", enabled, onArchive, onMove,
82
99
  return (
83
100
  <Tag
84
101
  {...rest}
85
- {...swipe.handlers}
102
+ {...press.handlers}
103
+ onTouchStart={both(press.handlers.onTouchStart, swipeTouch.onTouchStart)}
104
+ onTouchMove={both(press.handlers.onTouchMove, swipeTouch.onTouchMove)}
105
+ onTouchEnd={both(press.handlers.onTouchEnd, swipeTouch.onTouchEnd)}
106
+ onTouchCancel={both(press.handlers.onTouchCancel, swipeTouch.onTouchCancel)}
86
107
  ref={(node: HTMLElement | null) => {
87
108
  element.current = node;
88
109
  }}
@@ -90,6 +111,7 @@ export default function SwipeRow({ as: Tag = "div", enabled, onArchive, onMove,
90
111
  style={{
91
112
  ...style,
92
113
  ...swipe.style,
114
+ ...press.style,
93
115
  transform: active ? `translateX(${offset}px)` : undefined,
94
116
  transition: dragging ? "none" : SLIDE,
95
117
  }}