@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
@@ -3,7 +3,9 @@
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import { apiUrl } from "../../../../../lib/util/api.js";
6
- import { getMessageRawContent } from "../../../../../lib/mail/mailApi.js";
6
+ import { attachmentContentUrl, getMessageRawContent, listAttachments } from "../../../../../lib/mail/mailApi.js";
7
+ import { MAX_QUOTED_IMAGE_BYTES, embedQuotedImages } from "../../../../../lib/mail/compose/quotedImages.js";
8
+ import { EMBEDDED_IMAGE_TYPES, findInlineAttachment, toDataUri } from "../../../../../lib/mail/inlineImages.js";
7
9
  import { decodeHeaderText, extractDisplayBody, parseMimeEntity } from "../../../../../lib/crypto/mime.js";
8
10
  import { parseRecipientList } from "./recipients.js";
9
11
  /** How long a reply waits for the original's body before it goes ahead with the preview - a slow or hung request must not
@@ -75,6 +77,37 @@ function headerRecipients(value, type) {
75
77
  .filter((recipient) => recipient.address.includes("@"))
76
78
  .map((recipient) => ({ address: recipient.address, ...(recipient.displayName ? { displayName: recipient.displayName } : {}), type }));
77
79
  }
80
+ /** The `data:` URI of the picture a quoted image of `message` stands for - one of the message's own attachments, fetched with the session -
81
+ * or `undefined` when there is none, it is not a raster image, it is too large or it could not be had. Never rejects. */
82
+ function serverImageResolver(message) {
83
+ let attachments;
84
+ return async (reference) => {
85
+ try {
86
+ attachments ?? (attachments = listAttachments(message.folderUid, message.uid));
87
+ const attachment = findInlineAttachment(await attachments, reference);
88
+ const type = attachment?.mimeType.split(";")[0].trim().toLowerCase();
89
+ if (!attachment || !type || !EMBEDDED_IMAGE_TYPES.test(type) || attachment.sizeBytes > MAX_QUOTED_IMAGE_BYTES) {
90
+ return undefined;
91
+ }
92
+ const res = await fetch(attachmentContentUrl(attachment.uid), { credentials: "include" });
93
+ const bytes = res.ok ? new Uint8Array(await res.arrayBuffer()) : undefined;
94
+ return bytes && bytes.length <= MAX_QUOTED_IMAGE_BYTES ? toDataUri(type, bytes) : undefined;
95
+ }
96
+ catch {
97
+ return undefined;
98
+ }
99
+ };
100
+ }
101
+ /** As `serverImageResolver()`, for the parts of a message this browser decrypted or verified (`MessageSecurityResult.attachments`). */
102
+ function partImageResolver(parts) {
103
+ const candidates = parts.map((part, index) => ({ uid: String(index), filename: part.filename ?? "", mimeType: part.contentType, contentId: part.contentId }));
104
+ return async (reference) => {
105
+ const found = findInlineAttachment(candidates, reference);
106
+ const part = found ? parts[Number(found.uid)] : undefined;
107
+ const bytes = part && EMBEDDED_IMAGE_TYPES.test(part.contentType) ? part.decode() : undefined;
108
+ return part && bytes && bytes.length <= MAX_QUOTED_IMAGE_BYTES ? toDataUri(part.contentType, bytes) : undefined;
109
+ };
110
+ }
78
111
  /**
79
112
  * Loads what a Reply/Reply All/Forward needs from `message`: the full body to quote - the same content
80
113
  * `MessageDetailPane` shows, never the truncated `bodyPreview` unless nothing else can be had - and, with
@@ -96,6 +129,9 @@ function headerRecipients(value, type) {
96
129
  * `message.recipients` alone would reduce every Reply All to a plain reply. Unprotected headers are the sender's own
97
130
  * claim, exactly as in every other mail client's Reply All.
98
131
  *
132
+ * The original's embedded images (`cid:`) are replaced by `data:` URIs of the pictures themselves - fetched from the message's
133
+ * attachments, or taken from the decrypted entity - so the quote shows them and the new message can carry them (see `quotedImages.ts`).
134
+ *
99
135
  * The quote builders sanitize whatever HTML this returns (`sanitizeQuotedHtml()`), whichever source it came from.
100
136
  */
101
137
  export async function loadOriginalMessage(message, security, options = {}) {
@@ -137,5 +173,11 @@ export async function loadOriginalMessage(message, security, options = {}) {
137
173
  // Whatever was loaded before the failure stands: the quote falls back to the preview, Reply All to the
138
174
  // message's own recipients.
139
175
  }
176
+ if (result.body.html !== undefined) {
177
+ const resolver = recovered ? partImageResolver(security?.attachments ?? []) : serverImageResolver(message);
178
+ const html = result.body.html;
179
+ // Bounded like the body's own request: a picture that will not arrive is left out of the quote, not waited for.
180
+ result.body = { html: await withTimeout(embedQuotedImages(html, resolver), QUOTE_FETCH_TIMEOUT_MS).catch(() => html) };
181
+ }
140
182
  return result;
141
183
  }
@@ -89,9 +89,11 @@ export interface LabelMenuButtonProps extends Omit<LabelSectionsOptions, "state"
89
89
  disabled?: boolean;
90
90
  title?: string;
91
91
  className?: string;
92
+ /** The trigger is just its icon (`className` then styles all of it) - `aria-label` and `title` name it. */
93
+ iconOnly?: boolean;
92
94
  /** With `onLabelCreated`, adds a "New label" row that opens this component's own dialog. */
93
95
  mailboxUid?: string;
94
96
  onLabelCreated?: (label: Label) => void;
95
97
  }
96
98
  /** The label list as a menu button of its own - the bulk Apply label action and the reading pane's Labels. */
97
- export default function LabelMenuButton({ applied, partial, onCommit, labels, commit, clear, emptyNote, note, busy, label, "aria-label": ariaLabel, disabled, title, className, mailboxUid, onLabelCreated, }: LabelMenuButtonProps): React.JSX.Element;
99
+ export default function LabelMenuButton({ applied, partial, onCommit, labels, commit, clear, emptyNote, note, busy, label, "aria-label": ariaLabel, disabled, title, className, iconOnly, mailboxUid, onLabelCreated, }: LabelMenuButtonProps): React.JSX.Element;
@@ -119,11 +119,11 @@ export function NewLabelDialog({ open, onClose, mailboxUid, onCreated }) {
119
119
  return (_jsxs(Modal, { open: open, onClose: onClose, title: "New label", children: [error && _jsx(Alert, { children: error }), _jsx(FormField, { label: "Name", htmlFor: "new-label-name", children: _jsx("input", { id: "new-label-name", type: "text", value: name, onChange: (e) => setName(e.target.value), className: "w-full text-sm px-3 py-1.5 rounded-md border border-border bg-surface" }) }), _jsxs("div", { className: "flex justify-end gap-2 mt-2", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", loading: saving, disabled: saving || !name.trim(), onClick: () => void save(), children: "Create" })] })] }));
120
120
  }
121
121
  /** The label list as a menu button of its own - the bulk Apply label action and the reading pane's Labels. */
122
- export default function LabelMenuButton({ applied, partial = [], onCommit, labels, commit, clear, emptyNote, note, busy, label, "aria-label": ariaLabel, disabled, title, className, mailboxUid, onLabelCreated, }) {
122
+ export default function LabelMenuButton({ applied, partial = [], onCommit, labels, commit, clear, emptyNote, note, busy, label, "aria-label": ariaLabel, disabled, title, className, iconOnly, mailboxUid, onLabelCreated, }) {
123
123
  const [open, setOpen] = useState(false);
124
124
  const [creating, setCreating] = useState(false);
125
125
  const state = useLabelDraft(applied, partial, open);
126
- return (_jsxs(_Fragment, { children: [_jsx(MenuButton, { "aria-label": ariaLabel, label: label, icon: _jsx(HiOutlineTag, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), disabled: disabled, title: title, className: className, onOpenChange: setOpen, sections: labelSections({
126
+ return (_jsxs(_Fragment, { children: [_jsx(MenuButton, { "aria-label": ariaLabel, label: label, icon: _jsx(HiOutlineTag, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), disabled: disabled, title: title, className: className, iconOnly: iconOnly, onOpenChange: setOpen, sections: labelSections({
127
127
  labels,
128
128
  state,
129
129
  commit: { ...commit, disabled: commit.disabled || !state.dirty, onSelect: () => onCommit(state.draft, state.mixed) },
@@ -31,6 +31,12 @@ import { ariaKeyShortcuts, withHint } from "../../../keyboard/format.js";
31
31
  import { SHORTCUTS } from "../../../keyboard/keymap.js";
32
32
  import { useKeyEnvironment } from "../../../keyboard/ShortcutProvider.js";
33
33
  import { useShortcut } from "../../../keyboard/useShortcut.js";
34
+ /**
35
+ * From `md` up the sidebar and the content beside it are each exactly as tall as the window under the title bar, so each scrolls by itself (the frame's
36
+ * own height is open-ended - `min-h-screen` - so a column's `overflow` would otherwise never apply and the whole page would scroll instead). On a phone
37
+ * the page scrolls as a whole, as it always did. The same height the Contacts page uses; `--rr-header-h` is the title bar's (or branding header's) height.
38
+ */
39
+ const DESKTOP_PANE_HEIGHT = "md:h-[calc(100dvh_-_var(--rr-header-h,4rem))]";
34
40
  /** The well-known mail folder types an "aggregate" pseudo-folder can merge across every accessible
35
41
  * mailbox - a fixed, small set: `outbox` is deliberately excluded (transient per-mailbox send-queue
36
42
  * state, not a "merge across mailboxes" concept), and there's no aggregate concept for `calendar`/
@@ -239,7 +245,7 @@ export default function MailShell({ userUid, authServerUrl, impersonating, imper
239
245
  ].join(" "), children: [_jsxs("span", { className: ["flex items-center gap-2.5 min-w-0", badge?.kind === "unread" ? "font-semibold" : ""].join(" "), children: [_jsx(FolderTypeIcon, { type: folder.type }), _jsx("span", { className: "truncate", children: FOLDER_LABELS[folder.type] ?? folder.name })] }), badge &&
240
246
  (folder.type === "outbox" ? (_jsx(OutboxBadge, { total: badge.value, status: outboxStatuses[folder.uid], pendingHere: pendingCountFor(mailbox.uid, pendingSends) })) : (_jsx(FolderBadgeChip, { badge: badge })))] }, folder.uid));
241
247
  }) }, mailbox.uid)))] }))] }));
242
- inner = (_jsxs(_Fragment, { children: [_jsx(MailShortcuts, { mailboxUid: defaultMailboxUid }), _jsx("aside", { className: "hidden md:flex w-64 shrink-0 bg-surface border-r border-border flex-col", children: sidebarContent("desktop") }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Folders", fullScreen: true, children: _jsx("div", { className: "flex flex-col", children: sidebarContent("mobile") }) }), _jsxs("main", { className: "flex-1 min-w-0 overflow-y-auto", children: [_jsxs("div", { className: "md:hidden flex items-center gap-2 p-3", children: [_jsx("button", { type: "button", className: "shrink-0 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open folders", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx("div", { ref: setMobileSearchSlot, className: "flex-1 min-w-0" })] }), _jsx(MobileComposeButton, { mailboxUid: defaultMailboxUid }), _jsx(MailShellContext.Provider, { value: contextValue, children: children })] })] }));
248
+ inner = (_jsxs(_Fragment, { children: [_jsx(MailShortcuts, { mailboxUid: defaultMailboxUid }), _jsx("aside", { className: ["hidden md:flex w-64 shrink-0 bg-surface border-r border-border flex-col", DESKTOP_PANE_HEIGHT].join(" "), children: sidebarContent("desktop") }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Folders", fullScreen: true, children: _jsx("div", { className: "flex flex-col", children: sidebarContent("mobile") }) }), _jsxs("main", { className: ["flex-1 min-w-0 overflow-y-auto", DESKTOP_PANE_HEIGHT].join(" "), children: [_jsxs("div", { className: "md:hidden flex items-center gap-2 p-3", children: [_jsx("button", { type: "button", className: "shrink-0 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open folders", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx("div", { ref: setMobileSearchSlot, className: "flex-1 min-w-0" })] }), _jsx(MobileComposeButton, { mailboxUid: defaultMailboxUid }), _jsx(MailShellContext.Provider, { value: contextValue, children: children })] })] }));
243
249
  }
244
250
  // Wraps AppShell unconditionally, at a stable tree position regardless of whether mailboxUid has
245
251
  // resolved yet - KeyEnrollmentGate itself passes `children` through untouched until a real
@@ -78,7 +78,7 @@ function HtmlBody({ messageUid, title, html, remoteAvailable, attachments, inlin
78
78
  const theme = useResolvedTheme();
79
79
  const surface = useThemeSurface(theme);
80
80
  // Keyed on what the resolver reads rather than on the arrays, which a caller may rebuild on every render.
81
- const inlineKey = [...(attachments ?? []).map((a) => `${a.uid}:${a.contentId ?? ""}`), inlineParts ? `parts:${inlineParts.length}` : ""].join("|");
81
+ const inlineKey = [...(attachments ?? []).map((a) => `${a.uid}:${a.contentId ?? ""}:${a.filename}:${a.mimeType}`), inlineParts ? `parts:${inlineParts.length}` : ""].join("|");
82
82
  const resolveCid = useMemo(() => makeCidResolver(attachments, inlineParts), [inlineKey]);
83
83
  const prepared = useMemo(() => prepareBodyHtml(html, { resolveCid }), [html, resolveCid]);
84
84
  const mode = prepared.declaresDarkSupport ? "native" : original ? "original" : "adapt";
@@ -34,5 +34,9 @@ export declare function attachmentSource(): string;
34
34
  * How an inline `cid:` image is resolved to something the frame may load: for a message shown from the server, the attachment whose
35
35
  * `Content-ID` matches (its URL on this server); for a decrypted or verified one, the part inside the signed/encrypted entity, embedded
36
36
  * as a `data:` URI (raster and SVG images up to `MAX_INLINE_IMAGE_BYTES`). Anything else has no source, so the image is not shown.
37
+ *
38
+ * The `Content-ID` is matched without regard to angle brackets, percent-encoding or case. An image that names no part (`contentId`
39
+ * undefined: the server left it with only its `alt`, as it does for a picture a sender attached without a `Content-ID`) is matched to the
40
+ * one image attachment of the same file name, when `alt` says it.
37
41
  */
38
- export declare function makeCidResolver(attachments: InlineCapable[] | undefined, parts: MimeAttachment[] | undefined): (contentId: string) => string | undefined;
42
+ export declare function makeCidResolver(attachments: InlineCapable[] | undefined, parts: MimeAttachment[] | undefined): (contentId: string | undefined, alt?: string) => string | undefined;
@@ -4,6 +4,7 @@
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import { ApiRequestError, apiUrl } from "../../../../../lib/util/api.js";
6
6
  import { attachmentContentUrl } from "../../../../../lib/mail/mailApi.js";
7
+ import { findInlineAttachment, toDataUri } from "../../../../../lib/mail/inlineImages.js";
7
8
  /** How many bodies are remembered for the session - a thread that is expanded, collapsed and expanded again does not ask the server twice. */
8
9
  const CACHE_LIMIT = 40;
9
10
  /** What the pane asks `/content` for. */
@@ -51,13 +52,6 @@ export async function fetchBodyContent(uid, version, signal) {
51
52
  /** The largest inline image embedded into a body from a signed or encrypted message. */
52
53
  export const MAX_INLINE_IMAGE_BYTES = 5000000;
53
54
  const INLINE_IMAGE_TYPES = /^image\/(?:png|jpe?g|gif|webp|avif|bmp|svg\+xml)$/;
54
- function base64(bytes) {
55
- let binary = "";
56
- for (let i = 0; i < bytes.length; i += 0x8000) {
57
- binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
58
- }
59
- return btoa(binary);
60
- }
61
55
  /** The absolute URL of an attachment - the frame is a document of its own, where a relative URL would not mean what it does here. */
62
56
  export function absoluteAttachmentUrl(uid) {
63
57
  return new URL(attachmentContentUrl(uid), window.location.href).href;
@@ -70,15 +64,21 @@ export function attachmentSource() {
70
64
  * How an inline `cid:` image is resolved to something the frame may load: for a message shown from the server, the attachment whose
71
65
  * `Content-ID` matches (its URL on this server); for a decrypted or verified one, the part inside the signed/encrypted entity, embedded
72
66
  * as a `data:` URI (raster and SVG images up to `MAX_INLINE_IMAGE_BYTES`). Anything else has no source, so the image is not shown.
67
+ *
68
+ * The `Content-ID` is matched without regard to angle brackets, percent-encoding or case. An image that names no part (`contentId`
69
+ * undefined: the server left it with only its `alt`, as it does for a picture a sender attached without a `Content-ID`) is matched to the
70
+ * one image attachment of the same file name, when `alt` says it.
73
71
  */
74
72
  export function makeCidResolver(attachments, parts) {
75
- return (contentId) => {
73
+ return (contentId, alt) => {
76
74
  if (parts) {
77
- const part = parts.find((candidate) => candidate.contentId === contentId);
75
+ const candidates = parts.map((part, index) => ({ uid: String(index), filename: part.filename ?? "", mimeType: part.contentType, contentId: part.contentId }));
76
+ const found = findInlineAttachment(candidates, { cid: contentId, alt });
77
+ const part = found ? parts[Number(found.uid)] : undefined;
78
78
  const bytes = part && INLINE_IMAGE_TYPES.test(part.contentType) ? part.decode() : undefined;
79
- return part && bytes && bytes.length <= MAX_INLINE_IMAGE_BYTES ? `data:${part.contentType};base64,${base64(bytes)}` : undefined;
79
+ return part && bytes && bytes.length <= MAX_INLINE_IMAGE_BYTES ? toDataUri(part.contentType, bytes) : undefined;
80
80
  }
81
- const attachment = attachments?.find((candidate) => candidate.contentId === contentId);
81
+ const attachment = findInlineAttachment(attachments, { cid: contentId, alt });
82
82
  return attachment ? absoluteAttachmentUrl(attachment.uid) : undefined;
83
83
  };
84
84
  }
@@ -29,8 +29,9 @@ export interface PreparedBody {
29
29
  }
30
30
  export interface PrepareOptions {
31
31
  /** The URL an inline image's `cid:` reference stands for, or `undefined` when there is no such part. Only `data:` URIs of an
32
- * image type and `http(s)` URLs are accepted; anything else is treated as unresolved. */
33
- resolveCid?: (contentId: string) => string | undefined;
32
+ * image type and `http(s)` URLs are accepted; anything else is treated as unresolved. An image with no source at all (the
33
+ * server removes one it could not point at a part) is asked for with no `contentId` and its `alt`. */
34
+ resolveCid?: (contentId: string | undefined, alt?: string) => string | undefined;
34
35
  }
35
36
  /** CSS with the constructs above neutralised. */
36
37
  export declare function sanitizeCss(css: string): string;
@@ -189,10 +189,14 @@ function hardenLink(element) {
189
189
  element.removeAttribute("target");
190
190
  }
191
191
  }
192
+ /** Whether the image ends up pointing at a part of the message (so the frame's CSP must allow this server's attachment URLs). */
192
193
  function hardenImage(element, options) {
193
194
  const src = (element.getAttribute("src") ?? "").trim();
194
- const inline = /^cid:/i.test(src);
195
- const target = inline ? options.resolveCid?.(src.slice(4).replace(/^<|>$/g, "")) : src;
195
+ const cid = /^cid:/i.test(src);
196
+ // A picture the server could not tie to a part keeps its `alt` and loses its `src`: the message's own attachment of that name, if any.
197
+ const inline = cid || src === "";
198
+ const alt = element.getAttribute("alt") ?? undefined;
199
+ const target = cid ? options.resolveCid?.(src.slice(4).replace(/^<|>$/g, ""), alt) : inline ? options.resolveCid?.(undefined, alt) : src;
196
200
  // Remote images are never fetched (they would tell the sender the message was read); an inline image resolves to an embedded
197
201
  // image or to this server's own attachment URL, and one that doesn't resolve has nothing to show.
198
202
  if (target !== undefined && (IMAGE_DATA_URI.test(target) || (inline && /^https?:\/\//i.test(target)))) {
@@ -201,6 +205,7 @@ function hardenImage(element, options) {
201
205
  else {
202
206
  element.removeAttribute("src");
203
207
  }
208
+ return cid || (inline && element.hasAttribute("src"));
204
209
  }
205
210
  /**
206
211
  * The second, structural pass over already-sanitized markup: everything a mail body has no use for is removed or unwrapped, every
@@ -235,10 +240,9 @@ function harden(body, options) {
235
240
  hardenLink(element);
236
241
  }
237
242
  else if (tag === "img") {
238
- if (/^cid:/i.test(element.getAttribute("src") ?? "")) {
243
+ if (hardenImage(element, options)) {
239
244
  inlineImages++;
240
245
  }
241
- hardenImage(element, options);
242
246
  }
243
247
  }
244
248
  return { elements: all.length, inlineImages };
@@ -1,6 +1,8 @@
1
1
  import { type CSSProperties } from "react";
2
2
  /** How long the new contents take to settle. Short, so a run of steps (a wheel kept turning) never waits on the last one. */
3
3
  export declare const ENTER_SLIDE_MS = 140;
4
+ /** Whether the user asked their device for less motion. */
5
+ export declare function prefersReducedMotion(): boolean;
4
6
  /**
5
7
  * The look of a page that has just been swapped for the next or previous one by a vertical step (the wheel): the new contents start
6
8
  * a little way off in the direction the step went - below for `1` (the next), above for `-1` - and faded, and settle into place.
@@ -11,7 +11,7 @@ const ENTER_START_OPACITY = 0.35;
11
11
  /** The pause that lets the browser draw the contents at their starting place before they move. */
12
12
  const ENTER_SETTLE_MS = 20;
13
13
  /** Whether the user asked their device for less motion. */
14
- function prefersReducedMotion() {
14
+ export function prefersReducedMotion() {
15
15
  return typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
16
16
  }
17
17
  /**
@@ -0,0 +1,41 @@
1
+ import { type MouseEvent, type TouchEvent } from "react";
2
+ export interface UseLongPressOptions {
3
+ /** Nothing starts while this is `false` (a desktop pointer, select mode); a press already held out is still seen through. Default `true`. */
4
+ enabled?: boolean;
5
+ /** How long a finger has to stay down, in ms. Default `LONG_PRESS_MS`. */
6
+ delay?: number;
7
+ /** How far it may wander meanwhile, in px. Default `LONG_PRESS_SLOP`. */
8
+ tolerance?: number;
9
+ /** Called once when the press has been held for `delay`. */
10
+ onLongPress: () => void;
11
+ }
12
+ /** How long a finger holds still to count as a press and hold, and how far it may drift (an unsteady hand) before it is a scroll or a swipe instead. */
13
+ export declare const LONG_PRESS_MS = 480;
14
+ export declare const LONG_PRESS_SLOP = 8;
15
+ /**
16
+ * Press and hold by one finger, for the touch screens the app's phone layout is for: mouse and pen are left alone.
17
+ *
18
+ * Spread the handlers on the element. The press is given up the moment the finger moves more than `tolerance`, a second finger lands, the touch is
19
+ * cancelled (the browser took it over to scroll) or something scrolls, so it never fights vertical scrolling or `useSwipe()`: a swipe has to travel
20
+ * further than the tolerance to be one. Once it fires, the finger lifting must not also be a click on whatever is under it (the click that follows is
21
+ * swallowed once), and the browser's own long-press (the context menu, the text selection and the callout of a link) is suppressed.
22
+ *
23
+ * The handlers stay on even while `enabled` is `false`, because the row that was pressed usually makes itself disabled in answer (select mode),
24
+ * and the click that ends the press still has to be swallowed after that.
25
+ */
26
+ export declare function useLongPress({ enabled, delay, tolerance, onLongPress }: UseLongPressOptions): {
27
+ handlers: {
28
+ onTouchStart: (event: TouchEvent<HTMLElement>) => void;
29
+ onTouchMove: (event: TouchEvent<HTMLElement>) => void;
30
+ onTouchEnd: (event: TouchEvent<HTMLElement>) => void;
31
+ onTouchCancel: () => void;
32
+ onClickCapture: (event: MouseEvent<HTMLElement>) => void;
33
+ onContextMenu: (event: MouseEvent<HTMLElement>) => void;
34
+ };
35
+ /** No text selection and no callout on what is pressed, while a press can start: the page's text stays selectable elsewhere. */
36
+ style: {
37
+ readonly WebkitTouchCallout: "none";
38
+ readonly WebkitUserSelect: "none";
39
+ readonly userSelect: "none";
40
+ } | undefined;
41
+ };
@@ -0,0 +1,112 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { useEffect, useRef } from "react";
6
+ /** How long a finger holds still to count as a press and hold, and how far it may drift (an unsteady hand) before it is a scroll or a swipe instead. */
7
+ export const LONG_PRESS_MS = 480;
8
+ export const LONG_PRESS_SLOP = 8;
9
+ /** How long after the finger lifts the click it would make is still swallowed; a press that makes none (the browser took it as a context menu) must not eat a later one. */
10
+ const CLICK_WINDOW_MS = 500;
11
+ /**
12
+ * Press and hold by one finger, for the touch screens the app's phone layout is for: mouse and pen are left alone.
13
+ *
14
+ * Spread the handlers on the element. The press is given up the moment the finger moves more than `tolerance`, a second finger lands, the touch is
15
+ * cancelled (the browser took it over to scroll) or something scrolls, so it never fights vertical scrolling or `useSwipe()`: a swipe has to travel
16
+ * further than the tolerance to be one. Once it fires, the finger lifting must not also be a click on whatever is under it (the click that follows is
17
+ * swallowed once), and the browser's own long-press (the context menu, the text selection and the callout of a link) is suppressed.
18
+ *
19
+ * The handlers stay on even while `enabled` is `false`, because the row that was pressed usually makes itself disabled in answer (select mode),
20
+ * and the click that ends the press still has to be swallowed after that.
21
+ */
22
+ export function useLongPress({ enabled = true, delay = LONG_PRESS_MS, tolerance = LONG_PRESS_SLOP, onLongPress }) {
23
+ const timer = useRef(null);
24
+ const start = useRef({ x: 0, y: 0 });
25
+ /** The press fired and its click has not come yet. */
26
+ const fired = useRef(false);
27
+ const clickWindow = useRef(null);
28
+ const latest = useRef(onLongPress);
29
+ latest.current = onLongPress;
30
+ // One function for the life of the row: it is also the scroll listener, which has to be removed with the very function it was added with.
31
+ const forget = useRef(() => {
32
+ if (timer.current !== null) {
33
+ clearTimeout(timer.current);
34
+ timer.current = null;
35
+ window.removeEventListener("scroll", forget, true);
36
+ }
37
+ }).current;
38
+ function clearClickWindow() {
39
+ if (clickWindow.current !== null) {
40
+ clearTimeout(clickWindow.current);
41
+ clickWindow.current = null;
42
+ }
43
+ }
44
+ // A timer left running would fire into a row that is gone.
45
+ useEffect(() => () => {
46
+ forget();
47
+ if (clickWindow.current !== null) {
48
+ clearTimeout(clickWindow.current);
49
+ }
50
+ }, [forget]);
51
+ function onTouchStart(event) {
52
+ forget();
53
+ clearClickWindow();
54
+ fired.current = false;
55
+ if (!enabled || event.touches.length !== 1) {
56
+ return;
57
+ }
58
+ start.current = { x: event.touches[0].clientX, y: event.touches[0].clientY };
59
+ // Scroll events don't bubble: only a capturing listener hears the list (or the page) moving under a finger that hasn't moved.
60
+ window.addEventListener("scroll", forget, { capture: true, passive: true });
61
+ timer.current = setTimeout(() => {
62
+ timer.current = null;
63
+ window.removeEventListener("scroll", forget, true);
64
+ fired.current = true;
65
+ navigator.vibrate?.(10);
66
+ latest.current();
67
+ }, delay);
68
+ }
69
+ function onTouchMove(event) {
70
+ if (timer.current === null) {
71
+ return;
72
+ }
73
+ const touch = event.touches[0];
74
+ if (event.touches.length !== 1 || Math.hypot(touch.clientX - start.current.x, touch.clientY - start.current.y) >= tolerance) {
75
+ forget();
76
+ }
77
+ }
78
+ function onTouchEnd(event) {
79
+ forget();
80
+ if (fired.current) {
81
+ // No click of the browser's own after this; the one that comes anyway (older browsers) is swallowed by `onClickCapture`.
82
+ event.preventDefault();
83
+ clearClickWindow();
84
+ clickWindow.current = setTimeout(() => {
85
+ clickWindow.current = null;
86
+ fired.current = false;
87
+ }, CLICK_WINDOW_MS);
88
+ }
89
+ }
90
+ function onTouchCancel() {
91
+ forget();
92
+ fired.current = false;
93
+ }
94
+ function onClickCapture(event) {
95
+ if (fired.current) {
96
+ fired.current = false;
97
+ event.preventDefault();
98
+ event.stopPropagation();
99
+ }
100
+ }
101
+ function onContextMenu(event) {
102
+ // The long-press menu of a touch screen comes while the finger is still down (Android) or just after it fired.
103
+ if (timer.current !== null || fired.current) {
104
+ event.preventDefault();
105
+ }
106
+ }
107
+ return {
108
+ handlers: { onTouchStart, onTouchMove, onTouchEnd, onTouchCancel, onClickCapture, onContextMenu },
109
+ /** No text selection and no callout on what is pressed, while a press can start: the page's text stays selectable elsewhere. */
110
+ style: enabled ? { WebkitTouchCallout: "none", WebkitUserSelect: "none", userSelect: "none" } : undefined,
111
+ };
112
+ }
@@ -268,6 +268,18 @@ export declare const SHORTCUTS: {
268
268
  label: string;
269
269
  scope: "calendar";
270
270
  };
271
+ readonly list: {
272
+ id: string;
273
+ keys: string[];
274
+ label: string;
275
+ scope: "calendar";
276
+ };
277
+ readonly search: {
278
+ id: string;
279
+ keys: string[];
280
+ label: string;
281
+ scope: "calendar";
282
+ };
271
283
  };
272
284
  readonly contacts: {
273
285
  readonly create: {
@@ -67,6 +67,8 @@ export const SHORTCUTS = {
67
67
  workWeek: def({ id: "calendar.workWeek", keys: ["ctrl+alt+2"], label: "Work week view", scope: "calendar" }),
68
68
  week: def({ id: "calendar.week", keys: ["ctrl+alt+3"], label: "Week view", scope: "calendar" }),
69
69
  month: def({ id: "calendar.month", keys: ["ctrl+alt+4"], label: "Month view", scope: "calendar" }),
70
+ list: def({ id: "calendar.list", keys: ["ctrl+alt+5"], label: "List view", scope: "calendar" }),
71
+ search: def({ id: "calendar.search", keys: ["/", "mod+e"], label: "Search events", scope: "calendar" }),
70
72
  },
71
73
  contacts: {
72
74
  create: def({ id: "contacts.new", keys: ["alt+n"], electronKeys: ["mod+n"], label: "New contact", scope: "contacts" }),
@@ -0,0 +1,20 @@
1
+ /** The modifier keys of a click - what a mouse event, or the native event behind a checkbox's change, carries. */
2
+ export interface ClickModifiers {
3
+ shiftKey?: boolean;
4
+ ctrlKey?: boolean;
5
+ metaKey?: boolean;
6
+ }
7
+ /** Whether a click is a multi-select click (Shift, Ctrl, or Cmd on a Mac) rather than the plain click that opens a row. */
8
+ export declare function isSelectionClick(modifiers: ClickModifiers | undefined): boolean;
9
+ /** Ctrl (or Cmd on a Mac): toggle just the clicked row. */
10
+ export declare function isToggleClick(modifiers: ClickModifiers | undefined): boolean;
11
+ /**
12
+ * The anchor of a Shift+click range, as Explorer and Outlook have it: the row last clicked (or opened) - `anchor` - while it is still in
13
+ * the list, else the row open in the reading pane (`open`), else nothing (the range is then just the clicked row).
14
+ */
15
+ export declare function resolveAnchor(keys: readonly string[], anchor: string | null, open: string | null): string | null;
16
+ /**
17
+ * Every key from `anchor` to `target` inclusive, in the order `keys` has them - the order the list is displayed in, whichever end of the
18
+ * range was clicked first. With no usable anchor the range is `target` alone.
19
+ */
20
+ export declare function rangeBetween(keys: readonly string[], anchor: string | null, target: string): string[];
@@ -0,0 +1,34 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /** Whether a click is a multi-select click (Shift, Ctrl, or Cmd on a Mac) rather than the plain click that opens a row. */
6
+ export function isSelectionClick(modifiers) {
7
+ return !!modifiers && !!(modifiers.shiftKey || modifiers.ctrlKey || modifiers.metaKey);
8
+ }
9
+ /** Ctrl (or Cmd on a Mac): toggle just the clicked row. */
10
+ export function isToggleClick(modifiers) {
11
+ return !!modifiers && !!(modifiers.ctrlKey || modifiers.metaKey);
12
+ }
13
+ /**
14
+ * The anchor of a Shift+click range, as Explorer and Outlook have it: the row last clicked (or opened) - `anchor` - while it is still in
15
+ * the list, else the row open in the reading pane (`open`), else nothing (the range is then just the clicked row).
16
+ */
17
+ export function resolveAnchor(keys, anchor, open) {
18
+ if (anchor !== null && keys.includes(anchor)) {
19
+ return anchor;
20
+ }
21
+ return open !== null && keys.includes(open) ? open : null;
22
+ }
23
+ /**
24
+ * Every key from `anchor` to `target` inclusive, in the order `keys` has them - the order the list is displayed in, whichever end of the
25
+ * range was clicked first. With no usable anchor the range is `target` alone.
26
+ */
27
+ export function rangeBetween(keys, anchor, target) {
28
+ const from = anchor === null ? -1 : keys.indexOf(anchor);
29
+ const to = keys.indexOf(target);
30
+ if (from === -1 || to === -1) {
31
+ return [target];
32
+ }
33
+ return keys.slice(Math.min(from, to), Math.max(from, to) + 1);
34
+ }
@@ -0,0 +1,12 @@
1
+ /** How long a row that leaves the list (moved, deleted, archived) takes to collapse. The list drops it from its data after this long. */
2
+ export declare const ROW_EXIT_MS = 200;
3
+ /**
4
+ * Whether a row that leaves should collapse rather than vanish: the browser can animate an element (`Element.animate()` - every current one
5
+ * can) and the reader has not asked for less motion (`prefers-reduced-motion`). Otherwise the row is removed at once.
6
+ */
7
+ export declare function canAnimateRowExit(): boolean;
8
+ /**
9
+ * Collapses a row that is leaving: its height and opacity run to zero over `ROW_EXIT_MS`, so the rows below slide up into the gap, and it
10
+ * stays collapsed (`fill: forwards`) until the list drops it. The row is marked so a second call for the same element does nothing.
11
+ */
12
+ export declare function collapseRow(row: HTMLElement): void;
@@ -0,0 +1,33 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /** How long a row that leaves the list (moved, deleted, archived) takes to collapse. The list drops it from its data after this long. */
6
+ export const ROW_EXIT_MS = 200;
7
+ /**
8
+ * Whether a row that leaves should collapse rather than vanish: the browser can animate an element (`Element.animate()` - every current one
9
+ * can) and the reader has not asked for less motion (`prefers-reduced-motion`). Otherwise the row is removed at once.
10
+ */
11
+ export function canAnimateRowExit() {
12
+ // Only called from event handlers, i.e. in a browser.
13
+ if (typeof Element.prototype.animate !== "function") {
14
+ return false;
15
+ }
16
+ return !(typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
17
+ }
18
+ /**
19
+ * Collapses a row that is leaving: its height and opacity run to zero over `ROW_EXIT_MS`, so the rows below slide up into the gap, and it
20
+ * stays collapsed (`fill: forwards`) until the list drops it. The row is marked so a second call for the same element does nothing.
21
+ */
22
+ export function collapseRow(row) {
23
+ if (row.dataset.collapsing) {
24
+ return;
25
+ }
26
+ row.dataset.collapsing = "true";
27
+ const height = row.getBoundingClientRect().height;
28
+ row.style.overflow = "hidden";
29
+ row.animate([
30
+ { height: `${height}px`, opacity: 1 },
31
+ { height: "0px", opacity: 0, borderBottomWidth: "0px" },
32
+ ], { duration: ROW_EXIT_MS, easing: "ease-out", fill: "forwards" });
33
+ }