@rapidmx/web-client 0.24.0 → 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 (74) hide show
  1. package/apps/shared/components/admin/layout/AdminShell.tsx +39 -4
  2. package/apps/shared/components/admin/layout/railPreference.ts +25 -0
  3. package/apps/shared/components/calendar/CalendarListSidebar.tsx +6 -2
  4. package/apps/shared/components/calendar/EventDetails.tsx +5 -2
  5. package/apps/shared/components/contacts/ContactCard.tsx +330 -0
  6. package/apps/shared/components/contacts/ContactCardProvider.tsx +72 -0
  7. package/apps/shared/components/contacts/ParticipantLink.tsx +42 -0
  8. package/apps/shared/components/contacts/contactCardData.ts +220 -0
  9. package/apps/shared/components/contacts/participantDetails.ts +169 -0
  10. package/apps/shared/components/layout/AppShell.tsx +4 -0
  11. package/apps/shared/components/layout/ThemeSwitch.tsx +3 -2
  12. package/apps/shared/components/layout/UserMenu.tsx +30 -10
  13. package/apps/shared/components/mail/InviteCard.tsx +27 -5
  14. package/apps/shared/components/mail/MailAddress.tsx +8 -3
  15. package/apps/shared/components/mail/MessageDetailPane.tsx +63 -18
  16. package/apps/shared/components/mail/VCardAttachmentChip.tsx +85 -0
  17. package/apps/shared/components/mail/compose/ComposeWindow.tsx +112 -1
  18. package/apps/shared/components/mail/compose/RecipientInput.tsx +1 -0
  19. package/apps/shared/components/mail/compose/RichTextEditor.tsx +15 -2
  20. package/apps/shared/components/mail/compose/dropFiles.ts +21 -0
  21. package/apps/shared/components/mail/layout/MailShell.tsx +13 -5
  22. package/apps/shared/components/mail/layout/folderIcons.tsx +54 -0
  23. package/apps/shared/components/settings/layout/SettingsShell.tsx +32 -13
  24. package/apps/shared/navigation/appHrefs.ts +3 -3
  25. package/dist/apps/shared/components/admin/layout/AdminShell.js +21 -4
  26. package/dist/apps/shared/components/admin/layout/railPreference.d.ts +6 -0
  27. package/dist/apps/shared/components/admin/layout/railPreference.js +24 -0
  28. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +3 -2
  29. package/dist/apps/shared/components/calendar/EventDetails.js +2 -1
  30. package/dist/apps/shared/components/contacts/ContactCard.d.ts +19 -0
  31. package/dist/apps/shared/components/contacts/ContactCard.js +132 -0
  32. package/dist/apps/shared/components/contacts/ContactCardProvider.d.ts +25 -0
  33. package/dist/apps/shared/components/contacts/ContactCardProvider.js +41 -0
  34. package/dist/apps/shared/components/contacts/ParticipantLink.d.ts +18 -0
  35. package/dist/apps/shared/components/contacts/ParticipantLink.js +16 -0
  36. package/dist/apps/shared/components/contacts/contactCardData.d.ts +69 -0
  37. package/dist/apps/shared/components/contacts/contactCardData.js +158 -0
  38. package/dist/apps/shared/components/contacts/participantDetails.d.ts +64 -0
  39. package/dist/apps/shared/components/contacts/participantDetails.js +129 -0
  40. package/dist/apps/shared/components/layout/AppShell.js +11 -10
  41. package/dist/apps/shared/components/layout/ThemeSwitch.js +2 -2
  42. package/dist/apps/shared/components/layout/UserMenu.d.ts +1 -2
  43. package/dist/apps/shared/components/layout/UserMenu.js +4 -4
  44. package/dist/apps/shared/components/mail/InviteCard.js +5 -3
  45. package/dist/apps/shared/components/mail/MailAddress.d.ts +5 -2
  46. package/dist/apps/shared/components/mail/MailAddress.js +5 -3
  47. package/dist/apps/shared/components/mail/MessageDetailPane.js +19 -4
  48. package/dist/apps/shared/components/mail/VCardAttachmentChip.d.ts +18 -0
  49. package/dist/apps/shared/components/mail/VCardAttachmentChip.js +53 -0
  50. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +81 -4
  51. package/dist/apps/shared/components/mail/compose/RecipientInput.js +1 -0
  52. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +5 -2
  53. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +10 -1
  54. package/dist/apps/shared/components/mail/compose/dropFiles.d.ts +8 -0
  55. package/dist/apps/shared/components/mail/compose/dropFiles.js +18 -0
  56. package/dist/apps/shared/components/mail/layout/MailShell.js +5 -4
  57. package/dist/apps/shared/components/mail/layout/folderIcons.d.ts +8 -0
  58. package/dist/apps/shared/components/mail/layout/folderIcons.js +25 -0
  59. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
  60. package/dist/apps/shared/components/settings/layout/SettingsShell.js +16 -15
  61. package/dist/apps/shared/navigation/appHrefs.d.ts +3 -3
  62. package/dist/apps/shared/navigation/appHrefs.js +3 -3
  63. package/dist/lib/components/pickers/TimeZonePicker.js +14 -5
  64. package/dist/lib/contacts/contactsApi.d.ts +3 -0
  65. package/dist/lib/contacts/contactsApi.js +1 -1
  66. package/dist/lib/mail/directoryApi.d.ts +16 -10
  67. package/dist/lib/mail/directoryApi.js +20 -12
  68. package/dist/lib/util/timeZone.d.ts +1 -1
  69. package/dist/lib/util/timeZone.js +22 -9
  70. package/lib/components/pickers/TimeZonePicker.tsx +14 -9
  71. package/lib/contacts/contactsApi.ts +1 -1
  72. package/lib/mail/directoryApi.ts +30 -12
  73. package/lib/util/timeZone.ts +24 -9
  74. package/package.json +1 -1
@@ -27,6 +27,10 @@ import { loadOriginalMessage, prefetchOriginalMessage } from "./compose/quotedBo
27
27
  import { formatRecipient } from "./compose/recipients.js";
28
28
  import { formatMailAddress } from "../../../../lib/mail/mailAddress.js";
29
29
  import MailAddress, { RecipientLine } from "./MailAddress.js";
30
+ import ParticipantLink from "../contacts/ParticipantLink.js";
31
+ import { fetchVCardText } from "../contacts/contactCardData.js";
32
+ import { isVCardAttachment } from "../contacts/participantDetails.js";
33
+ import VCardAttachmentChip from "./VCardAttachmentChip.js";
30
34
  import { useMailShell } from "./layout/MailShell.js";
31
35
  import { ariaKeyShortcuts, withHint } from "../../keyboard/format.js";
32
36
  import { SHORTCUTS } from "../../keyboard/keymap.js";
@@ -130,6 +134,15 @@ export function formatFingerprint(fingerprint) {
130
134
  const compact = fingerprint.replace(/[\s:]/g, "").toUpperCase();
131
135
  return compact.match(/.{1,4}/g)?.join(" ") ?? compact;
132
136
  }
137
+ /** Saves the file at `url` under `filename`: the browser is handed the link as a download. */
138
+ function downloadFromUrl(url, filename) {
139
+ const a = document.createElement("a");
140
+ a.href = url;
141
+ a.download = filename;
142
+ document.body.appendChild(a);
143
+ a.click();
144
+ a.remove();
145
+ }
133
146
  /** Saves one attachment recovered from inside a signed/encrypted entity. Always handed to the browser as an
134
147
  * opaque download (`application/octet-stream`), never rendered in this origin. */
135
148
  function downloadMimeAttachment(attachment) {
@@ -772,6 +785,8 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
772
785
  const senderNameCheck = checkSenderName(senderName, senderAddress);
773
786
  // Always `Name <address>` (or the bare address): a name alone hides who a message is really from.
774
787
  const senderLabel = formatMailAddress({ displayName: senderName, address: senderAddress });
788
+ // What the contact card of anyone named in this message is given: the message, for its mailbox and any vCard it carries.
789
+ const cardContext = { message, attachments };
775
790
  // Offered only for a valid signature from a certificate nobody pinned for this sender - never to replace a pin.
776
791
  // A recorded signing-key conflict for this sender is resolved from the contact, never by trusting another key.
777
792
  const pendingConflict = senderUnpinned && senderKeyState?.conflict !== undefined;
@@ -913,10 +928,10 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
913
928
  : "This message can't be read, so it can't be printed";
914
929
  // In Junk Email the card's button is the way back (Not junk), which only needs the right to change the message.
915
930
  const reportReason = !writable ? "View-only mailbox" : undefined;
916
- const card = (_jsxs(CardShell, { unread: cardUnread, children: [_jsxs("div", { className: ["flex flex-wrap items-start gap-x-3 gap-y-1 px-4 pt-3 pb-2", cardUnread ? "bg-primary/[0.07]" : ""].join(" "), children: [_jsx(SenderAvatar, { from: senderRecipient }), _jsxs("div", { className: "flex-1 min-w-[12rem]", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-x-2 gap-y-1", children: [threadHeader ? (_jsx("h2", { className: "min-w-0 flex-1", style: BODY_FONT_STYLE, children: _jsxs("button", { type: "button", ref: threadHeader.buttonRef, onClick: threadHeader.onToggle, "aria-expanded": true, "aria-controls": threadHeader.bodyId, className: [
931
+ const card = (_jsxs(CardShell, { unread: cardUnread, children: [_jsxs("div", { className: ["flex flex-wrap items-start gap-x-3 gap-y-1 px-4 pt-3 pb-2", cardUnread ? "bg-primary/[0.07]" : ""].join(" "), children: [threadHeader ? (_jsx(ParticipantLink, { participant: { address: senderAddress, displayName: senderName }, context: cardContext, label: `Contact card for ${senderLabel}`, children: _jsx(SenderAvatar, { from: senderRecipient }) })) : (_jsx(SenderAvatar, { from: senderRecipient })), _jsxs("div", { className: "flex-1 min-w-[12rem]", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-x-2 gap-y-1", children: [threadHeader ? (_jsx("h2", { className: "min-w-0 flex-1", style: BODY_FONT_STYLE, children: _jsxs("button", { type: "button", ref: threadHeader.buttonRef, onClick: threadHeader.onToggle, "aria-expanded": true, "aria-controls": threadHeader.bodyId, className: [
917
932
  "w-full text-left rounded-sm text-sm",
918
933
  ROW_FOCUS_CLASS,
919
- ].join(" "), children: [_jsx(UnreadLabel, { unread: cardUnread }), _jsx(MailAddress, { recipient: senderRecipient, className: senderClass(cardUnread) })] }) })) : (_jsxs("p", { className: "text-sm break-words min-w-0", children: ["From ", _jsx("span", { className: "font-semibold text-text", children: senderLabel })] })), security && _jsx(SecurityIndicator, { security: security })] }), senderNameCheck.misleading && (_jsxs("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: ["The sender\u2019s name \u201C", senderName, "\u201D looks like an email address, but this message was sent from ", _jsx("span", { className: "font-medium", children: senderAddress }), ". Don\u2019t trust it based on the name."] })), _jsx(RecipientLine, { label: "To", recipients: message.recipients.filter((r) => r.type !== "cc" && r.type !== "bcc") }), _jsx(RecipientLine, { label: "Cc", recipients: message.recipients.filter((r) => r.type === "cc") }), _jsx(RecipientLine, { label: "Bcc", recipients: message.recipients.filter((r) => r.type === "bcc") })] }), _jsxs("div", { className: "ml-auto flex flex-wrap items-center justify-end gap-1", children: [_jsx("time", { dateTime: message.receivedDate, className: ["text-xs mr-1", dateClass(cardUnread)].join(" "), children: new Date(message.receivedDate).toLocaleString() }), adaptable && (_jsx("button", { type: "button", "aria-label": "View original", "aria-pressed": viewOriginal, title: viewOriginal ? "Follow the theme" : "View original", onClick: () => setViewOriginal(!viewOriginal), className: [
934
+ ].join(" "), children: [_jsx(UnreadLabel, { unread: cardUnread }), _jsx(MailAddress, { recipient: senderRecipient, className: senderClass(cardUnread) })] }) })) : (_jsxs("p", { className: "text-sm break-words min-w-0", children: ["From", " ", _jsx(ParticipantLink, { participant: { address: senderAddress, displayName: senderName }, context: cardContext, className: "font-semibold text-text", children: senderLabel })] })), security && _jsx(SecurityIndicator, { security: security })] }), senderNameCheck.misleading && (_jsxs("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: ["The sender\u2019s name \u201C", senderName, "\u201D looks like an email address, but this message was sent from ", _jsx("span", { className: "font-medium", children: senderAddress }), ". Don\u2019t trust it based on the name."] })), _jsx(RecipientLine, { label: "To", recipients: message.recipients.filter((r) => r.type !== "cc" && r.type !== "bcc"), context: cardContext }), _jsx(RecipientLine, { label: "Cc", recipients: message.recipients.filter((r) => r.type === "cc"), context: cardContext }), _jsx(RecipientLine, { label: "Bcc", recipients: message.recipients.filter((r) => r.type === "bcc"), context: cardContext })] }), _jsxs("div", { className: "ml-auto flex flex-wrap items-center justify-end gap-1", children: [_jsx("time", { dateTime: message.receivedDate, className: ["text-xs mr-1", dateClass(cardUnread)].join(" "), children: new Date(message.receivedDate).toLocaleString() }), adaptable && (_jsx("button", { type: "button", "aria-label": "View original", "aria-pressed": viewOriginal, title: viewOriginal ? "Follow the theme" : "View original", onClick: () => setViewOriginal(!viewOriginal), className: [
920
935
  "inline-flex items-center justify-center p-1.5 rounded-md border border-border text-sm text-text hover:bg-surface-alt",
921
936
  viewOriginal ? "bg-primary/10" : "",
922
937
  ].join(" "), children: viewOriginal ? _jsx(HiOutlineMoon, { size: 16, "aria-hidden": "true" }) : _jsx(HiOutlineSun, { size: 16, "aria-hidden": "true" }) })), _jsx(IconAction, { icon: _jsx(HiOutlineArrowUturnLeft, { size: 16, "aria-hidden": "true" }), label: "Reply", shortcut: keyboard ? SHORTCUTS.mail.reply : undefined, disabled: preparingCompose, onPrefetch: prefetchReply, onClick: () => void handleReplyOrForward("reply") }), _jsx(IconAction, { icon: _jsxs("span", { className: "inline-flex items-center", "aria-hidden": "true", children: [_jsx(HiOutlineArrowUturnLeft, { size: 16 }), _jsx(HiOutlineArrowUturnLeft, { size: 16, className: "-ml-2.5" })] }), label: "Reply All", shortcut: keyboard ? SHORTCUTS.mail.replyAll : undefined, disabled: preparingCompose, onPrefetch: prefetchReply, onClick: () => void handleReplyOrForward("replyAll") }), _jsx(IconAction, { icon: _jsx(HiOutlineArrowUturnRight, { size: 16, "aria-hidden": "true" }), label: "Forward", shortcut: keyboard ? SHORTCUTS.mail.forward : undefined, disabled: preparingCompose, onPrefetch: prefetchReply, onClick: () => void handleReplyOrForward("forward") }), archivable && (_jsx(IconAction, { icon: _jsx(HiOutlineArchiveBox, { size: 16, "aria-hidden": "true" }), label: "Archive", shortcut: keyboard ? SHORTCUTS.mail.archive : undefined, busy: archiving, disabled: archiving, onClick: handleArchive })), movable && (_jsx(IconAction, { icon: _jsx(HiOutlineFolderArrowDown, { size: 16, "aria-hidden": "true" }), label: "Move to", shortcut: keyboard ? SHORTCUTS.mail.move : undefined, onClick: () => setMovePrompt(true) })), reportable && (_jsx(IconAction, { icon: inJunk ? _jsx(HiOutlineInboxArrowDown, { size: 16, "aria-hidden": "true" }) : _jsx(HiOutlineNoSymbol, { size: 16, "aria-hidden": "true" }), label: inJunk ? "Not junk" : "Report junk", busy: messageActions.busy, disabled: messageActions.busy || reportReason !== undefined, reason: reportReason, onClick: () => void (inJunk ? messageActions.notJunk() : messageActions.reportJunk()) })), labels && labels.length > 0 && (_jsx(LabelMenuButton, { "aria-label": "Labels", label: "Labels", className: "border border-border py-1.5", labels: labels, mailboxUid: message.mailboxUid, onLabelCreated: onLabelCreated, applied: currentLabelsMessage.labelUids ?? [], onCommit: (labelUids) => void handleApplyLabels(labelUids), busy: savingLabels, note: "Ticked labels are applied to this message and unticked ones removed.", emptyNote: "This mailbox has no labels yet.", commit: { label: "Apply" }, clear: { label: "Remove all labels" } })), archivable && (_jsx(MessageMoreMenu, { actions: menuActions, senderAddress: message.from.address, read: currentLabelsMessage.flags.read === true, flagged: currentLabelsMessage.flags.flagged === true, writable: writable, busy: messageActions.busy, inJunk: inJunk, inDeletedItems: inDeletedItems, sent: !!isSentItems, ownSender: readerOwnAddresses.includes(message.from.address.toLowerCase()), canTrustSender: writable, printReason: printReason, composing: preparingCompose, triggerClassName: ICON_BUTTON_CLASS }))] })] }), (answered || forwarded) && (_jsx("p", { className: "px-4 py-1.5 text-xs text-text-muted bg-surface-alt border-y border-border", children: answered && forwarded ? "You replied to and forwarded this message." : answered ? "You replied to this message." : "You forwarded this message." })), _jsxs("div", { id: threadHeader?.bodyId, className: "px-4 pb-3 flex flex-col gap-2", children: [inThread && (subjectDiffers || !sameSubject(shownSubject, threadSubject)) && (_jsx("h3", { className: "text-sm font-semibold break-words", children: shownSubject })), _jsx("div", { className: "flex flex-wrap items-center gap-2 empty:hidden", children: sendingStatus }), inOutbox && message.scheduledSendError && _jsxs(Alert, { children: ["This message wasn\u2019t sent: ", message.scheduledSendError] }), cancelError && _jsx(Alert, { children: cancelError }), keyChangeNotice && (_jsx("p", { role: "status", className: "py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: keyChangeNotice })), security && UNVERIFIED_SIGNER_STATES.has(security.state) && (_jsxs("p", { role: "status", className: "py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: ["Signed, but the signer isn\u2019t a trusted contact key, so the sender isn\u2019t verified.", security.signerEmails && security.signerEmails.length > 0 && _jsxs(_Fragment, { children: [" Certificate for ", security.signerEmails.join(", "), "."] }), security.signerFingerprint && (_jsxs(_Fragment, { children: [" ", "Fingerprint ", _jsx("span", { className: "font-mono text-xs break-all", children: security.signerFingerprint }), "."] }))] })), pendingConflict && (_jsxs("p", { role: "status", className: "py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: ["This sender has a signing key change waiting for your review, so this signer can\u2019t be trusted from here.", " ", _jsx("a", { href: `/contacts/${encodeURIComponent(senderKeyState.contactUid)}`, className: "font-medium text-primary-dark hover:underline", children: "Review it in Contacts" })] })), trustableCertificate && (_jsx("div", { children: _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: trusting, onClick: () => {
@@ -935,7 +950,7 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
935
950
  .map((l) => (_jsxs("span", { className: "inline-flex items-center gap-1.5 text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt", children: [_jsx("span", { className: "w-2 h-2 rounded-full shrink-0", style: { backgroundColor: l.color ?? "#6366f1" } }), l.name] }, l.uid))) })), ["delivery", "read"]
936
951
  .filter((type) => (type === "delivery" ? message.deliveryReceiptPending : message.readReceiptPending))
937
952
  .map((type) => (_jsxs("div", { className: "flex flex-wrap items-center gap-2 py-2 px-3 rounded-sm bg-surface-alt text-sm", children: [_jsxs("span", { children: [senderLabel, " requested a ", type, " receipt for this message."] }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: receiptBusy === type, disabled: receiptBusy !== null, onClick: () => handleReceipt(type, "approve"), children: "Send receipt" }), _jsx(Button, { type: "button", variant: "text", disabled: receiptBusy !== null, onClick: () => handleReceipt(type, "decline"), children: "Decline" })] }, type))), receiptError && _jsx(Alert, { children: receiptError }), mayHoldInvite && _jsx(InviteCard, { messageUid: message.uid, headingLevel: inThread ? 3 : 2 }), security?.state === "signature_failed" && innerAttachments && innerAttachments.length > 0 && (_jsx("p", { role: "status", className: "py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: "These attachments come from a message whose signature couldn\u2019t be verified. Open them only if you trust the sender." })), innerAttachments
938
- ? innerAttachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2", children: innerAttachments.map((attachment, index) => (_jsx("li", { children: _jsx("button", { type: "button", onClick: () => downloadMimeAttachment(attachment), className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark", children: attachment.filename ?? `Unnamed ${attachment.contentType} attachment` }) }, `${index}:${attachment.filename ?? ""}`))) }))
939
- : listedAttachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2", children: listedAttachments.map((attachment) => (_jsx("li", { children: _jsxs("a", { href: attachmentContentUrl(attachment.uid), className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark", children: [attachment.filename, " (", formatBytes(attachment.sizeBytes), ")"] }) }, attachment.uid))) })), _jsx("div", { ref: bodyRegionRef, tabIndex: -1, className: "outline-none", children: message.encrypted && security === null ? (_jsx(BodySkeleton, {})) : security?.decryptError !== undefined ? (_jsx(EncryptedBody, { locked: lockedRef.current, reason: security.decryptError, unlocking: unlocking, onUnlock: () => void handleUnlockToView() })) : (_jsx(MessageBody, { messageUid: message.uid, messageVersion: message.version, title: bodyTitle, content: bodyContent, attachments: attachments, inlineParts: innerAttachments, original: viewOriginal, onAdaptable: setAdaptable })) })] }), showFooter && (_jsxs("div", { className: "flex flex-wrap gap-2 px-4 py-3 border-t border-border print:hidden", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Reply to this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("reply"), children: "Reply" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Reply all to this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("replyAll"), children: "Reply All" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Forward this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("forward"), children: "Forward" })] }))] }));
953
+ ? innerAttachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2", children: innerAttachments.map((attachment, index) => (_jsx("li", { children: isVCardAttachment({ filename: attachment.filename, mimeType: attachment.contentType }) ? (_jsx(VCardAttachmentChip, { label: attachment.filename ?? `Unnamed ${attachment.contentType} attachment`, message: message, loadText: async () => new TextDecoder().decode(attachment.decode() ?? new Uint8Array(0)), onDownload: () => downloadMimeAttachment(attachment) })) : (_jsx("button", { type: "button", onClick: () => downloadMimeAttachment(attachment), className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark", children: attachment.filename ?? `Unnamed ${attachment.contentType} attachment` })) }, `${index}:${attachment.filename ?? ""}`))) }))
954
+ : listedAttachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2", children: listedAttachments.map((attachment) => (_jsx("li", { children: isVCardAttachment(attachment) ? (_jsx(VCardAttachmentChip, { label: `${attachment.filename} (${formatBytes(attachment.sizeBytes)})`, message: message, loadText: () => fetchVCardText(attachment.uid), onDownload: () => downloadFromUrl(attachmentContentUrl(attachment.uid), attachment.filename) })) : (_jsxs("a", { href: attachmentContentUrl(attachment.uid), className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark", children: [attachment.filename, " (", formatBytes(attachment.sizeBytes), ")"] })) }, attachment.uid))) })), _jsx("div", { ref: bodyRegionRef, tabIndex: -1, className: "outline-none", children: message.encrypted && security === null ? (_jsx(BodySkeleton, {})) : security?.decryptError !== undefined ? (_jsx(EncryptedBody, { locked: lockedRef.current, reason: security.decryptError, unlocking: unlocking, onUnlock: () => void handleUnlockToView() })) : (_jsx(MessageBody, { messageUid: message.uid, messageVersion: message.version, title: bodyTitle, content: bodyContent, attachments: attachments, inlineParts: innerAttachments, original: viewOriginal, onAdaptable: setAdaptable })) })] }), showFooter && (_jsxs("div", { className: "flex flex-wrap gap-2 px-4 py-3 border-t border-border print:hidden", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Reply to this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("reply"), children: "Reply" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Reply all to this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("replyAll"), children: "Reply All" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Forward this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("forward"), children: "Forward" })] }))] }));
940
955
  return (_jsxs("div", { className: inThread ? "min-w-0" : "flex-1 min-w-0 min-h-0 flex flex-col h-full", children: [inThread ? (card) : (_jsxs(_Fragment, { children: [backHref && (_jsx("a", { href: backHref, className: "text-sm text-primary-dark hover:underline block px-4 pt-3", children: "\u2190 Back to messages" })), _jsx(SubjectCard, { subject: shownSubject }), _jsx("div", { className: "flex-1 min-h-0 overflow-y-auto p-2 sm:p-3 flex flex-col gap-3", children: card })] })), _jsx(MoveToFolderDialog, { open: movePrompt, onClose: () => setMovePrompt(false), mailboxUid: message.mailboxUid, folders: folders ?? [], currentFolderUid: message.folderUid, onMove: handleMove, onFolderCreated: onFolderCreated }), messageActions.dialog, _jsx(MessageSourceDialog, { open: sourceView !== null, onClose: closeSource, mode: sourceView?.mode ?? "source", source: sourceView?.source ?? { status: "loading" }, encrypted: !!message.encrypted }), _jsxs(Modal, { open: confirming, onClose: () => setConfirming(false), title: "Recall this message?", children: [_jsx("p", { className: "text-sm text-text-muted mb-4", children: "This asks every original recipient's mail system to delete their copy, but only if it's still unread there \u2014 there's no way to guarantee it, and no confirmation once it either succeeds or fails. Recipients who already read the message will keep it." }), error && _jsx(Alert, { children: error }), _jsxs("div", { className: "flex gap-3", children: [_jsx(Button, { type: "button", loading: recalling, disabled: recalling, onClick: handleRecall, className: "!w-auto", children: "Recall message" }), _jsx(Button, { type: "button", variant: "secondary", disabled: recalling, onClick: () => setConfirming(false), className: "!w-auto", children: "Cancel" })] })] }), _jsx(Modal, { open: trustConfirmOpen && !!trustableCertificate, onClose: () => !trusting && setTrustConfirmOpen(false), title: "Trust this signer?", children: _jsxs("div", { className: "flex flex-col gap-3 text-sm", children: [_jsxs("p", { children: ["Mail from ", _jsx("span", { className: "font-medium", children: senderAddress }), " signed with this certificate will show as verified."] }), _jsxs("dl", { className: "flex flex-col gap-1", children: [_jsx("dt", { className: "text-xs text-text-muted", children: "Certificate issued to" }), _jsx("dd", { children: security?.signerEmails?.length ? security.signerEmails.join(", ") : "No email address" }), _jsx("dt", { className: "text-xs text-text-muted", children: "Fingerprint" }), _jsx("dd", { className: "font-mono text-xs break-all", children: security?.signerFingerprint ? formatFingerprint(security.signerFingerprint) : "Unknown" })] }), _jsx("p", { className: "text-text-muted", children: "Anyone can create a certificate naming any address. Before trusting it, confirm this fingerprint with the sender through another channel, such as a phone call." }), trustError && _jsx(Alert, { children: trustError }), _jsxs("div", { className: "flex gap-3", children: [_jsx(Button, { type: "button", className: "!w-auto", loading: trusting, disabled: trusting, onClick: () => handleTrustSigner(senderAddress, trustableCertificate), children: "Trust" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: trusting, onClick: () => setTrustConfirmOpen(false), children: "Cancel" })] })] }) })] }));
941
956
  }
@@ -0,0 +1,18 @@
1
+ import React from "react";
2
+ import type { Message } from "../../../../lib/mail/mailApi.js";
3
+ export interface VCardAttachmentChipProps {
4
+ /** The file's name, as the chip shows it (with the size, when known). */
5
+ label: string;
6
+ /** The message the file is attached to: a new contact goes to its mailbox's address book when that is the user's own. */
7
+ message: Message;
8
+ /** The vCard file's text. */
9
+ loadText: () => Promise<string>;
10
+ /** Saves the file to the device. */
11
+ onDownload: () => void;
12
+ }
13
+ /**
14
+ * A vCard attachment (`.vcf`, `text/vcard`) in a message's attachment list. Clicking it opens a menu - "Add to address book" (every card in the file
15
+ * becomes a contact, except the ones already there) and "Download" - instead of downloading at once. Outside a `ContactCardProvider` there is no
16
+ * address book to add to, and the chip is the plain download it always was.
17
+ */
18
+ export default function VCardAttachmentChip({ label, message, loadText, onDownload }: VCardAttachmentChipProps): React.JSX.Element;
@@ -0,0 +1,53 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { notify } from "../../notifications/store.js";
3
+ import { notifyApiError } from "../../notifications/apiErrors.js";
4
+ import { useContactCard } from "../contacts/ContactCardProvider.js";
5
+ import { AddCardsError } from "../contacts/contactCardData.js";
6
+ import MenuButton from "./MenuButton.js";
7
+ const CHIP_CLASS = "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark";
8
+ /** "1 contact", "3 contacts". */
9
+ function contacts(count) {
10
+ return `${count} ${count === 1 ? "contact" : "contacts"}`;
11
+ }
12
+ /** What adding a vCard file ended with, for the pop-up that says so. */
13
+ function addedMessage(added, skipped) {
14
+ if (added + skipped === 0) {
15
+ return "This file has no contacts in it.";
16
+ }
17
+ const already = skipped === 0 ? "" : `${added === 0 ? "" : " "}${contacts(skipped)} ${skipped === 1 ? "was" : "were"} already in your address book.`;
18
+ return `${added === 0 ? "" : `${contacts(added)} added to your address book.`}${already}`;
19
+ }
20
+ /**
21
+ * A vCard attachment (`.vcf`, `text/vcard`) in a message's attachment list. Clicking it opens a menu - "Add to address book" (every card in the file
22
+ * becomes a contact, except the ones already there) and "Download" - instead of downloading at once. Outside a `ContactCardProvider` there is no
23
+ * address book to add to, and the chip is the plain download it always was.
24
+ */
25
+ export default function VCardAttachmentChip({ label, message, loadText, onDownload }) {
26
+ const { addVCards, available } = useContactCard();
27
+ async function handleAdd() {
28
+ try {
29
+ const { added, skipped } = await addVCards(await loadText(), message);
30
+ notify({
31
+ kind: added + skipped === 0 ? "warning" : "success",
32
+ title: added + skipped === 0 ? "No contacts found" : "Address book updated",
33
+ message: addedMessage(added, skipped),
34
+ });
35
+ }
36
+ catch (err) {
37
+ const stored = err instanceof AddCardsError && err.added > 0 ? ` ${contacts(err.added)} could be added before it failed.` : "";
38
+ notifyApiError(err, `Couldn't add to your address book.${stored}`);
39
+ }
40
+ }
41
+ if (!available) {
42
+ return (_jsx("button", { type: "button", onClick: onDownload, className: CHIP_CLASS, children: label }));
43
+ }
44
+ return (_jsx(MenuButton, { label: label, "aria-label": `${label}, contact card actions`, className: CHIP_CLASS, sections: [
45
+ {
46
+ key: "vcard",
47
+ items: [
48
+ { key: "add", label: "Add to address book", onSelect: () => void handleAdd() },
49
+ { key: "download", label: "Download", onSelect: onDownload },
50
+ ],
51
+ },
52
+ ] }));
53
+ }
@@ -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
+ }
@@ -22,6 +22,7 @@ import { primaryMailboxUid } from "../../../mail/primaryMailbox.js";
22
22
  import { badgeFor, countOfFolder, inboxUnreadTotal, unreadBadgeTotal, } from "../../../mail/folderCounts.js";
23
23
  import { ALL_MAILBOXES_SECTION, useSidebarSections } from "../../../mail/useCollapsedSections.js";
24
24
  import FolderBadgeChip from "./FolderBadgeChip.js";
25
+ import { FolderTypeIcon, SharedMailboxMark } from "./folderIcons.js";
25
26
  import SidebarSection from "./SidebarSection.js";
26
27
  import { useUnreadTitle } from "../../../mail/useUnreadTitle.js";
27
28
  import { pendingCountFor, usePendingSends } from "../../../mail/outbox/pendingSends.js";
@@ -220,13 +221,13 @@ export default function MailShell({ userUid, authServerUrl, impersonating, imper
220
221
  aggregateFolderType === type
221
222
  ? "bg-primary/10 text-primary-dark font-semibold"
222
223
  : "text-text hover:bg-surface-alt",
223
- ].join(" "), children: [_jsx("span", { className: badge?.kind === "unread" ? "font-semibold" : undefined, children: FOLDER_LABELS[type] }), badge && _jsx(FolderBadgeChip, { badge: badge })] }, type));
224
- }) })), mailboxFolders.map(({ mailbox, folders, error: mailboxError }) => (_jsx(SidebarSection, { domId: `${idPrefix}-mail-section-${mailbox.uid}`, label: _jsxs(_Fragment, { children: [mailbox.displayName, mailbox.ownerUserUid ? "" : " (shared)"] }), collapsible: collapsible, expanded: sections.isExpanded(mailbox.uid), onToggle: () => sections.toggle(mailbox.uid), unread: unreadBadgeTotal(folders, counts), notice: mailboxError && (_jsx("div", { className: "px-2.5 pb-1", children: _jsx(Alert, { children: mailboxError }) })), children: folderRows(folders, pendingCountFor(mailbox.uid, pendingSends)).map((row) => {
224
+ ].join(" "), children: [_jsxs("span", { className: ["flex items-center gap-2.5 min-w-0", badge?.kind === "unread" ? "font-semibold" : ""].join(" "), children: [_jsx(FolderTypeIcon, { type: type }), _jsx("span", { className: "truncate", children: FOLDER_LABELS[type] })] }), badge && _jsx(FolderBadgeChip, { badge: badge })] }, type));
225
+ }) })), mailboxFolders.map(({ mailbox, folders, error: mailboxError }) => (_jsx(SidebarSection, { domId: `${idPrefix}-mail-section-${mailbox.uid}`, label: _jsxs(_Fragment, { children: [mailbox.displayName, !mailbox.ownerUserUid && _jsx(SharedMailboxMark, {})] }), collapsible: collapsible, expanded: sections.isExpanded(mailbox.uid), onToggle: () => sections.toggle(mailbox.uid), unread: unreadBadgeTotal(folders, counts), notice: mailboxError && (_jsx("div", { className: "px-2.5 pb-1", children: _jsx(Alert, { children: mailboxError }) })), children: folderRows(folders, pendingCountFor(mailbox.uid, pendingSends)).map((row) => {
225
226
  if (row.kind === "placeholder") {
226
227
  // A folder the server has not made (or told us about) yet - the Outbox and Sent Items are made with the first message
227
228
  // sent - shown the moment a message is on its way. Nothing to open; the real folder replaces it when it arrives.
228
229
  const sending = pendingCountFor(mailbox.uid, pendingSends);
229
- return (_jsxs("div", { "data-folder-placeholder": row.type, className: "flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5 text-text-muted", children: [_jsx("span", { children: FOLDER_LABELS[row.type] }), row.type === "outbox" && _jsx(OutboxBadge, { total: sending, pendingHere: sending })] }, `placeholder-${row.type}`));
230
+ return (_jsxs("div", { "data-folder-placeholder": row.type, className: "flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5 text-text-muted", children: [_jsxs("span", { className: "flex items-center gap-2.5 min-w-0", children: [_jsx(FolderTypeIcon, { type: row.type }), _jsx("span", { className: "truncate", children: FOLDER_LABELS[row.type] })] }), row.type === "outbox" && _jsx(OutboxBadge, { total: sending, pendingHere: sending })] }, `placeholder-${row.type}`));
230
231
  }
231
232
  const folder = row.folder;
232
233
  const badge = badgeFor(folder.type, countOfFolder(folder, counts));
@@ -235,7 +236,7 @@ export default function MailShell({ userUid, authServerUrl, impersonating, imper
235
236
  folder.uid === folderUid
236
237
  ? "bg-primary/10 text-primary-dark font-semibold"
237
238
  : "text-text hover:bg-surface-alt",
238
- ].join(" "), children: [_jsx("span", { className: badge?.kind === "unread" ? "font-semibold" : undefined, children: FOLDER_LABELS[folder.type] ?? folder.name }), badge &&
239
+ ].join(" "), children: [_jsxs("span", { className: ["flex items-center gap-2.5 min-w-0", badge?.kind === "unread" ? "font-semibold" : ""].join(" "), children: [_jsx(FolderTypeIcon, { type: folder.type }), _jsx("span", { className: "truncate", children: FOLDER_LABELS[folder.type] ?? folder.name })] }), badge &&
239
240
  (folder.type === "outbox" ? (_jsx(OutboxBadge, { total: badge.value, status: outboxStatuses[folder.uid], pendingHere: pendingCountFor(mailbox.uid, pendingSends) })) : (_jsx(FolderBadgeChip, { badge: badge })))] }, folder.uid));
240
241
  }) }, mailbox.uid)))] }))] }));
241
242
  inner = (_jsxs(_Fragment, { children: [_jsx(MailShortcuts, { mailboxUid: defaultMailboxUid }), _jsx("aside", { className: "hidden md:flex w-64 shrink-0 bg-surface border-r border-border flex-col", children: sidebarContent("desktop") }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Folders", fullScreen: true, children: _jsx("div", { className: "flex flex-col", children: sidebarContent("mobile") }) }), _jsxs("main", { className: "flex-1 min-w-0 overflow-y-auto", children: [_jsxs("div", { className: "md:hidden flex items-center gap-2 p-3", children: [_jsx("button", { type: "button", className: "shrink-0 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open folders", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx("div", { ref: setMobileSearchSlot, className: "flex-1 min-w-0" })] }), _jsx(MobileComposeButton, { mailboxUid: defaultMailboxUid }), _jsx(MailShellContext.Provider, { value: contextValue, children: children })] })] }));
@@ -0,0 +1,8 @@
1
+ import React from "react";
2
+ import type { FolderType } from "../../../../../lib/mail/mailApi.js";
3
+ /** The icon drawn to the left of a folder's name: one per well-known folder, a plain folder for the user's own. */
4
+ export declare function FolderTypeIcon({ type }: {
5
+ type: FolderType | string;
6
+ }): React.JSX.Element;
7
+ /** What marks a mailbox other people share with this user, beside its name: the group icon, and for a screen reader the word it stands for. */
8
+ export declare function SharedMailboxMark(): React.JSX.Element;
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { HiOutlineArchiveBox, HiOutlineArrowUpTray, HiOutlineBookOpen, HiOutlineCalendarDays, HiOutlineClipboardDocumentList, HiOutlineFolder, HiOutlineInbox, HiOutlinePaperAirplane, HiOutlinePencilSquare, HiOutlineShieldExclamation, HiOutlineTrash, HiOutlineUsers, HiUserGroup, } from "react-icons/hi2";
3
+ const FOLDER_ICONS = {
4
+ inbox: HiOutlineInbox,
5
+ drafts: HiOutlinePencilSquare,
6
+ sent_items: HiOutlinePaperAirplane,
7
+ outbox: HiOutlineArrowUpTray,
8
+ junk: HiOutlineShieldExclamation,
9
+ archive: HiOutlineArchiveBox,
10
+ deleted_items: HiOutlineTrash,
11
+ calendar: HiOutlineCalendarDays,
12
+ contacts: HiOutlineUsers,
13
+ tasks: HiOutlineClipboardDocumentList,
14
+ notes: HiOutlineBookOpen,
15
+ user: HiOutlineFolder,
16
+ };
17
+ /** The icon drawn to the left of a folder's name: one per well-known folder, a plain folder for the user's own. */
18
+ export function FolderTypeIcon({ type }) {
19
+ const Icon = FOLDER_ICONS[type] ?? HiOutlineFolder;
20
+ return _jsx(Icon, { size: 18, "aria-hidden": "true", className: "shrink-0 text-text-muted" });
21
+ }
22
+ /** What marks a mailbox other people share with this user, beside its name: the group icon, and for a screen reader the word it stands for. */
23
+ export function SharedMailboxMark() {
24
+ return (_jsxs(_Fragment, { children: [_jsx(HiUserGroup, { size: 14, "aria-hidden": "true", className: "inline-block ml-1.5 align-[-2px] shrink-0 text-text-muted" }), " ", _jsx("span", { className: "sr-only", children: "(shared)" })] }));
25
+ }
@@ -1,4 +1,5 @@
1
1
  import React, { PropsWithChildren } from "react";
2
+ import type { IconType } from "react-icons";
2
3
  import { Mailbox } from "../../../../../lib/mail/mailApi.js";
3
4
  import { AppShellProps } from "../../layout/AppShell.js";
4
5
  import { PluginNav } from "../../../plugins/pluginNav.js";
@@ -6,6 +7,8 @@ export interface SettingsSectionDef {
6
7
  id: string;
7
8
  href: string;
8
9
  label: string;
10
+ /** Drawn beside the label in the sidebar. */
11
+ icon: IconType;
9
12
  }
10
13
  /** Every settings section with its own sidebar entry. */
11
14
  export declare const SETTINGS_SECTIONS: SettingsSectionDef[];