@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.
- package/README.md +3 -0
- package/apps/shared/components/admin/diagnostics/DiagnosticsManager.tsx +22 -10
- package/apps/shared/components/admin/diagnostics/InformationPanel.tsx +60 -0
- package/apps/shared/components/admin/diagnostics/SettingsTable.tsx +108 -0
- package/apps/shared/components/admin/diagnostics/diagnosticsApi.ts +25 -1
- package/apps/shared/components/calendar/CalendarSearchBox.tsx +83 -0
- package/apps/shared/components/calendar/EventMatchNav.tsx +72 -0
- package/apps/shared/components/calendar/EventModal.tsx +33 -12
- package/apps/shared/components/calendar/ListView.tsx +207 -0
- package/apps/shared/components/calendar/MonthView.tsx +14 -2
- package/apps/shared/components/calendar/SlideIn.tsx +47 -0
- package/apps/shared/components/calendar/SplitDayView.tsx +5 -0
- package/apps/shared/components/calendar/TimeGridView.tsx +23 -12
- package/apps/shared/components/calendar/activeOccurrence.ts +28 -0
- package/apps/shared/components/calendar/allDay.ts +16 -1
- package/apps/shared/components/calendar/calendarView.ts +41 -0
- package/apps/shared/components/layout/AppShell.tsx +13 -6
- package/apps/shared/components/mail/ConversationList.tsx +29 -3
- package/apps/shared/components/mail/ConversationThreadPane.tsx +15 -0
- package/apps/shared/components/mail/MailSelectionBar.tsx +117 -55
- package/apps/shared/components/mail/MessageDetailPane.tsx +3 -0
- package/apps/shared/components/mail/compose/ComposeContext.tsx +193 -35
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +62 -11
- package/apps/shared/components/mail/compose/ComposeWindowPlaceholder.tsx +21 -10
- package/apps/shared/components/mail/compose/RichTextEditor.tsx +2 -1
- package/apps/shared/components/mail/compose/quotedBody.ts +45 -2
- package/apps/shared/components/mail/labelMenu.tsx +4 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +9 -2
- package/apps/shared/components/mail/reading/MessageBody.tsx +1 -1
- package/apps/shared/components/mail/reading/bodyContent.ts +15 -13
- package/apps/shared/components/mail/reading/bodyHtml.ts +12 -7
- package/apps/shared/gestures/useEnterSlide.ts +1 -1
- package/apps/shared/keyboard/keymap.ts +2 -0
- package/apps/shared/mail/rangeSelection.ts +45 -0
- package/apps/shared/mail/rowExit.ts +39 -0
- package/apps/www/calendar/index.tsx +251 -53
- package/apps/www/index.tsx +522 -343
- package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.d.ts +3 -3
- package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.js +15 -9
- package/dist/apps/shared/components/admin/diagnostics/InformationPanel.d.ts +15 -0
- package/dist/apps/shared/components/admin/diagnostics/InformationPanel.js +16 -0
- package/dist/apps/shared/components/admin/diagnostics/SettingsTable.d.ts +20 -0
- package/dist/apps/shared/components/admin/diagnostics/SettingsTable.js +31 -0
- package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.d.ts +20 -1
- package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.js +7 -0
- package/dist/apps/shared/components/calendar/CalendarSearchBox.d.ts +22 -0
- package/dist/apps/shared/components/calendar/CalendarSearchBox.js +24 -0
- package/dist/apps/shared/components/calendar/EventMatchNav.d.ts +17 -0
- package/dist/apps/shared/components/calendar/EventMatchNav.js +37 -0
- package/dist/apps/shared/components/calendar/EventModal.d.ts +17 -1
- package/dist/apps/shared/components/calendar/EventModal.js +13 -11
- package/dist/apps/shared/components/calendar/ListView.d.ts +30 -0
- package/dist/apps/shared/components/calendar/ListView.js +111 -0
- package/dist/apps/shared/components/calendar/MonthView.js +12 -3
- package/dist/apps/shared/components/calendar/SlideIn.d.ts +16 -0
- package/dist/apps/shared/components/calendar/SlideIn.js +32 -0
- package/dist/apps/shared/components/calendar/SplitDayView.js +5 -1
- package/dist/apps/shared/components/calendar/TimeGridView.js +11 -3
- package/dist/apps/shared/components/calendar/activeOccurrence.d.ts +15 -0
- package/dist/apps/shared/components/calendar/activeOccurrence.js +21 -0
- package/dist/apps/shared/components/calendar/allDay.d.ts +4 -0
- package/dist/apps/shared/components/calendar/allDay.js +14 -1
- package/dist/apps/shared/components/calendar/calendarView.d.ts +16 -0
- package/dist/apps/shared/components/calendar/calendarView.js +36 -0
- package/dist/apps/shared/components/layout/AppShell.js +8 -4
- package/dist/apps/shared/components/mail/ConversationList.d.ts +9 -1
- package/dist/apps/shared/components/mail/ConversationList.js +10 -2
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +9 -1
- package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -4
- package/dist/apps/shared/components/mail/MessageDetailPane.js +3 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +31 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +123 -11
- package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +6 -1
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +37 -11
- package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.d.ts +5 -1
- package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.js +10 -6
- package/dist/apps/shared/components/mail/compose/RichTextEditor.js +2 -1
- package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +3 -0
- package/dist/apps/shared/components/mail/compose/quotedBody.js +43 -1
- package/dist/apps/shared/components/mail/labelMenu.d.ts +3 -1
- package/dist/apps/shared/components/mail/labelMenu.js +2 -2
- package/dist/apps/shared/components/mail/layout/MailShell.js +7 -1
- package/dist/apps/shared/components/mail/reading/MessageBody.js +1 -1
- package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +5 -1
- package/dist/apps/shared/components/mail/reading/bodyContent.js +11 -11
- package/dist/apps/shared/components/mail/reading/bodyHtml.d.ts +3 -2
- package/dist/apps/shared/components/mail/reading/bodyHtml.js +8 -4
- package/dist/apps/shared/gestures/useEnterSlide.d.ts +2 -0
- package/dist/apps/shared/gestures/useEnterSlide.js +1 -1
- package/dist/apps/shared/keyboard/keymap.d.ts +12 -0
- package/dist/apps/shared/keyboard/keymap.js +2 -0
- package/dist/apps/shared/mail/rangeSelection.d.ts +20 -0
- package/dist/apps/shared/mail/rangeSelection.js +34 -0
- package/dist/apps/shared/mail/rowExit.d.ts +12 -0
- package/dist/apps/shared/mail/rowExit.js +33 -0
- package/dist/apps/www/calendar/index.js +177 -18
- package/dist/apps/www/index.js +220 -65
- package/dist/lib/calendar/eventSearch.d.ts +42 -0
- package/dist/lib/calendar/eventSearch.js +152 -0
- package/dist/lib/components/navigation/BottomTabBar.d.ts +15 -3
- package/dist/lib/components/navigation/BottomTabBar.js +52 -15
- package/dist/lib/components/navigation/NavBadge.d.ts +13 -0
- package/dist/lib/components/navigation/NavBadge.js +18 -0
- package/dist/lib/mail/compose/quotedImages.d.ts +21 -0
- package/dist/lib/mail/compose/quotedImages.js +41 -0
- package/dist/lib/mail/inlineImages.d.ts +38 -0
- package/dist/lib/mail/inlineImages.js +90 -0
- package/dist/lib/mail/mailApi.d.ts +8 -1
- package/dist/lib/mail/mailApi.js +57 -5
- package/lib/calendar/eventSearch.ts +172 -0
- package/lib/components/navigation/BottomTabBar.tsx +107 -22
- package/lib/components/navigation/NavBadge.tsx +33 -0
- package/lib/mail/compose/quotedImages.ts +49 -0
- package/lib/mail/inlineImages.ts +104 -0
- package/lib/mail/mailApi.ts +64 -5
- package/package.json +1 -1
- package/apps/shared/components/admin/diagnostics/VersionsPanel.tsx +0 -36
- package/dist/apps/shared/components/admin/diagnostics/VersionsPanel.d.ts +0 -10
- 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
|
|
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={() =>
|
|
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={() =>
|
|
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
|
-
|
|
69
|
-
|
|
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-
|
|
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
|
-
<
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
<
|
|
163
|
-
|
|
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
|
-
<
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
>
|
|
186
|
-
Archive
|
|
187
|
-
</button>
|
|
252
|
+
reason={archiveReason}
|
|
253
|
+
/>
|
|
188
254
|
<LabelMenuButton
|
|
189
255
|
aria-label="Apply label"
|
|
190
256
|
label="Apply label"
|
|
191
|
-
|
|
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
|
-
<
|
|
211
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
233
|
-
{
|
|
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
|
-
//
|
|
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 =
|
|
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
|
-
|
|
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
|
-
{
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
<
|
|
299
|
-
{
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
)}
|
|
316
|
-
|
|
317
|
-
)
|
|
318
|
-
|
|
319
|
-
|
|
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
|
}
|