@rapidmx/web-client 0.28.0 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/apps/shared/components/admin/diagnostics/DiagnosticsManager.tsx +22 -10
- package/apps/shared/components/admin/diagnostics/InformationPanel.tsx +60 -0
- package/apps/shared/components/admin/diagnostics/SettingsTable.tsx +108 -0
- package/apps/shared/components/admin/diagnostics/diagnosticsApi.ts +25 -1
- package/apps/shared/components/calendar/CalendarSearchBox.tsx +83 -0
- package/apps/shared/components/calendar/EventMatchNav.tsx +72 -0
- package/apps/shared/components/calendar/EventModal.tsx +33 -12
- package/apps/shared/components/calendar/ListView.tsx +207 -0
- package/apps/shared/components/calendar/MonthView.tsx +14 -2
- package/apps/shared/components/calendar/SlideIn.tsx +47 -0
- package/apps/shared/components/calendar/SplitDayView.tsx +5 -0
- package/apps/shared/components/calendar/TimeGridView.tsx +23 -12
- package/apps/shared/components/calendar/activeOccurrence.ts +28 -0
- package/apps/shared/components/calendar/allDay.ts +16 -1
- package/apps/shared/components/calendar/calendarView.ts +41 -0
- package/apps/shared/components/layout/AppShell.tsx +13 -6
- package/apps/shared/components/mail/ConversationList.tsx +29 -3
- package/apps/shared/components/mail/ConversationThreadPane.tsx +15 -0
- package/apps/shared/components/mail/MailSelectionBar.tsx +117 -55
- package/apps/shared/components/mail/MessageDetailPane.tsx +3 -0
- package/apps/shared/components/mail/compose/ComposeContext.tsx +193 -35
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +62 -11
- package/apps/shared/components/mail/compose/ComposeWindowPlaceholder.tsx +21 -10
- package/apps/shared/components/mail/compose/RichTextEditor.tsx +2 -1
- package/apps/shared/components/mail/compose/quotedBody.ts +45 -2
- package/apps/shared/components/mail/labelMenu.tsx +4 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +9 -2
- package/apps/shared/components/mail/reading/MessageBody.tsx +1 -1
- package/apps/shared/components/mail/reading/bodyContent.ts +15 -13
- package/apps/shared/components/mail/reading/bodyHtml.ts +12 -7
- package/apps/shared/gestures/useEnterSlide.ts +1 -1
- package/apps/shared/keyboard/keymap.ts +2 -0
- package/apps/shared/mail/rangeSelection.ts +45 -0
- package/apps/shared/mail/rowExit.ts +39 -0
- package/apps/www/calendar/index.tsx +251 -53
- package/apps/www/index.tsx +522 -343
- package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.d.ts +3 -3
- package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.js +15 -9
- package/dist/apps/shared/components/admin/diagnostics/InformationPanel.d.ts +15 -0
- package/dist/apps/shared/components/admin/diagnostics/InformationPanel.js +16 -0
- package/dist/apps/shared/components/admin/diagnostics/SettingsTable.d.ts +20 -0
- package/dist/apps/shared/components/admin/diagnostics/SettingsTable.js +31 -0
- package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.d.ts +20 -1
- package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.js +7 -0
- package/dist/apps/shared/components/calendar/CalendarSearchBox.d.ts +22 -0
- package/dist/apps/shared/components/calendar/CalendarSearchBox.js +24 -0
- package/dist/apps/shared/components/calendar/EventMatchNav.d.ts +17 -0
- package/dist/apps/shared/components/calendar/EventMatchNav.js +37 -0
- package/dist/apps/shared/components/calendar/EventModal.d.ts +17 -1
- package/dist/apps/shared/components/calendar/EventModal.js +13 -11
- package/dist/apps/shared/components/calendar/ListView.d.ts +30 -0
- package/dist/apps/shared/components/calendar/ListView.js +111 -0
- package/dist/apps/shared/components/calendar/MonthView.js +12 -3
- package/dist/apps/shared/components/calendar/SlideIn.d.ts +16 -0
- package/dist/apps/shared/components/calendar/SlideIn.js +32 -0
- package/dist/apps/shared/components/calendar/SplitDayView.js +5 -1
- package/dist/apps/shared/components/calendar/TimeGridView.js +11 -3
- package/dist/apps/shared/components/calendar/activeOccurrence.d.ts +15 -0
- package/dist/apps/shared/components/calendar/activeOccurrence.js +21 -0
- package/dist/apps/shared/components/calendar/allDay.d.ts +4 -0
- package/dist/apps/shared/components/calendar/allDay.js +14 -1
- package/dist/apps/shared/components/calendar/calendarView.d.ts +16 -0
- package/dist/apps/shared/components/calendar/calendarView.js +36 -0
- package/dist/apps/shared/components/layout/AppShell.js +8 -4
- package/dist/apps/shared/components/mail/ConversationList.d.ts +9 -1
- package/dist/apps/shared/components/mail/ConversationList.js +10 -2
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +9 -1
- package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -4
- package/dist/apps/shared/components/mail/MessageDetailPane.js +3 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +31 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +123 -11
- package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +6 -1
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +37 -11
- package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.d.ts +5 -1
- package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.js +10 -6
- package/dist/apps/shared/components/mail/compose/RichTextEditor.js +2 -1
- package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +3 -0
- package/dist/apps/shared/components/mail/compose/quotedBody.js +43 -1
- package/dist/apps/shared/components/mail/labelMenu.d.ts +3 -1
- package/dist/apps/shared/components/mail/labelMenu.js +2 -2
- package/dist/apps/shared/components/mail/layout/MailShell.js +7 -1
- package/dist/apps/shared/components/mail/reading/MessageBody.js +1 -1
- package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +5 -1
- package/dist/apps/shared/components/mail/reading/bodyContent.js +11 -11
- package/dist/apps/shared/components/mail/reading/bodyHtml.d.ts +3 -2
- package/dist/apps/shared/components/mail/reading/bodyHtml.js +8 -4
- package/dist/apps/shared/gestures/useEnterSlide.d.ts +2 -0
- package/dist/apps/shared/gestures/useEnterSlide.js +1 -1
- package/dist/apps/shared/keyboard/keymap.d.ts +12 -0
- package/dist/apps/shared/keyboard/keymap.js +2 -0
- package/dist/apps/shared/mail/rangeSelection.d.ts +20 -0
- package/dist/apps/shared/mail/rangeSelection.js +34 -0
- package/dist/apps/shared/mail/rowExit.d.ts +12 -0
- package/dist/apps/shared/mail/rowExit.js +33 -0
- package/dist/apps/www/calendar/index.js +177 -18
- package/dist/apps/www/index.js +220 -65
- package/dist/lib/calendar/eventSearch.d.ts +42 -0
- package/dist/lib/calendar/eventSearch.js +152 -0
- package/dist/lib/components/navigation/BottomTabBar.d.ts +15 -3
- package/dist/lib/components/navigation/BottomTabBar.js +52 -15
- package/dist/lib/components/navigation/NavBadge.d.ts +13 -0
- package/dist/lib/components/navigation/NavBadge.js +18 -0
- package/dist/lib/mail/compose/quotedImages.d.ts +21 -0
- package/dist/lib/mail/compose/quotedImages.js +41 -0
- package/dist/lib/mail/inlineImages.d.ts +38 -0
- package/dist/lib/mail/inlineImages.js +90 -0
- package/dist/lib/mail/mailApi.d.ts +8 -1
- package/dist/lib/mail/mailApi.js +57 -5
- package/lib/calendar/eventSearch.ts +172 -0
- package/lib/components/navigation/BottomTabBar.tsx +107 -22
- package/lib/components/navigation/NavBadge.tsx +33 -0
- package/lib/mail/compose/quotedImages.ts +49 -0
- package/lib/mail/inlineImages.ts +104 -0
- package/lib/mail/mailApi.ts +64 -5
- package/package.json +1 -1
- package/apps/shared/components/admin/diagnostics/VersionsPanel.tsx +0 -36
- package/dist/apps/shared/components/admin/diagnostics/VersionsPanel.d.ts +0 -10
- package/dist/apps/shared/components/admin/diagnostics/VersionsPanel.js +0 -11
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* The calendar's search: which events match what was typed, and the chronological list of their occurrences to step through. The calendar page
|
|
7
|
+
* already holds every event of the checked calendars (`listCalendarEvents()` fetches whole folders - there is no server-side text or range query
|
|
8
|
+
* to lean on), so this is entirely client-side and covers all time, not just the visible range. A series contributes the occurrences inside a window
|
|
9
|
+
* of `SEARCH_WINDOW_YEARS` either side of today, at most `SEARCH_MAX_OCCURRENCES_PER_SERIES` of them (the ones nearest today).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { addYears } from "date-fns";
|
|
13
|
+
import { CalendarEvent } from "./calendarApi.js";
|
|
14
|
+
import { CalendarOccurrence, expandOccurrences } from "./recurrence.js";
|
|
15
|
+
|
|
16
|
+
/** How far either side of today a recurring event's occurrences are searched. */
|
|
17
|
+
export const SEARCH_WINDOW_YEARS = 5;
|
|
18
|
+
/** The most occurrences one recurring event contributes (the ones nearest today), so a daily series cannot flood the results. */
|
|
19
|
+
export const SEARCH_MAX_OCCURRENCES_PER_SERIES = 500;
|
|
20
|
+
|
|
21
|
+
/** Lower-cases `text` and strips its accents, so "cafe" finds "Café". */
|
|
22
|
+
function fold(text: string): string {
|
|
23
|
+
return text.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The words of `query`, folded; an event must match every one of them. Empty for a blank query. */
|
|
27
|
+
export function searchTerms(query: string): string[] {
|
|
28
|
+
return fold(query)
|
|
29
|
+
.split(/\s+/)
|
|
30
|
+
.filter((term) => term !== "");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The text of `event` a search looks in: title, location, description (plain text), organizer and guests (name and address). */
|
|
34
|
+
function searchableText(event: CalendarEvent): string {
|
|
35
|
+
const parts: (string | null | undefined)[] = [
|
|
36
|
+
event.title,
|
|
37
|
+
event.location,
|
|
38
|
+
event.description,
|
|
39
|
+
event.organizer?.displayName,
|
|
40
|
+
event.organizer?.address,
|
|
41
|
+
];
|
|
42
|
+
for (const attendee of event.attendees ?? []) {
|
|
43
|
+
parts.push(attendee.displayName, attendee.address);
|
|
44
|
+
}
|
|
45
|
+
return fold(parts.filter(Boolean).join("\n"));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Whether `event` has every one of `terms` (from `searchTerms()`) somewhere in its readable text. A busy block (`redacted`: a private event on a
|
|
50
|
+
* shared calendar) never matches - its title is a placeholder, not what the event says.
|
|
51
|
+
*/
|
|
52
|
+
export function eventMatchesTerms(event: CalendarEvent, terms: string[]): boolean {
|
|
53
|
+
if (terms.length === 0 || event.redacted) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
const text = searchableText(event);
|
|
57
|
+
return terms.every((term) => text.includes(term));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function byStart(a: CalendarOccurrence, b: CalendarOccurrence): number {
|
|
61
|
+
return new Date(a.startDate).getTime() - new Date(b.startDate).getTime() || a.title.localeCompare(b.title);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The occurrences of the events of `events` that match `query`, in chronological order. A single event is one occurrence wherever it falls; a
|
|
66
|
+
* recurring one contributes those within `SEARCH_WINDOW_YEARS` of `now`, the `SEARCH_MAX_OCCURRENCES_PER_SERIES` nearest to it at most.
|
|
67
|
+
*/
|
|
68
|
+
export function searchOccurrences(events: CalendarEvent[], query: string, now: Date = new Date()): CalendarOccurrence[] {
|
|
69
|
+
const terms = searchTerms(query);
|
|
70
|
+
return occurrencesOf(events, (event) => eventMatchesTerms(event, terms), now);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Every occurrence of every event, in chronological order, bounded the same way a search is - the calendar's List view shows these. */
|
|
74
|
+
export function allOccurrences(events: CalendarEvent[], now: Date = new Date()): CalendarOccurrence[] {
|
|
75
|
+
return occurrencesOf(events, () => true, now);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The chronological occurrences of the `events` that `include` accepts (see `searchOccurrences()` for how a series is bounded). */
|
|
79
|
+
function occurrencesOf(events: CalendarEvent[], include: (event: CalendarEvent) => boolean, now: Date): CalendarOccurrence[] {
|
|
80
|
+
const from = addYears(now, -SEARCH_WINDOW_YEARS);
|
|
81
|
+
const to = addYears(now, SEARCH_WINDOW_YEARS);
|
|
82
|
+
const found: CalendarOccurrence[] = [];
|
|
83
|
+
for (const event of events) {
|
|
84
|
+
if (!include(event)) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (!event.recurrenceRule) {
|
|
88
|
+
found.push(...expandOccurrences(event, new Date(new Date(event.startDate).getTime() - 1), new Date(new Date(event.endDate).getTime() + 1)));
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const occurrences = expandOccurrences(event, from, to);
|
|
92
|
+
if (occurrences.length > SEARCH_MAX_OCCURRENCES_PER_SERIES) {
|
|
93
|
+
const distance = (o: CalendarOccurrence) => Math.abs(new Date(o.startDate).getTime() - now.getTime());
|
|
94
|
+
occurrences.sort((a, b) => distance(a) - distance(b)).length = SEARCH_MAX_OCCURRENCES_PER_SERIES;
|
|
95
|
+
}
|
|
96
|
+
found.push(...occurrences);
|
|
97
|
+
}
|
|
98
|
+
return found.sort(byStart);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The `occurrences` that overlap `[rangeStart, rangeEnd]` - what `expandOccurrences()` keeps, so a view shows exactly these. */
|
|
102
|
+
export function occurrencesInRange(occurrences: CalendarOccurrence[], rangeStart: Date, rangeEnd: Date): CalendarOccurrence[] {
|
|
103
|
+
return occurrences.filter((o) => new Date(o.startDate) < rangeEnd && new Date(o.endDate) > rangeStart);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Which of the chronological `matches` to show first for a search: the first one inside the view (`[rangeStart, rangeEnd]`) when there is one,
|
|
108
|
+
* else the first that ends after `from` (the day being viewed), else the last (the nearest before). `-1` when there are no matches.
|
|
109
|
+
*/
|
|
110
|
+
export function initialMatchIndex(matches: CalendarOccurrence[], rangeStart: Date, rangeEnd: Date, from: Date): number {
|
|
111
|
+
if (matches.length === 0) {
|
|
112
|
+
return -1;
|
|
113
|
+
}
|
|
114
|
+
const inView = matches.findIndex((o) => new Date(o.startDate) < rangeEnd && new Date(o.endDate) > rangeStart);
|
|
115
|
+
if (inView >= 0) {
|
|
116
|
+
return inView;
|
|
117
|
+
}
|
|
118
|
+
const after = matches.findIndex((o) => new Date(o.endDate) > from);
|
|
119
|
+
return after >= 0 ? after : matches.length - 1;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The index of the occurrence one step from the current one in `sequence` (chronological), or `-1` for none. The current occurrence is found by
|
|
124
|
+
* `currentKey`; when it is not in the sequence (it was deleted or changed under an open card) the step is from where it started
|
|
125
|
+
* (`currentStart`): the first after it or the last before it, and from the first or last when that is not known either. Past either end it
|
|
126
|
+
* wraps round when `wrap`, else there is nothing there (`-1`).
|
|
127
|
+
*/
|
|
128
|
+
export function stepSequenceIndex(
|
|
129
|
+
sequence: CalendarOccurrence[],
|
|
130
|
+
currentKey: string | null,
|
|
131
|
+
currentStart: Date | undefined,
|
|
132
|
+
direction: 1 | -1,
|
|
133
|
+
wrap: boolean,
|
|
134
|
+
): number {
|
|
135
|
+
const count = sequence.length;
|
|
136
|
+
if (count === 0) {
|
|
137
|
+
return -1;
|
|
138
|
+
}
|
|
139
|
+
const edge = direction === 1 ? 0 : count - 1;
|
|
140
|
+
const index = sequence.findIndex((o) => o.occurrenceKey === currentKey);
|
|
141
|
+
if (index >= 0) {
|
|
142
|
+
const target = index + direction;
|
|
143
|
+
if (target >= 0 && target < count) {
|
|
144
|
+
return target;
|
|
145
|
+
}
|
|
146
|
+
return wrap ? edge : -1;
|
|
147
|
+
}
|
|
148
|
+
if (!currentStart) {
|
|
149
|
+
return edge;
|
|
150
|
+
}
|
|
151
|
+
const from = currentStart.getTime();
|
|
152
|
+
let target = -1;
|
|
153
|
+
if (direction === 1) {
|
|
154
|
+
target = sequence.findIndex((o) => new Date(o.startDate).getTime() > from);
|
|
155
|
+
} else {
|
|
156
|
+
for (let i = count - 1; i >= 0 && target < 0; i--) {
|
|
157
|
+
target = new Date(sequence[i].startDate).getTime() < from ? i : -1;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return target >= 0 ? target : wrap ? edge : -1;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The index one step from `index` (`-1`: none is current) in `direction` through `count` matches, wrapping round at either end. `-1` with none. */
|
|
164
|
+
export function stepMatchIndex(index: number, count: number, direction: 1 | -1): number {
|
|
165
|
+
if (count === 0) {
|
|
166
|
+
return -1;
|
|
167
|
+
}
|
|
168
|
+
if (index < 0) {
|
|
169
|
+
return direction === 1 ? 0 : count - 1;
|
|
170
|
+
}
|
|
171
|
+
return (index + direction + count) % count;
|
|
172
|
+
}
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
-
import React, { useEffect, useRef } from "react";
|
|
5
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
6
6
|
import type { IconType } from "react-icons";
|
|
7
|
+
import { HiOutlineEllipsisHorizontal } from "react-icons/hi2";
|
|
8
|
+
import NavBadge, { navItemLabel } from "./NavBadge.js";
|
|
7
9
|
|
|
8
10
|
/** A single icon-rail/bottom-tab entry — deliberately generic (not tied to `AppShell`'s own
|
|
9
11
|
* `AppDef`/`AppShellApp`) so both `AppShell` (www) and `AdminShell` (admin) can share this component
|
|
@@ -13,6 +15,8 @@ export interface NavItem {
|
|
|
13
15
|
href: string;
|
|
14
16
|
label: string;
|
|
15
17
|
icon: IconType;
|
|
18
|
+
/** A count drawn as a chip on the icon (unread mail) and named in the link's accessible name; nothing at zero or when absent. */
|
|
19
|
+
badge?: number;
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
export interface BottomTabBarProps {
|
|
@@ -20,6 +24,27 @@ export interface BottomTabBarProps {
|
|
|
20
24
|
active: string;
|
|
21
25
|
}
|
|
22
26
|
|
|
27
|
+
/** The most entries the bar shows at once: any more and the last slot becomes "More", holding the rest. */
|
|
28
|
+
export const MAX_TABS = 5;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The entries to draw directly on the bar and the ones left for the "More" menu. Up to `MAX_TABS` entries all show. With more, the bar shows the first
|
|
32
|
+
* `MAX_TABS - 1` and "More" holds the rest - except that the active entry is always on the bar (it takes the last slot when it would otherwise be hidden), so the reader
|
|
33
|
+
* can see where they are.
|
|
34
|
+
*/
|
|
35
|
+
export function splitTabs(apps: NavItem[], active: string): { shown: NavItem[]; more: NavItem[] } {
|
|
36
|
+
if (apps.length <= MAX_TABS) {
|
|
37
|
+
return { shown: apps, more: [] };
|
|
38
|
+
}
|
|
39
|
+
const slots = MAX_TABS - 1;
|
|
40
|
+
const activeItem = apps.find((app) => app.id === active);
|
|
41
|
+
const hidden = !!activeItem && apps.indexOf(activeItem) >= slots;
|
|
42
|
+
const shown = hidden ? [...apps.slice(0, slots - 1), activeItem] : apps.slice(0, slots);
|
|
43
|
+
return { shown, more: apps.filter((app) => !shown.includes(app)) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const TAB_CLASS = "flex-1 min-w-0 overflow-hidden px-1 flex flex-col items-center justify-center gap-0.5 text-[10px] leading-tight text-center font-medium";
|
|
47
|
+
|
|
23
48
|
/**
|
|
24
49
|
* The mobile counterpart to an icon rail (`AppShell`'s Mail/Calendar/Contacts/Tasks, or `AdminShell`'s
|
|
25
50
|
* Mailboxes/Quarantine/Ingest Queue) — same data, same full-page-nav `<a href>` links (this framework
|
|
@@ -27,40 +52,100 @@ export interface BottomTabBarProps {
|
|
|
27
52
|
* which doesn't fit below the `md` breakpoint. Only ever rendered alongside that rail (`md:hidden` here,
|
|
28
53
|
* `hidden md:flex` there) — never both hidden or both visible at once.
|
|
29
54
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* sideways - opening with the active item in view.
|
|
55
|
+
* The entries share the width equally and never run off the screen: with more than `MAX_TABS`, the last slot is a "More" (…) button that opens a menu
|
|
56
|
+
* of the others (see `splitTabs()`). The menu closes on Escape, on a click elsewhere and on choosing an entry.
|
|
33
57
|
*/
|
|
34
58
|
export default function BottomTabBar({ apps, active }: BottomTabBarProps) {
|
|
35
|
-
const
|
|
59
|
+
const { shown, more } = splitTabs(apps, active);
|
|
60
|
+
const [open, setOpen] = useState(false);
|
|
61
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
62
|
+
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
63
|
+
|
|
36
64
|
useEffect(() => {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
65
|
+
if (!open) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
function handlePointer(event: MouseEvent | TouchEvent) {
|
|
69
|
+
if (!rootRef.current?.contains(event.target as Node)) {
|
|
70
|
+
setOpen(false);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function handleKey(event: KeyboardEvent) {
|
|
74
|
+
if (event.key === "Escape") {
|
|
75
|
+
setOpen(false);
|
|
76
|
+
buttonRef.current?.focus();
|
|
77
|
+
}
|
|
42
78
|
}
|
|
43
|
-
|
|
79
|
+
document.addEventListener("mousedown", handlePointer);
|
|
80
|
+
document.addEventListener("touchstart", handlePointer);
|
|
81
|
+
document.addEventListener("keydown", handleKey);
|
|
82
|
+
return () => {
|
|
83
|
+
document.removeEventListener("mousedown", handlePointer);
|
|
84
|
+
document.removeEventListener("touchstart", handlePointer);
|
|
85
|
+
document.removeEventListener("keydown", handleKey);
|
|
86
|
+
};
|
|
87
|
+
}, [open]);
|
|
88
|
+
|
|
89
|
+
// The unread count of an entry that is behind "More" is still worth a dot on it.
|
|
90
|
+
const hiddenBadge = more.some((app) => (app.badge ?? 0) > 0);
|
|
91
|
+
|
|
44
92
|
return (
|
|
45
|
-
<nav
|
|
46
|
-
|
|
47
|
-
className="md:hidden fixed bottom-0 inset-x-0 z-30 h-14 bg-surface border-t border-border flex items-stretch overflow-x-auto overflow-y-hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
|
48
|
-
>
|
|
49
|
-
{apps.map(({ id, href, label, icon: Icon }) => (
|
|
93
|
+
<nav aria-label="Mobile navigation" className="md:hidden fixed bottom-0 inset-x-0 z-30 h-14 bg-surface border-t border-border flex items-stretch">
|
|
94
|
+
{shown.map(({ id, href, label, icon: Icon, badge }) => (
|
|
50
95
|
<a
|
|
51
96
|
key={id}
|
|
52
97
|
href={href}
|
|
53
|
-
|
|
98
|
+
aria-label={badge ? navItemLabel(label, badge) : undefined}
|
|
54
99
|
aria-current={id === active ? "page" : undefined}
|
|
55
|
-
className={[
|
|
56
|
-
"flex-1 shrink-0 min-w-[4.75rem] px-1 flex flex-col items-center justify-center gap-0.5 text-[10px] leading-tight text-center font-medium",
|
|
57
|
-
id === active ? "text-primary-dark" : "text-text-muted",
|
|
58
|
-
].join(" ")}
|
|
100
|
+
className={[TAB_CLASS, id === active ? "text-primary-dark" : "text-text-muted"].join(" ")}
|
|
59
101
|
>
|
|
60
|
-
<
|
|
102
|
+
<span className="relative inline-flex">
|
|
103
|
+
<Icon size={20} aria-hidden="true" />
|
|
104
|
+
<NavBadge count={badge} />
|
|
105
|
+
</span>
|
|
61
106
|
{label}
|
|
62
107
|
</a>
|
|
63
108
|
))}
|
|
109
|
+
{more.length > 0 && (
|
|
110
|
+
<div ref={rootRef} className="flex-1 min-w-0 flex items-stretch relative">
|
|
111
|
+
<button
|
|
112
|
+
ref={buttonRef}
|
|
113
|
+
type="button"
|
|
114
|
+
aria-label={hiddenBadge ? "More, with unread items" : "More"}
|
|
115
|
+
aria-haspopup="menu"
|
|
116
|
+
aria-expanded={open}
|
|
117
|
+
onClick={() => setOpen((value) => !value)}
|
|
118
|
+
className={[TAB_CLASS, "w-full", open ? "text-primary-dark" : "text-text-muted"].join(" ")}
|
|
119
|
+
>
|
|
120
|
+
<span className="relative inline-flex">
|
|
121
|
+
<HiOutlineEllipsisHorizontal size={20} aria-hidden="true" />
|
|
122
|
+
{hiddenBadge && <span aria-hidden="true" data-testid="more-dot" className="absolute -top-0.5 -right-1 size-2 rounded-full bg-danger" />}
|
|
123
|
+
</span>
|
|
124
|
+
<span>More</span>
|
|
125
|
+
</button>
|
|
126
|
+
{open && (
|
|
127
|
+
<ul role="menu" aria-label="More" className="absolute bottom-full right-1 mb-2 min-w-48 max-w-[calc(100vw-1rem)] rounded-md border border-border bg-surface py-1 shadow-modal">
|
|
128
|
+
{more.map(({ id, href, label, icon: Icon, badge }) => (
|
|
129
|
+
<li key={id} role="none">
|
|
130
|
+
<a
|
|
131
|
+
role="menuitem"
|
|
132
|
+
href={href}
|
|
133
|
+
aria-label={badge ? navItemLabel(label, badge) : undefined}
|
|
134
|
+
onClick={() => setOpen(false)}
|
|
135
|
+
className="flex items-center gap-3 px-4 py-3 text-sm text-text hover:bg-surface-alt focus-visible:outline-2 focus-visible:outline-primary"
|
|
136
|
+
>
|
|
137
|
+
<span className="relative inline-flex">
|
|
138
|
+
<Icon size={20} aria-hidden="true" />
|
|
139
|
+
<NavBadge count={badge} />
|
|
140
|
+
</span>
|
|
141
|
+
{label}
|
|
142
|
+
</a>
|
|
143
|
+
</li>
|
|
144
|
+
))}
|
|
145
|
+
</ul>
|
|
146
|
+
)}
|
|
147
|
+
</div>
|
|
148
|
+
)}
|
|
64
149
|
</nav>
|
|
65
150
|
);
|
|
66
151
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React from "react";
|
|
6
|
+
|
|
7
|
+
/** The most a nav badge spells out; a larger count reads "99+". */
|
|
8
|
+
export const NAV_BADGE_MAX = 99;
|
|
9
|
+
|
|
10
|
+
/** A nav item's accessible name: its label, plus its unread count when it has one ("Mail, 3 unread"). */
|
|
11
|
+
export function navItemLabel(label: string, badge?: number): string {
|
|
12
|
+
return badge ? `${label}, ${badge} unread` : label;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The small round count chip on the corner of a nav icon (the Mail rail icon's unread mail). Drawn only for a count above zero; it is
|
|
17
|
+
* hidden from assistive technology because the link it sits on carries the count in its own name (`navItemLabel()`). Position it by
|
|
18
|
+
* placing it inside a `relative` wrapper around the icon.
|
|
19
|
+
*/
|
|
20
|
+
export default function NavBadge({ count }: { count?: number }) {
|
|
21
|
+
if (!count) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
return (
|
|
25
|
+
<span
|
|
26
|
+
aria-hidden="true"
|
|
27
|
+
data-testid="nav-badge"
|
|
28
|
+
className="absolute -top-1.5 -right-2 min-w-4 h-4 px-1 rounded-pill bg-danger text-white text-[10px] font-bold leading-4 text-center"
|
|
29
|
+
>
|
|
30
|
+
{count > NAV_BADGE_MAX ? `${NAV_BADGE_MAX}+` : count}
|
|
31
|
+
</span>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* The pictures of a quoted original, carried into the reply or forward. The original refers to its embedded images by `cid:` (or, when
|
|
7
|
+
* the server could not tie one to a part, by nothing but its `alt`), which means nothing in a message of its own - so each is replaced by a
|
|
8
|
+
* `data:` URI of the picture itself. The editor shows it as it is; `assembleDraft()` attaches it to the draft as an inline part when the
|
|
9
|
+
* draft is saved, which is how it reaches the recipient (the server composes a plain message from attachments, never from `data:` URIs).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** The largest picture quoted. A larger one is left out of the quote rather than bloating the editor and the request that saves it. */
|
|
13
|
+
export const MAX_QUOTED_IMAGE_BYTES = 2_000_000;
|
|
14
|
+
/** The most picture data a quote carries, in all (as `data:` URI characters). */
|
|
15
|
+
export const MAX_QUOTED_IMAGES_CHARS = 6_000_000;
|
|
16
|
+
|
|
17
|
+
export interface QuotedImageReference {
|
|
18
|
+
/** The token of a `cid:` reference, absent for an image with no source. */
|
|
19
|
+
cid?: string;
|
|
20
|
+
alt?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `html` with each of its embedded images (`cid:`, or no source at all) replaced by the `data:` URI `resolve` gives for it. An image
|
|
25
|
+
* `resolve` has no picture for is left as it is (the quote's sanitizer then drops it); images that already are `data:` URIs and remote
|
|
26
|
+
* ones are not asked about. `html` is returned as given when it has no such image, or where there is no DOM to parse it with.
|
|
27
|
+
*/
|
|
28
|
+
export async function embedQuotedImages(html: string, resolve: (reference: QuotedImageReference) => Promise<string | undefined>): Promise<string> {
|
|
29
|
+
if (typeof DOMParser === "undefined" || !/<img\b/i.test(html)) {
|
|
30
|
+
return html;
|
|
31
|
+
}
|
|
32
|
+
const doc = new DOMParser().parseFromString(html, "text/html");
|
|
33
|
+
let budget = MAX_QUOTED_IMAGES_CHARS;
|
|
34
|
+
let changed = false;
|
|
35
|
+
for (const image of Array.from(doc.querySelectorAll("img"))) {
|
|
36
|
+
const src = (image.getAttribute("src") ?? "").trim();
|
|
37
|
+
const cid = /^cid:/i.test(src);
|
|
38
|
+
if (!cid && src !== "") {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const uri = await resolve({ ...(cid ? { cid: src.slice(4) } : {}), alt: image.getAttribute("alt") ?? undefined });
|
|
42
|
+
if (uri !== undefined && uri.length <= budget) {
|
|
43
|
+
budget -= uri.length;
|
|
44
|
+
image.setAttribute("src", uri);
|
|
45
|
+
changed = true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return changed ? doc.body.innerHTML : html;
|
|
49
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* How a message's embedded images are found among its attachments, shared by the reading pane (which shows them) and the reply/forward
|
|
7
|
+
* quote (which carries them into the new message). Pure: nothing here touches the network or the DOM.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** The image types an embedded image may have: raster only. SVG is left out - a quote becomes part of a message this server sends. */
|
|
11
|
+
export const EMBEDDED_IMAGE_TYPES = /^image\/(?:png|jpe?g|gif|webp|avif|bmp)$/i;
|
|
12
|
+
|
|
13
|
+
/** What identifies an attachment as far as embedded images are concerned. */
|
|
14
|
+
export interface InlineCandidate {
|
|
15
|
+
uid: string;
|
|
16
|
+
filename: string;
|
|
17
|
+
mimeType: string;
|
|
18
|
+
contentId?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A `Content-ID` (or the token of a `cid:` URL) in the one form two references to the same part have in common: without the angle brackets of
|
|
23
|
+
* the header, percent-decoding undone (a `cid:` URL is a URL), and in lower case (mail clients disagree on the case they write it in).
|
|
24
|
+
*/
|
|
25
|
+
export function normalizeContentId(value: string): string {
|
|
26
|
+
let text = value.trim();
|
|
27
|
+
try {
|
|
28
|
+
text = decodeURIComponent(text);
|
|
29
|
+
} catch {
|
|
30
|
+
// Not percent-encoded after all (a `%` of its own): taken as written.
|
|
31
|
+
}
|
|
32
|
+
return text.trim().replace(/^<|>$/g, "").toLowerCase();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A file name as a person would compare two of them: form-encoding (`+` for a space) and case undone. */
|
|
36
|
+
function normalizeFilename(value: string): string {
|
|
37
|
+
let text = value.trim().replace(/\+/g, " ");
|
|
38
|
+
try {
|
|
39
|
+
text = decodeURIComponent(text);
|
|
40
|
+
} catch {
|
|
41
|
+
// Taken as written.
|
|
42
|
+
}
|
|
43
|
+
return text.toLowerCase();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The attachment an embedded image stands for: the one whose `Content-ID` is `cid` (see `normalizeContentId()`) or, for an image that
|
|
48
|
+
* names no part we can find - a message from an older version of this app attached the picture without a `Content-ID`, and the server
|
|
49
|
+
* then leaves the `<img>` with its `alt` only - the one image attachment whose file name is `alt`, when there is exactly one.
|
|
50
|
+
*/
|
|
51
|
+
export function findInlineAttachment<T extends InlineCandidate>(attachments: T[] | undefined, reference: { cid?: string; alt?: string }): T | undefined {
|
|
52
|
+
if (!attachments) {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
if (reference.cid !== undefined) {
|
|
56
|
+
const wanted = normalizeContentId(reference.cid);
|
|
57
|
+
const byId = attachments.find((attachment) => attachment.contentId && normalizeContentId(attachment.contentId) === wanted);
|
|
58
|
+
if (byId) {
|
|
59
|
+
return byId;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const alt = reference.alt?.trim();
|
|
63
|
+
if (!alt) {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
const wanted = normalizeFilename(alt);
|
|
67
|
+
const named = attachments.filter((attachment) => EMBEDDED_IMAGE_TYPES.test(attachment.mimeType) && normalizeFilename(attachment.filename) === wanted);
|
|
68
|
+
return named.length === 1 ? named[0] : undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The base64 of `bytes`. */
|
|
72
|
+
export function bytesToBase64(bytes: Uint8Array): string {
|
|
73
|
+
let binary = "";
|
|
74
|
+
for (let i = 0; i < bytes.length; i += 0x8000) {
|
|
75
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
76
|
+
}
|
|
77
|
+
return btoa(binary);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A `data:` URI of `bytes` as `contentType`. */
|
|
81
|
+
export function toDataUri(contentType: string, bytes: Uint8Array): string {
|
|
82
|
+
return `data:${contentType};base64,${bytesToBase64(bytes)}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const DATA_IMAGE = /^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=\s]*)$/i;
|
|
86
|
+
|
|
87
|
+
/** The type and bytes of a base64 `data:image/...` URI, or `undefined` for anything else. */
|
|
88
|
+
export function parseDataImage(uri: string): { contentType: string; bytes: Uint8Array } | undefined {
|
|
89
|
+
const match = DATA_IMAGE.exec(uri.trim());
|
|
90
|
+
if (!match) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
const binary = atob(match[2].replace(/\s/g, ""));
|
|
95
|
+
return { contentType: match[1].toLowerCase(), bytes: Uint8Array.from(binary, (char) => char.charCodeAt(0)) };
|
|
96
|
+
} catch {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A new `Content-ID` for an image about to be attached to a draft. */
|
|
102
|
+
export function newContentId(): string {
|
|
103
|
+
return `${crypto.randomUUID()}@inline.rapidmx`;
|
|
104
|
+
}
|
package/lib/mail/mailApi.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { ListParams, buildQuery } from "../util/apiQuery.js";
|
|
|
18
18
|
import { deviceTimeZone } from "../util/timeZone.js";
|
|
19
19
|
import type { EncryptionPreference, PublicKey } from "../crypto/keyvaultApi.js";
|
|
20
20
|
import { bytesToBinaryString } from "../crypto/mime.js";
|
|
21
|
+
import { newContentId, parseDataImage } from "./inlineImages.js";
|
|
21
22
|
import type { ResolvedPrincipal } from "./mailboxAccessApi.js";
|
|
22
23
|
|
|
23
24
|
export type { ListParams };
|
|
@@ -1005,6 +1006,8 @@ export interface Attachment {
|
|
|
1005
1006
|
mimeType: string;
|
|
1006
1007
|
sizeBytes: number;
|
|
1007
1008
|
isInline: boolean;
|
|
1009
|
+
/** The part's `Content-ID`, without angle brackets: what an inline image's `cid:` reference names. */
|
|
1010
|
+
contentId?: string;
|
|
1008
1011
|
}
|
|
1009
1012
|
|
|
1010
1013
|
/** Lists the attachments belonging to a single message. */
|
|
@@ -1012,6 +1015,11 @@ export function listAttachments(folderUid: string, messageUid: string, client?:
|
|
|
1012
1015
|
return withClient(client, `/mail/attachments?${buildQuery({ limit: 200 }, { folderUid, messageUid })}`);
|
|
1013
1016
|
}
|
|
1014
1017
|
|
|
1018
|
+
/** Removes an attachment (from a draft). The server re-derives the message's `hasAttachments` itself. */
|
|
1019
|
+
export function deleteAttachment(uid: string, version: number, client?: ApiClient): Promise<void> {
|
|
1020
|
+
return withClient(client, `/mail/attachments/${encodeURIComponent(uid)}?version=${version}`, { method: "DELETE" });
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1015
1023
|
/** The URL to download/display an attachment's binary content — not fetched via `apiFetch`, used directly as
|
|
1016
1024
|
* a link/image `href`/`src`. Honors `configureApiBaseUrl()` (see `apiUrl()`). */
|
|
1017
1025
|
export function attachmentContentUrl(uid: string): string {
|
|
@@ -1023,9 +1031,19 @@ export function attachmentContentUrl(uid: string): string {
|
|
|
1023
1031
|
* always forces `Content-Type: application/json`, which would corrupt binary content; this sends the file's
|
|
1024
1032
|
* own bytes/type directly instead, matching `BaseAttachmentRoute.upload`'s expectation of a raw request body.
|
|
1025
1033
|
*/
|
|
1026
|
-
export async function uploadAttachment(messageUid: string, file: File): Promise<Attachment> {
|
|
1027
|
-
|
|
1028
|
-
const
|
|
1034
|
+
export async function uploadAttachment(messageUid: string, file: File, options: { inline?: boolean; contentId?: string } = {}): Promise<Attachment> {
|
|
1035
|
+
// Percent-encoded, not form-encoded (`URLSearchParams` writes a space as `+`, which the server reads as a plus sign: "My+photo.png").
|
|
1036
|
+
const query: Record<string, string> = { messageUid, filename: file.name, mimeType: file.type || "application/octet-stream" };
|
|
1037
|
+
if (options.inline) {
|
|
1038
|
+
// An image shown in the text of the message: it goes out as an inline part, which the text refers to by this `Content-ID`
|
|
1039
|
+
// (`BaseMailComposeRoute.rewriteInlineImageSources()` turns the editor's preview URL into that `cid:` reference).
|
|
1040
|
+
query.isInline = "true";
|
|
1041
|
+
query.contentId = options.contentId ?? newContentId();
|
|
1042
|
+
}
|
|
1043
|
+
const params = Object.entries(query)
|
|
1044
|
+
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
|
|
1045
|
+
.join("&");
|
|
1046
|
+
const res = await fetch(apiUrl(`/mail/attachments/upload?${params}`), {
|
|
1029
1047
|
method: "POST",
|
|
1030
1048
|
credentials: "include",
|
|
1031
1049
|
headers: withCsrfHeader({ "Content-Type": file.type || "application/octet-stream" }),
|
|
@@ -1130,13 +1148,54 @@ export function emptyFolder(folderUid: string, client?: ApiClient): Promise<void
|
|
|
1130
1148
|
* — see `BaseMailComposeRoute` (this app's own compose-assembly glue, since `@rapidmx/restapi`'s `send()`
|
|
1131
1149
|
* itself does no MIME composition). Does not send the message.
|
|
1132
1150
|
*/
|
|
1133
|
-
export function assembleDraft(messageUid: string, input: AssembleDraftInput, client?: ApiClient): Promise<Message> {
|
|
1151
|
+
export async function assembleDraft(messageUid: string, input: AssembleDraftInput, client?: ApiClient): Promise<Message> {
|
|
1152
|
+
const html = await attachDataImages(messageUid, input.html);
|
|
1134
1153
|
return withClient(client, `/mail/compose/${encodeURIComponent(messageUid)}/assemble`, {
|
|
1135
1154
|
method: "POST",
|
|
1136
|
-
body: JSON.stringify(input),
|
|
1155
|
+
body: JSON.stringify({ ...input, html }),
|
|
1137
1156
|
});
|
|
1138
1157
|
}
|
|
1139
1158
|
|
|
1159
|
+
/** `data:` images already attached to a draft: its uid and the image, to the URL of the attachment it became. */
|
|
1160
|
+
const attachedDataImages = new Map<string, string>();
|
|
1161
|
+
|
|
1162
|
+
const DATA_IMAGE_TAG = /(<img\b[^>]*?\bsrc\s*=\s*)(["'])(data:image\/[^"']*)\2/gi;
|
|
1163
|
+
|
|
1164
|
+
/** Names a picture by its type, for the attachment it becomes. */
|
|
1165
|
+
function pictureName(contentType: string): string {
|
|
1166
|
+
return `image.${contentType.split("/")[1].replace(/\+.*/, "").replace("jpeg", "jpg")}`;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
/**
|
|
1170
|
+
* `html` with each `<img>` that embeds its picture as a `data:` URI (a quoted original's image, one pasted into the editor) pointed at an
|
|
1171
|
+
* attachment of the draft instead: the server drops `data:` images from the message it composes, and an attachment is what it makes an
|
|
1172
|
+
* inline part of. A picture is attached once per draft however often the draft is saved.
|
|
1173
|
+
*/
|
|
1174
|
+
async function attachDataImages(messageUid: string, html: string): Promise<string> {
|
|
1175
|
+
const found = Array.from(html.matchAll(DATA_IMAGE_TAG));
|
|
1176
|
+
if (found.length === 0) {
|
|
1177
|
+
return html;
|
|
1178
|
+
}
|
|
1179
|
+
const urls = new Map<string, string>();
|
|
1180
|
+
for (const [, , , uri] of found) {
|
|
1181
|
+
const key = `${messageUid}\n${uri}`;
|
|
1182
|
+
const known = attachedDataImages.get(key);
|
|
1183
|
+
if (known) {
|
|
1184
|
+
urls.set(uri, known);
|
|
1185
|
+
continue;
|
|
1186
|
+
}
|
|
1187
|
+
const image = parseDataImage(uri);
|
|
1188
|
+
if (!image) {
|
|
1189
|
+
continue;
|
|
1190
|
+
}
|
|
1191
|
+
const attachment = await uploadAttachment(messageUid, new File([image.bytes as BlobPart], pictureName(image.contentType), { type: image.contentType }), { inline: true });
|
|
1192
|
+
const url = attachmentContentUrl(attachment.uid);
|
|
1193
|
+
attachedDataImages.set(key, url);
|
|
1194
|
+
urls.set(uri, url);
|
|
1195
|
+
}
|
|
1196
|
+
return html.replace(DATA_IMAGE_TAG, (match, start: string, quote: string, uri: string) => (urls.has(uri) ? `${start}${quote}${urls.get(uri)}${quote}` : match));
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1140
1199
|
export interface AssembleDraftRawInput {
|
|
1141
1200
|
to: ComposeRecipientInput[];
|
|
1142
1201
|
cc?: ComposeRecipientInput[];
|
package/package.json
CHANGED