@rapidmx/web-client 0.28.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) 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 +29 -3
  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/compose/ComposeContext.tsx +193 -35
  23. package/apps/shared/components/mail/compose/ComposeWindow.tsx +62 -11
  24. package/apps/shared/components/mail/compose/ComposeWindowPlaceholder.tsx +21 -10
  25. package/apps/shared/components/mail/compose/RichTextEditor.tsx +2 -1
  26. package/apps/shared/components/mail/compose/quotedBody.ts +45 -2
  27. package/apps/shared/components/mail/labelMenu.tsx +4 -0
  28. package/apps/shared/components/mail/layout/MailShell.tsx +9 -2
  29. package/apps/shared/components/mail/reading/MessageBody.tsx +1 -1
  30. package/apps/shared/components/mail/reading/bodyContent.ts +15 -13
  31. package/apps/shared/components/mail/reading/bodyHtml.ts +12 -7
  32. package/apps/shared/gestures/useEnterSlide.ts +1 -1
  33. package/apps/shared/keyboard/keymap.ts +2 -0
  34. package/apps/shared/mail/rangeSelection.ts +45 -0
  35. package/apps/shared/mail/rowExit.ts +39 -0
  36. package/apps/www/calendar/index.tsx +251 -53
  37. package/apps/www/index.tsx +522 -343
  38. package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.d.ts +3 -3
  39. package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.js +15 -9
  40. package/dist/apps/shared/components/admin/diagnostics/InformationPanel.d.ts +15 -0
  41. package/dist/apps/shared/components/admin/diagnostics/InformationPanel.js +16 -0
  42. package/dist/apps/shared/components/admin/diagnostics/SettingsTable.d.ts +20 -0
  43. package/dist/apps/shared/components/admin/diagnostics/SettingsTable.js +31 -0
  44. package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.d.ts +20 -1
  45. package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.js +7 -0
  46. package/dist/apps/shared/components/calendar/CalendarSearchBox.d.ts +22 -0
  47. package/dist/apps/shared/components/calendar/CalendarSearchBox.js +24 -0
  48. package/dist/apps/shared/components/calendar/EventMatchNav.d.ts +17 -0
  49. package/dist/apps/shared/components/calendar/EventMatchNav.js +37 -0
  50. package/dist/apps/shared/components/calendar/EventModal.d.ts +17 -1
  51. package/dist/apps/shared/components/calendar/EventModal.js +13 -11
  52. package/dist/apps/shared/components/calendar/ListView.d.ts +30 -0
  53. package/dist/apps/shared/components/calendar/ListView.js +111 -0
  54. package/dist/apps/shared/components/calendar/MonthView.js +12 -3
  55. package/dist/apps/shared/components/calendar/SlideIn.d.ts +16 -0
  56. package/dist/apps/shared/components/calendar/SlideIn.js +32 -0
  57. package/dist/apps/shared/components/calendar/SplitDayView.js +5 -1
  58. package/dist/apps/shared/components/calendar/TimeGridView.js +11 -3
  59. package/dist/apps/shared/components/calendar/activeOccurrence.d.ts +15 -0
  60. package/dist/apps/shared/components/calendar/activeOccurrence.js +21 -0
  61. package/dist/apps/shared/components/calendar/allDay.d.ts +4 -0
  62. package/dist/apps/shared/components/calendar/allDay.js +14 -1
  63. package/dist/apps/shared/components/calendar/calendarView.d.ts +16 -0
  64. package/dist/apps/shared/components/calendar/calendarView.js +36 -0
  65. package/dist/apps/shared/components/layout/AppShell.js +8 -4
  66. package/dist/apps/shared/components/mail/ConversationList.d.ts +9 -1
  67. package/dist/apps/shared/components/mail/ConversationList.js +10 -2
  68. package/dist/apps/shared/components/mail/ConversationThreadPane.js +9 -1
  69. package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -4
  70. package/dist/apps/shared/components/mail/MessageDetailPane.js +3 -0
  71. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +31 -0
  72. package/dist/apps/shared/components/mail/compose/ComposeContext.js +123 -11
  73. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +6 -1
  74. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +37 -11
  75. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.d.ts +5 -1
  76. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.js +10 -6
  77. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +2 -1
  78. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +3 -0
  79. package/dist/apps/shared/components/mail/compose/quotedBody.js +43 -1
  80. package/dist/apps/shared/components/mail/labelMenu.d.ts +3 -1
  81. package/dist/apps/shared/components/mail/labelMenu.js +2 -2
  82. package/dist/apps/shared/components/mail/layout/MailShell.js +7 -1
  83. package/dist/apps/shared/components/mail/reading/MessageBody.js +1 -1
  84. package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +5 -1
  85. package/dist/apps/shared/components/mail/reading/bodyContent.js +11 -11
  86. package/dist/apps/shared/components/mail/reading/bodyHtml.d.ts +3 -2
  87. package/dist/apps/shared/components/mail/reading/bodyHtml.js +8 -4
  88. package/dist/apps/shared/gestures/useEnterSlide.d.ts +2 -0
  89. package/dist/apps/shared/gestures/useEnterSlide.js +1 -1
  90. package/dist/apps/shared/keyboard/keymap.d.ts +12 -0
  91. package/dist/apps/shared/keyboard/keymap.js +2 -0
  92. package/dist/apps/shared/mail/rangeSelection.d.ts +20 -0
  93. package/dist/apps/shared/mail/rangeSelection.js +34 -0
  94. package/dist/apps/shared/mail/rowExit.d.ts +12 -0
  95. package/dist/apps/shared/mail/rowExit.js +33 -0
  96. package/dist/apps/www/calendar/index.js +177 -18
  97. package/dist/apps/www/index.js +220 -65
  98. package/dist/lib/calendar/eventSearch.d.ts +42 -0
  99. package/dist/lib/calendar/eventSearch.js +152 -0
  100. package/dist/lib/components/navigation/BottomTabBar.d.ts +15 -3
  101. package/dist/lib/components/navigation/BottomTabBar.js +52 -15
  102. package/dist/lib/components/navigation/NavBadge.d.ts +13 -0
  103. package/dist/lib/components/navigation/NavBadge.js +18 -0
  104. package/dist/lib/mail/compose/quotedImages.d.ts +21 -0
  105. package/dist/lib/mail/compose/quotedImages.js +41 -0
  106. package/dist/lib/mail/inlineImages.d.ts +38 -0
  107. package/dist/lib/mail/inlineImages.js +90 -0
  108. package/dist/lib/mail/mailApi.d.ts +8 -1
  109. package/dist/lib/mail/mailApi.js +57 -5
  110. package/lib/calendar/eventSearch.ts +172 -0
  111. package/lib/components/navigation/BottomTabBar.tsx +107 -22
  112. package/lib/components/navigation/NavBadge.tsx +33 -0
  113. package/lib/mail/compose/quotedImages.ts +49 -0
  114. package/lib/mail/inlineImages.ts +104 -0
  115. package/lib/mail/mailApi.ts +64 -5
  116. package/package.json +1 -1
  117. package/apps/shared/components/admin/diagnostics/VersionsPanel.tsx +0 -36
  118. package/dist/apps/shared/components/admin/diagnostics/VersionsPanel.d.ts +0 -10
  119. package/dist/apps/shared/components/admin/diagnostics/VersionsPanel.js +0 -11
@@ -7,6 +7,7 @@ import type { Editor } from "@tiptap/react";
7
7
  import {
8
8
  HiOutlineArrowsPointingIn,
9
9
  HiOutlineArrowsPointingOut,
10
+ HiOutlineArrowTopRightOnSquare,
10
11
  HiOutlineChevronDown,
11
12
  HiOutlineIdentification,
12
13
  HiOutlineLockClosed,
@@ -29,6 +30,7 @@ import {
29
30
  getMessage,
30
31
  listFolders,
31
32
  listMailboxes,
33
+ deleteAttachment,
32
34
  uploadAttachment,
33
35
  } from "../../../../../lib/mail/mailApi.js";
34
36
  import { listMailSignatures } from "../../../../../lib/mail/mailSignaturesApi.js";
@@ -65,6 +67,11 @@ export interface ComposeWindowProps {
65
67
  session: ComposeSession;
66
68
  onClose: () => void;
67
69
  onToggleMinimize: () => void;
70
+ /** The window is a card in the reading pane, above the message being replied to (see `ComposeSession.inlineFor`), not a floating
71
+ * window: full width, a fixed height, never minimized, resized or expanded, and "Pop out" in place of those. */
72
+ inline?: boolean;
73
+ /** Moves an `inline` window out to the floating stack at the bottom right. */
74
+ onPopOut?: () => void;
68
75
  /** The signed-in user - identifies their own ("primary") mailbox, the default From when the session
69
76
  * doesn't name a mailbox. */
70
77
  userUid?: string;
@@ -199,13 +206,17 @@ export default function ComposeWindow({
199
206
  session,
200
207
  onClose,
201
208
  onToggleMinimize,
209
+ inline,
210
+ onPopOut,
202
211
  userUid,
203
212
  trusted,
204
213
  autosaveDelayMs = DEFAULT_AUTOSAVE_DELAY_MS,
205
214
  autosaveMaxWaitMs = DEFAULT_AUTOSAVE_MAX_WAIT_MS,
206
215
  cryptoRetryDelaysMs = DEFAULT_CRYPTO_RETRY_DELAYS_MS,
207
216
  }: ComposeWindowProps) {
208
- const { id, initialTo, initialCc, initialSubject, initialQuotedHtml, initialEncrypt, signatureContext, suppressSigning, minimized, quotePending, late, resume } = session;
217
+ const { id, initialTo, initialCc, initialSubject, initialQuotedHtml, initialEncrypt, signatureContext, suppressSigning, quotePending, late, resume } = session;
218
+ // An inline card has no minimized state: the session's flag is for the floating window it may later be popped out to.
219
+ const minimized = session.minimized && !inline;
209
220
  // What a reply worked out after this window opened (see `OpenComposeInput.pending`): better recipients, applied below
210
221
  // only to fields still exactly as they opened - so these, not `initialTo`/`initialCc`, are what "untouched" is measured against.
211
222
  const baselineTo = late?.to ?? initialTo;
@@ -254,6 +265,8 @@ export default function ComposeWindow({
254
265
  const [html, setHtml] = useState(resume?.html ?? "");
255
266
  const [contentReady, setContentReady] = useState(!!resume);
256
267
  const [attachments, setAttachments] = useState<Attachment[]>(resume?.attachments ?? []);
268
+ // The attachment being removed from the draft, whose chip is held until the server has answered.
269
+ const [removingAttachmentUid, setRemovingAttachmentUid] = useState<string | null>(null);
257
270
  // The sender's own contact card is on the message (or on its way): "Attach my contact card" then has nothing more to do.
258
271
  const [ownCardAttached, setOwnCardAttached] = useState(false);
259
272
  const [attachError, setAttachError] = useState<string | null>(null);
@@ -726,7 +739,8 @@ export default function ComposeWindow({
726
739
  }
727
740
  uploadStarted();
728
741
  try {
729
- const attachment = await uploadAttachment(draft.uid, file);
742
+ // An inline part with a Content-ID of its own: without one the server has nothing to point the message's `cid:` reference at.
743
+ const attachment = await uploadAttachment(draft.uid, file, { inline: true });
730
744
  setHasUploads(true);
731
745
  void refreshDraftVersion(draft.uid);
732
746
  return attachmentContentUrl(attachment.uid);
@@ -797,6 +811,24 @@ export default function ComposeWindow({
797
811
  return allAttached;
798
812
  }
799
813
 
814
+ /** Removes one attachment from the draft, saying why when the server would not. A removed vCard can be attached again. */
815
+ async function handleRemoveAttachment(attachment: Attachment) {
816
+ setAttachError(null);
817
+ setRemovingAttachmentUid(attachment.uid);
818
+ try {
819
+ await deleteAttachment(attachment.uid, attachment.version, client);
820
+ setAttachments((prev) => prev.filter((other) => other.uid !== attachment.uid));
821
+ if (attachment.mimeType === "text/vcard") {
822
+ setOwnCardAttached(false);
823
+ }
824
+ void refreshDraftVersion(draft!.uid);
825
+ } catch (err) {
826
+ setAttachError(err instanceof ApiRequestError ? err.message : "Could not remove attachment.");
827
+ } finally {
828
+ setRemovingAttachmentUid(null);
829
+ }
830
+ }
831
+
800
832
  /** Attaches the sender's own vCard ("Attach my contact card"), once: the button is off from the click on, and back on if the upload failed. */
801
833
  async function handleAttachOwnCard() {
802
834
  setOwnCardAttached(true);
@@ -1455,19 +1487,21 @@ export default function ComposeWindow({
1455
1487
  <>
1456
1488
  <div
1457
1489
  ref={windowRef}
1458
- role="dialog"
1490
+ role={inline ? "region" : "dialog"}
1459
1491
  data-shortcut-scope="compose"
1460
1492
  aria-labelledby={titleId}
1461
1493
  onDragEnter={handleDragEnter}
1462
1494
  onDragOver={handleDragOver}
1463
1495
  onDragLeave={handleDragLeave}
1464
1496
  onDrop={handleDrop}
1465
- style={!isMobile && manualSize ? { width: manualSize.width, height: manualSize.height } : undefined}
1497
+ style={!inline && !isMobile && manualSize ? { width: manualSize.width, height: manualSize.height } : undefined}
1466
1498
  className={[
1467
1499
  "shrink-0 flex flex-col bg-surface border border-border shadow-modal overflow-hidden",
1468
1500
  // `fixed` and `relative` may not both be on the element: Tailwind emits `.relative` after `.fixed`, so it wins and the
1469
1501
  // sheet sat in the flow of the little fixed container the windows are stacked in instead of covering the screen.
1470
- isMobile
1502
+ inline
1503
+ ? "relative w-full h-[520px] rounded-md"
1504
+ : isMobile
1471
1505
  ? "fixed inset-0 w-full h-full rounded-none border-0"
1472
1506
  : ["relative border-b-0 rounded-t-md", manualSize ? "" : expanded ? "w-[720px] h-[85vh]" : "w-[480px] h-[520px]"].join(" "),
1473
1507
  ].join(" ")}
@@ -1481,7 +1515,7 @@ export default function ComposeWindow({
1481
1515
  <span className="text-sm">Pictures go into the message where the cursor is</span>
1482
1516
  </div>
1483
1517
  )}
1484
- {!isMobile && (
1518
+ {!inline && !isMobile && (
1485
1519
  <>
1486
1520
  <div
1487
1521
  onPointerDown={handleResizeStart("top")}
@@ -1503,15 +1537,19 @@ export default function ComposeWindow({
1503
1537
  )}
1504
1538
 
1505
1539
  <div
1506
- className="h-10 shrink-0 flex items-center justify-between gap-2 px-3 bg-primary-darker text-white cursor-pointer"
1507
- onClick={onToggleMinimize}
1540
+ className={["h-10 shrink-0 flex items-center justify-between gap-2 px-3 bg-primary-darker text-white", inline ? "" : "cursor-pointer"].join(" ")}
1541
+ onClick={inline ? undefined : onToggleMinimize}
1508
1542
  >
1509
1543
  <span id={titleId} className="text-sm font-medium truncate">
1510
1544
  {title}
1511
1545
  </span>
1512
1546
  <div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}>
1513
- <HeaderButton label="Minimize" onClick={onToggleMinimize} icon={HiOutlineMinus} />
1514
- {!isMobile && (
1547
+ {inline ? (
1548
+ <HeaderButton label="Pop out" onClick={() => onPopOut?.()} icon={HiOutlineArrowTopRightOnSquare} />
1549
+ ) : (
1550
+ <HeaderButton label="Minimize" onClick={onToggleMinimize} icon={HiOutlineMinus} />
1551
+ )}
1552
+ {!inline && !isMobile && (
1515
1553
  <HeaderButton
1516
1554
  label={expanded ? "Collapse" : "Expand"}
1517
1555
  onClick={toggleExpanded}
@@ -1703,8 +1741,21 @@ export default function ComposeWindow({
1703
1741
  {attachments.length > 0 && (
1704
1742
  <ul className="flex flex-wrap gap-2 px-3 pb-2">
1705
1743
  {attachments.map((attachment) => (
1706
- <li key={attachment.uid} className="text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted">
1744
+ <li
1745
+ key={attachment.uid}
1746
+ className="inline-flex items-center gap-1 text-xs font-medium py-1 pl-2.5 pr-1 rounded-pill bg-surface-alt text-text-muted"
1747
+ >
1707
1748
  {attachment.filename}
1749
+ <button
1750
+ type="button"
1751
+ aria-label={`Remove ${attachment.filename}`}
1752
+ title={`Remove ${attachment.filename}`}
1753
+ disabled={removingAttachmentUid === attachment.uid}
1754
+ onClick={() => void handleRemoveAttachment(attachment)}
1755
+ className="inline-flex items-center justify-center rounded-pill p-0.5 hover:bg-border hover:text-text disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-primary"
1756
+ >
1757
+ <HiOutlineXMark size={12} aria-hidden="true" />
1758
+ </button>
1708
1759
  </li>
1709
1760
  ))}
1710
1761
  </ul>
@@ -3,7 +3,7 @@
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import React, { useEffect } from "react";
6
- import { HiOutlineMinus, HiOutlineXMark } from "react-icons/hi2";
6
+ import { HiOutlineArrowTopRightOnSquare, HiOutlineMinus, HiOutlineXMark } from "react-icons/hi2";
7
7
  import Skeleton from "../../../../../lib/components/feedback/Skeleton.js";
8
8
  import Alert from "../../../../../lib/components/feedback/Alert.js";
9
9
  import Button from "../../../../../lib/components/buttons/Button.js";
@@ -18,6 +18,10 @@ export interface ComposeWindowPlaceholderProps {
18
18
  onRetry: () => void;
19
19
  onClose: () => void;
20
20
  onToggleMinimize: () => void;
21
+ /** The placeholder stands in for an inline card in the reading pane (see `ComposeWindowProps.inline`). */
22
+ inline?: boolean;
23
+ /** Moves an `inline` placeholder out to the floating stack. */
24
+ onPopOut?: () => void;
21
25
  }
22
26
 
23
27
  /**
@@ -28,14 +32,14 @@ export interface ComposeWindowPlaceholderProps {
28
32
  *
29
33
  * Nothing here can be typed into yet, and there is no draft to save or discard, so Close just closes.
30
34
  */
31
- export default function ComposeWindowPlaceholder({ session, failed, onRetry, onClose, onToggleMinimize }: ComposeWindowPlaceholderProps) {
35
+ export default function ComposeWindowPlaceholder({ session, failed, onRetry, onClose, onToggleMinimize, inline, onPopOut }: ComposeWindowPlaceholderProps) {
32
36
  const isMobile = useIsMobile();
33
37
  const title = session.initialSubject?.trim() || "New Message";
34
38
  const titleId = `compose-title-${session.id}`;
35
39
 
36
40
  useEffect(() => markComposePhase(session.id, "shell"), [session.id]);
37
41
 
38
- if (session.minimized) {
42
+ if (session.minimized && !inline) {
39
43
  return (
40
44
  <div role="dialog" aria-label={title} className="w-64 shrink-0 bg-surface border border-border border-b-0 rounded-t-md shadow-modal">
41
45
  <div className="h-10 flex items-center justify-between gap-2 px-3 rounded-t-md bg-primary-darker text-white cursor-pointer" onClick={onToggleMinimize}>
@@ -47,27 +51,34 @@ export default function ComposeWindowPlaceholder({ session, failed, onRetry, onC
47
51
 
48
52
  return (
49
53
  <div
50
- role="dialog"
54
+ role={inline ? "region" : "dialog"}
51
55
  aria-labelledby={titleId}
52
56
  aria-busy={!failed}
53
57
  className={[
54
58
  "relative shrink-0 flex flex-col bg-surface border border-border shadow-modal overflow-hidden",
55
- isMobile ? "fixed inset-0 w-full h-full rounded-none border-0" : "border-b-0 rounded-t-md w-[480px] h-[520px]",
59
+ inline
60
+ ? "w-full h-[520px] rounded-md"
61
+ : isMobile
62
+ ? "fixed inset-0 w-full h-full rounded-none border-0"
63
+ : "border-b-0 rounded-t-md w-[480px] h-[520px]",
56
64
  ].join(" ")}
57
65
  >
58
- <div className="h-10 shrink-0 flex items-center justify-between gap-2 px-3 bg-primary-darker text-white cursor-pointer" onClick={onToggleMinimize}>
66
+ <div
67
+ className={["h-10 shrink-0 flex items-center justify-between gap-2 px-3 bg-primary-darker text-white", inline ? "" : "cursor-pointer"].join(" ")}
68
+ onClick={inline ? undefined : onToggleMinimize}
69
+ >
59
70
  <span id={titleId} className="text-sm font-medium truncate">
60
71
  {title}
61
72
  </span>
62
73
  <div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}>
63
74
  <button
64
75
  type="button"
65
- aria-label="Minimize"
66
- title="Minimize"
67
- onClick={onToggleMinimize}
76
+ aria-label={inline ? "Pop out" : "Minimize"}
77
+ title={inline ? "Pop out" : "Minimize"}
78
+ onClick={inline ? onPopOut : onToggleMinimize}
68
79
  className="w-6 h-6 flex items-center justify-center rounded-sm text-white/80 hover:bg-white/15 hover:text-white"
69
80
  >
70
- <HiOutlineMinus size={14} />
81
+ {inline ? <HiOutlineArrowTopRightOnSquare size={14} /> : <HiOutlineMinus size={14} />}
71
82
  </button>
72
83
  <button
73
84
  type="button"
@@ -96,7 +96,8 @@ export default function RichTextEditor({
96
96
  TextStyleKit.configure({ backgroundColor: false, lineHeight: false }),
97
97
  TextAlign.configure({ types: ["heading", "paragraph"] }),
98
98
  Highlight,
99
- Image,
99
+ // A quoted original's pictures arrive as `data:` URIs; `assembleDraft()` attaches them to the draft when it is saved.
100
+ Image.configure({ allowBase64: true }),
100
101
  TableKit.configure({ table: { resizable: true } }),
101
102
  Placeholder.configure({ placeholder: "Write your message…" }),
102
103
  ],
@@ -3,10 +3,12 @@
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import { apiUrl } from "../../../../../lib/util/api.js";
6
- import { Message, Recipient, getMessageRawContent } from "../../../../../lib/mail/mailApi.js";
6
+ import { Message, Recipient, attachmentContentUrl, getMessageRawContent, listAttachments } from "../../../../../lib/mail/mailApi.js";
7
7
  import type { QuotedBody } from "../../../../../lib/mail/compose/composeQuoting.js";
8
+ import { MAX_QUOTED_IMAGE_BYTES, QuotedImageReference, embedQuotedImages } from "../../../../../lib/mail/compose/quotedImages.js";
9
+ import { EMBEDDED_IMAGE_TYPES, findInlineAttachment, toDataUri } from "../../../../../lib/mail/inlineImages.js";
8
10
  import type { MessageSecurityResult } from "../../../../../lib/crypto/messageSecurity.js";
9
- import { decodeHeaderText, extractDisplayBody, parseMimeEntity } from "../../../../../lib/crypto/mime.js";
11
+ import { MimeAttachment, decodeHeaderText, extractDisplayBody, parseMimeEntity } from "../../../../../lib/crypto/mime.js";
10
12
  import { parseRecipientList } from "./recipients.js";
11
13
 
12
14
  /** What a Reply/Reply All/Forward needs from the message being answered, beyond what its `Message` record holds. */
@@ -92,6 +94,38 @@ function headerRecipients(value: string | undefined, type: "to" | "cc"): Recipie
92
94
  .map((recipient) => ({ address: recipient.address, ...(recipient.displayName ? { displayName: recipient.displayName } : {}), type }));
93
95
  }
94
96
 
97
+ /** The `data:` URI of the picture a quoted image of `message` stands for - one of the message's own attachments, fetched with the session -
98
+ * or `undefined` when there is none, it is not a raster image, it is too large or it could not be had. Never rejects. */
99
+ function serverImageResolver(message: Message): (reference: QuotedImageReference) => Promise<string | undefined> {
100
+ let attachments: Promise<Awaited<ReturnType<typeof listAttachments>>> | undefined;
101
+ return async (reference) => {
102
+ try {
103
+ attachments ??= listAttachments(message.folderUid, message.uid);
104
+ const attachment = findInlineAttachment(await attachments, reference);
105
+ const type = attachment?.mimeType.split(";")[0].trim().toLowerCase();
106
+ if (!attachment || !type || !EMBEDDED_IMAGE_TYPES.test(type) || attachment.sizeBytes > MAX_QUOTED_IMAGE_BYTES) {
107
+ return undefined;
108
+ }
109
+ const res = await fetch(attachmentContentUrl(attachment.uid), { credentials: "include" });
110
+ const bytes = res.ok ? new Uint8Array(await res.arrayBuffer()) : undefined;
111
+ return bytes && bytes.length <= MAX_QUOTED_IMAGE_BYTES ? toDataUri(type, bytes) : undefined;
112
+ } catch {
113
+ return undefined;
114
+ }
115
+ };
116
+ }
117
+
118
+ /** As `serverImageResolver()`, for the parts of a message this browser decrypted or verified (`MessageSecurityResult.attachments`). */
119
+ function partImageResolver(parts: MimeAttachment[]): (reference: QuotedImageReference) => Promise<string | undefined> {
120
+ const candidates = parts.map((part, index) => ({ uid: String(index), filename: part.filename ?? "", mimeType: part.contentType, contentId: part.contentId }));
121
+ return async (reference) => {
122
+ const found = findInlineAttachment(candidates, reference);
123
+ const part = found ? parts[Number(found.uid)] : undefined;
124
+ const bytes = part && EMBEDDED_IMAGE_TYPES.test(part.contentType) ? part.decode() : undefined;
125
+ return part && bytes && bytes.length <= MAX_QUOTED_IMAGE_BYTES ? toDataUri(part.contentType, bytes) : undefined;
126
+ };
127
+ }
128
+
95
129
  /**
96
130
  * Loads what a Reply/Reply All/Forward needs from `message`: the full body to quote - the same content
97
131
  * `MessageDetailPane` shows, never the truncated `bodyPreview` unless nothing else can be had - and, with
@@ -113,6 +147,9 @@ function headerRecipients(value: string | undefined, type: "to" | "cc"): Recipie
113
147
  * `message.recipients` alone would reduce every Reply All to a plain reply. Unprotected headers are the sender's own
114
148
  * claim, exactly as in every other mail client's Reply All.
115
149
  *
150
+ * The original's embedded images (`cid:`) are replaced by `data:` URIs of the pictures themselves - fetched from the message's
151
+ * attachments, or taken from the decrypted entity - so the quote shows them and the new message can carry them (see `quotedImages.ts`).
152
+ *
116
153
  * The quote builders sanitize whatever HTML this returns (`sanitizeQuotedHtml()`), whichever source it came from.
117
154
  */
118
155
  export async function loadOriginalMessage(
@@ -157,5 +194,11 @@ export async function loadOriginalMessage(
157
194
  // Whatever was loaded before the failure stands: the quote falls back to the preview, Reply All to the
158
195
  // message's own recipients.
159
196
  }
197
+ if (result.body.html !== undefined) {
198
+ const resolver = recovered ? partImageResolver(security?.attachments ?? []) : serverImageResolver(message);
199
+ const html = result.body.html;
200
+ // Bounded like the body's own request: a picture that will not arrive is left out of the quote, not waited for.
201
+ result.body = { html: await withTimeout(embedQuotedImages(html, resolver), QUOTE_FETCH_TIMEOUT_MS).catch(() => html) };
202
+ }
160
203
  return result;
161
204
  }
@@ -219,6 +219,8 @@ export interface LabelMenuButtonProps extends Omit<LabelSectionsOptions, "state"
219
219
  disabled?: boolean;
220
220
  title?: string;
221
221
  className?: string;
222
+ /** The trigger is just its icon (`className` then styles all of it) - `aria-label` and `title` name it. */
223
+ iconOnly?: boolean;
222
224
  /** With `onLabelCreated`, adds a "New label" row that opens this component's own dialog. */
223
225
  mailboxUid?: string;
224
226
  onLabelCreated?: (label: Label) => void;
@@ -240,6 +242,7 @@ export default function LabelMenuButton({
240
242
  disabled,
241
243
  title,
242
244
  className,
245
+ iconOnly,
243
246
  mailboxUid,
244
247
  onLabelCreated,
245
248
  }: LabelMenuButtonProps) {
@@ -255,6 +258,7 @@ export default function LabelMenuButton({
255
258
  disabled={disabled}
256
259
  title={title}
257
260
  className={className}
261
+ iconOnly={iconOnly}
258
262
  onOpenChange={setOpen}
259
263
  sections={labelSections({
260
264
  labels,
@@ -42,6 +42,13 @@ import { useShortcut } from "../../../keyboard/useShortcut.js";
42
42
 
43
43
  export type MailShellProps = Omit<AppShellProps, "active">;
44
44
 
45
+ /**
46
+ * 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
47
+ * 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
48
+ * 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.
49
+ */
50
+ const DESKTOP_PANE_HEIGHT = "md:h-[calc(100dvh_-_var(--rr-header-h,4rem))]";
51
+
45
52
  /** One mailbox's own mail folders (already filtered to `MAIL_FOLDER_TYPES` and unsorted) - one entry per
46
53
  * mailbox in `mailboxes`, fetched in parallel so every accessible mailbox's folder tree can render
47
54
  * simultaneously (see `MailShell`'s own doc comment on why this replaced the old single-mailbox `folders`
@@ -485,11 +492,11 @@ export default function MailShell({
485
492
  inner = (
486
493
  <>
487
494
  <MailShortcuts mailboxUid={defaultMailboxUid} />
488
- <aside className="hidden md:flex w-64 shrink-0 bg-surface border-r border-border flex-col">{sidebarContent("desktop")}</aside>
495
+ <aside className={["hidden md:flex w-64 shrink-0 bg-surface border-r border-border flex-col", DESKTOP_PANE_HEIGHT].join(" ")}>{sidebarContent("desktop")}</aside>
489
496
  <Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} title="Folders" fullScreen>
490
497
  <div className="flex flex-col">{sidebarContent("mobile")}</div>
491
498
  </Drawer>
492
- <main className="flex-1 min-w-0 overflow-y-auto">
499
+ <main className={["flex-1 min-w-0 overflow-y-auto", DESKTOP_PANE_HEIGHT].join(" ")}>
493
500
  <div className="md:hidden flex items-center gap-2 p-3">
494
501
  <button
495
502
  type="button"
@@ -156,7 +156,7 @@ function HtmlBody({
156
156
  const theme = useResolvedTheme();
157
157
  const surface = useThemeSurface(theme);
158
158
  // Keyed on what the resolver reads rather than on the arrays, which a caller may rebuild on every render.
159
- const inlineKey = [...(attachments ?? []).map((a) => `${a.uid}:${(a as { contentId?: string }).contentId ?? ""}`), inlineParts ? `parts:${inlineParts.length}` : ""].join("|");
159
+ const inlineKey = [...(attachments ?? []).map((a) => `${a.uid}:${(a as { contentId?: string }).contentId ?? ""}:${a.filename}:${a.mimeType}`), inlineParts ? `parts:${inlineParts.length}` : ""].join("|");
160
160
  const resolveCid = useMemo(() => makeCidResolver(attachments, inlineParts), [inlineKey]);
161
161
  const prepared = useMemo(() => prepareBodyHtml(html, { resolveCid }), [html, resolveCid]);
162
162
  const mode: FrameMode = prepared.declaresDarkSupport ? "native" : original ? "original" : "adapt";
@@ -5,6 +5,7 @@
5
5
  import { ApiRequestError, apiUrl } from "../../../../../lib/util/api.js";
6
6
  import { Attachment, attachmentContentUrl } from "../../../../../lib/mail/mailApi.js";
7
7
  import type { MimeAttachment } from "../../../../../lib/crypto/mime.js";
8
+ import { bytesToBase64, findInlineAttachment, toDataUri } from "../../../../../lib/mail/inlineImages.js";
8
9
 
9
10
  /** What a message's body is: server-sanitized HTML, or plain text (a message with no HTML part, or the server's plain-text fallback). */
10
11
  export type BodyContent = { kind: "html"; html: string } | { kind: "text"; text: string };
@@ -61,14 +62,6 @@ export async function fetchBodyContent(uid: string, version: number, signal?: Ab
61
62
  export const MAX_INLINE_IMAGE_BYTES = 5_000_000;
62
63
  const INLINE_IMAGE_TYPES = /^image\/(?:png|jpe?g|gif|webp|avif|bmp|svg\+xml)$/;
63
64
 
64
- function base64(bytes: Uint8Array): string {
65
- let binary = "";
66
- for (let i = 0; i < bytes.length; i += 0x8000) {
67
- binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
68
- }
69
- return btoa(binary);
70
- }
71
-
72
65
  /** A server attachment record as it may really arrive: the model carries the part's `Content-ID`, though the client type doesn't list it. */
73
66
  export type InlineCapable = Attachment & { contentId?: string };
74
67
 
@@ -86,15 +79,24 @@ export function attachmentSource(): string {
86
79
  * How an inline `cid:` image is resolved to something the frame may load: for a message shown from the server, the attachment whose
87
80
  * `Content-ID` matches (its URL on this server); for a decrypted or verified one, the part inside the signed/encrypted entity, embedded
88
81
  * 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.
82
+ *
83
+ * The `Content-ID` is matched without regard to angle brackets, percent-encoding or case. An image that names no part (`contentId`
84
+ * 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
85
+ * one image attachment of the same file name, when `alt` says it.
89
86
  */
90
- export function makeCidResolver(attachments: InlineCapable[] | undefined, parts: MimeAttachment[] | undefined): (contentId: string) => string | undefined {
91
- return (contentId) => {
87
+ export function makeCidResolver(
88
+ attachments: InlineCapable[] | undefined,
89
+ parts: MimeAttachment[] | undefined,
90
+ ): (contentId: string | undefined, alt?: string) => string | undefined {
91
+ return (contentId, alt) => {
92
92
  if (parts) {
93
- const part = parts.find((candidate) => candidate.contentId === contentId);
93
+ const candidates = parts.map((part, index) => ({ uid: String(index), filename: part.filename ?? "", mimeType: part.contentType, contentId: part.contentId }));
94
+ const found = findInlineAttachment(candidates, { cid: contentId, alt });
95
+ const part = found ? parts[Number(found.uid)] : undefined;
94
96
  const bytes = part && INLINE_IMAGE_TYPES.test(part.contentType) ? part.decode() : undefined;
95
- return part && bytes && bytes.length <= MAX_INLINE_IMAGE_BYTES ? `data:${part.contentType};base64,${base64(bytes)}` : undefined;
97
+ return part && bytes && bytes.length <= MAX_INLINE_IMAGE_BYTES ? toDataUri(part.contentType, bytes) : undefined;
96
98
  }
97
- const attachment = attachments?.find((candidate) => candidate.contentId === contentId);
99
+ const attachment = findInlineAttachment(attachments, { cid: contentId, alt });
98
100
  return attachment ? absoluteAttachmentUrl(attachment.uid) : undefined;
99
101
  };
100
102
  }
@@ -39,8 +39,9 @@ export interface PreparedBody {
39
39
 
40
40
  export interface PrepareOptions {
41
41
  /** The URL an inline image's `cid:` reference stands for, or `undefined` when there is no such part. Only `data:` URIs of an
42
- * image type and `http(s)` URLs are accepted; anything else is treated as unresolved. */
43
- resolveCid?: (contentId: string) => string | undefined;
42
+ * image type and `http(s)` URLs are accepted; anything else is treated as unresolved. An image with no source at all (the
43
+ * server removes one it could not point at a part) is asked for with no `contentId` and its `alt`. */
44
+ resolveCid?: (contentId: string | undefined, alt?: string) => string | undefined;
44
45
  }
45
46
 
46
47
  /** What a stylesheet or a `style` attribute may not contain, whatever the sanitizers before it did. */
@@ -222,10 +223,14 @@ function hardenLink(element: Element): void {
222
223
  }
223
224
  }
224
225
 
225
- function hardenImage(element: Element, options: PrepareOptions): void {
226
+ /** Whether the image ends up pointing at a part of the message (so the frame's CSP must allow this server's attachment URLs). */
227
+ function hardenImage(element: Element, options: PrepareOptions): boolean {
226
228
  const src = (element.getAttribute("src") ?? "").trim();
227
- const inline = /^cid:/i.test(src);
228
- const target = inline ? options.resolveCid?.(src.slice(4).replace(/^<|>$/g, "")) : src;
229
+ const cid = /^cid:/i.test(src);
230
+ // 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.
231
+ const inline = cid || src === "";
232
+ const alt = element.getAttribute("alt") ?? undefined;
233
+ const target = cid ? options.resolveCid?.(src.slice(4).replace(/^<|>$/g, ""), alt) : inline ? options.resolveCid?.(undefined, alt) : src;
229
234
  // Remote images are never fetched (they would tell the sender the message was read); an inline image resolves to an embedded
230
235
  // image or to this server's own attachment URL, and one that doesn't resolve has nothing to show.
231
236
  if (target !== undefined && (IMAGE_DATA_URI.test(target) || (inline && /^https?:\/\//i.test(target)))) {
@@ -233,6 +238,7 @@ function hardenImage(element: Element, options: PrepareOptions): void {
233
238
  } else {
234
239
  element.removeAttribute("src");
235
240
  }
241
+ return cid || (inline && element.hasAttribute("src"));
236
242
  }
237
243
 
238
244
  /**
@@ -265,10 +271,9 @@ function harden(body: HTMLElement, options: PrepareOptions): { elements: number;
265
271
  } else if (tag === "a" || tag === "area") {
266
272
  hardenLink(element);
267
273
  } else if (tag === "img") {
268
- if (/^cid:/i.test(element.getAttribute("src") ?? "")) {
274
+ if (hardenImage(element, options)) {
269
275
  inlineImages++;
270
276
  }
271
- hardenImage(element, options);
272
277
  }
273
278
  }
274
279
  return { elements: all.length, inlineImages };
@@ -13,7 +13,7 @@ const ENTER_START_OPACITY = 0.35;
13
13
  const ENTER_SETTLE_MS = 20;
14
14
 
15
15
  /** Whether the user asked their device for less motion. */
16
- function prefersReducedMotion(): boolean {
16
+ export function prefersReducedMotion(): boolean {
17
17
  return typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
18
18
  }
19
19
 
@@ -95,6 +95,8 @@ export const SHORTCUTS = {
95
95
  workWeek: def({ id: "calendar.workWeek", keys: ["ctrl+alt+2"], label: "Work week view", scope: "calendar" }),
96
96
  week: def({ id: "calendar.week", keys: ["ctrl+alt+3"], label: "Week view", scope: "calendar" }),
97
97
  month: def({ id: "calendar.month", keys: ["ctrl+alt+4"], label: "Month view", scope: "calendar" }),
98
+ list: def({ id: "calendar.list", keys: ["ctrl+alt+5"], label: "List view", scope: "calendar" }),
99
+ search: def({ id: "calendar.search", keys: ["/", "mod+e"], label: "Search events", scope: "calendar" }),
98
100
  },
99
101
  contacts: {
100
102
  create: def({ id: "contacts.new", keys: ["alt+n"], electronKeys: ["mod+n"], label: "New contact", scope: "contacts" }),
@@ -0,0 +1,45 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+
6
+ /** The modifier keys of a click - what a mouse event, or the native event behind a checkbox's change, carries. */
7
+ export interface ClickModifiers {
8
+ shiftKey?: boolean;
9
+ ctrlKey?: boolean;
10
+ metaKey?: boolean;
11
+ }
12
+
13
+ /** Whether a click is a multi-select click (Shift, Ctrl, or Cmd on a Mac) rather than the plain click that opens a row. */
14
+ export function isSelectionClick(modifiers: ClickModifiers | undefined): boolean {
15
+ return !!modifiers && !!(modifiers.shiftKey || modifiers.ctrlKey || modifiers.metaKey);
16
+ }
17
+
18
+ /** Ctrl (or Cmd on a Mac): toggle just the clicked row. */
19
+ export function isToggleClick(modifiers: ClickModifiers | undefined): boolean {
20
+ return !!modifiers && !!(modifiers.ctrlKey || modifiers.metaKey);
21
+ }
22
+
23
+ /**
24
+ * 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
25
+ * the list, else the row open in the reading pane (`open`), else nothing (the range is then just the clicked row).
26
+ */
27
+ export function resolveAnchor(keys: readonly string[], anchor: string | null, open: string | null): string | null {
28
+ if (anchor !== null && keys.includes(anchor)) {
29
+ return anchor;
30
+ }
31
+ return open !== null && keys.includes(open) ? open : null;
32
+ }
33
+
34
+ /**
35
+ * Every key from `anchor` to `target` inclusive, in the order `keys` has them - the order the list is displayed in, whichever end of the
36
+ * range was clicked first. With no usable anchor the range is `target` alone.
37
+ */
38
+ export function rangeBetween(keys: readonly string[], anchor: string | null, target: string): string[] {
39
+ const from = anchor === null ? -1 : keys.indexOf(anchor);
40
+ const to = keys.indexOf(target);
41
+ if (from === -1 || to === -1) {
42
+ return [target];
43
+ }
44
+ return keys.slice(Math.min(from, to), Math.max(from, to) + 1);
45
+ }
@@ -0,0 +1,39 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+
6
+ /** How long a row that leaves the list (moved, deleted, archived) takes to collapse. The list drops it from its data after this long. */
7
+ export const ROW_EXIT_MS = 200;
8
+
9
+ /**
10
+ * Whether a row that leaves should collapse rather than vanish: the browser can animate an element (`Element.animate()` - every current one
11
+ * can) and the reader has not asked for less motion (`prefers-reduced-motion`). Otherwise the row is removed at once.
12
+ */
13
+ export function canAnimateRowExit(): boolean {
14
+ // Only called from event handlers, i.e. in a browser.
15
+ if (typeof Element.prototype.animate !== "function") {
16
+ return false;
17
+ }
18
+ return !(typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
19
+ }
20
+
21
+ /**
22
+ * 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
23
+ * stays collapsed (`fill: forwards`) until the list drops it. The row is marked so a second call for the same element does nothing.
24
+ */
25
+ export function collapseRow(row: HTMLElement): void {
26
+ if (row.dataset.collapsing) {
27
+ return;
28
+ }
29
+ row.dataset.collapsing = "true";
30
+ const height = row.getBoundingClientRect().height;
31
+ row.style.overflow = "hidden";
32
+ row.animate(
33
+ [
34
+ { height: `${height}px`, opacity: 1 },
35
+ { height: "0px", opacity: 0, borderBottomWidth: "0px" },
36
+ ],
37
+ { duration: ROW_EXIT_MS, easing: "ease-out", fill: "forwards" },
38
+ );
39
+ }