@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.
- package/apps/shared/components/calendar/EventDetails.tsx +5 -2
- package/apps/shared/components/contacts/ContactCard.tsx +330 -0
- package/apps/shared/components/contacts/ContactCardProvider.tsx +72 -0
- package/apps/shared/components/contacts/ParticipantLink.tsx +42 -0
- package/apps/shared/components/contacts/contactCardData.ts +220 -0
- package/apps/shared/components/contacts/participantDetails.ts +169 -0
- package/apps/shared/components/layout/AppShell.tsx +4 -0
- package/apps/shared/components/mail/InviteCard.tsx +27 -5
- package/apps/shared/components/mail/MailAddress.tsx +8 -3
- package/apps/shared/components/mail/MessageDetailPane.tsx +63 -18
- package/apps/shared/components/mail/VCardAttachmentChip.tsx +85 -0
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +112 -1
- package/apps/shared/components/mail/compose/RecipientInput.tsx +1 -0
- package/apps/shared/components/mail/compose/RichTextEditor.tsx +15 -2
- package/apps/shared/components/mail/compose/dropFiles.ts +21 -0
- package/dist/apps/shared/components/calendar/EventDetails.js +2 -1
- package/dist/apps/shared/components/contacts/ContactCard.d.ts +19 -0
- package/dist/apps/shared/components/contacts/ContactCard.js +132 -0
- package/dist/apps/shared/components/contacts/ContactCardProvider.d.ts +25 -0
- package/dist/apps/shared/components/contacts/ContactCardProvider.js +41 -0
- package/dist/apps/shared/components/contacts/ParticipantLink.d.ts +18 -0
- package/dist/apps/shared/components/contacts/ParticipantLink.js +16 -0
- package/dist/apps/shared/components/contacts/contactCardData.d.ts +69 -0
- package/dist/apps/shared/components/contacts/contactCardData.js +158 -0
- package/dist/apps/shared/components/contacts/participantDetails.d.ts +64 -0
- package/dist/apps/shared/components/contacts/participantDetails.js +129 -0
- package/dist/apps/shared/components/layout/AppShell.js +11 -10
- package/dist/apps/shared/components/mail/InviteCard.js +5 -3
- package/dist/apps/shared/components/mail/MailAddress.d.ts +5 -2
- package/dist/apps/shared/components/mail/MailAddress.js +5 -3
- package/dist/apps/shared/components/mail/MessageDetailPane.js +19 -4
- package/dist/apps/shared/components/mail/VCardAttachmentChip.d.ts +18 -0
- package/dist/apps/shared/components/mail/VCardAttachmentChip.js +53 -0
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +81 -4
- package/dist/apps/shared/components/mail/compose/RecipientInput.js +1 -0
- package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +5 -2
- package/dist/apps/shared/components/mail/compose/RichTextEditor.js +10 -1
- package/dist/apps/shared/components/mail/compose/dropFiles.d.ts +8 -0
- package/dist/apps/shared/components/mail/compose/dropFiles.js +18 -0
- package/dist/lib/contacts/contactsApi.d.ts +3 -0
- package/dist/lib/contacts/contactsApi.js +1 -1
- package/dist/lib/mail/directoryApi.d.ts +16 -10
- package/dist/lib/mail/directoryApi.js +20 -12
- package/lib/contacts/contactsApi.ts +1 -1
- package/lib/mail/directoryApi.ts +30 -12
- 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
|
|
5
|
-
* the
|
|
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,
|
|
9
|
-
|
|
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
|
-
/**
|
|
42
|
-
*
|
|
43
|
-
export declare function
|
|
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
|
|
46
|
-
* directory for a caller with no mailbox here, a 429, a network error
|
|
47
|
-
* both fail does this reject, with the contacts error
|
|
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
|
|
9
|
-
* the
|
|
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
|
-
/**
|
|
62
|
-
*
|
|
63
|
-
export function
|
|
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
|
|
83
|
-
* directory for a caller with no mailbox here, a 429, a network error
|
|
84
|
-
* both fail does this reject, with the contacts error
|
|
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++) {
|
package/lib/mail/directoryApi.ts
CHANGED
|
@@ -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
|
|
9
|
-
* the
|
|
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,
|
|
14
|
-
|
|
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
|
-
/**
|
|
108
|
-
*
|
|
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
|
|
135
|
-
* directory for a caller with no mailbox here, a 429, a network error
|
|
136
|
-
* both fail does this reject, with the contacts error
|
|
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