@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
|
@@ -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
|
}
|
|
@@ -7,6 +7,7 @@ import type { Editor } from "@tiptap/react";
|
|
|
7
7
|
import {
|
|
8
8
|
HiOutlineArrowsPointingIn,
|
|
9
9
|
HiOutlineArrowsPointingOut,
|
|
10
|
+
HiOutlineArrowTopRightOnSquare,
|
|
10
11
|
HiOutlineChevronDown,
|
|
11
12
|
HiOutlineIdentification,
|
|
12
13
|
HiOutlineLockClosed,
|
|
@@ -29,6 +30,7 @@ import {
|
|
|
29
30
|
getMessage,
|
|
30
31
|
listFolders,
|
|
31
32
|
listMailboxes,
|
|
33
|
+
deleteAttachment,
|
|
32
34
|
uploadAttachment,
|
|
33
35
|
} from "../../../../../lib/mail/mailApi.js";
|
|
34
36
|
import { listMailSignatures } from "../../../../../lib/mail/mailSignaturesApi.js";
|
|
@@ -65,6 +67,11 @@ export interface ComposeWindowProps {
|
|
|
65
67
|
session: ComposeSession;
|
|
66
68
|
onClose: () => void;
|
|
67
69
|
onToggleMinimize: () => void;
|
|
70
|
+
/** The window is a card in the reading pane, above the message being replied to (see `ComposeSession.inlineFor`), not a floating
|
|
71
|
+
* window: full width, a fixed height, never minimized, resized or expanded, and "Pop out" in place of those. */
|
|
72
|
+
inline?: boolean;
|
|
73
|
+
/** Moves an `inline` window out to the floating stack at the bottom right. */
|
|
74
|
+
onPopOut?: () => void;
|
|
68
75
|
/** The signed-in user - identifies their own ("primary") mailbox, the default From when the session
|
|
69
76
|
* doesn't name a mailbox. */
|
|
70
77
|
userUid?: string;
|
|
@@ -199,13 +206,17 @@ export default function ComposeWindow({
|
|
|
199
206
|
session,
|
|
200
207
|
onClose,
|
|
201
208
|
onToggleMinimize,
|
|
209
|
+
inline,
|
|
210
|
+
onPopOut,
|
|
202
211
|
userUid,
|
|
203
212
|
trusted,
|
|
204
213
|
autosaveDelayMs = DEFAULT_AUTOSAVE_DELAY_MS,
|
|
205
214
|
autosaveMaxWaitMs = DEFAULT_AUTOSAVE_MAX_WAIT_MS,
|
|
206
215
|
cryptoRetryDelaysMs = DEFAULT_CRYPTO_RETRY_DELAYS_MS,
|
|
207
216
|
}: ComposeWindowProps) {
|
|
208
|
-
const { id, initialTo, initialCc, initialSubject, initialQuotedHtml, initialEncrypt, signatureContext, suppressSigning,
|
|
217
|
+
const { id, initialTo, initialCc, initialSubject, initialQuotedHtml, initialEncrypt, signatureContext, suppressSigning, quotePending, late, resume } = session;
|
|
218
|
+
// An inline card has no minimized state: the session's flag is for the floating window it may later be popped out to.
|
|
219
|
+
const minimized = session.minimized && !inline;
|
|
209
220
|
// What a reply worked out after this window opened (see `OpenComposeInput.pending`): better recipients, applied below
|
|
210
221
|
// only to fields still exactly as they opened - so these, not `initialTo`/`initialCc`, are what "untouched" is measured against.
|
|
211
222
|
const baselineTo = late?.to ?? initialTo;
|
|
@@ -254,6 +265,8 @@ export default function ComposeWindow({
|
|
|
254
265
|
const [html, setHtml] = useState(resume?.html ?? "");
|
|
255
266
|
const [contentReady, setContentReady] = useState(!!resume);
|
|
256
267
|
const [attachments, setAttachments] = useState<Attachment[]>(resume?.attachments ?? []);
|
|
268
|
+
// The attachment being removed from the draft, whose chip is held until the server has answered.
|
|
269
|
+
const [removingAttachmentUid, setRemovingAttachmentUid] = useState<string | null>(null);
|
|
257
270
|
// The sender's own contact card is on the message (or on its way): "Attach my contact card" then has nothing more to do.
|
|
258
271
|
const [ownCardAttached, setOwnCardAttached] = useState(false);
|
|
259
272
|
const [attachError, setAttachError] = useState<string | null>(null);
|
|
@@ -726,7 +739,8 @@ export default function ComposeWindow({
|
|
|
726
739
|
}
|
|
727
740
|
uploadStarted();
|
|
728
741
|
try {
|
|
729
|
-
|
|
742
|
+
// An inline part with a Content-ID of its own: without one the server has nothing to point the message's `cid:` reference at.
|
|
743
|
+
const attachment = await uploadAttachment(draft.uid, file, { inline: true });
|
|
730
744
|
setHasUploads(true);
|
|
731
745
|
void refreshDraftVersion(draft.uid);
|
|
732
746
|
return attachmentContentUrl(attachment.uid);
|
|
@@ -797,6 +811,24 @@ export default function ComposeWindow({
|
|
|
797
811
|
return allAttached;
|
|
798
812
|
}
|
|
799
813
|
|
|
814
|
+
/** Removes one attachment from the draft, saying why when the server would not. A removed vCard can be attached again. */
|
|
815
|
+
async function handleRemoveAttachment(attachment: Attachment) {
|
|
816
|
+
setAttachError(null);
|
|
817
|
+
setRemovingAttachmentUid(attachment.uid);
|
|
818
|
+
try {
|
|
819
|
+
await deleteAttachment(attachment.uid, attachment.version, client);
|
|
820
|
+
setAttachments((prev) => prev.filter((other) => other.uid !== attachment.uid));
|
|
821
|
+
if (attachment.mimeType === "text/vcard") {
|
|
822
|
+
setOwnCardAttached(false);
|
|
823
|
+
}
|
|
824
|
+
void refreshDraftVersion(draft!.uid);
|
|
825
|
+
} catch (err) {
|
|
826
|
+
setAttachError(err instanceof ApiRequestError ? err.message : "Could not remove attachment.");
|
|
827
|
+
} finally {
|
|
828
|
+
setRemovingAttachmentUid(null);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
800
832
|
/** 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. */
|
|
801
833
|
async function handleAttachOwnCard() {
|
|
802
834
|
setOwnCardAttached(true);
|
|
@@ -1455,19 +1487,21 @@ export default function ComposeWindow({
|
|
|
1455
1487
|
<>
|
|
1456
1488
|
<div
|
|
1457
1489
|
ref={windowRef}
|
|
1458
|
-
role="dialog"
|
|
1490
|
+
role={inline ? "region" : "dialog"}
|
|
1459
1491
|
data-shortcut-scope="compose"
|
|
1460
1492
|
aria-labelledby={titleId}
|
|
1461
1493
|
onDragEnter={handleDragEnter}
|
|
1462
1494
|
onDragOver={handleDragOver}
|
|
1463
1495
|
onDragLeave={handleDragLeave}
|
|
1464
1496
|
onDrop={handleDrop}
|
|
1465
|
-
style={!isMobile && manualSize ? { width: manualSize.width, height: manualSize.height } : undefined}
|
|
1497
|
+
style={!inline && !isMobile && manualSize ? { width: manualSize.width, height: manualSize.height } : undefined}
|
|
1466
1498
|
className={[
|
|
1467
1499
|
"shrink-0 flex flex-col bg-surface border border-border shadow-modal overflow-hidden",
|
|
1468
1500
|
// `fixed` and `relative` may not both be on the element: Tailwind emits `.relative` after `.fixed`, so it wins and the
|
|
1469
1501
|
// sheet sat in the flow of the little fixed container the windows are stacked in instead of covering the screen.
|
|
1470
|
-
|
|
1502
|
+
inline
|
|
1503
|
+
? "relative w-full h-[520px] rounded-md"
|
|
1504
|
+
: isMobile
|
|
1471
1505
|
? "fixed inset-0 w-full h-full rounded-none border-0"
|
|
1472
1506
|
: ["relative border-b-0 rounded-t-md", manualSize ? "" : expanded ? "w-[720px] h-[85vh]" : "w-[480px] h-[520px]"].join(" "),
|
|
1473
1507
|
].join(" ")}
|
|
@@ -1481,7 +1515,7 @@ export default function ComposeWindow({
|
|
|
1481
1515
|
<span className="text-sm">Pictures go into the message where the cursor is</span>
|
|
1482
1516
|
</div>
|
|
1483
1517
|
)}
|
|
1484
|
-
{!isMobile && (
|
|
1518
|
+
{!inline && !isMobile && (
|
|
1485
1519
|
<>
|
|
1486
1520
|
<div
|
|
1487
1521
|
onPointerDown={handleResizeStart("top")}
|
|
@@ -1503,15 +1537,19 @@ export default function ComposeWindow({
|
|
|
1503
1537
|
)}
|
|
1504
1538
|
|
|
1505
1539
|
<div
|
|
1506
|
-
className="h-10 shrink-0 flex items-center justify-between gap-2 px-3 bg-primary-darker text-white cursor-pointer"
|
|
1507
|
-
onClick={onToggleMinimize}
|
|
1540
|
+
className={["h-10 shrink-0 flex items-center justify-between gap-2 px-3 bg-primary-darker text-white", inline ? "" : "cursor-pointer"].join(" ")}
|
|
1541
|
+
onClick={inline ? undefined : onToggleMinimize}
|
|
1508
1542
|
>
|
|
1509
1543
|
<span id={titleId} className="text-sm font-medium truncate">
|
|
1510
1544
|
{title}
|
|
1511
1545
|
</span>
|
|
1512
1546
|
<div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}>
|
|
1513
|
-
|
|
1514
|
-
|
|
1547
|
+
{inline ? (
|
|
1548
|
+
<HeaderButton label="Pop out" onClick={() => onPopOut?.()} icon={HiOutlineArrowTopRightOnSquare} />
|
|
1549
|
+
) : (
|
|
1550
|
+
<HeaderButton label="Minimize" onClick={onToggleMinimize} icon={HiOutlineMinus} />
|
|
1551
|
+
)}
|
|
1552
|
+
{!inline && !isMobile && (
|
|
1515
1553
|
<HeaderButton
|
|
1516
1554
|
label={expanded ? "Collapse" : "Expand"}
|
|
1517
1555
|
onClick={toggleExpanded}
|
|
@@ -1703,8 +1741,21 @@ export default function ComposeWindow({
|
|
|
1703
1741
|
{attachments.length > 0 && (
|
|
1704
1742
|
<ul className="flex flex-wrap gap-2 px-3 pb-2">
|
|
1705
1743
|
{attachments.map((attachment) => (
|
|
1706
|
-
<li
|
|
1744
|
+
<li
|
|
1745
|
+
key={attachment.uid}
|
|
1746
|
+
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"
|
|
1747
|
+
>
|
|
1707
1748
|
{attachment.filename}
|
|
1749
|
+
<button
|
|
1750
|
+
type="button"
|
|
1751
|
+
aria-label={`Remove ${attachment.filename}`}
|
|
1752
|
+
title={`Remove ${attachment.filename}`}
|
|
1753
|
+
disabled={removingAttachmentUid === attachment.uid}
|
|
1754
|
+
onClick={() => void handleRemoveAttachment(attachment)}
|
|
1755
|
+
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"
|
|
1756
|
+
>
|
|
1757
|
+
<HiOutlineXMark size={12} aria-hidden="true" />
|
|
1758
|
+
</button>
|
|
1708
1759
|
</li>
|
|
1709
1760
|
))}
|
|
1710
1761
|
</ul>
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
5
|
import React, { useEffect } from "react";
|
|
6
|
-
import { HiOutlineMinus, HiOutlineXMark } from "react-icons/hi2";
|
|
6
|
+
import { HiOutlineArrowTopRightOnSquare, HiOutlineMinus, HiOutlineXMark } from "react-icons/hi2";
|
|
7
7
|
import Skeleton from "../../../../../lib/components/feedback/Skeleton.js";
|
|
8
8
|
import Alert from "../../../../../lib/components/feedback/Alert.js";
|
|
9
9
|
import Button from "../../../../../lib/components/buttons/Button.js";
|
|
@@ -18,6 +18,10 @@ export interface ComposeWindowPlaceholderProps {
|
|
|
18
18
|
onRetry: () => void;
|
|
19
19
|
onClose: () => void;
|
|
20
20
|
onToggleMinimize: () => void;
|
|
21
|
+
/** The placeholder stands in for an inline card in the reading pane (see `ComposeWindowProps.inline`). */
|
|
22
|
+
inline?: boolean;
|
|
23
|
+
/** Moves an `inline` placeholder out to the floating stack. */
|
|
24
|
+
onPopOut?: () => void;
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
/**
|
|
@@ -28,14 +32,14 @@ export interface ComposeWindowPlaceholderProps {
|
|
|
28
32
|
*
|
|
29
33
|
* Nothing here can be typed into yet, and there is no draft to save or discard, so Close just closes.
|
|
30
34
|
*/
|
|
31
|
-
export default function ComposeWindowPlaceholder({ session, failed, onRetry, onClose, onToggleMinimize }: ComposeWindowPlaceholderProps) {
|
|
35
|
+
export default function ComposeWindowPlaceholder({ session, failed, onRetry, onClose, onToggleMinimize, inline, onPopOut }: ComposeWindowPlaceholderProps) {
|
|
32
36
|
const isMobile = useIsMobile();
|
|
33
37
|
const title = session.initialSubject?.trim() || "New Message";
|
|
34
38
|
const titleId = `compose-title-${session.id}`;
|
|
35
39
|
|
|
36
40
|
useEffect(() => markComposePhase(session.id, "shell"), [session.id]);
|
|
37
41
|
|
|
38
|
-
if (session.minimized) {
|
|
42
|
+
if (session.minimized && !inline) {
|
|
39
43
|
return (
|
|
40
44
|
<div role="dialog" aria-label={title} className="w-64 shrink-0 bg-surface border border-border border-b-0 rounded-t-md shadow-modal">
|
|
41
45
|
<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}>
|
|
@@ -47,27 +51,34 @@ export default function ComposeWindowPlaceholder({ session, failed, onRetry, onC
|
|
|
47
51
|
|
|
48
52
|
return (
|
|
49
53
|
<div
|
|
50
|
-
role="dialog"
|
|
54
|
+
role={inline ? "region" : "dialog"}
|
|
51
55
|
aria-labelledby={titleId}
|
|
52
56
|
aria-busy={!failed}
|
|
53
57
|
className={[
|
|
54
58
|
"relative shrink-0 flex flex-col bg-surface border border-border shadow-modal overflow-hidden",
|
|
55
|
-
|
|
59
|
+
inline
|
|
60
|
+
? "w-full h-[520px] rounded-md"
|
|
61
|
+
: isMobile
|
|
62
|
+
? "fixed inset-0 w-full h-full rounded-none border-0"
|
|
63
|
+
: "border-b-0 rounded-t-md w-[480px] h-[520px]",
|
|
56
64
|
].join(" ")}
|
|
57
65
|
>
|
|
58
|
-
<div
|
|
66
|
+
<div
|
|
67
|
+
className={["h-10 shrink-0 flex items-center justify-between gap-2 px-3 bg-primary-darker text-white", inline ? "" : "cursor-pointer"].join(" ")}
|
|
68
|
+
onClick={inline ? undefined : onToggleMinimize}
|
|
69
|
+
>
|
|
59
70
|
<span id={titleId} className="text-sm font-medium truncate">
|
|
60
71
|
{title}
|
|
61
72
|
</span>
|
|
62
73
|
<div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}>
|
|
63
74
|
<button
|
|
64
75
|
type="button"
|
|
65
|
-
aria-label="Minimize"
|
|
66
|
-
title="Minimize"
|
|
67
|
-
onClick={onToggleMinimize}
|
|
76
|
+
aria-label={inline ? "Pop out" : "Minimize"}
|
|
77
|
+
title={inline ? "Pop out" : "Minimize"}
|
|
78
|
+
onClick={inline ? onPopOut : onToggleMinimize}
|
|
68
79
|
className="w-6 h-6 flex items-center justify-center rounded-sm text-white/80 hover:bg-white/15 hover:text-white"
|
|
69
80
|
>
|
|
70
|
-
<HiOutlineMinus size={14} />
|
|
81
|
+
{inline ? <HiOutlineArrowTopRightOnSquare size={14} /> : <HiOutlineMinus size={14} />}
|
|
71
82
|
</button>
|
|
72
83
|
<button
|
|
73
84
|
type="button"
|
|
@@ -96,7 +96,8 @@ export default function RichTextEditor({
|
|
|
96
96
|
TextStyleKit.configure({ backgroundColor: false, lineHeight: false }),
|
|
97
97
|
TextAlign.configure({ types: ["heading", "paragraph"] }),
|
|
98
98
|
Highlight,
|
|
99
|
-
|
|
99
|
+
// A quoted original's pictures arrive as `data:` URIs; `assembleDraft()` attaches them to the draft when it is saved.
|
|
100
|
+
Image.configure({ allowBase64: true }),
|
|
100
101
|
TableKit.configure({ table: { resizable: true } }),
|
|
101
102
|
Placeholder.configure({ placeholder: "Write your message…" }),
|
|
102
103
|
],
|
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
5
|
import { apiUrl } from "../../../../../lib/util/api.js";
|
|
6
|
-
import { Message, Recipient, getMessageRawContent } from "../../../../../lib/mail/mailApi.js";
|
|
6
|
+
import { Message, Recipient, attachmentContentUrl, getMessageRawContent, listAttachments } from "../../../../../lib/mail/mailApi.js";
|
|
7
7
|
import type { QuotedBody } from "../../../../../lib/mail/compose/composeQuoting.js";
|
|
8
|
+
import { MAX_QUOTED_IMAGE_BYTES, QuotedImageReference, embedQuotedImages } from "../../../../../lib/mail/compose/quotedImages.js";
|
|
9
|
+
import { EMBEDDED_IMAGE_TYPES, findInlineAttachment, toDataUri } from "../../../../../lib/mail/inlineImages.js";
|
|
8
10
|
import type { MessageSecurityResult } from "../../../../../lib/crypto/messageSecurity.js";
|
|
9
|
-
import { decodeHeaderText, extractDisplayBody, parseMimeEntity } from "../../../../../lib/crypto/mime.js";
|
|
11
|
+
import { MimeAttachment, decodeHeaderText, extractDisplayBody, parseMimeEntity } from "../../../../../lib/crypto/mime.js";
|
|
10
12
|
import { parseRecipientList } from "./recipients.js";
|
|
11
13
|
|
|
12
14
|
/** What a Reply/Reply All/Forward needs from the message being answered, beyond what its `Message` record holds. */
|
|
@@ -92,6 +94,38 @@ function headerRecipients(value: string | undefined, type: "to" | "cc"): Recipie
|
|
|
92
94
|
.map((recipient) => ({ address: recipient.address, ...(recipient.displayName ? { displayName: recipient.displayName } : {}), type }));
|
|
93
95
|
}
|
|
94
96
|
|
|
97
|
+
/** The `data:` URI of the picture a quoted image of `message` stands for - one of the message's own attachments, fetched with the session -
|
|
98
|
+
* or `undefined` when there is none, it is not a raster image, it is too large or it could not be had. Never rejects. */
|
|
99
|
+
function serverImageResolver(message: Message): (reference: QuotedImageReference) => Promise<string | undefined> {
|
|
100
|
+
let attachments: Promise<Awaited<ReturnType<typeof listAttachments>>> | undefined;
|
|
101
|
+
return async (reference) => {
|
|
102
|
+
try {
|
|
103
|
+
attachments ??= listAttachments(message.folderUid, message.uid);
|
|
104
|
+
const attachment = findInlineAttachment(await attachments, reference);
|
|
105
|
+
const type = attachment?.mimeType.split(";")[0].trim().toLowerCase();
|
|
106
|
+
if (!attachment || !type || !EMBEDDED_IMAGE_TYPES.test(type) || attachment.sizeBytes > MAX_QUOTED_IMAGE_BYTES) {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
const res = await fetch(attachmentContentUrl(attachment.uid), { credentials: "include" });
|
|
110
|
+
const bytes = res.ok ? new Uint8Array(await res.arrayBuffer()) : undefined;
|
|
111
|
+
return bytes && bytes.length <= MAX_QUOTED_IMAGE_BYTES ? toDataUri(type, bytes) : undefined;
|
|
112
|
+
} catch {
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** As `serverImageResolver()`, for the parts of a message this browser decrypted or verified (`MessageSecurityResult.attachments`). */
|
|
119
|
+
function partImageResolver(parts: MimeAttachment[]): (reference: QuotedImageReference) => Promise<string | undefined> {
|
|
120
|
+
const candidates = parts.map((part, index) => ({ uid: String(index), filename: part.filename ?? "", mimeType: part.contentType, contentId: part.contentId }));
|
|
121
|
+
return async (reference) => {
|
|
122
|
+
const found = findInlineAttachment(candidates, reference);
|
|
123
|
+
const part = found ? parts[Number(found.uid)] : undefined;
|
|
124
|
+
const bytes = part && EMBEDDED_IMAGE_TYPES.test(part.contentType) ? part.decode() : undefined;
|
|
125
|
+
return part && bytes && bytes.length <= MAX_QUOTED_IMAGE_BYTES ? toDataUri(part.contentType, bytes) : undefined;
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
95
129
|
/**
|
|
96
130
|
* Loads what a Reply/Reply All/Forward needs from `message`: the full body to quote - the same content
|
|
97
131
|
* `MessageDetailPane` shows, never the truncated `bodyPreview` unless nothing else can be had - and, with
|
|
@@ -113,6 +147,9 @@ function headerRecipients(value: string | undefined, type: "to" | "cc"): Recipie
|
|
|
113
147
|
* `message.recipients` alone would reduce every Reply All to a plain reply. Unprotected headers are the sender's own
|
|
114
148
|
* claim, exactly as in every other mail client's Reply All.
|
|
115
149
|
*
|
|
150
|
+
* The original's embedded images (`cid:`) are replaced by `data:` URIs of the pictures themselves - fetched from the message's
|
|
151
|
+
* attachments, or taken from the decrypted entity - so the quote shows them and the new message can carry them (see `quotedImages.ts`).
|
|
152
|
+
*
|
|
116
153
|
* The quote builders sanitize whatever HTML this returns (`sanitizeQuotedHtml()`), whichever source it came from.
|
|
117
154
|
*/
|
|
118
155
|
export async function loadOriginalMessage(
|
|
@@ -157,5 +194,11 @@ export async function loadOriginalMessage(
|
|
|
157
194
|
// Whatever was loaded before the failure stands: the quote falls back to the preview, Reply All to the
|
|
158
195
|
// message's own recipients.
|
|
159
196
|
}
|
|
197
|
+
if (result.body.html !== undefined) {
|
|
198
|
+
const resolver = recovered ? partImageResolver(security?.attachments ?? []) : serverImageResolver(message);
|
|
199
|
+
const html = result.body.html;
|
|
200
|
+
// Bounded like the body's own request: a picture that will not arrive is left out of the quote, not waited for.
|
|
201
|
+
result.body = { html: await withTimeout(embedQuotedImages(html, resolver), QUOTE_FETCH_TIMEOUT_MS).catch(() => html) };
|
|
202
|
+
}
|
|
160
203
|
return result;
|
|
161
204
|
}
|