@rapidmx/web-client 0.25.1 → 0.26.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 (46) hide show
  1. package/apps/shared/components/calendar/EventDetails.tsx +5 -2
  2. package/apps/shared/components/contacts/ContactCard.tsx +330 -0
  3. package/apps/shared/components/contacts/ContactCardProvider.tsx +72 -0
  4. package/apps/shared/components/contacts/ParticipantLink.tsx +42 -0
  5. package/apps/shared/components/contacts/contactCardData.ts +220 -0
  6. package/apps/shared/components/contacts/participantDetails.ts +169 -0
  7. package/apps/shared/components/layout/AppShell.tsx +4 -0
  8. package/apps/shared/components/mail/InviteCard.tsx +27 -5
  9. package/apps/shared/components/mail/MailAddress.tsx +8 -3
  10. package/apps/shared/components/mail/MessageDetailPane.tsx +63 -18
  11. package/apps/shared/components/mail/VCardAttachmentChip.tsx +85 -0
  12. package/apps/shared/components/mail/compose/ComposeWindow.tsx +112 -1
  13. package/apps/shared/components/mail/compose/RecipientInput.tsx +1 -0
  14. package/apps/shared/components/mail/compose/RichTextEditor.tsx +15 -2
  15. package/apps/shared/components/mail/compose/dropFiles.ts +21 -0
  16. package/dist/apps/shared/components/calendar/EventDetails.js +2 -1
  17. package/dist/apps/shared/components/contacts/ContactCard.d.ts +19 -0
  18. package/dist/apps/shared/components/contacts/ContactCard.js +132 -0
  19. package/dist/apps/shared/components/contacts/ContactCardProvider.d.ts +25 -0
  20. package/dist/apps/shared/components/contacts/ContactCardProvider.js +41 -0
  21. package/dist/apps/shared/components/contacts/ParticipantLink.d.ts +18 -0
  22. package/dist/apps/shared/components/contacts/ParticipantLink.js +16 -0
  23. package/dist/apps/shared/components/contacts/contactCardData.d.ts +69 -0
  24. package/dist/apps/shared/components/contacts/contactCardData.js +158 -0
  25. package/dist/apps/shared/components/contacts/participantDetails.d.ts +64 -0
  26. package/dist/apps/shared/components/contacts/participantDetails.js +129 -0
  27. package/dist/apps/shared/components/layout/AppShell.js +11 -10
  28. package/dist/apps/shared/components/mail/InviteCard.js +5 -3
  29. package/dist/apps/shared/components/mail/MailAddress.d.ts +5 -2
  30. package/dist/apps/shared/components/mail/MailAddress.js +5 -3
  31. package/dist/apps/shared/components/mail/MessageDetailPane.js +19 -4
  32. package/dist/apps/shared/components/mail/VCardAttachmentChip.d.ts +18 -0
  33. package/dist/apps/shared/components/mail/VCardAttachmentChip.js +53 -0
  34. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +81 -4
  35. package/dist/apps/shared/components/mail/compose/RecipientInput.js +1 -0
  36. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +5 -2
  37. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +10 -1
  38. package/dist/apps/shared/components/mail/compose/dropFiles.d.ts +8 -0
  39. package/dist/apps/shared/components/mail/compose/dropFiles.js +18 -0
  40. package/dist/lib/contacts/contactsApi.d.ts +3 -0
  41. package/dist/lib/contacts/contactsApi.js +1 -1
  42. package/dist/lib/mail/directoryApi.d.ts +16 -10
  43. package/dist/lib/mail/directoryApi.js +20 -12
  44. package/lib/contacts/contactsApi.ts +1 -1
  45. package/lib/mail/directoryApi.ts +30 -12
  46. package/package.json +1 -1
@@ -4,7 +4,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import { useEffect, useRef, useState } from "react";
7
- import { HiOutlineArrowsPointingIn, HiOutlineArrowsPointingOut, HiOutlineChevronDown, HiOutlineLockClosed, HiOutlineMinus, HiOutlinePaperClip, HiOutlineTrash, HiOutlineXMark, } from "react-icons/hi2";
7
+ import { HiOutlineArrowsPointingIn, HiOutlineArrowsPointingOut, HiOutlineChevronDown, HiOutlineIdentification, HiOutlineLockClosed, HiOutlineMinus, HiOutlinePaperClip, HiOutlineTrash, HiOutlineXMark, } from "react-icons/hi2";
8
8
  import { ApiRequestError } from "../../../../../lib/util/api.js";
9
9
  import { assembleDraft, attachmentContentUrl, createDraft, deleteMessage, getMailbox, getMessage, listFolders, listMailboxes, uploadAttachment, } from "../../../../../lib/mail/mailApi.js";
10
10
  import { listMailSignatures } from "../../../../../lib/mail/mailSignaturesApi.js";
@@ -15,8 +15,10 @@ import { resolveRecipientEncryption } from "../../../../../lib/crypto/composeSec
15
15
  import { getUnlockedKeys, subscribeKeySession } from "../../../../../lib/crypto/keySession.js";
16
16
  import { findActivePublicKey, getEncryptionPolicy, lookupKeys } from "../../../../../lib/crypto/keyvaultApi.js";
17
17
  import { useUnlockPrompt } from "../../layout/UnlockPromptProvider.js";
18
+ import { buildOwnCardFile } from "../../contacts/contactCardData.js";
18
19
  import useIsMobile from "../../../../../lib/util/useIsMobile.js";
19
20
  import { notify } from "../../../notifications/store.js";
21
+ import { isFileDrag, isInlineImage } from "./dropFiles.js";
20
22
  import { evaluateEncryptionRequirement } from "./encryptionRequirement.js";
21
23
  import { decideSend } from "../../../mail/outbox/sendDecision.js";
22
24
  import { sendDecisionInput, startSend } from "../../../mail/outbox/sendJob.js";
@@ -147,7 +149,14 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
147
149
  const [html, setHtml] = useState(resume?.html ?? "");
148
150
  const [contentReady, setContentReady] = useState(!!resume);
149
151
  const [attachments, setAttachments] = useState(resume?.attachments ?? []);
152
+ // The sender's own contact card is on the message (or on its way): "Attach my contact card" then has nothing more to do.
153
+ const [ownCardAttached, setOwnCardAttached] = useState(false);
150
154
  const [attachError, setAttachError] = useState(null);
155
+ // The message editor, for putting a dropped picture into the text; and whether files are being dragged over the window right now.
156
+ const editorRef = useRef(null);
157
+ const [dropActive, setDropActive] = useState(false);
158
+ // Dragging over a child fires leave on the parent, then enter on the child: only when the count is back to zero has the drag left the window.
159
+ const dragDepthRef = useRef(0);
151
160
  // A problem with the message as composed (no recipient, an attachment that can't be signed): shown here, and the window stays open. What
152
161
  // goes wrong after Send was accepted is a pop-up instead (see `sendJob.ts`).
153
162
  const [sendError, setSendError] = useState(null);
@@ -622,6 +631,11 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
622
631
  const files = Array.from(e.target.files ?? []);
623
632
  e.target.value = "";
624
633
  setAttachError(null);
634
+ await attachFiles(files);
635
+ }
636
+ /** Uploads each of `files` as an attachment on the draft, one after another, saying why any that could not be. Resolves whether they all were. */
637
+ async function attachFiles(files) {
638
+ let allAttached = true;
625
639
  uploadStarted();
626
640
  try {
627
641
  for (const file of files) {
@@ -632,6 +646,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
632
646
  void refreshDraftVersion(draft.uid);
633
647
  }
634
648
  catch (err) {
649
+ allAttached = false;
635
650
  setAttachError(err instanceof ApiRequestError ? err.message : "Could not upload attachment.");
636
651
  }
637
652
  }
@@ -639,6 +654,68 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
639
654
  finally {
640
655
  uploadFinished();
641
656
  }
657
+ return allAttached;
658
+ }
659
+ /** 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. */
660
+ async function handleAttachOwnCard() {
661
+ setOwnCardAttached(true);
662
+ setAttachError(null);
663
+ const file = await buildOwnCardFile(mailbox, mailboxes, userUid, client);
664
+ if (!(await attachFiles([file]))) {
665
+ setOwnCardAttached(false);
666
+ }
667
+ }
668
+ /**
669
+ * Files dropped on the window: a picture goes into the message, at the caret, the way "Insert image" would put it; anything else is attached,
670
+ * as "Attach files" would. Done in the order they were dropped.
671
+ */
672
+ async function handleDroppedFiles(files) {
673
+ if (!draft) {
674
+ setAttachError("Please wait for the draft to finish loading before adding files.");
675
+ return;
676
+ }
677
+ setAttachError(null);
678
+ const toAttach = [];
679
+ for (const file of files) {
680
+ if (!isInlineImage(file)) {
681
+ toAttach.push(file);
682
+ continue;
683
+ }
684
+ const url = await handleUploadImage(file);
685
+ if (url) {
686
+ editorRef.current?.chain().focus().setImage({ src: url, alt: file.name }).run();
687
+ }
688
+ }
689
+ await attachFiles(toAttach);
690
+ }
691
+ function handleDragEnter(e) {
692
+ if (isFileDrag(e.dataTransfer)) {
693
+ dragDepthRef.current += 1;
694
+ setDropActive(true);
695
+ }
696
+ }
697
+ function handleDragOver(e) {
698
+ if (isFileDrag(e.dataTransfer)) {
699
+ // What makes this a place a file may be dropped (otherwise the browser opens it instead).
700
+ e.preventDefault();
701
+ e.dataTransfer.dropEffect = "copy";
702
+ }
703
+ }
704
+ function handleDragLeave(e) {
705
+ if (isFileDrag(e.dataTransfer)) {
706
+ dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
707
+ if (dragDepthRef.current === 0) {
708
+ setDropActive(false);
709
+ }
710
+ }
711
+ }
712
+ function handleDrop(e) {
713
+ if (isFileDrag(e.dataTransfer)) {
714
+ e.preventDefault();
715
+ dragDepthRef.current = 0;
716
+ setDropActive(false);
717
+ void handleDroppedFiles(Array.from(e.dataTransfer.files));
718
+ }
642
719
  }
643
720
  /**
644
721
  * Send (or, given `scheduledSendTimeIso`, Send later) - and get out of the way. The window is the place the message is *written*; nothing
@@ -1167,14 +1244,14 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
1167
1244
  if (minimized) {
1168
1245
  return (_jsxs(_Fragment, { children: [_jsx("div", { role: "dialog", "aria-label": title, className: "w-64 shrink-0 bg-surface border border-border border-b-0 rounded-t-md shadow-modal", children: _jsxs("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, children: [_jsx("span", { className: "text-sm font-medium truncate", children: title }), _jsxs("div", { className: "flex items-center gap-0.5", onClick: (e) => e.stopPropagation(), children: [_jsx(HeaderButton, { label: "Restore", onClick: onToggleMinimize, icon: HiOutlineArrowsPointingOut }), _jsx(HeaderButton, { label: "Discard draft", onClick: handleDiscard, icon: HiOutlineXMark, disabled: closing })] })] }) }), discardModal] }));
1169
1246
  }
1170
- return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: windowRef, role: "dialog", "data-shortcut-scope": "compose", "aria-labelledby": titleId, style: !isMobile && manualSize ? { width: manualSize.width, height: manualSize.height } : undefined, className: [
1247
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: windowRef, role: "dialog", "data-shortcut-scope": "compose", "aria-labelledby": titleId, onDragEnter: handleDragEnter, onDragOver: handleDragOver, onDragLeave: handleDragLeave, onDrop: handleDrop, style: !isMobile && manualSize ? { width: manualSize.width, height: manualSize.height } : undefined, className: [
1171
1248
  "shrink-0 flex flex-col bg-surface border border-border shadow-modal overflow-hidden",
1172
1249
  // `fixed` and `relative` may not both be on the element: Tailwind emits `.relative` after `.fixed`, so it wins and the
1173
1250
  // sheet sat in the flow of the little fixed container the windows are stacked in instead of covering the screen.
1174
1251
  isMobile
1175
1252
  ? "fixed inset-0 w-full h-full rounded-none border-0"
1176
1253
  : ["relative border-b-0 rounded-t-md", manualSize ? "" : expanded ? "w-[720px] h-[85vh]" : "w-[480px] h-[520px]"].join(" "),
1177
- ].join(" "), children: [!isMobile && (_jsxs(_Fragment, { children: [_jsx("div", { onPointerDown: handleResizeStart("top"), "aria-hidden": "true", className: "absolute top-0 left-0 right-0 h-1.5 cursor-ns-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("left"), "aria-hidden": "true", className: "absolute top-0 left-0 bottom-0 w-1.5 cursor-ew-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("corner"), role: "separator", "aria-label": "Resize", className: "absolute top-0 left-0 w-3 h-3 cursor-nwse-resize z-30" })] })), _jsxs("div", { className: "h-10 shrink-0 flex items-center justify-between gap-2 px-3 bg-primary-darker text-white cursor-pointer", onClick: onToggleMinimize, children: [_jsx("span", { id: titleId, className: "text-sm font-medium truncate", children: title }), _jsxs("div", { className: "flex items-center gap-0.5", onClick: (e) => e.stopPropagation(), children: [_jsx(HeaderButton, { label: "Minimize", onClick: onToggleMinimize, icon: HiOutlineMinus }), !isMobile && (_jsx(HeaderButton, { label: expanded ? "Collapse" : "Expand", onClick: toggleExpanded, icon: expanded ? HiOutlineArrowsPointingIn : HiOutlineArrowsPointingOut })), _jsx(HeaderButton, { label: "Close", onClick: () => void handleClose(), icon: HiOutlineXMark, disabled: closing, shortcut: SHORTCUTS.compose.close })] })] }), _jsxs("div", { className: "flex-1 min-h-0 flex flex-col", children: [(folderError || draftError || sendError || attachError || discardError) && (_jsxs("div", { className: "px-3 pt-2 shrink-0 max-h-[45%] overflow-y-auto", children: [folderError && _jsx(Alert, { children: folderError }), draftError && _jsx(Alert, { children: draftError }), sendError && _jsx(Alert, { children: sendError }), attachError && _jsx(Alert, { children: attachError }), discardError && _jsx(Alert, { children: discardError })] })), securityBlock && (_jsx("div", { className: "px-3 pt-2", children: _jsxs(Alert, { children: [_jsx("p", { className: "mb-2", children: securityBlock.message }), _jsx(Button, { type: "button", onClick: () => void submit(true, blockedScheduleIso), children: securityBlock.overrideLabel })] }) })), fromOptions.length > 1 && (_jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-from-${id}`, className: "text-xs text-text-muted shrink-0", children: "From" }), _jsx("select", { id: `compose-from-${id}`, className: `${FIELD_INPUT} disabled:opacity-55`,
1254
+ ].join(" "), children: [dropActive && (_jsxs("div", { "data-drop-overlay": "", className: "pointer-events-none absolute inset-0 z-30 flex flex-col items-center justify-center gap-1 border-2 border-dashed border-primary bg-primary/10 text-center text-primary-dark", children: [_jsx("span", { className: "text-base font-semibold", children: "Drop files to attach them" }), _jsx("span", { className: "text-sm", children: "Pictures go into the message where the cursor is" })] })), !isMobile && (_jsxs(_Fragment, { children: [_jsx("div", { onPointerDown: handleResizeStart("top"), "aria-hidden": "true", className: "absolute top-0 left-0 right-0 h-1.5 cursor-ns-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("left"), "aria-hidden": "true", className: "absolute top-0 left-0 bottom-0 w-1.5 cursor-ew-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("corner"), role: "separator", "aria-label": "Resize", className: "absolute top-0 left-0 w-3 h-3 cursor-nwse-resize z-30" })] })), _jsxs("div", { className: "h-10 shrink-0 flex items-center justify-between gap-2 px-3 bg-primary-darker text-white cursor-pointer", onClick: onToggleMinimize, children: [_jsx("span", { id: titleId, className: "text-sm font-medium truncate", children: title }), _jsxs("div", { className: "flex items-center gap-0.5", onClick: (e) => e.stopPropagation(), children: [_jsx(HeaderButton, { label: "Minimize", onClick: onToggleMinimize, icon: HiOutlineMinus }), !isMobile && (_jsx(HeaderButton, { label: expanded ? "Collapse" : "Expand", onClick: toggleExpanded, icon: expanded ? HiOutlineArrowsPointingIn : HiOutlineArrowsPointingOut })), _jsx(HeaderButton, { label: "Close", onClick: () => void handleClose(), icon: HiOutlineXMark, disabled: closing, shortcut: SHORTCUTS.compose.close })] })] }), _jsxs("div", { className: "flex-1 min-h-0 flex flex-col", children: [(folderError || draftError || sendError || attachError || discardError) && (_jsxs("div", { className: "px-3 pt-2 shrink-0 max-h-[45%] overflow-y-auto", children: [folderError && _jsx(Alert, { children: folderError }), draftError && _jsx(Alert, { children: draftError }), sendError && _jsx(Alert, { children: sendError }), attachError && _jsx(Alert, { children: attachError }), discardError && _jsx(Alert, { children: discardError })] })), securityBlock && (_jsx("div", { className: "px-3 pt-2", children: _jsxs(Alert, { children: [_jsx("p", { className: "mb-2", children: securityBlock.message }), _jsx(Button, { type: "button", onClick: () => void submit(true, blockedScheduleIso), children: securityBlock.overrideLabel })] }) })), fromOptions.length > 1 && (_jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-from-${id}`, className: "text-xs text-text-muted shrink-0", children: "From" }), _jsx("select", { id: `compose-from-${id}`, className: `${FIELD_INPUT} disabled:opacity-55`,
1178
1255
  // Two or more options only exist once the mailboxes have loaded, and the same answer that lets them list
1179
1256
  // (one that isn't known to be view-only) is what picks the default sender, so a sender is always chosen by now.
1180
1257
  value: mailboxUid, disabled: hasUploads, title: hasUploads ? "The sender can't be changed after adding attachments or images." : undefined, onChange: (e) => handleFromChange(e.target.value), children: fromOptions.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName ? `${mb.displayName} <${mb.primarySmtpAddress}>` : mb.primarySmtpAddress, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) })] })), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-to-${id}`, className: "text-xs text-text-muted shrink-0", children: "To" }), _jsx(RecipientInput, { id: `compose-to-${id}`, label: "To", value: to, mailboxUid: mailboxUid, onChange: setTo, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)), autoFocus: focusToOnMount }), !showCcBcc && (_jsx("button", { type: "button", onClick: () => setShowCcBcc(true), className: "text-xs text-text-muted hover:text-text shrink-0", children: "Cc Bcc" }))] }), showCcBcc && (_jsxs(_Fragment, { children: [_jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-cc-${id}`, className: "text-xs text-text-muted shrink-0", children: "Cc" }), _jsx(RecipientInput, { id: `compose-cc-${id}`, label: "Cc", value: cc, mailboxUid: mailboxUid, onChange: setCc, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)) })] }), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-bcc-${id}`, className: "text-xs text-text-muted shrink-0", children: "Bcc" }), _jsx(RecipientInput, { id: `compose-bcc-${id}`, label: "Bcc", value: bcc, mailboxUid: mailboxUid, onChange: setBcc, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)) })] })] })), (() => {
@@ -1199,5 +1276,5 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
1199
1276
  return null;
1200
1277
  }
1201
1278
  return (_jsx("ul", { className: "flex flex-wrap gap-1.5 px-3 pt-1.5", "aria-label": "Recipient encryption availability", children: knownRecipients.map((status) => (_jsxs("li", { className: `text-xs font-medium py-0.5 px-2 rounded-pill ${status.canEncrypt ? "bg-primary/10 text-primary-dark" : "bg-surface-alt text-text-muted"}`, children: [status.address, " ", status.canEncrypt ? "supports encryption" : "no encryption key found"] }, status.address))) }));
1202
- })(), _jsx("div", { className: FIELD_ROW, children: _jsx("input", { "aria-label": "Subject", type: "text", placeholder: "Subject", className: FIELD_INPUT, value: subject, autoFocus: focusSubjectOnMount, onChange: (e) => setSubject(e.target.value) }) }), _jsx("div", { className: "flex-1 min-h-0 p-2", children: contentReady && (_jsx(RichTextEditor, { appendHtml: lateQuoteHtml, onAppended: handleQuoteAppended, value: html, onChange: setHtml, fill: true, onUploadImage: handleUploadImage, autoFocusStart: focusBodyOnMount, onInitialized: handleEditorInitialized })) }), quotePending && (_jsx("p", { role: "status", className: "px-3 pb-1 text-xs text-text-muted", children: "Loading the original message\u2026" })), attachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2 px-3 pb-2", children: attachments.map((attachment) => (_jsx("li", { className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted", children: attachment.filename }, attachment.uid))) })), _jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: requestReceipt, onChange: (e) => setRequestReceipt(e.target.checked) }), "Request a read receipt"] }), needsUnlockForCrypto && (_jsx("div", { className: "px-3 pb-1", children: _jsxs("button", { type: "button", onClick: handleUnlockForCrypto, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to sign or encrypt this message"] }) })), unlockedKeys?.signingPrivateKey && (_jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: signEnabled, onChange: (e) => setSignEnabled(e.target.checked) }), "Digitally sign this message"] })), unlockedKeys?.encryptionPrivateKey && (_jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: encryptRequested, onChange: (e) => setEncryptRequested(e.target.checked) }), "Encrypt this message"] })), _jsxs("div", { className: "shrink-0 flex items-center gap-1 px-3 py-2 border-t border-border", children: [_jsxs("div", { className: "flex items-center rounded-pill bg-primary text-white overflow-hidden", children: [_jsx("button", { type: "button", onClick: () => void submit(false), title: withHint("Send", SHORTCUTS.compose.send, keyEnv), "aria-keyshortcuts": ariaKeyShortcuts(SHORTCUTS.compose.send, keyEnv), disabled: !draft || closing || waitingForUploads, className: "py-1.5 pl-5 pr-3 font-semibold text-sm hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed", children: "Send" }), _jsx("button", { ref: scheduleButtonRef, type: "button", "aria-label": "Send later", "aria-haspopup": "true", "aria-expanded": schedulePickerOpen, disabled: !draft || closing || waitingForUploads, onClick: () => setSchedulePickerOpen((o) => !o), className: "py-1.5 px-2 border-l border-white/30 hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed", children: _jsx(HiOutlineChevronDown, { size: 14 }) })] }), schedulePickerOpen && (_jsx(ScheduleSendPicker, { anchorRef: scheduleButtonRef, onClose: () => setSchedulePickerOpen(false), onSchedule: handleScheduleSend })), _jsxs("label", { "aria-label": "Attach files", title: "Attach files", className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text cursor-pointer has-[:disabled]:opacity-40 has-[:disabled]:cursor-not-allowed", children: [_jsx(HiOutlinePaperClip, { size: 18 }), _jsx("input", { type: "file", multiple: true, disabled: !draft, onChange: handleFilesSelected, className: "sr-only" })] }), _jsx("span", { role: "status", "aria-live": "polite", className: "ml-auto text-xs text-text-muted", children: waitingForUploads ? "Waiting for attachments to finish uploading…" : SAVE_STATUS_LABEL[saveStatus] }), _jsx("button", { type: "button", "aria-label": "Discard draft", title: "Discard draft", onClick: handleDiscard, disabled: closing, className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text disabled:opacity-40 disabled:cursor-not-allowed", children: _jsx(HiOutlineTrash, { size: 18 }) })] })] })] }), discardModal] }));
1279
+ })(), _jsx("div", { className: FIELD_ROW, children: _jsx("input", { "aria-label": "Subject", type: "text", placeholder: "Subject", className: FIELD_INPUT, value: subject, autoFocus: focusSubjectOnMount, onChange: (e) => setSubject(e.target.value) }) }), _jsx("div", { className: "flex-1 min-h-0 p-2", children: contentReady && (_jsx(RichTextEditor, { appendHtml: lateQuoteHtml, onAppended: handleQuoteAppended, value: html, onChange: setHtml, fill: true, onUploadImage: handleUploadImage, autoFocusStart: focusBodyOnMount, onInitialized: handleEditorInitialized, editorRef: editorRef })) }), quotePending && (_jsx("p", { role: "status", className: "px-3 pb-1 text-xs text-text-muted", children: "Loading the original message\u2026" })), attachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2 px-3 pb-2", children: attachments.map((attachment) => (_jsx("li", { className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted", children: attachment.filename }, attachment.uid))) })), _jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: requestReceipt, onChange: (e) => setRequestReceipt(e.target.checked) }), "Request a read receipt"] }), needsUnlockForCrypto && (_jsx("div", { className: "px-3 pb-1", children: _jsxs("button", { type: "button", onClick: handleUnlockForCrypto, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to sign or encrypt this message"] }) })), unlockedKeys?.signingPrivateKey && (_jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: signEnabled, onChange: (e) => setSignEnabled(e.target.checked) }), "Digitally sign this message"] })), unlockedKeys?.encryptionPrivateKey && (_jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: encryptRequested, onChange: (e) => setEncryptRequested(e.target.checked) }), "Encrypt this message"] })), _jsxs("div", { className: "shrink-0 flex items-center gap-1 px-3 py-2 border-t border-border", children: [_jsxs("div", { className: "flex items-center rounded-pill bg-primary text-white overflow-hidden", children: [_jsx("button", { type: "button", onClick: () => void submit(false), title: withHint("Send", SHORTCUTS.compose.send, keyEnv), "aria-keyshortcuts": ariaKeyShortcuts(SHORTCUTS.compose.send, keyEnv), disabled: !draft || closing || waitingForUploads, className: "py-1.5 pl-5 pr-3 font-semibold text-sm hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed", children: "Send" }), _jsx("button", { ref: scheduleButtonRef, type: "button", "aria-label": "Send later", "aria-haspopup": "true", "aria-expanded": schedulePickerOpen, disabled: !draft || closing || waitingForUploads, onClick: () => setSchedulePickerOpen((o) => !o), className: "py-1.5 px-2 border-l border-white/30 hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed", children: _jsx(HiOutlineChevronDown, { size: 14 }) })] }), schedulePickerOpen && (_jsx(ScheduleSendPicker, { anchorRef: scheduleButtonRef, onClose: () => setSchedulePickerOpen(false), onSchedule: handleScheduleSend })), _jsxs("label", { "aria-label": "Attach files", title: "Attach files", className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text cursor-pointer has-[:disabled]:opacity-40 has-[:disabled]:cursor-not-allowed", children: [_jsx(HiOutlinePaperClip, { size: 18 }), _jsx("input", { type: "file", multiple: true, disabled: !draft, onChange: handleFilesSelected, className: "sr-only" })] }), _jsx("button", { type: "button", "aria-label": "Attach my contact card", title: ownCardAttached ? "Your contact card is attached" : "Attach my contact card", "aria-pressed": ownCardAttached, disabled: !draft || !mailbox || ownCardAttached, onClick: () => void handleAttachOwnCard(), className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:not-disabled:bg-surface-alt hover:not-disabled:text-text disabled:opacity-40 disabled:cursor-not-allowed", children: _jsx(HiOutlineIdentification, { size: 18 }) }), _jsx("span", { role: "status", "aria-live": "polite", className: "ml-auto text-xs text-text-muted", children: waitingForUploads ? "Waiting for attachments to finish uploading…" : SAVE_STATUS_LABEL[saveStatus] }), _jsx("button", { type: "button", "aria-label": "Discard draft", title: "Discard draft", onClick: handleDiscard, disabled: closing, className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text disabled:opacity-40 disabled:cursor-not-allowed", children: _jsx(HiOutlineTrash, { size: 18 }) })] })] })] }), discardModal] }));
1203
1280
  }
@@ -17,6 +17,7 @@ const KIND_LABELS = {
17
17
  room: "Room",
18
18
  equipment: "Equipment",
19
19
  list: "Group",
20
+ correspondent: "Recent",
20
21
  };
21
22
  /** Where the suggestions go: under the field (or above it when there's much more room there), as wide as the field. */
22
23
  function dropdownPosition(anchor) {
@@ -1,4 +1,5 @@
1
- import React from "react";
1
+ import React, { MutableRefObject } from "react";
2
+ import { type Editor } from "@tiptap/react";
2
3
  export interface RichTextEditorProps {
3
4
  value: string;
4
5
  onChange: (value: string) => void;
@@ -26,6 +27,8 @@ export interface RichTextEditorProps {
26
27
  appendHtml?: string;
27
28
  /** Called with the document as the editor serializes it, right after `appendHtml` was added. */
28
29
  onAppended?: (value: string) => void;
30
+ /** Set to the editor while it exists (and to `null` before and after), for a caller that has to put content into the message from outside the toolbar - a picture dropped on the window. */
31
+ editorRef?: MutableRefObject<Editor | null>;
29
32
  }
30
33
  /**
31
34
  * A WYSIWYG rich-text editor for the Compose window's message body — replaces `MonacoHtmlEditor` (an HTML
@@ -48,4 +51,4 @@ export interface RichTextEditorProps {
48
51
  * sufficient here since TipTap (unlike Monaco) never touches the DOM at module-evaluation time, only when
49
52
  * an editor view actually mounts.
50
53
  */
51
- export default function RichTextEditor({ value, onChange, height, fill, onUploadImage, autoFocusStart, onInitialized, appendHtml, onAppended, }: RichTextEditorProps): React.JSX.Element;
54
+ export default function RichTextEditor({ value, onChange, height, fill, onUploadImage, autoFocusStart, onInitialized, appendHtml, onAppended, editorRef, }: RichTextEditorProps): React.JSX.Element;
@@ -34,7 +34,7 @@ import ComposeToolbar from "./ComposeToolbar.js";
34
34
  * sufficient here since TipTap (unlike Monaco) never touches the DOM at module-evaluation time, only when
35
35
  * an editor view actually mounts.
36
36
  */
37
- export default function RichTextEditor({ value, onChange, height = "360px", fill = false, onUploadImage, autoFocusStart = false, onInitialized, appendHtml, onAppended, }) {
37
+ export default function RichTextEditor({ value, onChange, height = "360px", fill = false, onUploadImage, autoFocusStart = false, onInitialized, appendHtml, onAppended, editorRef, }) {
38
38
  const onChangeRef = useRef(onChange);
39
39
  onChangeRef.current = onChange;
40
40
  const onInitializedRef = useRef(onInitialized);
@@ -78,6 +78,15 @@ export default function RichTextEditor({ value, onChange, height = "360px", fill
78
78
  }
79
79
  },
80
80
  });
81
+ useEffect(() => {
82
+ if (!editorRef) {
83
+ return undefined;
84
+ }
85
+ editorRef.current = editor;
86
+ return () => {
87
+ editorRef.current = null;
88
+ };
89
+ }, [editor, editorRef]);
81
90
  // Added once, at the end, whatever the reader has typed meanwhile (the caret stays where it is).
82
91
  useEffect(() => {
83
92
  if (!editor || appendHtml === undefined || appendedRef.current === appendHtml) {
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Whether a file dropped on the compose window goes into the message itself, at the caret, rather than onto it as an attachment: pictures do (a
3
+ * photo or a screenshot is meant to be seen in the text), everything else is attached. The browser's own idea of the type decides, and the file's name
4
+ * does when it had none.
5
+ */
6
+ export declare function isInlineImage(file: Pick<File, "name" | "type">): boolean;
7
+ /** Whether a drag carries files from outside the page (as opposed to the text or an image being dragged around inside the editor). */
8
+ export declare function isFileDrag(dataTransfer: Pick<DataTransfer, "types"> | null | undefined): boolean;
@@ -0,0 +1,18 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /** Picture files by name, for a file the browser gave no type to. */
6
+ const IMAGE_EXTENSION = /\.(jpe?g|png|gif|webp|svg|avif|bmp|ico|apng)$/i;
7
+ /**
8
+ * Whether a file dropped on the compose window goes into the message itself, at the caret, rather than onto it as an attachment: pictures do (a
9
+ * photo or a screenshot is meant to be seen in the text), everything else is attached. The browser's own idea of the type decides, and the file's name
10
+ * does when it had none.
11
+ */
12
+ export function isInlineImage(file) {
13
+ return file.type ? file.type.startsWith("image/") : IMAGE_EXTENSION.test(file.name);
14
+ }
15
+ /** Whether a drag carries files from outside the page (as opposed to the text or an image being dragged around inside the editor). */
16
+ export function isFileDrag(dataTransfer) {
17
+ return Array.from(dataTransfer?.types ?? []).includes("Files");
18
+ }
@@ -77,6 +77,9 @@ export declare function pinnedSigningFingerprintsFor(contacts: Contact[], addres
77
77
  * `limit` cap). */
78
78
  export declare const PINNED_FINGERPRINT_PAGE_SIZE = 500;
79
79
  export declare const PINNED_FINGERPRINT_MAX_PAGES = 20;
80
+ /** Every contact in `folderUids`, paged (`PINNED_FINGERPRINT_PAGE_SIZE`, at most `PINNED_FINGERPRINT_MAX_PAGES` pages
81
+ * per folder). A plain contacts read - never a Discovery lookup. */
82
+ export declare function listContactsInFolders(folderUids: string[], client?: ApiClient): Promise<Contact[]>;
80
83
  /**
81
84
  * Fetches the trusted signing-key fingerprints (pinned and previous - see `pinnedSigningFingerprintsFor()`) for a
82
85
  * message sender's `address` from the contacts in `folderUids` (the reader's contacts folders), for
@@ -29,7 +29,7 @@ export const PINNED_FINGERPRINT_PAGE_SIZE = 500;
29
29
  export const PINNED_FINGERPRINT_MAX_PAGES = 20;
30
30
  /** Every contact in `folderUids`, paged (`PINNED_FINGERPRINT_PAGE_SIZE`, at most `PINNED_FINGERPRINT_MAX_PAGES` pages
31
31
  * per folder). A plain contacts read - never a Discovery lookup. */
32
- async function listContactsInFolders(folderUids, client) {
32
+ export async function listContactsInFolders(folderUids, client) {
33
33
  const contacts = [];
34
34
  for (const folderUid of folderUids) {
35
35
  for (let page = 0; page < PINNED_FINGERPRINT_MAX_PAGES; page++) {
@@ -1,12 +1,14 @@
1
1
  /**
2
2
  * Typed wrappers over `@rapidmx/restapi`'s `BaseDirectoryRoute` - recipient suggestions for compose. `GET
3
3
  * /mail/directory` searches the server's mailboxes (people, shared mailboxes, rooms and equipment) and distribution
4
- * lists; `GET /mail/directory/contacts` searches the caller's own contacts. Both match every word of the query against
5
- * the start of a name word or of an address, and return only a display name, an address and a kind.
4
+ * lists; `GET /mail/directory/contacts` searches the caller's own contacts; `GET /mail/directory/correspondents` searches
5
+ * everyone the caller's mailboxes have exchanged mail or shared an event with, address book or not. All match every word of
6
+ * the query against the start of a name word or of an address, and return only a display name, an address and a kind.
6
7
  */
7
8
  import { ApiClient } from "../util/api.js";
8
- /** What a suggestion names. `"contact"` comes from the caller's contacts, everything else from the server directory. */
9
- export type RecipientSuggestionKind = "user" | "shared" | "room" | "equipment" | "list" | "contact";
9
+ /** What a suggestion names. `"contact"` comes from the caller's contacts, `"correspondent"` from the people they have written to, heard from or
10
+ * met with, everything else from the server directory. */
11
+ export type RecipientSuggestionKind = "user" | "shared" | "room" | "equipment" | "list" | "contact" | "correspondent";
10
12
  export interface RecipientSuggestion {
11
13
  displayName: string;
12
14
  address: string;
@@ -38,12 +40,16 @@ export interface ContactSuggestionOptions extends RecipientSuggestionOptions {
38
40
  export declare function searchDirectory(query: string, options?: RecipientSuggestionOptions, client?: ApiClient): Promise<RecipientSuggestion[]>;
39
41
  /** Searches the caller's contacts (every address of a contact whose name matches, or the addresses that match). */
40
42
  export declare function searchContactSuggestions(query: string, options?: ContactSuggestionOptions, client?: ApiClient): Promise<RecipientSuggestion[]>;
41
- /** Contacts first, then directory entries, without repeating an address (compared case-insensitively; the first
42
- * occurrence wins), at most `limit`. */
43
- export declare function mergeRecipientSuggestions(contacts: RecipientSuggestion[], directory: RecipientSuggestion[], limit?: number): RecipientSuggestion[];
43
+ /** Searches the people the caller's mailboxes have exchanged mail or shared an event with, whether or not they are in the address book. Rejects like
44
+ * `searchDirectory()`; a server that predates the endpoint answers 404, which `fetchRecipientSuggestions()` treats as "none". */
45
+ export declare function searchCorrespondents(query: string, options?: ContactSuggestionOptions, client?: ApiClient): Promise<RecipientSuggestion[]>;
46
+ /** Contacts first, then directory entries, then the people the caller has corresponded with, without repeating an address (compared
47
+ * case-insensitively; the first occurrence wins), at most `limit`. */
48
+ export declare function mergeRecipientSuggestions(contacts: RecipientSuggestion[], directory: RecipientSuggestion[], limit?: number, correspondents?: RecipientSuggestion[]): RecipientSuggestion[];
44
49
  /**
45
- * Contacts and directory suggestions for `query`, merged by `mergeRecipientSuggestions()`. Either source failing (a 403
46
- * directory for a caller with no mailbox here, a 429, a network error) still returns the other's entries; only when
47
- * both fail does this reject, with the contacts error. An aborted `signal` always rejects with the `AbortError`.
50
+ * Contacts, directory and correspondent suggestions for `query`, merged by `mergeRecipientSuggestions()`. Any source failing (a 403
51
+ * directory for a caller with no mailbox here, a 429, a network error, a server without the correspondents endpoint) still returns
52
+ * the others' entries; only when the contacts and the directory both fail does this reject, with the contacts error - the
53
+ * correspondents are a bonus and never turn a working search into an error. An aborted `signal` always rejects with the `AbortError`.
48
54
  */
49
55
  export declare function fetchRecipientSuggestions(query: string, options?: ContactSuggestionOptions, client?: ApiClient): Promise<RecipientSuggestion[]>;
@@ -5,8 +5,9 @@
5
5
  /**
6
6
  * Typed wrappers over `@rapidmx/restapi`'s `BaseDirectoryRoute` - recipient suggestions for compose. `GET
7
7
  * /mail/directory` searches the server's mailboxes (people, shared mailboxes, rooms and equipment) and distribution
8
- * lists; `GET /mail/directory/contacts` searches the caller's own contacts. Both match every word of the query against
9
- * the start of a name word or of an address, and return only a display name, an address and a kind.
8
+ * lists; `GET /mail/directory/contacts` searches the caller's own contacts; `GET /mail/directory/correspondents` searches
9
+ * everyone the caller's mailboxes have exchanged mail or shared an event with, address book or not. All match every word of
10
+ * the query against the start of a name word or of an address, and return only a display name, an address and a kind.
10
11
  */
11
12
  import { ApiRequestError, withClient } from "../util/api.js";
12
13
  /** The shortest trimmed query the server searches for - shorter ones resolve to `[]` without a request. */
@@ -58,12 +59,17 @@ export function searchDirectory(query, options = {}, client) {
58
59
  export function searchContactSuggestions(query, options = {}, client) {
59
60
  return fetchSuggestions("/mail/directory/contacts", query, { limit: options.limit?.toString(), mailboxUid: options.mailboxUid }, options.signal, client);
60
61
  }
61
- /** Contacts first, then directory entries, without repeating an address (compared case-insensitively; the first
62
- * occurrence wins), at most `limit`. */
63
- export function mergeRecipientSuggestions(contacts, directory, limit = RECIPIENT_SUGGESTION_DEFAULT_LIMIT) {
62
+ /** Searches the people the caller's mailboxes have exchanged mail or shared an event with, whether or not they are in the address book. Rejects like
63
+ * `searchDirectory()`; a server that predates the endpoint answers 404, which `fetchRecipientSuggestions()` treats as "none". */
64
+ export function searchCorrespondents(query, options = {}, client) {
65
+ return fetchSuggestions("/mail/directory/correspondents", query, { limit: options.limit?.toString(), mailboxUid: options.mailboxUid }, options.signal, client);
66
+ }
67
+ /** Contacts first, then directory entries, then the people the caller has corresponded with, without repeating an address (compared
68
+ * case-insensitively; the first occurrence wins), at most `limit`. */
69
+ export function mergeRecipientSuggestions(contacts, directory, limit = RECIPIENT_SUGGESTION_DEFAULT_LIMIT, correspondents = []) {
64
70
  const seen = new Set();
65
71
  const result = [];
66
- for (const entry of [...contacts, ...directory]) {
72
+ for (const entry of [...contacts, ...directory, ...correspondents]) {
67
73
  const key = entry.address.trim().toLowerCase();
68
74
  if (result.length >= limit) {
69
75
  break;
@@ -79,17 +85,19 @@ function isAbort(error) {
79
85
  return error?.name === "AbortError";
80
86
  }
81
87
  /**
82
- * Contacts and directory suggestions for `query`, merged by `mergeRecipientSuggestions()`. Either source failing (a 403
83
- * directory for a caller with no mailbox here, a 429, a network error) still returns the other's entries; only when
84
- * both fail does this reject, with the contacts error. An aborted `signal` always rejects with the `AbortError`.
88
+ * Contacts, directory and correspondent suggestions for `query`, merged by `mergeRecipientSuggestions()`. Any source failing (a 403
89
+ * directory for a caller with no mailbox here, a 429, a network error, a server without the correspondents endpoint) still returns
90
+ * the others' entries; only when the contacts and the directory both fail does this reject, with the contacts error - the
91
+ * correspondents are a bonus and never turn a working search into an error. An aborted `signal` always rejects with the `AbortError`.
85
92
  */
86
93
  export async function fetchRecipientSuggestions(query, options = {}, client) {
87
94
  const limit = options.limit ?? RECIPIENT_SUGGESTION_DEFAULT_LIMIT;
88
- const [contacts, directory] = await Promise.allSettled([
95
+ const [contacts, directory, correspondents] = await Promise.allSettled([
89
96
  searchContactSuggestions(query, { ...options, limit }, client),
90
97
  searchDirectory(query, { limit, signal: options.signal }, client),
98
+ searchCorrespondents(query, { ...options, limit }, client),
91
99
  ]);
92
- for (const outcome of [contacts, directory]) {
100
+ for (const outcome of [contacts, directory, correspondents]) {
93
101
  if (outcome.status === "rejected" && isAbort(outcome.reason)) {
94
102
  throw outcome.reason;
95
103
  }
@@ -97,5 +105,5 @@ export async function fetchRecipientSuggestions(query, options = {}, client) {
97
105
  if (contacts.status === "rejected" && directory.status === "rejected") {
98
106
  throw contacts.reason instanceof Error ? contacts.reason : new ApiRequestError("Suggestions couldn't be loaded.", 0);
99
107
  }
100
- return mergeRecipientSuggestions(contacts.status === "fulfilled" ? contacts.value : [], directory.status === "fulfilled" ? directory.value : [], limit);
108
+ return mergeRecipientSuggestions(contacts.status === "fulfilled" ? contacts.value : [], directory.status === "fulfilled" ? directory.value : [], limit, correspondents.status === "fulfilled" ? correspondents.value : []);
101
109
  }
@@ -109,7 +109,7 @@ export const PINNED_FINGERPRINT_MAX_PAGES = 20;
109
109
 
110
110
  /** Every contact in `folderUids`, paged (`PINNED_FINGERPRINT_PAGE_SIZE`, at most `PINNED_FINGERPRINT_MAX_PAGES` pages
111
111
  * per folder). A plain contacts read - never a Discovery lookup. */
112
- async function listContactsInFolders(folderUids: string[], client?: ApiClient): Promise<Contact[]> {
112
+ export async function listContactsInFolders(folderUids: string[], client?: ApiClient): Promise<Contact[]> {
113
113
  const contacts: Contact[] = [];
114
114
  for (const folderUid of folderUids) {
115
115
  for (let page = 0; page < PINNED_FINGERPRINT_MAX_PAGES; page++) {
@@ -5,13 +5,15 @@
5
5
  /**
6
6
  * Typed wrappers over `@rapidmx/restapi`'s `BaseDirectoryRoute` - recipient suggestions for compose. `GET
7
7
  * /mail/directory` searches the server's mailboxes (people, shared mailboxes, rooms and equipment) and distribution
8
- * lists; `GET /mail/directory/contacts` searches the caller's own contacts. Both match every word of the query against
9
- * the start of a name word or of an address, and return only a display name, an address and a kind.
8
+ * lists; `GET /mail/directory/contacts` searches the caller's own contacts; `GET /mail/directory/correspondents` searches
9
+ * everyone the caller's mailboxes have exchanged mail or shared an event with, address book or not. All match every word of
10
+ * the query against the start of a name word or of an address, and return only a display name, an address and a kind.
10
11
  */
11
12
  import { ApiClient, ApiRequestError, withClient } from "../util/api.js";
12
13
 
13
- /** What a suggestion names. `"contact"` comes from the caller's contacts, everything else from the server directory. */
14
- export type RecipientSuggestionKind = "user" | "shared" | "room" | "equipment" | "list" | "contact";
14
+ /** What a suggestion names. `"contact"` comes from the caller's contacts, `"correspondent"` from the people they have written to, heard from or
15
+ * met with, everything else from the server directory. */
16
+ export type RecipientSuggestionKind = "user" | "shared" | "room" | "equipment" | "list" | "contact" | "correspondent";
15
17
 
16
18
  export interface RecipientSuggestion {
17
19
  displayName: string;
@@ -104,16 +106,29 @@ export function searchContactSuggestions(
104
106
  );
105
107
  }
106
108
 
107
- /** Contacts first, then directory entries, without repeating an address (compared case-insensitively; the first
108
- * occurrence wins), at most `limit`. */
109
+ /** Searches the people the caller's mailboxes have exchanged mail or shared an event with, whether or not they are in the address book. Rejects like
110
+ * `searchDirectory()`; a server that predates the endpoint answers 404, which `fetchRecipientSuggestions()` treats as "none". */
111
+ export function searchCorrespondents(query: string, options: ContactSuggestionOptions = {}, client?: ApiClient): Promise<RecipientSuggestion[]> {
112
+ return fetchSuggestions(
113
+ "/mail/directory/correspondents",
114
+ query,
115
+ { limit: options.limit?.toString(), mailboxUid: options.mailboxUid },
116
+ options.signal,
117
+ client,
118
+ );
119
+ }
120
+
121
+ /** Contacts first, then directory entries, then the people the caller has corresponded with, without repeating an address (compared
122
+ * case-insensitively; the first occurrence wins), at most `limit`. */
109
123
  export function mergeRecipientSuggestions(
110
124
  contacts: RecipientSuggestion[],
111
125
  directory: RecipientSuggestion[],
112
126
  limit: number = RECIPIENT_SUGGESTION_DEFAULT_LIMIT,
127
+ correspondents: RecipientSuggestion[] = [],
113
128
  ): RecipientSuggestion[] {
114
129
  const seen = new Set<string>();
115
130
  const result: RecipientSuggestion[] = [];
116
- for (const entry of [...contacts, ...directory]) {
131
+ for (const entry of [...contacts, ...directory, ...correspondents]) {
117
132
  const key = entry.address.trim().toLowerCase();
118
133
  if (result.length >= limit) {
119
134
  break;
@@ -131,9 +146,10 @@ function isAbort(error: unknown): boolean {
131
146
  }
132
147
 
133
148
  /**
134
- * Contacts and directory suggestions for `query`, merged by `mergeRecipientSuggestions()`. Either source failing (a 403
135
- * directory for a caller with no mailbox here, a 429, a network error) still returns the other's entries; only when
136
- * both fail does this reject, with the contacts error. An aborted `signal` always rejects with the `AbortError`.
149
+ * Contacts, directory and correspondent suggestions for `query`, merged by `mergeRecipientSuggestions()`. Any source failing (a 403
150
+ * directory for a caller with no mailbox here, a 429, a network error, a server without the correspondents endpoint) still returns
151
+ * the others' entries; only when the contacts and the directory both fail does this reject, with the contacts error - the
152
+ * correspondents are a bonus and never turn a working search into an error. An aborted `signal` always rejects with the `AbortError`.
137
153
  */
138
154
  export async function fetchRecipientSuggestions(
139
155
  query: string,
@@ -141,11 +157,12 @@ export async function fetchRecipientSuggestions(
141
157
  client?: ApiClient,
142
158
  ): Promise<RecipientSuggestion[]> {
143
159
  const limit = options.limit ?? RECIPIENT_SUGGESTION_DEFAULT_LIMIT;
144
- const [contacts, directory] = await Promise.allSettled([
160
+ const [contacts, directory, correspondents] = await Promise.allSettled([
145
161
  searchContactSuggestions(query, { ...options, limit }, client),
146
162
  searchDirectory(query, { limit, signal: options.signal }, client),
163
+ searchCorrespondents(query, { ...options, limit }, client),
147
164
  ]);
148
- for (const outcome of [contacts, directory]) {
165
+ for (const outcome of [contacts, directory, correspondents]) {
149
166
  if (outcome.status === "rejected" && isAbort(outcome.reason)) {
150
167
  throw outcome.reason;
151
168
  }
@@ -157,5 +174,6 @@ export async function fetchRecipientSuggestions(
157
174
  contacts.status === "fulfilled" ? contacts.value : [],
158
175
  directory.status === "fulfilled" ? directory.value : [],
159
176
  limit,
177
+ correspondents.status === "fulfilled" ? correspondents.value : [],
160
178
  );
161
179
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rapidmx/web-client",
3
- "version": "0.25.1",
3
+ "version": "0.26.0",
4
4
  "description": "RapidMX's webmail and admin console React UI for web-based clients.",
5
5
  "type": "module",
6
6
  "repository": "https://github.com/rapidmx/web-client",