@rapidmx/web-client 0.27.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 +32 -6
- 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/shared/mail/usePermanentDelete.tsx +14 -7
- package/apps/www/calendar/index.tsx +251 -53
- package/apps/www/index.tsx +563 -376
- 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 +11 -3
- package/dist/apps/shared/components/mail/ConversationList.js +11 -3
- 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/shared/mail/usePermanentDelete.d.ts +2 -0
- package/dist/apps/shared/mail/usePermanentDelete.js +13 -8
- package/dist/apps/www/calendar/index.js +177 -18
- package/dist/apps/www/index.js +257 -93
- 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
|
@@ -38,6 +38,8 @@ export interface ComposeSession {
|
|
|
38
38
|
late?: ComposeLateInput;
|
|
39
39
|
/** See `OpenComposeInput.resume`. */
|
|
40
40
|
resume?: ResumeCompose;
|
|
41
|
+
/** See `OpenComposeInput.inlineFor`. Absent once the window was opened without a host for it. */
|
|
42
|
+
inlineFor?: string;
|
|
41
43
|
minimized: boolean;
|
|
42
44
|
}
|
|
43
45
|
/** What a reply or forward could only work out after its window was already open (see `OpenComposeInput.pending`). */
|
|
@@ -92,6 +94,13 @@ export interface OpenComposeInput {
|
|
|
92
94
|
/** Re-opens a message that was already composed - a failed send's "Open draft": the window continues that server draft, with every field
|
|
93
95
|
* exactly as it was typed, instead of starting a new one. */
|
|
94
96
|
resume?: ResumeCompose;
|
|
97
|
+
/**
|
|
98
|
+
* The uid of the message this reply or forward answers. When the reading pane is showing a thread that holds that message (see
|
|
99
|
+
* `useInlineCompose()`), the compose window opens as a card above the thread instead of floating at the bottom right; the
|
|
100
|
+
* user can still pop it out to the floating stack, and it goes there by itself when the thread is left. Ignored - the window
|
|
101
|
+
* floats - when no pane is showing that message. Absent for a new message, which always floats.
|
|
102
|
+
*/
|
|
103
|
+
inlineFor?: string;
|
|
95
104
|
}
|
|
96
105
|
export interface ComposeContextValue {
|
|
97
106
|
/** Opens a new compose window, stacked alongside any already open (Gmail allows several at once). */
|
|
@@ -105,6 +114,28 @@ export interface ComposeContextValue {
|
|
|
105
114
|
*/
|
|
106
115
|
client?: ApiClient;
|
|
107
116
|
}
|
|
117
|
+
/** The part of a session a reading pane needs to draw its slot. */
|
|
118
|
+
export interface InlineComposeSession {
|
|
119
|
+
id: string;
|
|
120
|
+
/** The uid of the message the window answers. */
|
|
121
|
+
inlineFor: string;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* For a reading pane: announces that it is showing the messages `uids`, so a Reply or Forward of one of them opens its compose
|
|
125
|
+
* window as a card in that pane (drawn with `InlineComposeSlot`) rather than a floating window, and returns the sessions to
|
|
126
|
+
* draw a slot for. When the pane stops holding a message - it unmounts, or moves to another conversation - the window answering
|
|
127
|
+
* it moves out to the floating stack, with everything typed into it.
|
|
128
|
+
*/
|
|
129
|
+
export declare function useInlineCompose(uids: string[]): InlineComposeSession[];
|
|
130
|
+
/**
|
|
131
|
+
* The list item a reading pane holds an inline compose window in. The window itself is owned (and kept alive) by
|
|
132
|
+
* `ComposeProvider`; this only moves the element it renders into into place. `onPlaced` is called once, as it arrives, for the pane
|
|
133
|
+
* to bring it into view.
|
|
134
|
+
*/
|
|
135
|
+
export declare function InlineComposeSlot({ id, onPlaced }: {
|
|
136
|
+
id: string;
|
|
137
|
+
onPlaced?: (slot: HTMLLIElement) => void;
|
|
138
|
+
}): React.JSX.Element;
|
|
108
139
|
/** Opens the floating Compose window from anywhere inside `AppShell` (any of the four webmail apps). */
|
|
109
140
|
export declare function useCompose(): ComposeContextValue;
|
|
110
141
|
/**
|
|
@@ -3,7 +3,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
-
import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
6
|
+
import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
7
|
+
import { createPortal } from "react-dom";
|
|
7
8
|
import { useApiClient } from "../../../../../lib/util/apiClientContext.js";
|
|
8
9
|
import useIsMobile from "../../../../../lib/util/useIsMobile.js";
|
|
9
10
|
import { registerComposeOpener } from "../../../mail/outbox/composeBridge.js";
|
|
@@ -69,6 +70,38 @@ function useComposeWindowComponent(wanted) {
|
|
|
69
70
|
return { Component, failed, retry: () => setAttempt((n) => n + 1) };
|
|
70
71
|
}
|
|
71
72
|
const ComposeContext = createContext({ openCompose: () => undefined });
|
|
73
|
+
const InlineComposeContext = createContext({ sessions: [], register: () => () => undefined, containerFor: () => undefined });
|
|
74
|
+
/**
|
|
75
|
+
* For a reading pane: announces that it is showing the messages `uids`, so a Reply or Forward of one of them opens its compose
|
|
76
|
+
* window as a card in that pane (drawn with `InlineComposeSlot`) rather than a floating window, and returns the sessions to
|
|
77
|
+
* draw a slot for. When the pane stops holding a message - it unmounts, or moves to another conversation - the window answering
|
|
78
|
+
* it moves out to the floating stack, with everything typed into it.
|
|
79
|
+
*/
|
|
80
|
+
export function useInlineCompose(uids) {
|
|
81
|
+
const { sessions, register } = useContext(InlineComposeContext);
|
|
82
|
+
const key = uids.join(",");
|
|
83
|
+
useEffect(() => register(uids), [key, register]);
|
|
84
|
+
return useMemo(() => sessions.filter((session) => uids.includes(session.inlineFor)), [sessions, key]);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The list item a reading pane holds an inline compose window in. The window itself is owned (and kept alive) by
|
|
88
|
+
* `ComposeProvider`; this only moves the element it renders into into place. `onPlaced` is called once, as it arrives, for the pane
|
|
89
|
+
* to bring it into view.
|
|
90
|
+
*/
|
|
91
|
+
export function InlineComposeSlot({ id, onPlaced }) {
|
|
92
|
+
const { containerFor } = useContext(InlineComposeContext);
|
|
93
|
+
const slotRef = useRef(null);
|
|
94
|
+
useLayoutEffect(() => {
|
|
95
|
+
const slot = slotRef.current;
|
|
96
|
+
// Nothing to hold outside a `ComposeProvider`.
|
|
97
|
+
const container = containerFor(id);
|
|
98
|
+
if (container) {
|
|
99
|
+
slot.appendChild(container);
|
|
100
|
+
}
|
|
101
|
+
onPlaced?.(slot);
|
|
102
|
+
}, [id]);
|
|
103
|
+
return _jsx("li", { ref: slotRef, "data-inline-compose": id });
|
|
104
|
+
}
|
|
72
105
|
/** Opens the floating Compose window from anywhere inside `AppShell` (any of the four webmail apps). */
|
|
73
106
|
export function useCompose() {
|
|
74
107
|
return useContext(ComposeContext);
|
|
@@ -85,9 +118,55 @@ export default function ComposeProvider({ children, userUid, trusted }) {
|
|
|
85
118
|
const client = useApiClient();
|
|
86
119
|
const isMobile = useIsMobile();
|
|
87
120
|
const { Component: ComposeWindow, failed, retry: retryLoad } = useComposeWindowComponent(sessions.length > 0);
|
|
88
|
-
//
|
|
121
|
+
// Every session's window is rendered (through a portal) into an element of its own that is *moved* between the floating stack
|
|
122
|
+
// 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.
|
|
123
|
+
const containersRef = useRef(new Map());
|
|
124
|
+
// The messages the reading panes currently showing a thread hold, one entry per pane (a ref too, for `openCompose()` to read at the click).
|
|
125
|
+
const hostsRef = useRef(new Map());
|
|
126
|
+
const [hostedUids, setHostedUids] = useState([]);
|
|
127
|
+
// The inline sessions that were popped out or whose pane went away: floating for good, even if a pane shows their message again.
|
|
128
|
+
const [parked, setParked] = useState(new Set());
|
|
129
|
+
const register = useCallback((uids) => {
|
|
130
|
+
const token = Symbol();
|
|
131
|
+
hostsRef.current.set(token, uids);
|
|
132
|
+
setHostedUids([...hostsRef.current.values()].flat());
|
|
133
|
+
return () => {
|
|
134
|
+
hostsRef.current.delete(token);
|
|
135
|
+
setHostedUids([...hostsRef.current.values()].flat());
|
|
136
|
+
};
|
|
137
|
+
}, []);
|
|
138
|
+
const hosted = new Set(hostedUids);
|
|
139
|
+
const isInline = (session) => !!session.inlineFor && !parked.has(session.id) && hosted.has(session.inlineFor);
|
|
140
|
+
const inlineKey = sessions.filter(isInline).map((session) => `${session.id}:${session.inlineFor}`).join("|");
|
|
141
|
+
const inlineSessions = useMemo(() => sessions.filter(isInline).map((session) => ({ id: session.id, inlineFor: session.inlineFor })), [inlineKey]);
|
|
142
|
+
const inlineValue = useMemo(() => ({ sessions: inlineSessions, register, containerFor: (id) => containersRef.current.get(id) }), [inlineSessions, register]);
|
|
143
|
+
const floatingSessions = sessions.filter((session) => !inlineSessions.some((inline) => inline.id === session.id));
|
|
144
|
+
// A session whose message is no longer in any pane (the reader went to another conversation) is parked in the stack.
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
const orphans = sessions.filter((session) => session.inlineFor && !parked.has(session.id) && !hosted.has(session.inlineFor));
|
|
147
|
+
if (orphans.length > 0) {
|
|
148
|
+
setParked((prev) => new Set([...prev, ...orphans.map((session) => session.id)]));
|
|
149
|
+
}
|
|
150
|
+
}, [sessions, hostedUids, parked]);
|
|
151
|
+
// The elements of closed sessions go away with them.
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
const open = new Set(sessions.map((session) => session.id));
|
|
154
|
+
for (const [id, element] of containersRef.current) {
|
|
155
|
+
if (!open.has(id)) {
|
|
156
|
+
element.remove();
|
|
157
|
+
containersRef.current.delete(id);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}, [sessions]);
|
|
161
|
+
useEffect(() => () => {
|
|
162
|
+
for (const element of containersRef.current.values()) {
|
|
163
|
+
element.remove();
|
|
164
|
+
}
|
|
165
|
+
containersRef.current.clear();
|
|
166
|
+
}, []);
|
|
167
|
+
// The top edge of the floating compose windows, published as `--rr-compose-top` so the pop-up stack (`NotificationCenter`) can keep clear of them.
|
|
89
168
|
const windowsRef = useRef(null);
|
|
90
|
-
const hasSessions =
|
|
169
|
+
const hasSessions = floatingSessions.length > 0;
|
|
91
170
|
useEffect(() => {
|
|
92
171
|
const element = windowsRef.current;
|
|
93
172
|
if (!element) {
|
|
@@ -104,9 +183,14 @@ export default function ComposeProvider({ children, userUid, trusted }) {
|
|
|
104
183
|
document.documentElement.style.removeProperty(COMPOSE_TOP_VAR);
|
|
105
184
|
};
|
|
106
185
|
}, [hasSessions]);
|
|
107
|
-
function openCompose({ mailboxUid, to, cc, subject, quotedHtml, signatureContext = "new", suppressSigning, encrypt, threading, pending, resume, }) {
|
|
186
|
+
function openCompose({ mailboxUid, to, cc, subject, quotedHtml, signatureContext = "new", suppressSigning, encrypt, threading, pending, resume, inlineFor, }) {
|
|
108
187
|
const id = crypto.randomUUID();
|
|
109
188
|
markComposePhase(id, "click");
|
|
189
|
+
// 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).
|
|
190
|
+
const container = document.createElement("div");
|
|
191
|
+
container.className = "contents";
|
|
192
|
+
document.body.appendChild(container);
|
|
193
|
+
containersRef.current.set(id, container);
|
|
110
194
|
setSessions((prev) => [
|
|
111
195
|
...prev,
|
|
112
196
|
{
|
|
@@ -122,6 +206,8 @@ export default function ComposeProvider({ children, userUid, trusted }) {
|
|
|
122
206
|
suppressSigning,
|
|
123
207
|
quotePending: !!pending,
|
|
124
208
|
resume,
|
|
209
|
+
// Only a pane that is showing the message can hold the window.
|
|
210
|
+
inlineFor: inlineFor && [...hostsRef.current.values()].some((uids) => uids.includes(inlineFor)) ? inlineFor : undefined,
|
|
125
211
|
minimized: false,
|
|
126
212
|
},
|
|
127
213
|
]);
|
|
@@ -132,6 +218,17 @@ export default function ComposeProvider({ children, userUid, trusted }) {
|
|
|
132
218
|
}
|
|
133
219
|
function closeCompose(id) {
|
|
134
220
|
setSessions((prev) => prev.filter((s) => s.id !== id));
|
|
221
|
+
setParked((prev) => {
|
|
222
|
+
if (!prev.has(id)) {
|
|
223
|
+
return prev;
|
|
224
|
+
}
|
|
225
|
+
const next = new Set(prev);
|
|
226
|
+
next.delete(id);
|
|
227
|
+
return next;
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
function popOut(id) {
|
|
231
|
+
setParked((prev) => new Set(prev).add(id));
|
|
135
232
|
}
|
|
136
233
|
function toggleMinimize(id) {
|
|
137
234
|
setSessions((prev) => prev.map((s) => (s.id === id ? { ...s, minimized: !s.minimized } : s)));
|
|
@@ -139,16 +236,31 @@ export default function ComposeProvider({ children, userUid, trusted }) {
|
|
|
139
236
|
const value = useMemo(() => ({ openCompose, client }), [client]);
|
|
140
237
|
// Code with no React context - a failed send's "Open draft" pop-up - opens windows through this.
|
|
141
238
|
useEffect(() => registerComposeOpener((input) => value.openCompose(input)), [value]);
|
|
142
|
-
// On mobile, a non-minimized `ComposeWindow` renders full-screen (see that component's own doc
|
|
239
|
+
// On mobile, a non-minimized floating `ComposeWindow` renders full-screen (see that component's own doc
|
|
143
240
|
// comment) — Gmail-style stacking of several full-screen overlays at once makes no sense there, so
|
|
144
241
|
// at most one non-minimized session is ever shown: the most recently opened one. Minimized
|
|
145
242
|
// sessions are small chips regardless of device, so every one of those still shows — an earlier
|
|
146
243
|
// session becomes visible again (as its own chip, or full-screen if it's the new most-recent
|
|
147
244
|
// non-minimized one) once whatever's currently "on top" is closed or minimized. The others stay
|
|
148
|
-
// mounted, just hidden: unmounting them would throw away everything typed into them.
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
245
|
+
// mounted, just hidden: unmounting them would throw away everything typed into them. An inline card
|
|
246
|
+
// is part of the reading pane and is never hidden.
|
|
247
|
+
const lastNonMinimizedId = isMobile ? [...floatingSessions].reverse().find((s) => !s.minimized)?.id : undefined;
|
|
248
|
+
// Puts every floating session's element in the stack (an inline one is put in its slot by `InlineComposeSlot`), and hides those
|
|
249
|
+
// the mobile rule above hides.
|
|
250
|
+
useLayoutEffect(() => {
|
|
251
|
+
for (const session of sessions) {
|
|
252
|
+
const element = containersRef.current.get(session.id);
|
|
253
|
+
const floating = floatingSessions.some((s) => s.id === session.id);
|
|
254
|
+
const hidden = floating && isMobile && !session.minimized && session.id !== lastNonMinimizedId;
|
|
255
|
+
element.hidden = hidden;
|
|
256
|
+
element.className = hidden ? "hidden" : "contents";
|
|
257
|
+
if (floating && element.parentElement !== windowsRef.current) {
|
|
258
|
+
windowsRef.current.appendChild(element);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
return (_jsx(ComposeContext.Provider, { value: value, children: _jsxs(InlineComposeContext.Provider, { value: inlineValue, children: [children, sessions.map((session) => {
|
|
263
|
+
const inline = inlineSessions.some((s) => s.id === session.id);
|
|
264
|
+
return createPortal(ComposeWindow ? (_jsx(ComposeWindow, { session: session, userUid: userUid, trusted: trusted, inline: inline, onPopOut: () => popOut(session.id), onClose: () => closeCompose(session.id), onToggleMinimize: () => toggleMinimize(session.id) })) : (_jsx(ComposeWindowPlaceholder, { session: session, failed: failed, inline: inline, onPopOut: () => popOut(session.id), onRetry: retryLoad, onClose: () => closeCompose(session.id), onToggleMinimize: () => toggleMinimize(session.id) })), containersRef.current.get(session.id), session.id);
|
|
265
|
+
}), hasSessions && _jsx("div", { ref: windowsRef, className: "fixed bottom-0 right-6 flex items-end gap-3 z-50" })] }) }));
|
|
154
266
|
}
|
|
@@ -4,6 +4,11 @@ export interface ComposeWindowProps {
|
|
|
4
4
|
session: ComposeSession;
|
|
5
5
|
onClose: () => void;
|
|
6
6
|
onToggleMinimize: () => void;
|
|
7
|
+
/** The window is a card in the reading pane, above the message being replied to (see `ComposeSession.inlineFor`), not a floating
|
|
8
|
+
* window: full width, a fixed height, never minimized, resized or expanded, and "Pop out" in place of those. */
|
|
9
|
+
inline?: boolean;
|
|
10
|
+
/** Moves an `inline` window out to the floating stack at the bottom right. */
|
|
11
|
+
onPopOut?: () => void;
|
|
7
12
|
/** The signed-in user - identifies their own ("primary") mailbox, the default From when the session
|
|
8
13
|
* doesn't name a mailbox. */
|
|
9
14
|
userUid?: string;
|
|
@@ -41,4 +46,4 @@ export declare const DEFAULT_CRYPTO_RETRY_DELAYS_MS: number[];
|
|
|
41
46
|
* see `ComposeContext.tsx`'s own doc comment for how that interacts with several sessions being open at
|
|
42
47
|
* once.
|
|
43
48
|
*/
|
|
44
|
-
export default function ComposeWindow({ session, onClose, onToggleMinimize, userUid, trusted, autosaveDelayMs, autosaveMaxWaitMs, cryptoRetryDelaysMs, }: ComposeWindowProps): React.JSX.Element;
|
|
49
|
+
export default function ComposeWindow({ session, onClose, onToggleMinimize, inline, onPopOut, userUid, trusted, autosaveDelayMs, autosaveMaxWaitMs, cryptoRetryDelaysMs, }: ComposeWindowProps): React.JSX.Element;
|
|
@@ -4,9 +4,9 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
6
|
import { useEffect, useRef, useState } from "react";
|
|
7
|
-
import { HiOutlineArrowsPointingIn, HiOutlineArrowsPointingOut, HiOutlineChevronDown, HiOutlineIdentification, HiOutlineLockClosed, HiOutlineMinus, HiOutlinePaperClip, HiOutlineTrash, HiOutlineXMark, } from "react-icons/hi2";
|
|
7
|
+
import { HiOutlineArrowsPointingIn, HiOutlineArrowsPointingOut, HiOutlineArrowTopRightOnSquare, HiOutlineChevronDown, HiOutlineIdentification, HiOutlineLockClosed, HiOutlineMinus, HiOutlinePaperClip, HiOutlineTrash, HiOutlineXMark, } from "react-icons/hi2";
|
|
8
8
|
import { ApiRequestError } from "../../../../../lib/util/api.js";
|
|
9
|
-
import { assembleDraft, attachmentContentUrl, createDraft, deleteMessage, getMailbox, getMessage, listFolders, listMailboxes, uploadAttachment, } from "../../../../../lib/mail/mailApi.js";
|
|
9
|
+
import { assembleDraft, attachmentContentUrl, createDraft, deleteMessage, getMailbox, getMessage, listFolders, listMailboxes, deleteAttachment, uploadAttachment, } from "../../../../../lib/mail/mailApi.js";
|
|
10
10
|
import { listMailSignatures } from "../../../../../lib/mail/mailSignaturesApi.js";
|
|
11
11
|
import { buildComposeBodyHtml } from "../../../../../lib/mail/compose/composeQuoting.js";
|
|
12
12
|
import { peekMailboxWritability, useMailboxWritability } from "../writableMailboxes.js";
|
|
@@ -100,8 +100,10 @@ function HeaderButton({ label, onClick, icon: Icon, disabled, shortcut, }) {
|
|
|
100
100
|
* see `ComposeContext.tsx`'s own doc comment for how that interacts with several sessions being open at
|
|
101
101
|
* once.
|
|
102
102
|
*/
|
|
103
|
-
export default function ComposeWindow({ session, onClose, onToggleMinimize, userUid, trusted, autosaveDelayMs = DEFAULT_AUTOSAVE_DELAY_MS, autosaveMaxWaitMs = DEFAULT_AUTOSAVE_MAX_WAIT_MS, cryptoRetryDelaysMs = DEFAULT_CRYPTO_RETRY_DELAYS_MS, }) {
|
|
104
|
-
const { id, initialTo, initialCc, initialSubject, initialQuotedHtml, initialEncrypt, signatureContext, suppressSigning,
|
|
103
|
+
export default function ComposeWindow({ session, onClose, onToggleMinimize, inline, onPopOut, userUid, trusted, autosaveDelayMs = DEFAULT_AUTOSAVE_DELAY_MS, autosaveMaxWaitMs = DEFAULT_AUTOSAVE_MAX_WAIT_MS, cryptoRetryDelaysMs = DEFAULT_CRYPTO_RETRY_DELAYS_MS, }) {
|
|
104
|
+
const { id, initialTo, initialCc, initialSubject, initialQuotedHtml, initialEncrypt, signatureContext, suppressSigning, quotePending, late, resume } = session;
|
|
105
|
+
// An inline card has no minimized state: the session's flag is for the floating window it may later be popped out to.
|
|
106
|
+
const minimized = session.minimized && !inline;
|
|
105
107
|
// What a reply worked out after this window opened (see `OpenComposeInput.pending`): better recipients, applied below
|
|
106
108
|
// only to fields still exactly as they opened - so these, not `initialTo`/`initialCc`, are what "untouched" is measured against.
|
|
107
109
|
const baselineTo = late?.to ?? initialTo;
|
|
@@ -149,6 +151,8 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
149
151
|
const [html, setHtml] = useState(resume?.html ?? "");
|
|
150
152
|
const [contentReady, setContentReady] = useState(!!resume);
|
|
151
153
|
const [attachments, setAttachments] = useState(resume?.attachments ?? []);
|
|
154
|
+
// The attachment being removed from the draft, whose chip is held until the server has answered.
|
|
155
|
+
const [removingAttachmentUid, setRemovingAttachmentUid] = useState(null);
|
|
152
156
|
// The sender's own contact card is on the message (or on its way): "Attach my contact card" then has nothing more to do.
|
|
153
157
|
const [ownCardAttached, setOwnCardAttached] = useState(false);
|
|
154
158
|
const [attachError, setAttachError] = useState(null);
|
|
@@ -585,7 +589,8 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
585
589
|
}
|
|
586
590
|
uploadStarted();
|
|
587
591
|
try {
|
|
588
|
-
|
|
592
|
+
// An inline part with a Content-ID of its own: without one the server has nothing to point the message's `cid:` reference at.
|
|
593
|
+
const attachment = await uploadAttachment(draft.uid, file, { inline: true });
|
|
589
594
|
setHasUploads(true);
|
|
590
595
|
void refreshDraftVersion(draft.uid);
|
|
591
596
|
return attachmentContentUrl(attachment.uid);
|
|
@@ -656,6 +661,25 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
656
661
|
}
|
|
657
662
|
return allAttached;
|
|
658
663
|
}
|
|
664
|
+
/** Removes one attachment from the draft, saying why when the server would not. A removed vCard can be attached again. */
|
|
665
|
+
async function handleRemoveAttachment(attachment) {
|
|
666
|
+
setAttachError(null);
|
|
667
|
+
setRemovingAttachmentUid(attachment.uid);
|
|
668
|
+
try {
|
|
669
|
+
await deleteAttachment(attachment.uid, attachment.version, client);
|
|
670
|
+
setAttachments((prev) => prev.filter((other) => other.uid !== attachment.uid));
|
|
671
|
+
if (attachment.mimeType === "text/vcard") {
|
|
672
|
+
setOwnCardAttached(false);
|
|
673
|
+
}
|
|
674
|
+
void refreshDraftVersion(draft.uid);
|
|
675
|
+
}
|
|
676
|
+
catch (err) {
|
|
677
|
+
setAttachError(err instanceof ApiRequestError ? err.message : "Could not remove attachment.");
|
|
678
|
+
}
|
|
679
|
+
finally {
|
|
680
|
+
setRemovingAttachmentUid(null);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
659
683
|
/** Attaches the sender's own vCard ("Attach my contact card"), once: the button is off from the click on, and back on if the upload failed. */
|
|
660
684
|
async function handleAttachOwnCard() {
|
|
661
685
|
setOwnCardAttached(true);
|
|
@@ -1244,14 +1268,16 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
1244
1268
|
if (minimized) {
|
|
1245
1269
|
return (_jsxs(_Fragment, { children: [_jsx("div", { role: "dialog", "aria-label": title, className: "w-64 shrink-0 bg-surface border border-border border-b-0 rounded-t-md shadow-modal", children: _jsxs("div", { className: "h-10 flex items-center justify-between gap-2 px-3 rounded-t-md bg-primary-darker text-white cursor-pointer", onClick: onToggleMinimize, children: [_jsx("span", { className: "text-sm font-medium truncate", children: title }), _jsxs("div", { className: "flex items-center gap-0.5", onClick: (e) => e.stopPropagation(), children: [_jsx(HeaderButton, { label: "Restore", onClick: onToggleMinimize, icon: HiOutlineArrowsPointingOut }), _jsx(HeaderButton, { label: "Discard draft", onClick: handleDiscard, icon: HiOutlineXMark, disabled: closing })] })] }) }), discardModal] }));
|
|
1246
1270
|
}
|
|
1247
|
-
return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: windowRef, role: "dialog", "data-shortcut-scope": "compose", "aria-labelledby": titleId, onDragEnter: handleDragEnter, onDragOver: handleDragOver, onDragLeave: handleDragLeave, onDrop: handleDrop, style: !isMobile && manualSize ? { width: manualSize.width, height: manualSize.height } : undefined, className: [
|
|
1271
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: windowRef, role: inline ? "region" : "dialog", "data-shortcut-scope": "compose", "aria-labelledby": titleId, onDragEnter: handleDragEnter, onDragOver: handleDragOver, onDragLeave: handleDragLeave, onDrop: handleDrop, style: !inline && !isMobile && manualSize ? { width: manualSize.width, height: manualSize.height } : undefined, className: [
|
|
1248
1272
|
"shrink-0 flex flex-col bg-surface border border-border shadow-modal overflow-hidden",
|
|
1249
1273
|
// `fixed` and `relative` may not both be on the element: Tailwind emits `.relative` after `.fixed`, so it wins and the
|
|
1250
1274
|
// sheet sat in the flow of the little fixed container the windows are stacked in instead of covering the screen.
|
|
1251
|
-
|
|
1252
|
-
? "
|
|
1253
|
-
:
|
|
1254
|
-
|
|
1275
|
+
inline
|
|
1276
|
+
? "relative w-full h-[520px] rounded-md"
|
|
1277
|
+
: isMobile
|
|
1278
|
+
? "fixed inset-0 w-full h-full rounded-none border-0"
|
|
1279
|
+
: ["relative border-b-0 rounded-t-md", manualSize ? "" : expanded ? "w-[720px] h-[85vh]" : "w-[480px] h-[520px]"].join(" "),
|
|
1280
|
+
].join(" "), children: [dropActive && (_jsxs("div", { "data-drop-overlay": "", className: "pointer-events-none absolute inset-0 z-30 flex flex-col items-center justify-center gap-1 border-2 border-dashed border-primary bg-primary/10 text-center text-primary-dark", children: [_jsx("span", { className: "text-base font-semibold", children: "Drop files to attach them" }), _jsx("span", { className: "text-sm", children: "Pictures go into the message where the cursor is" })] })), !inline && !isMobile && (_jsxs(_Fragment, { children: [_jsx("div", { onPointerDown: handleResizeStart("top"), "aria-hidden": "true", className: "absolute top-0 left-0 right-0 h-1.5 cursor-ns-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("left"), "aria-hidden": "true", className: "absolute top-0 left-0 bottom-0 w-1.5 cursor-ew-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("corner"), role: "separator", "aria-label": "Resize", className: "absolute top-0 left-0 w-3 h-3 cursor-nwse-resize z-30" })] })), _jsxs("div", { className: ["h-10 shrink-0 flex items-center justify-between gap-2 px-3 bg-primary-darker text-white", inline ? "" : "cursor-pointer"].join(" "), onClick: inline ? undefined : onToggleMinimize, children: [_jsx("span", { id: titleId, className: "text-sm font-medium truncate", children: title }), _jsxs("div", { className: "flex items-center gap-0.5", onClick: (e) => e.stopPropagation(), children: [inline ? (_jsx(HeaderButton, { label: "Pop out", onClick: () => onPopOut?.(), icon: HiOutlineArrowTopRightOnSquare })) : (_jsx(HeaderButton, { label: "Minimize", onClick: onToggleMinimize, icon: HiOutlineMinus })), !inline && !isMobile && (_jsx(HeaderButton, { label: expanded ? "Collapse" : "Expand", onClick: toggleExpanded, icon: expanded ? HiOutlineArrowsPointingIn : HiOutlineArrowsPointingOut })), _jsx(HeaderButton, { label: "Close", onClick: () => void handleClose(), icon: HiOutlineXMark, disabled: closing, shortcut: SHORTCUTS.compose.close })] })] }), _jsxs("div", { className: "flex-1 min-h-0 flex flex-col", children: [(folderError || draftError || sendError || attachError || discardError) && (_jsxs("div", { className: "px-3 pt-2 shrink-0 max-h-[45%] overflow-y-auto", children: [folderError && _jsx(Alert, { children: folderError }), draftError && _jsx(Alert, { children: draftError }), sendError && _jsx(Alert, { children: sendError }), attachError && _jsx(Alert, { children: attachError }), discardError && _jsx(Alert, { children: discardError })] })), securityBlock && (_jsx("div", { className: "px-3 pt-2", children: _jsxs(Alert, { children: [_jsx("p", { className: "mb-2", children: securityBlock.message }), _jsx(Button, { type: "button", onClick: () => void submit(true, blockedScheduleIso), children: securityBlock.overrideLabel })] }) })), fromOptions.length > 1 && (_jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-from-${id}`, className: "text-xs text-text-muted shrink-0", children: "From" }), _jsx("select", { id: `compose-from-${id}`, className: `${FIELD_INPUT} disabled:opacity-55`,
|
|
1255
1281
|
// Two or more options only exist once the mailboxes have loaded, and the same answer that lets them list
|
|
1256
1282
|
// (one that isn't known to be view-only) is what picks the default sender, so a sender is always chosen by now.
|
|
1257
1283
|
value: mailboxUid, disabled: hasUploads, title: hasUploads ? "The sender can't be changed after adding attachments or images." : undefined, onChange: (e) => handleFromChange(e.target.value), children: fromOptions.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName ? `${mb.displayName} <${mb.primarySmtpAddress}>` : mb.primarySmtpAddress, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) })] })), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-to-${id}`, className: "text-xs text-text-muted shrink-0", children: "To" }), _jsx(RecipientInput, { id: `compose-to-${id}`, label: "To", value: to, mailboxUid: mailboxUid, onChange: setTo, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)), autoFocus: focusToOnMount }), !showCcBcc && (_jsx("button", { type: "button", onClick: () => setShowCcBcc(true), className: "text-xs text-text-muted hover:text-text shrink-0", children: "Cc Bcc" }))] }), showCcBcc && (_jsxs(_Fragment, { children: [_jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-cc-${id}`, className: "text-xs text-text-muted shrink-0", children: "Cc" }), _jsx(RecipientInput, { id: `compose-cc-${id}`, label: "Cc", value: cc, mailboxUid: mailboxUid, onChange: setCc, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)) })] }), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-bcc-${id}`, className: "text-xs text-text-muted shrink-0", children: "Bcc" }), _jsx(RecipientInput, { id: `compose-bcc-${id}`, label: "Bcc", value: bcc, mailboxUid: mailboxUid, onChange: setBcc, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)) })] })] })), (() => {
|
|
@@ -1276,5 +1302,5 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
1276
1302
|
return null;
|
|
1277
1303
|
}
|
|
1278
1304
|
return (_jsx("ul", { className: "flex flex-wrap gap-1.5 px-3 pt-1.5", "aria-label": "Recipient encryption availability", children: knownRecipients.map((status) => (_jsxs("li", { className: `text-xs font-medium py-0.5 px-2 rounded-pill ${status.canEncrypt ? "bg-primary/10 text-primary-dark" : "bg-surface-alt text-text-muted"}`, children: [status.address, " ", status.canEncrypt ? "supports encryption" : "no encryption key found"] }, status.address))) }));
|
|
1279
|
-
})(), _jsx("div", { className: FIELD_ROW, children: _jsx("input", { "aria-label": "Subject", type: "text", placeholder: "Subject", className: FIELD_INPUT, value: subject, autoFocus: focusSubjectOnMount, onChange: (e) => setSubject(e.target.value) }) }), _jsx("div", { className: "flex-1 min-h-0 p-2", children: contentReady && (_jsx(RichTextEditor, { appendHtml: lateQuoteHtml, onAppended: handleQuoteAppended, value: html, onChange: setHtml, fill: true, onUploadImage: handleUploadImage, autoFocusStart: focusBodyOnMount, onInitialized: handleEditorInitialized, editorRef: editorRef })) }), quotePending && (_jsx("p", { role: "status", className: "px-3 pb-1 text-xs text-text-muted", children: "Loading the original message\u2026" })), attachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2 px-3 pb-2", children: attachments.map((attachment) => (
|
|
1305
|
+
})(), _jsx("div", { className: FIELD_ROW, children: _jsx("input", { "aria-label": "Subject", type: "text", placeholder: "Subject", className: FIELD_INPUT, value: subject, autoFocus: focusSubjectOnMount, onChange: (e) => setSubject(e.target.value) }) }), _jsx("div", { className: "flex-1 min-h-0 p-2", children: contentReady && (_jsx(RichTextEditor, { appendHtml: lateQuoteHtml, onAppended: handleQuoteAppended, value: html, onChange: setHtml, fill: true, onUploadImage: handleUploadImage, autoFocusStart: focusBodyOnMount, onInitialized: handleEditorInitialized, editorRef: editorRef })) }), quotePending && (_jsx("p", { role: "status", className: "px-3 pb-1 text-xs text-text-muted", children: "Loading the original message\u2026" })), attachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2 px-3 pb-2", children: attachments.map((attachment) => (_jsxs("li", { className: "inline-flex items-center gap-1 text-xs font-medium py-1 pl-2.5 pr-1 rounded-pill bg-surface-alt text-text-muted", children: [attachment.filename, _jsx("button", { type: "button", "aria-label": `Remove ${attachment.filename}`, title: `Remove ${attachment.filename}`, disabled: removingAttachmentUid === attachment.uid, onClick: () => void handleRemoveAttachment(attachment), className: "inline-flex items-center justify-center rounded-pill p-0.5 hover:bg-border hover:text-text disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-primary", children: _jsx(HiOutlineXMark, { size: 12, "aria-hidden": "true" }) })] }, attachment.uid))) })), _jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: requestReceipt, onChange: (e) => setRequestReceipt(e.target.checked) }), "Request a read receipt"] }), needsUnlockForCrypto && (_jsx("div", { className: "px-3 pb-1", children: _jsxs("button", { type: "button", onClick: handleUnlockForCrypto, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to sign or encrypt this message"] }) })), unlockedKeys?.signingPrivateKey && (_jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: signEnabled, onChange: (e) => setSignEnabled(e.target.checked) }), "Digitally sign this message"] })), unlockedKeys?.encryptionPrivateKey && (_jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: encryptRequested, onChange: (e) => setEncryptRequested(e.target.checked) }), "Encrypt this message"] })), _jsxs("div", { className: "shrink-0 flex items-center gap-1 px-3 py-2 border-t border-border", children: [_jsxs("div", { className: "flex items-center rounded-pill bg-primary text-white overflow-hidden", children: [_jsx("button", { type: "button", onClick: () => void submit(false), title: withHint("Send", SHORTCUTS.compose.send, keyEnv), "aria-keyshortcuts": ariaKeyShortcuts(SHORTCUTS.compose.send, keyEnv), disabled: !draft || closing || waitingForUploads, className: "py-1.5 pl-5 pr-3 font-semibold text-sm hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed", children: "Send" }), _jsx("button", { ref: scheduleButtonRef, type: "button", "aria-label": "Send later", "aria-haspopup": "true", "aria-expanded": schedulePickerOpen, disabled: !draft || closing || waitingForUploads, onClick: () => setSchedulePickerOpen((o) => !o), className: "py-1.5 px-2 border-l border-white/30 hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed", children: _jsx(HiOutlineChevronDown, { size: 14 }) })] }), schedulePickerOpen && (_jsx(ScheduleSendPicker, { anchorRef: scheduleButtonRef, onClose: () => setSchedulePickerOpen(false), onSchedule: handleScheduleSend })), _jsxs("label", { "aria-label": "Attach files", title: "Attach files", className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text cursor-pointer has-[:disabled]:opacity-40 has-[:disabled]:cursor-not-allowed", children: [_jsx(HiOutlinePaperClip, { size: 18 }), _jsx("input", { type: "file", multiple: true, disabled: !draft, onChange: handleFilesSelected, className: "sr-only" })] }), _jsx("button", { type: "button", "aria-label": "Attach my contact card", title: ownCardAttached ? "Your contact card is attached" : "Attach my contact card", "aria-pressed": ownCardAttached, disabled: !draft || !mailbox || ownCardAttached, onClick: () => void handleAttachOwnCard(), className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:not-disabled:bg-surface-alt hover:not-disabled:text-text disabled:opacity-40 disabled:cursor-not-allowed", children: _jsx(HiOutlineIdentification, { size: 18 }) }), _jsx("span", { role: "status", "aria-live": "polite", className: "ml-auto text-xs text-text-muted", children: waitingForUploads ? "Waiting for attachments to finish uploading…" : SAVE_STATUS_LABEL[saveStatus] }), _jsx("button", { type: "button", "aria-label": "Discard draft", title: "Discard draft", onClick: handleDiscard, disabled: closing, className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text disabled:opacity-40 disabled:cursor-not-allowed", children: _jsx(HiOutlineTrash, { size: 18 }) })] })] })] }), discardModal] }));
|
|
1280
1306
|
}
|
|
@@ -7,6 +7,10 @@ export interface ComposeWindowPlaceholderProps {
|
|
|
7
7
|
onRetry: () => void;
|
|
8
8
|
onClose: () => void;
|
|
9
9
|
onToggleMinimize: () => void;
|
|
10
|
+
/** The placeholder stands in for an inline card in the reading pane (see `ComposeWindowProps.inline`). */
|
|
11
|
+
inline?: boolean;
|
|
12
|
+
/** Moves an `inline` placeholder out to the floating stack. */
|
|
13
|
+
onPopOut?: () => void;
|
|
10
14
|
}
|
|
11
15
|
/**
|
|
12
16
|
* What stands in for the compose window in the moment between clicking Compose/Reply/Forward and the window's code being
|
|
@@ -16,4 +20,4 @@ export interface ComposeWindowPlaceholderProps {
|
|
|
16
20
|
*
|
|
17
21
|
* Nothing here can be typed into yet, and there is no draft to save or discard, so Close just closes.
|
|
18
22
|
*/
|
|
19
|
-
export default function ComposeWindowPlaceholder({ session, failed, onRetry, onClose, onToggleMinimize }: ComposeWindowPlaceholderProps): React.JSX.Element;
|
|
23
|
+
export default function ComposeWindowPlaceholder({ session, failed, onRetry, onClose, onToggleMinimize, inline, onPopOut }: ComposeWindowPlaceholderProps): React.JSX.Element;
|
|
@@ -4,7 +4,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
6
|
import { useEffect } from "react";
|
|
7
|
-
import { HiOutlineMinus, HiOutlineXMark } from "react-icons/hi2";
|
|
7
|
+
import { HiOutlineArrowTopRightOnSquare, HiOutlineMinus, HiOutlineXMark } from "react-icons/hi2";
|
|
8
8
|
import Skeleton from "../../../../../lib/components/feedback/Skeleton.js";
|
|
9
9
|
import Alert from "../../../../../lib/components/feedback/Alert.js";
|
|
10
10
|
import Button from "../../../../../lib/components/buttons/Button.js";
|
|
@@ -18,16 +18,20 @@ import { markComposePhase } from "./composePerf.js";
|
|
|
18
18
|
*
|
|
19
19
|
* Nothing here can be typed into yet, and there is no draft to save or discard, so Close just closes.
|
|
20
20
|
*/
|
|
21
|
-
export default function ComposeWindowPlaceholder({ session, failed, onRetry, onClose, onToggleMinimize }) {
|
|
21
|
+
export default function ComposeWindowPlaceholder({ session, failed, onRetry, onClose, onToggleMinimize, inline, onPopOut }) {
|
|
22
22
|
const isMobile = useIsMobile();
|
|
23
23
|
const title = session.initialSubject?.trim() || "New Message";
|
|
24
24
|
const titleId = `compose-title-${session.id}`;
|
|
25
25
|
useEffect(() => markComposePhase(session.id, "shell"), [session.id]);
|
|
26
|
-
if (session.minimized) {
|
|
26
|
+
if (session.minimized && !inline) {
|
|
27
27
|
return (_jsx("div", { role: "dialog", "aria-label": title, className: "w-64 shrink-0 bg-surface border border-border border-b-0 rounded-t-md shadow-modal", children: _jsx("div", { className: "h-10 flex items-center justify-between gap-2 px-3 rounded-t-md bg-primary-darker text-white cursor-pointer", onClick: onToggleMinimize, children: _jsx("span", { className: "text-sm font-medium truncate", children: title }) }) }));
|
|
28
28
|
}
|
|
29
|
-
return (_jsxs("div", { role: "dialog", "aria-labelledby": titleId, "aria-busy": !failed, className: [
|
|
29
|
+
return (_jsxs("div", { role: inline ? "region" : "dialog", "aria-labelledby": titleId, "aria-busy": !failed, className: [
|
|
30
30
|
"relative shrink-0 flex flex-col bg-surface border border-border shadow-modal overflow-hidden",
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
inline
|
|
32
|
+
? "w-full h-[520px] rounded-md"
|
|
33
|
+
: isMobile
|
|
34
|
+
? "fixed inset-0 w-full h-full rounded-none border-0"
|
|
35
|
+
: "border-b-0 rounded-t-md w-[480px] h-[520px]",
|
|
36
|
+
].join(" "), children: [_jsxs("div", { className: ["h-10 shrink-0 flex items-center justify-between gap-2 px-3 bg-primary-darker text-white", inline ? "" : "cursor-pointer"].join(" "), onClick: inline ? undefined : onToggleMinimize, children: [_jsx("span", { id: titleId, className: "text-sm font-medium truncate", children: title }), _jsxs("div", { className: "flex items-center gap-0.5", onClick: (e) => e.stopPropagation(), children: [_jsx("button", { type: "button", "aria-label": inline ? "Pop out" : "Minimize", title: inline ? "Pop out" : "Minimize", onClick: inline ? onPopOut : onToggleMinimize, className: "w-6 h-6 flex items-center justify-center rounded-sm text-white/80 hover:bg-white/15 hover:text-white", children: inline ? _jsx(HiOutlineArrowTopRightOnSquare, { size: 14 }) : _jsx(HiOutlineMinus, { size: 14 }) }), _jsx("button", { type: "button", "aria-label": "Close", title: "Close", onClick: onClose, className: "w-6 h-6 flex items-center justify-center rounded-sm text-white/80 hover:bg-white/15 hover:text-white", children: _jsx(HiOutlineXMark, { size: 14 }) })] })] }), failed ? (_jsx("div", { className: "px-3 pt-2", children: _jsxs(Alert, { children: [_jsx("p", { className: "mb-2", children: "The compose window couldn\u2019t be loaded. Check your connection and try again." }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onRetry, children: "Retry" })] }) })) : (_jsxs("div", { role: "status", className: "flex-1 min-h-0 flex flex-col", children: [_jsx("span", { className: "sr-only", children: "Opening the compose window" }), _jsxs("div", { className: "flex items-center gap-2 px-3 py-1.5 border-b border-border text-sm", children: [_jsx("span", { className: "text-text-muted", children: "To" }), _jsx("span", { className: "truncate", children: session.initialTo })] }), _jsx("div", { className: "flex items-center gap-2 px-3 py-1.5 border-b border-border text-sm", children: _jsx("span", { className: "truncate", children: session.initialSubject || _jsx("span", { className: "text-text-muted", children: "Subject" }) }) }), _jsxs("div", { className: "flex-1 p-3 flex flex-col gap-2", "aria-hidden": "true", children: [_jsx(Skeleton, { height: "h-4" }), _jsx(Skeleton, { height: "h-4" }), _jsx(Skeleton, { height: "h-4" })] })] }))] }));
|
|
33
37
|
}
|
|
@@ -53,7 +53,8 @@ export default function RichTextEditor({ value, onChange, height = "360px", fill
|
|
|
53
53
|
TextStyleKit.configure({ backgroundColor: false, lineHeight: false }),
|
|
54
54
|
TextAlign.configure({ types: ["heading", "paragraph"] }),
|
|
55
55
|
Highlight,
|
|
56
|
-
|
|
56
|
+
// A quoted original's pictures arrive as `data:` URIs; `assembleDraft()` attaches them to the draft when it is saved.
|
|
57
|
+
Image.configure({ allowBase64: true }),
|
|
57
58
|
TableKit.configure({ table: { resizable: true } }),
|
|
58
59
|
Placeholder.configure({ placeholder: "Write your message…" }),
|
|
59
60
|
],
|
|
@@ -43,6 +43,9 @@ export declare function clearOriginalMessageCache(): void;
|
|
|
43
43
|
* `message.recipients` alone would reduce every Reply All to a plain reply. Unprotected headers are the sender's own
|
|
44
44
|
* claim, exactly as in every other mail client's Reply All.
|
|
45
45
|
*
|
|
46
|
+
* The original's embedded images (`cid:`) are replaced by `data:` URIs of the pictures themselves - fetched from the message's
|
|
47
|
+
* attachments, or taken from the decrypted entity - so the quote shows them and the new message can carry them (see `quotedImages.ts`).
|
|
48
|
+
*
|
|
46
49
|
* The quote builders sanitize whatever HTML this returns (`sanitizeQuotedHtml()`), whichever source it came from.
|
|
47
50
|
*/
|
|
48
51
|
export declare function loadOriginalMessage(message: Message, security: MessageSecurityResult | null, options?: {
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
5
|
import { apiUrl } from "../../../../../lib/util/api.js";
|
|
6
|
-
import { getMessageRawContent } from "../../../../../lib/mail/mailApi.js";
|
|
6
|
+
import { attachmentContentUrl, getMessageRawContent, listAttachments } from "../../../../../lib/mail/mailApi.js";
|
|
7
|
+
import { MAX_QUOTED_IMAGE_BYTES, embedQuotedImages } from "../../../../../lib/mail/compose/quotedImages.js";
|
|
8
|
+
import { EMBEDDED_IMAGE_TYPES, findInlineAttachment, toDataUri } from "../../../../../lib/mail/inlineImages.js";
|
|
7
9
|
import { decodeHeaderText, extractDisplayBody, parseMimeEntity } from "../../../../../lib/crypto/mime.js";
|
|
8
10
|
import { parseRecipientList } from "./recipients.js";
|
|
9
11
|
/** How long a reply waits for the original's body before it goes ahead with the preview - a slow or hung request must not
|
|
@@ -75,6 +77,37 @@ function headerRecipients(value, type) {
|
|
|
75
77
|
.filter((recipient) => recipient.address.includes("@"))
|
|
76
78
|
.map((recipient) => ({ address: recipient.address, ...(recipient.displayName ? { displayName: recipient.displayName } : {}), type }));
|
|
77
79
|
}
|
|
80
|
+
/** The `data:` URI of the picture a quoted image of `message` stands for - one of the message's own attachments, fetched with the session -
|
|
81
|
+
* or `undefined` when there is none, it is not a raster image, it is too large or it could not be had. Never rejects. */
|
|
82
|
+
function serverImageResolver(message) {
|
|
83
|
+
let attachments;
|
|
84
|
+
return async (reference) => {
|
|
85
|
+
try {
|
|
86
|
+
attachments ?? (attachments = listAttachments(message.folderUid, message.uid));
|
|
87
|
+
const attachment = findInlineAttachment(await attachments, reference);
|
|
88
|
+
const type = attachment?.mimeType.split(";")[0].trim().toLowerCase();
|
|
89
|
+
if (!attachment || !type || !EMBEDDED_IMAGE_TYPES.test(type) || attachment.sizeBytes > MAX_QUOTED_IMAGE_BYTES) {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
const res = await fetch(attachmentContentUrl(attachment.uid), { credentials: "include" });
|
|
93
|
+
const bytes = res.ok ? new Uint8Array(await res.arrayBuffer()) : undefined;
|
|
94
|
+
return bytes && bytes.length <= MAX_QUOTED_IMAGE_BYTES ? toDataUri(type, bytes) : undefined;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/** As `serverImageResolver()`, for the parts of a message this browser decrypted or verified (`MessageSecurityResult.attachments`). */
|
|
102
|
+
function partImageResolver(parts) {
|
|
103
|
+
const candidates = parts.map((part, index) => ({ uid: String(index), filename: part.filename ?? "", mimeType: part.contentType, contentId: part.contentId }));
|
|
104
|
+
return async (reference) => {
|
|
105
|
+
const found = findInlineAttachment(candidates, reference);
|
|
106
|
+
const part = found ? parts[Number(found.uid)] : undefined;
|
|
107
|
+
const bytes = part && EMBEDDED_IMAGE_TYPES.test(part.contentType) ? part.decode() : undefined;
|
|
108
|
+
return part && bytes && bytes.length <= MAX_QUOTED_IMAGE_BYTES ? toDataUri(part.contentType, bytes) : undefined;
|
|
109
|
+
};
|
|
110
|
+
}
|
|
78
111
|
/**
|
|
79
112
|
* Loads what a Reply/Reply All/Forward needs from `message`: the full body to quote - the same content
|
|
80
113
|
* `MessageDetailPane` shows, never the truncated `bodyPreview` unless nothing else can be had - and, with
|
|
@@ -96,6 +129,9 @@ function headerRecipients(value, type) {
|
|
|
96
129
|
* `message.recipients` alone would reduce every Reply All to a plain reply. Unprotected headers are the sender's own
|
|
97
130
|
* claim, exactly as in every other mail client's Reply All.
|
|
98
131
|
*
|
|
132
|
+
* The original's embedded images (`cid:`) are replaced by `data:` URIs of the pictures themselves - fetched from the message's
|
|
133
|
+
* attachments, or taken from the decrypted entity - so the quote shows them and the new message can carry them (see `quotedImages.ts`).
|
|
134
|
+
*
|
|
99
135
|
* The quote builders sanitize whatever HTML this returns (`sanitizeQuotedHtml()`), whichever source it came from.
|
|
100
136
|
*/
|
|
101
137
|
export async function loadOriginalMessage(message, security, options = {}) {
|
|
@@ -137,5 +173,11 @@ export async function loadOriginalMessage(message, security, options = {}) {
|
|
|
137
173
|
// Whatever was loaded before the failure stands: the quote falls back to the preview, Reply All to the
|
|
138
174
|
// message's own recipients.
|
|
139
175
|
}
|
|
176
|
+
if (result.body.html !== undefined) {
|
|
177
|
+
const resolver = recovered ? partImageResolver(security?.attachments ?? []) : serverImageResolver(message);
|
|
178
|
+
const html = result.body.html;
|
|
179
|
+
// Bounded like the body's own request: a picture that will not arrive is left out of the quote, not waited for.
|
|
180
|
+
result.body = { html: await withTimeout(embedQuotedImages(html, resolver), QUOTE_FETCH_TIMEOUT_MS).catch(() => html) };
|
|
181
|
+
}
|
|
140
182
|
return result;
|
|
141
183
|
}
|
|
@@ -89,9 +89,11 @@ export interface LabelMenuButtonProps extends Omit<LabelSectionsOptions, "state"
|
|
|
89
89
|
disabled?: boolean;
|
|
90
90
|
title?: string;
|
|
91
91
|
className?: string;
|
|
92
|
+
/** The trigger is just its icon (`className` then styles all of it) - `aria-label` and `title` name it. */
|
|
93
|
+
iconOnly?: boolean;
|
|
92
94
|
/** With `onLabelCreated`, adds a "New label" row that opens this component's own dialog. */
|
|
93
95
|
mailboxUid?: string;
|
|
94
96
|
onLabelCreated?: (label: Label) => void;
|
|
95
97
|
}
|
|
96
98
|
/** The label list as a menu button of its own - the bulk Apply label action and the reading pane's Labels. */
|
|
97
|
-
export default function LabelMenuButton({ applied, partial, onCommit, labels, commit, clear, emptyNote, note, busy, label, "aria-label": ariaLabel, disabled, title, className, mailboxUid, onLabelCreated, }: LabelMenuButtonProps): React.JSX.Element;
|
|
99
|
+
export default function LabelMenuButton({ applied, partial, onCommit, labels, commit, clear, emptyNote, note, busy, label, "aria-label": ariaLabel, disabled, title, className, iconOnly, mailboxUid, onLabelCreated, }: LabelMenuButtonProps): React.JSX.Element;
|
|
@@ -119,11 +119,11 @@ export function NewLabelDialog({ open, onClose, mailboxUid, onCreated }) {
|
|
|
119
119
|
return (_jsxs(Modal, { open: open, onClose: onClose, title: "New label", children: [error && _jsx(Alert, { children: error }), _jsx(FormField, { label: "Name", htmlFor: "new-label-name", children: _jsx("input", { id: "new-label-name", type: "text", value: name, onChange: (e) => setName(e.target.value), className: "w-full text-sm px-3 py-1.5 rounded-md border border-border bg-surface" }) }), _jsxs("div", { className: "flex justify-end gap-2 mt-2", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", loading: saving, disabled: saving || !name.trim(), onClick: () => void save(), children: "Create" })] })] }));
|
|
120
120
|
}
|
|
121
121
|
/** The label list as a menu button of its own - the bulk Apply label action and the reading pane's Labels. */
|
|
122
|
-
export default function LabelMenuButton({ applied, partial = [], onCommit, labels, commit, clear, emptyNote, note, busy, label, "aria-label": ariaLabel, disabled, title, className, mailboxUid, onLabelCreated, }) {
|
|
122
|
+
export default function LabelMenuButton({ applied, partial = [], onCommit, labels, commit, clear, emptyNote, note, busy, label, "aria-label": ariaLabel, disabled, title, className, iconOnly, mailboxUid, onLabelCreated, }) {
|
|
123
123
|
const [open, setOpen] = useState(false);
|
|
124
124
|
const [creating, setCreating] = useState(false);
|
|
125
125
|
const state = useLabelDraft(applied, partial, open);
|
|
126
|
-
return (_jsxs(_Fragment, { children: [_jsx(MenuButton, { "aria-label": ariaLabel, label: label, icon: _jsx(HiOutlineTag, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), disabled: disabled, title: title, className: className, onOpenChange: setOpen, sections: labelSections({
|
|
126
|
+
return (_jsxs(_Fragment, { children: [_jsx(MenuButton, { "aria-label": ariaLabel, label: label, icon: _jsx(HiOutlineTag, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), disabled: disabled, title: title, className: className, iconOnly: iconOnly, onOpenChange: setOpen, sections: labelSections({
|
|
127
127
|
labels,
|
|
128
128
|
state,
|
|
129
129
|
commit: { ...commit, disabled: commit.disabled || !state.dirty, onSelect: () => onCommit(state.draft, state.mixed) },
|