@rapidmx/web-client 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/README.md +3 -0
  2. package/apps/shared/components/admin/diagnostics/DiagnosticsManager.tsx +22 -10
  3. package/apps/shared/components/admin/diagnostics/InformationPanel.tsx +60 -0
  4. package/apps/shared/components/admin/diagnostics/SettingsTable.tsx +108 -0
  5. package/apps/shared/components/admin/diagnostics/diagnosticsApi.ts +25 -1
  6. package/apps/shared/components/calendar/CalendarSearchBox.tsx +83 -0
  7. package/apps/shared/components/calendar/EventMatchNav.tsx +72 -0
  8. package/apps/shared/components/calendar/EventModal.tsx +33 -12
  9. package/apps/shared/components/calendar/ListView.tsx +207 -0
  10. package/apps/shared/components/calendar/MonthView.tsx +14 -2
  11. package/apps/shared/components/calendar/SlideIn.tsx +47 -0
  12. package/apps/shared/components/calendar/SplitDayView.tsx +5 -0
  13. package/apps/shared/components/calendar/TimeGridView.tsx +23 -12
  14. package/apps/shared/components/calendar/activeOccurrence.ts +28 -0
  15. package/apps/shared/components/calendar/allDay.ts +16 -1
  16. package/apps/shared/components/calendar/calendarView.ts +41 -0
  17. package/apps/shared/components/layout/AppShell.tsx +13 -6
  18. package/apps/shared/components/mail/ConversationList.tsx +38 -5
  19. package/apps/shared/components/mail/ConversationThreadPane.tsx +15 -0
  20. package/apps/shared/components/mail/MailSelectionBar.tsx +117 -55
  21. package/apps/shared/components/mail/MessageDetailPane.tsx +3 -0
  22. package/apps/shared/components/mail/SwipeRow.tsx +26 -4
  23. package/apps/shared/components/mail/compose/ComposeContext.tsx +193 -35
  24. package/apps/shared/components/mail/compose/ComposeWindow.tsx +62 -11
  25. package/apps/shared/components/mail/compose/ComposeWindowPlaceholder.tsx +21 -10
  26. package/apps/shared/components/mail/compose/RichTextEditor.tsx +2 -1
  27. package/apps/shared/components/mail/compose/quotedBody.ts +45 -2
  28. package/apps/shared/components/mail/labelMenu.tsx +4 -0
  29. package/apps/shared/components/mail/layout/MailShell.tsx +9 -2
  30. package/apps/shared/components/mail/reading/MessageBody.tsx +1 -1
  31. package/apps/shared/components/mail/reading/bodyContent.ts +15 -13
  32. package/apps/shared/components/mail/reading/bodyHtml.ts +12 -7
  33. package/apps/shared/gestures/useEnterSlide.ts +1 -1
  34. package/apps/shared/gestures/useLongPress.ts +138 -0
  35. package/apps/shared/keyboard/keymap.ts +2 -0
  36. package/apps/shared/mail/rangeSelection.ts +45 -0
  37. package/apps/shared/mail/rowExit.ts +39 -0
  38. package/apps/www/calendar/index.tsx +251 -53
  39. package/apps/www/index.tsx +570 -358
  40. package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.d.ts +3 -3
  41. package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.js +15 -9
  42. package/dist/apps/shared/components/admin/diagnostics/InformationPanel.d.ts +15 -0
  43. package/dist/apps/shared/components/admin/diagnostics/InformationPanel.js +16 -0
  44. package/dist/apps/shared/components/admin/diagnostics/SettingsTable.d.ts +20 -0
  45. package/dist/apps/shared/components/admin/diagnostics/SettingsTable.js +31 -0
  46. package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.d.ts +20 -1
  47. package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.js +7 -0
  48. package/dist/apps/shared/components/calendar/CalendarSearchBox.d.ts +22 -0
  49. package/dist/apps/shared/components/calendar/CalendarSearchBox.js +24 -0
  50. package/dist/apps/shared/components/calendar/EventMatchNav.d.ts +17 -0
  51. package/dist/apps/shared/components/calendar/EventMatchNav.js +37 -0
  52. package/dist/apps/shared/components/calendar/EventModal.d.ts +17 -1
  53. package/dist/apps/shared/components/calendar/EventModal.js +13 -11
  54. package/dist/apps/shared/components/calendar/ListView.d.ts +30 -0
  55. package/dist/apps/shared/components/calendar/ListView.js +111 -0
  56. package/dist/apps/shared/components/calendar/MonthView.js +12 -3
  57. package/dist/apps/shared/components/calendar/SlideIn.d.ts +16 -0
  58. package/dist/apps/shared/components/calendar/SlideIn.js +32 -0
  59. package/dist/apps/shared/components/calendar/SplitDayView.js +5 -1
  60. package/dist/apps/shared/components/calendar/TimeGridView.js +11 -3
  61. package/dist/apps/shared/components/calendar/activeOccurrence.d.ts +15 -0
  62. package/dist/apps/shared/components/calendar/activeOccurrence.js +21 -0
  63. package/dist/apps/shared/components/calendar/allDay.d.ts +4 -0
  64. package/dist/apps/shared/components/calendar/allDay.js +14 -1
  65. package/dist/apps/shared/components/calendar/calendarView.d.ts +16 -0
  66. package/dist/apps/shared/components/calendar/calendarView.js +36 -0
  67. package/dist/apps/shared/components/layout/AppShell.js +8 -4
  68. package/dist/apps/shared/components/mail/ConversationList.d.ts +16 -3
  69. package/dist/apps/shared/components/mail/ConversationList.js +10 -2
  70. package/dist/apps/shared/components/mail/ConversationThreadPane.js +9 -1
  71. package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -4
  72. package/dist/apps/shared/components/mail/MessageDetailPane.js +3 -0
  73. package/dist/apps/shared/components/mail/SwipeRow.d.ts +5 -1
  74. package/dist/apps/shared/components/mail/SwipeRow.js +17 -3
  75. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +31 -0
  76. package/dist/apps/shared/components/mail/compose/ComposeContext.js +123 -11
  77. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +6 -1
  78. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +37 -11
  79. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.d.ts +5 -1
  80. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.js +10 -6
  81. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +2 -1
  82. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +3 -0
  83. package/dist/apps/shared/components/mail/compose/quotedBody.js +43 -1
  84. package/dist/apps/shared/components/mail/labelMenu.d.ts +3 -1
  85. package/dist/apps/shared/components/mail/labelMenu.js +2 -2
  86. package/dist/apps/shared/components/mail/layout/MailShell.js +7 -1
  87. package/dist/apps/shared/components/mail/reading/MessageBody.js +1 -1
  88. package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +5 -1
  89. package/dist/apps/shared/components/mail/reading/bodyContent.js +11 -11
  90. package/dist/apps/shared/components/mail/reading/bodyHtml.d.ts +3 -2
  91. package/dist/apps/shared/components/mail/reading/bodyHtml.js +8 -4
  92. package/dist/apps/shared/gestures/useEnterSlide.d.ts +2 -0
  93. package/dist/apps/shared/gestures/useEnterSlide.js +1 -1
  94. package/dist/apps/shared/gestures/useLongPress.d.ts +41 -0
  95. package/dist/apps/shared/gestures/useLongPress.js +112 -0
  96. package/dist/apps/shared/keyboard/keymap.d.ts +12 -0
  97. package/dist/apps/shared/keyboard/keymap.js +2 -0
  98. package/dist/apps/shared/mail/rangeSelection.d.ts +20 -0
  99. package/dist/apps/shared/mail/rangeSelection.js +34 -0
  100. package/dist/apps/shared/mail/rowExit.d.ts +12 -0
  101. package/dist/apps/shared/mail/rowExit.js +33 -0
  102. package/dist/apps/www/calendar/index.js +177 -18
  103. package/dist/apps/www/index.js +257 -73
  104. package/dist/lib/calendar/eventSearch.d.ts +42 -0
  105. package/dist/lib/calendar/eventSearch.js +152 -0
  106. package/dist/lib/components/navigation/BottomTabBar.d.ts +15 -3
  107. package/dist/lib/components/navigation/BottomTabBar.js +52 -15
  108. package/dist/lib/components/navigation/NavBadge.d.ts +13 -0
  109. package/dist/lib/components/navigation/NavBadge.js +18 -0
  110. package/dist/lib/mail/compose/quotedImages.d.ts +21 -0
  111. package/dist/lib/mail/compose/quotedImages.js +41 -0
  112. package/dist/lib/mail/inlineImages.d.ts +38 -0
  113. package/dist/lib/mail/inlineImages.js +90 -0
  114. package/dist/lib/mail/mailApi.d.ts +8 -1
  115. package/dist/lib/mail/mailApi.js +57 -5
  116. package/lib/calendar/eventSearch.ts +172 -0
  117. package/lib/components/navigation/BottomTabBar.tsx +107 -22
  118. package/lib/components/navigation/NavBadge.tsx +33 -0
  119. package/lib/mail/compose/quotedImages.ts +49 -0
  120. package/lib/mail/inlineImages.ts +104 -0
  121. package/lib/mail/mailApi.ts +64 -5
  122. package/package.json +1 -1
  123. package/apps/shared/components/admin/diagnostics/VersionsPanel.tsx +0 -36
  124. package/dist/apps/shared/components/admin/diagnostics/VersionsPanel.d.ts +0 -10
  125. package/dist/apps/shared/components/admin/diagnostics/VersionsPanel.js +0 -11
@@ -0,0 +1,152 @@
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
+ import { addYears } from "date-fns";
12
+ import { expandOccurrences } from "./recurrence.js";
13
+ /** How far either side of today a recurring event's occurrences are searched. */
14
+ export const SEARCH_WINDOW_YEARS = 5;
15
+ /** The most occurrences one recurring event contributes (the ones nearest today), so a daily series cannot flood the results. */
16
+ export const SEARCH_MAX_OCCURRENCES_PER_SERIES = 500;
17
+ /** Lower-cases `text` and strips its accents, so "cafe" finds "Café". */
18
+ function fold(text) {
19
+ return text.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
20
+ }
21
+ /** The words of `query`, folded; an event must match every one of them. Empty for a blank query. */
22
+ export function searchTerms(query) {
23
+ return fold(query)
24
+ .split(/\s+/)
25
+ .filter((term) => term !== "");
26
+ }
27
+ /** The text of `event` a search looks in: title, location, description (plain text), organizer and guests (name and address). */
28
+ function searchableText(event) {
29
+ const parts = [
30
+ event.title,
31
+ event.location,
32
+ event.description,
33
+ event.organizer?.displayName,
34
+ event.organizer?.address,
35
+ ];
36
+ for (const attendee of event.attendees ?? []) {
37
+ parts.push(attendee.displayName, attendee.address);
38
+ }
39
+ return fold(parts.filter(Boolean).join("\n"));
40
+ }
41
+ /**
42
+ * Whether `event` has every one of `terms` (from `searchTerms()`) somewhere in its readable text. A busy block (`redacted`: a private event on a
43
+ * shared calendar) never matches - its title is a placeholder, not what the event says.
44
+ */
45
+ export function eventMatchesTerms(event, terms) {
46
+ if (terms.length === 0 || event.redacted) {
47
+ return false;
48
+ }
49
+ const text = searchableText(event);
50
+ return terms.every((term) => text.includes(term));
51
+ }
52
+ function byStart(a, b) {
53
+ return new Date(a.startDate).getTime() - new Date(b.startDate).getTime() || a.title.localeCompare(b.title);
54
+ }
55
+ /**
56
+ * The occurrences of the events of `events` that match `query`, in chronological order. A single event is one occurrence wherever it falls; a
57
+ * recurring one contributes those within `SEARCH_WINDOW_YEARS` of `now`, the `SEARCH_MAX_OCCURRENCES_PER_SERIES` nearest to it at most.
58
+ */
59
+ export function searchOccurrences(events, query, now = new Date()) {
60
+ const terms = searchTerms(query);
61
+ return occurrencesOf(events, (event) => eventMatchesTerms(event, terms), now);
62
+ }
63
+ /** Every occurrence of every event, in chronological order, bounded the same way a search is - the calendar's List view shows these. */
64
+ export function allOccurrences(events, now = new Date()) {
65
+ return occurrencesOf(events, () => true, now);
66
+ }
67
+ /** The chronological occurrences of the `events` that `include` accepts (see `searchOccurrences()` for how a series is bounded). */
68
+ function occurrencesOf(events, include, now) {
69
+ const from = addYears(now, -SEARCH_WINDOW_YEARS);
70
+ const to = addYears(now, SEARCH_WINDOW_YEARS);
71
+ const found = [];
72
+ for (const event of events) {
73
+ if (!include(event)) {
74
+ continue;
75
+ }
76
+ if (!event.recurrenceRule) {
77
+ found.push(...expandOccurrences(event, new Date(new Date(event.startDate).getTime() - 1), new Date(new Date(event.endDate).getTime() + 1)));
78
+ continue;
79
+ }
80
+ const occurrences = expandOccurrences(event, from, to);
81
+ if (occurrences.length > SEARCH_MAX_OCCURRENCES_PER_SERIES) {
82
+ const distance = (o) => Math.abs(new Date(o.startDate).getTime() - now.getTime());
83
+ occurrences.sort((a, b) => distance(a) - distance(b)).length = SEARCH_MAX_OCCURRENCES_PER_SERIES;
84
+ }
85
+ found.push(...occurrences);
86
+ }
87
+ return found.sort(byStart);
88
+ }
89
+ /** The `occurrences` that overlap `[rangeStart, rangeEnd]` - what `expandOccurrences()` keeps, so a view shows exactly these. */
90
+ export function occurrencesInRange(occurrences, rangeStart, rangeEnd) {
91
+ return occurrences.filter((o) => new Date(o.startDate) < rangeEnd && new Date(o.endDate) > rangeStart);
92
+ }
93
+ /**
94
+ * Which of the chronological `matches` to show first for a search: the first one inside the view (`[rangeStart, rangeEnd]`) when there is one,
95
+ * else the first that ends after `from` (the day being viewed), else the last (the nearest before). `-1` when there are no matches.
96
+ */
97
+ export function initialMatchIndex(matches, rangeStart, rangeEnd, from) {
98
+ if (matches.length === 0) {
99
+ return -1;
100
+ }
101
+ const inView = matches.findIndex((o) => new Date(o.startDate) < rangeEnd && new Date(o.endDate) > rangeStart);
102
+ if (inView >= 0) {
103
+ return inView;
104
+ }
105
+ const after = matches.findIndex((o) => new Date(o.endDate) > from);
106
+ return after >= 0 ? after : matches.length - 1;
107
+ }
108
+ /**
109
+ * The index of the occurrence one step from the current one in `sequence` (chronological), or `-1` for none. The current occurrence is found by
110
+ * `currentKey`; when it is not in the sequence (it was deleted or changed under an open card) the step is from where it started
111
+ * (`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
112
+ * wraps round when `wrap`, else there is nothing there (`-1`).
113
+ */
114
+ export function stepSequenceIndex(sequence, currentKey, currentStart, direction, wrap) {
115
+ const count = sequence.length;
116
+ if (count === 0) {
117
+ return -1;
118
+ }
119
+ const edge = direction === 1 ? 0 : count - 1;
120
+ const index = sequence.findIndex((o) => o.occurrenceKey === currentKey);
121
+ if (index >= 0) {
122
+ const target = index + direction;
123
+ if (target >= 0 && target < count) {
124
+ return target;
125
+ }
126
+ return wrap ? edge : -1;
127
+ }
128
+ if (!currentStart) {
129
+ return edge;
130
+ }
131
+ const from = currentStart.getTime();
132
+ let target = -1;
133
+ if (direction === 1) {
134
+ target = sequence.findIndex((o) => new Date(o.startDate).getTime() > from);
135
+ }
136
+ else {
137
+ for (let i = count - 1; i >= 0 && target < 0; i--) {
138
+ target = new Date(sequence[i].startDate).getTime() < from ? i : -1;
139
+ }
140
+ }
141
+ return target >= 0 ? target : wrap ? edge : -1;
142
+ }
143
+ /** The index one step from `index` (`-1`: none is current) in `direction` through `count` matches, wrapping round at either end. `-1` with none. */
144
+ export function stepMatchIndex(index, count, direction) {
145
+ if (count === 0) {
146
+ return -1;
147
+ }
148
+ if (index < 0) {
149
+ return direction === 1 ? 0 : count - 1;
150
+ }
151
+ return (index + direction + count) % count;
152
+ }
@@ -8,11 +8,24 @@ export interface NavItem {
8
8
  href: string;
9
9
  label: string;
10
10
  icon: IconType;
11
+ /** 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. */
12
+ badge?: number;
11
13
  }
12
14
  export interface BottomTabBarProps {
13
15
  apps: NavItem[];
14
16
  active: string;
15
17
  }
18
+ /** The most entries the bar shows at once: any more and the last slot becomes "More", holding the rest. */
19
+ export declare const MAX_TABS = 5;
20
+ /**
21
+ * 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
22
+ * `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
23
+ * can see where they are.
24
+ */
25
+ export declare function splitTabs(apps: NavItem[], active: string): {
26
+ shown: NavItem[];
27
+ more: NavItem[];
28
+ };
16
29
  /**
17
30
  * The mobile counterpart to an icon rail (`AppShell`'s Mail/Calendar/Contacts/Tasks, or `AdminShell`'s
18
31
  * Mailboxes/Quarantine/Ingest Queue) — same data, same full-page-nav `<a href>` links (this framework
@@ -20,8 +33,7 @@ export interface BottomTabBarProps {
20
33
  * which doesn't fit below the `md` breakpoint. Only ever rendered alongside that rail (`md:hidden` here,
21
34
  * `hidden md:flex` there) — never both hidden or both visible at once.
22
35
  *
23
- * A few items share the width equally; a long list (the admin console has ten or so) doesn't fit at a readable size, so each
24
- * item keeps a minimum width, its label wraps onto a second line rather than running into its neighbour's, and the bar scrolls
25
- * sideways - opening with the active item in view.
36
+ * 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
37
+ * of the others (see `splitTabs()`). The menu closes on Escape, on a click elsewhere and on choosing an entry.
26
38
  */
27
39
  export default function BottomTabBar({ apps, active }: BottomTabBarProps): React.JSX.Element;
@@ -3,7 +3,27 @@ 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 { useEffect, useRef } from "react";
6
+ import { useEffect, useRef, useState } from "react";
7
+ import { HiOutlineEllipsisHorizontal } from "react-icons/hi2";
8
+ import NavBadge, { navItemLabel } from "./NavBadge.js";
9
+ /** The most entries the bar shows at once: any more and the last slot becomes "More", holding the rest. */
10
+ export const MAX_TABS = 5;
11
+ /**
12
+ * 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
13
+ * `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
14
+ * can see where they are.
15
+ */
16
+ export function splitTabs(apps, active) {
17
+ if (apps.length <= MAX_TABS) {
18
+ return { shown: apps, more: [] };
19
+ }
20
+ const slots = MAX_TABS - 1;
21
+ const activeItem = apps.find((app) => app.id === active);
22
+ const hidden = !!activeItem && apps.indexOf(activeItem) >= slots;
23
+ const shown = hidden ? [...apps.slice(0, slots - 1), activeItem] : apps.slice(0, slots);
24
+ return { shown, more: apps.filter((app) => !shown.includes(app)) };
25
+ }
26
+ 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";
7
27
  /**
8
28
  * The mobile counterpart to an icon rail (`AppShell`'s Mail/Calendar/Contacts/Tasks, or `AdminShell`'s
9
29
  * Mailboxes/Quarantine/Ingest Queue) — same data, same full-page-nav `<a href>` links (this framework
@@ -11,22 +31,39 @@ import { useEffect, useRef } from "react";
11
31
  * which doesn't fit below the `md` breakpoint. Only ever rendered alongside that rail (`md:hidden` here,
12
32
  * `hidden md:flex` there) — never both hidden or both visible at once.
13
33
  *
14
- * A few items share the width equally; a long list (the admin console has ten or so) doesn't fit at a readable size, so each
15
- * item keeps a minimum width, its label wraps onto a second line rather than running into its neighbour's, and the bar scrolls
16
- * sideways - opening with the active item in view.
34
+ * 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
35
+ * of the others (see `splitTabs()`). The menu closes on Escape, on a click elsewhere and on choosing an entry.
17
36
  */
18
37
  export default function BottomTabBar({ apps, active }) {
19
- const activeRef = useRef(null);
38
+ const { shown, more } = splitTabs(apps, active);
39
+ const [open, setOpen] = useState(false);
40
+ const rootRef = useRef(null);
41
+ const buttonRef = useRef(null);
20
42
  useEffect(() => {
21
- const item = activeRef.current;
22
- const bar = item?.parentElement;
23
- if (item && bar && bar.scrollWidth > bar.clientWidth) {
24
- // Set directly rather than `scrollIntoView()`, which would scroll the page as well.
25
- bar.scrollLeft = item.offsetLeft - (bar.clientWidth - item.offsetWidth) / 2;
43
+ if (!open) {
44
+ return;
45
+ }
46
+ function handlePointer(event) {
47
+ if (!rootRef.current?.contains(event.target)) {
48
+ setOpen(false);
49
+ }
50
+ }
51
+ function handleKey(event) {
52
+ if (event.key === "Escape") {
53
+ setOpen(false);
54
+ buttonRef.current?.focus();
55
+ }
26
56
  }
27
- }, [active]);
28
- return (_jsx("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 overflow-x-auto overflow-y-hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", children: apps.map(({ id, href, label, icon: Icon }) => (_jsxs("a", { href: href, ref: id === active ? activeRef : undefined, "aria-current": id === active ? "page" : undefined, className: [
29
- "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",
30
- id === active ? "text-primary-dark" : "text-text-muted",
31
- ].join(" "), children: [_jsx(Icon, { size: 20, "aria-hidden": "true" }), label] }, id))) }));
57
+ document.addEventListener("mousedown", handlePointer);
58
+ document.addEventListener("touchstart", handlePointer);
59
+ document.addEventListener("keydown", handleKey);
60
+ return () => {
61
+ document.removeEventListener("mousedown", handlePointer);
62
+ document.removeEventListener("touchstart", handlePointer);
63
+ document.removeEventListener("keydown", handleKey);
64
+ };
65
+ }, [open]);
66
+ // The unread count of an entry that is behind "More" is still worth a dot on it.
67
+ const hiddenBadge = more.some((app) => (app.badge ?? 0) > 0);
68
+ return (_jsxs("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", children: [shown.map(({ id, href, label, icon: Icon, badge }) => (_jsxs("a", { href: href, "aria-label": badge ? navItemLabel(label, badge) : undefined, "aria-current": id === active ? "page" : undefined, className: [TAB_CLASS, id === active ? "text-primary-dark" : "text-text-muted"].join(" "), children: [_jsxs("span", { className: "relative inline-flex", children: [_jsx(Icon, { size: 20, "aria-hidden": "true" }), _jsx(NavBadge, { count: badge })] }), label] }, id))), more.length > 0 && (_jsxs("div", { ref: rootRef, className: "flex-1 min-w-0 flex items-stretch relative", children: [_jsxs("button", { ref: buttonRef, type: "button", "aria-label": hiddenBadge ? "More, with unread items" : "More", "aria-haspopup": "menu", "aria-expanded": open, onClick: () => setOpen((value) => !value), className: [TAB_CLASS, "w-full", open ? "text-primary-dark" : "text-text-muted"].join(" "), children: [_jsxs("span", { className: "relative inline-flex", children: [_jsx(HiOutlineEllipsisHorizontal, { size: 20, "aria-hidden": "true" }), hiddenBadge && _jsx("span", { "aria-hidden": "true", "data-testid": "more-dot", className: "absolute -top-0.5 -right-1 size-2 rounded-full bg-danger" })] }), _jsx("span", { children: "More" })] }), open && (_jsx("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", children: more.map(({ id, href, label, icon: Icon, badge }) => (_jsx("li", { role: "none", children: _jsxs("a", { role: "menuitem", href: href, "aria-label": badge ? navItemLabel(label, badge) : undefined, onClick: () => setOpen(false), 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", children: [_jsxs("span", { className: "relative inline-flex", children: [_jsx(Icon, { size: 20, "aria-hidden": "true" }), _jsx(NavBadge, { count: badge })] }), label] }) }, id))) }))] }))] }));
32
69
  }
@@ -0,0 +1,13 @@
1
+ import React from "react";
2
+ /** The most a nav badge spells out; a larger count reads "99+". */
3
+ export declare const NAV_BADGE_MAX = 99;
4
+ /** A nav item's accessible name: its label, plus its unread count when it has one ("Mail, 3 unread"). */
5
+ export declare function navItemLabel(label: string, badge?: number): string;
6
+ /**
7
+ * 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
8
+ * hidden from assistive technology because the link it sits on carries the count in its own name (`navItemLabel()`). Position it by
9
+ * placing it inside a `relative` wrapper around the icon.
10
+ */
11
+ export default function NavBadge({ count }: {
12
+ count?: number;
13
+ }): React.JSX.Element | null;
@@ -0,0 +1,18 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /** The most a nav badge spells out; a larger count reads "99+". */
3
+ export const NAV_BADGE_MAX = 99;
4
+ /** A nav item's accessible name: its label, plus its unread count when it has one ("Mail, 3 unread"). */
5
+ export function navItemLabel(label, badge) {
6
+ return badge ? `${label}, ${badge} unread` : label;
7
+ }
8
+ /**
9
+ * 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
10
+ * hidden from assistive technology because the link it sits on carries the count in its own name (`navItemLabel()`). Position it by
11
+ * placing it inside a `relative` wrapper around the icon.
12
+ */
13
+ export default function NavBadge({ count }) {
14
+ if (!count) {
15
+ return null;
16
+ }
17
+ return (_jsx("span", { "aria-hidden": "true", "data-testid": "nav-badge", 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", children: count > NAV_BADGE_MAX ? `${NAV_BADGE_MAX}+` : count }));
18
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The pictures of a quoted original, carried into the reply or forward. The original refers to its embedded images by `cid:` (or, when
3
+ * 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
4
+ * `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
5
+ * draft is saved, which is how it reaches the recipient (the server composes a plain message from attachments, never from `data:` URIs).
6
+ */
7
+ /** The largest picture quoted. A larger one is left out of the quote rather than bloating the editor and the request that saves it. */
8
+ export declare const MAX_QUOTED_IMAGE_BYTES = 2000000;
9
+ /** The most picture data a quote carries, in all (as `data:` URI characters). */
10
+ export declare const MAX_QUOTED_IMAGES_CHARS = 6000000;
11
+ export interface QuotedImageReference {
12
+ /** The token of a `cid:` reference, absent for an image with no source. */
13
+ cid?: string;
14
+ alt?: string;
15
+ }
16
+ /**
17
+ * `html` with each of its embedded images (`cid:`, or no source at all) replaced by the `data:` URI `resolve` gives for it. An image
18
+ * `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
19
+ * 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.
20
+ */
21
+ export declare function embedQuotedImages(html: string, resolve: (reference: QuotedImageReference) => Promise<string | undefined>): Promise<string>;
@@ -0,0 +1,41 @@
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
+ /** The largest picture quoted. A larger one is left out of the quote rather than bloating the editor and the request that saves it. */
12
+ export const MAX_QUOTED_IMAGE_BYTES = 2000000;
13
+ /** The most picture data a quote carries, in all (as `data:` URI characters). */
14
+ export const MAX_QUOTED_IMAGES_CHARS = 6000000;
15
+ /**
16
+ * `html` with each of its embedded images (`cid:`, or no source at all) replaced by the `data:` URI `resolve` gives for it. An image
17
+ * `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
18
+ * 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.
19
+ */
20
+ export async function embedQuotedImages(html, resolve) {
21
+ if (typeof DOMParser === "undefined" || !/<img\b/i.test(html)) {
22
+ return html;
23
+ }
24
+ const doc = new DOMParser().parseFromString(html, "text/html");
25
+ let budget = MAX_QUOTED_IMAGES_CHARS;
26
+ let changed = false;
27
+ for (const image of Array.from(doc.querySelectorAll("img"))) {
28
+ const src = (image.getAttribute("src") ?? "").trim();
29
+ const cid = /^cid:/i.test(src);
30
+ if (!cid && src !== "") {
31
+ continue;
32
+ }
33
+ const uri = await resolve({ ...(cid ? { cid: src.slice(4) } : {}), alt: image.getAttribute("alt") ?? undefined });
34
+ if (uri !== undefined && uri.length <= budget) {
35
+ budget -= uri.length;
36
+ image.setAttribute("src", uri);
37
+ changed = true;
38
+ }
39
+ }
40
+ return changed ? doc.body.innerHTML : html;
41
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * How a message's embedded images are found among its attachments, shared by the reading pane (which shows them) and the reply/forward
3
+ * quote (which carries them into the new message). Pure: nothing here touches the network or the DOM.
4
+ */
5
+ /** The image types an embedded image may have: raster only. SVG is left out - a quote becomes part of a message this server sends. */
6
+ export declare const EMBEDDED_IMAGE_TYPES: RegExp;
7
+ /** What identifies an attachment as far as embedded images are concerned. */
8
+ export interface InlineCandidate {
9
+ uid: string;
10
+ filename: string;
11
+ mimeType: string;
12
+ contentId?: string;
13
+ }
14
+ /**
15
+ * 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
16
+ * 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).
17
+ */
18
+ export declare function normalizeContentId(value: string): string;
19
+ /**
20
+ * The attachment an embedded image stands for: the one whose `Content-ID` is `cid` (see `normalizeContentId()`) or, for an image that
21
+ * 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
22
+ * then leaves the `<img>` with its `alt` only - the one image attachment whose file name is `alt`, when there is exactly one.
23
+ */
24
+ export declare function findInlineAttachment<T extends InlineCandidate>(attachments: T[] | undefined, reference: {
25
+ cid?: string;
26
+ alt?: string;
27
+ }): T | undefined;
28
+ /** The base64 of `bytes`. */
29
+ export declare function bytesToBase64(bytes: Uint8Array): string;
30
+ /** A `data:` URI of `bytes` as `contentType`. */
31
+ export declare function toDataUri(contentType: string, bytes: Uint8Array): string;
32
+ /** The type and bytes of a base64 `data:image/...` URI, or `undefined` for anything else. */
33
+ export declare function parseDataImage(uri: string): {
34
+ contentType: string;
35
+ bytes: Uint8Array;
36
+ } | undefined;
37
+ /** A new `Content-ID` for an image about to be attached to a draft. */
38
+ export declare function newContentId(): string;
@@ -0,0 +1,90 @@
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
+ /** The image types an embedded image may have: raster only. SVG is left out - a quote becomes part of a message this server sends. */
10
+ export const EMBEDDED_IMAGE_TYPES = /^image\/(?:png|jpe?g|gif|webp|avif|bmp)$/i;
11
+ /**
12
+ * 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
13
+ * 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).
14
+ */
15
+ export function normalizeContentId(value) {
16
+ let text = value.trim();
17
+ try {
18
+ text = decodeURIComponent(text);
19
+ }
20
+ catch {
21
+ // Not percent-encoded after all (a `%` of its own): taken as written.
22
+ }
23
+ return text.trim().replace(/^<|>$/g, "").toLowerCase();
24
+ }
25
+ /** A file name as a person would compare two of them: form-encoding (`+` for a space) and case undone. */
26
+ function normalizeFilename(value) {
27
+ let text = value.trim().replace(/\+/g, " ");
28
+ try {
29
+ text = decodeURIComponent(text);
30
+ }
31
+ catch {
32
+ // Taken as written.
33
+ }
34
+ return text.toLowerCase();
35
+ }
36
+ /**
37
+ * The attachment an embedded image stands for: the one whose `Content-ID` is `cid` (see `normalizeContentId()`) or, for an image that
38
+ * 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
39
+ * then leaves the `<img>` with its `alt` only - the one image attachment whose file name is `alt`, when there is exactly one.
40
+ */
41
+ export function findInlineAttachment(attachments, reference) {
42
+ if (!attachments) {
43
+ return undefined;
44
+ }
45
+ if (reference.cid !== undefined) {
46
+ const wanted = normalizeContentId(reference.cid);
47
+ const byId = attachments.find((attachment) => attachment.contentId && normalizeContentId(attachment.contentId) === wanted);
48
+ if (byId) {
49
+ return byId;
50
+ }
51
+ }
52
+ const alt = reference.alt?.trim();
53
+ if (!alt) {
54
+ return undefined;
55
+ }
56
+ const wanted = normalizeFilename(alt);
57
+ const named = attachments.filter((attachment) => EMBEDDED_IMAGE_TYPES.test(attachment.mimeType) && normalizeFilename(attachment.filename) === wanted);
58
+ return named.length === 1 ? named[0] : undefined;
59
+ }
60
+ /** The base64 of `bytes`. */
61
+ export function bytesToBase64(bytes) {
62
+ let binary = "";
63
+ for (let i = 0; i < bytes.length; i += 0x8000) {
64
+ binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
65
+ }
66
+ return btoa(binary);
67
+ }
68
+ /** A `data:` URI of `bytes` as `contentType`. */
69
+ export function toDataUri(contentType, bytes) {
70
+ return `data:${contentType};base64,${bytesToBase64(bytes)}`;
71
+ }
72
+ const DATA_IMAGE = /^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=\s]*)$/i;
73
+ /** The type and bytes of a base64 `data:image/...` URI, or `undefined` for anything else. */
74
+ export function parseDataImage(uri) {
75
+ const match = DATA_IMAGE.exec(uri.trim());
76
+ if (!match) {
77
+ return undefined;
78
+ }
79
+ try {
80
+ const binary = atob(match[2].replace(/\s/g, ""));
81
+ return { contentType: match[1].toLowerCase(), bytes: Uint8Array.from(binary, (char) => char.charCodeAt(0)) };
82
+ }
83
+ catch {
84
+ return undefined;
85
+ }
86
+ }
87
+ /** A new `Content-ID` for an image about to be attached to a draft. */
88
+ export function newContentId() {
89
+ return `${crypto.randomUUID()}@inline.rapidmx`;
90
+ }
@@ -679,9 +679,13 @@ export interface Attachment {
679
679
  mimeType: string;
680
680
  sizeBytes: number;
681
681
  isInline: boolean;
682
+ /** The part's `Content-ID`, without angle brackets: what an inline image's `cid:` reference names. */
683
+ contentId?: string;
682
684
  }
683
685
  /** Lists the attachments belonging to a single message. */
684
686
  export declare function listAttachments(folderUid: string, messageUid: string, client?: ApiClient): Promise<Attachment[]>;
687
+ /** Removes an attachment (from a draft). The server re-derives the message's `hasAttachments` itself. */
688
+ export declare function deleteAttachment(uid: string, version: number, client?: ApiClient): Promise<void>;
685
689
  /** The URL to download/display an attachment's binary content — not fetched via `apiFetch`, used directly as
686
690
  * a link/image `href`/`src`. Honors `configureApiBaseUrl()` (see `apiUrl()`). */
687
691
  export declare function attachmentContentUrl(uid: string): string;
@@ -690,7 +694,10 @@ export declare function attachmentContentUrl(uid: string): string;
690
694
  * always forces `Content-Type: application/json`, which would corrupt binary content; this sends the file's
691
695
  * own bytes/type directly instead, matching `BaseAttachmentRoute.upload`'s expectation of a raw request body.
692
696
  */
693
- export declare function uploadAttachment(messageUid: string, file: File): Promise<Attachment>;
697
+ export declare function uploadAttachment(messageUid: string, file: File, options?: {
698
+ inline?: boolean;
699
+ contentId?: string;
700
+ }): Promise<Attachment>;
694
701
  export interface ComposeRecipientInput {
695
702
  address: string;
696
703
  displayName?: string;
@@ -16,6 +16,7 @@ import { ApiRequestError, apiFetch, apiUrl, authApiFetch, withClient, withCsrfHe
16
16
  import { buildQuery } from "../util/apiQuery.js";
17
17
  import { deviceTimeZone } from "../util/timeZone.js";
18
18
  import { bytesToBinaryString } from "../crypto/mime.js";
19
+ import { newContentId, parseDataImage } from "./inlineImages.js";
19
20
  /** Whether `mailbox` is one shared with the caller rather than their own: a delegate grant on somebody's mailbox, or a mailbox with no
20
21
  * owner at all (a shared/org mailbox they were granted). */
21
22
  export function isSharedWithMe(mailbox) {
@@ -404,6 +405,10 @@ export function declineReceipt(uid, type, client) {
404
405
  export function listAttachments(folderUid, messageUid, client) {
405
406
  return withClient(client, `/mail/attachments?${buildQuery({ limit: 200 }, { folderUid, messageUid })}`);
406
407
  }
408
+ /** Removes an attachment (from a draft). The server re-derives the message's `hasAttachments` itself. */
409
+ export function deleteAttachment(uid, version, client) {
410
+ return withClient(client, `/mail/attachments/${encodeURIComponent(uid)}?version=${version}`, { method: "DELETE" });
411
+ }
407
412
  /** The URL to download/display an attachment's binary content — not fetched via `apiFetch`, used directly as
408
413
  * a link/image `href`/`src`. Honors `configureApiBaseUrl()` (see `apiUrl()`). */
409
414
  export function attachmentContentUrl(uid) {
@@ -414,9 +419,19 @@ export function attachmentContentUrl(uid) {
414
419
  * always forces `Content-Type: application/json`, which would corrupt binary content; this sends the file's
415
420
  * own bytes/type directly instead, matching `BaseAttachmentRoute.upload`'s expectation of a raw request body.
416
421
  */
417
- export async function uploadAttachment(messageUid, file) {
418
- const params = new URLSearchParams({ messageUid, filename: file.name, mimeType: file.type || "application/octet-stream" });
419
- const res = await fetch(apiUrl(`/mail/attachments/upload?${params.toString()}`), {
422
+ export async function uploadAttachment(messageUid, file, options = {}) {
423
+ // Percent-encoded, not form-encoded (`URLSearchParams` writes a space as `+`, which the server reads as a plus sign: "My+photo.png").
424
+ const query = { messageUid, filename: file.name, mimeType: file.type || "application/octet-stream" };
425
+ if (options.inline) {
426
+ // 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`
427
+ // (`BaseMailComposeRoute.rewriteInlineImageSources()` turns the editor's preview URL into that `cid:` reference).
428
+ query.isInline = "true";
429
+ query.contentId = options.contentId ?? newContentId();
430
+ }
431
+ const params = Object.entries(query)
432
+ .map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
433
+ .join("&");
434
+ const res = await fetch(apiUrl(`/mail/attachments/upload?${params}`), {
420
435
  method: "POST",
421
436
  credentials: "include",
422
437
  headers: withCsrfHeader({ "Content-Type": file.type || "application/octet-stream" }),
@@ -490,12 +505,49 @@ export function emptyFolder(folderUid, client) {
490
505
  * — see `BaseMailComposeRoute` (this app's own compose-assembly glue, since `@rapidmx/restapi`'s `send()`
491
506
  * itself does no MIME composition). Does not send the message.
492
507
  */
493
- export function assembleDraft(messageUid, input, client) {
508
+ export async function assembleDraft(messageUid, input, client) {
509
+ const html = await attachDataImages(messageUid, input.html);
494
510
  return withClient(client, `/mail/compose/${encodeURIComponent(messageUid)}/assemble`, {
495
511
  method: "POST",
496
- body: JSON.stringify(input),
512
+ body: JSON.stringify({ ...input, html }),
497
513
  });
498
514
  }
515
+ /** `data:` images already attached to a draft: its uid and the image, to the URL of the attachment it became. */
516
+ const attachedDataImages = new Map();
517
+ const DATA_IMAGE_TAG = /(<img\b[^>]*?\bsrc\s*=\s*)(["'])(data:image\/[^"']*)\2/gi;
518
+ /** Names a picture by its type, for the attachment it becomes. */
519
+ function pictureName(contentType) {
520
+ return `image.${contentType.split("/")[1].replace(/\+.*/, "").replace("jpeg", "jpg")}`;
521
+ }
522
+ /**
523
+ * `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
524
+ * attachment of the draft instead: the server drops `data:` images from the message it composes, and an attachment is what it makes an
525
+ * inline part of. A picture is attached once per draft however often the draft is saved.
526
+ */
527
+ async function attachDataImages(messageUid, html) {
528
+ const found = Array.from(html.matchAll(DATA_IMAGE_TAG));
529
+ if (found.length === 0) {
530
+ return html;
531
+ }
532
+ const urls = new Map();
533
+ for (const [, , , uri] of found) {
534
+ const key = `${messageUid}\n${uri}`;
535
+ const known = attachedDataImages.get(key);
536
+ if (known) {
537
+ urls.set(uri, known);
538
+ continue;
539
+ }
540
+ const image = parseDataImage(uri);
541
+ if (!image) {
542
+ continue;
543
+ }
544
+ const attachment = await uploadAttachment(messageUid, new File([image.bytes], pictureName(image.contentType), { type: image.contentType }), { inline: true });
545
+ const url = attachmentContentUrl(attachment.uid);
546
+ attachedDataImages.set(key, url);
547
+ urls.set(uri, url);
548
+ }
549
+ return html.replace(DATA_IMAGE_TAG, (match, start, quote, uri) => (urls.has(uri) ? `${start}${quote}${urls.get(uri)}${quote}` : match));
550
+ }
499
551
  /**
500
552
  * Stores an already-signed/encrypted draft's raw MIME source as its `bodyBlobKey`, completely
501
553
  * unmodified — the E2E counterpart to `assembleDraft()`, for when the message body was built