@rapidmx/web-client 0.25.1 → 0.27.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 (85) hide show
  1. package/README.md +15 -3
  2. package/apps/shared/components/admin/layout/AdminShell.tsx +3 -2
  3. package/apps/shared/components/calendar/EventDetails.tsx +5 -2
  4. package/apps/shared/components/contacts/ContactCard.tsx +356 -0
  5. package/apps/shared/components/contacts/ContactCardProvider.tsx +72 -0
  6. package/apps/shared/components/contacts/ContactDetailPane.tsx +25 -5
  7. package/apps/shared/components/contacts/ContactForm.tsx +36 -5
  8. package/apps/shared/components/contacts/ContactPhotoField.tsx +86 -0
  9. package/apps/shared/components/contacts/FavoriteStarButton.tsx +36 -0
  10. package/apps/shared/components/contacts/ParticipantLink.tsx +42 -0
  11. package/apps/shared/components/contacts/contactCardData.ts +220 -0
  12. package/apps/shared/components/contacts/participantDetails.ts +169 -0
  13. package/apps/shared/components/layout/AppShell.tsx +18 -6
  14. package/apps/shared/components/mail/InviteCard.tsx +27 -5
  15. package/apps/shared/components/mail/MailAddress.tsx +8 -3
  16. package/apps/shared/components/mail/MessageDetailPane.tsx +72 -27
  17. package/apps/shared/components/mail/VCardAttachmentChip.tsx +85 -0
  18. package/apps/shared/components/mail/compose/ComposeWindow.tsx +112 -1
  19. package/apps/shared/components/mail/compose/RecipientInput.tsx +1 -0
  20. package/apps/shared/components/mail/compose/RichTextEditor.tsx +15 -2
  21. package/apps/shared/components/mail/compose/dropFiles.ts +21 -0
  22. package/apps/shared/components/settings/layout/SettingsShell.tsx +3 -3
  23. package/apps/shared/plugins/pluginIcons.ts +124 -0
  24. package/apps/shared/plugins/pluginNav.ts +8 -0
  25. package/apps/shared/plugins/useResolvedRailItems.ts +54 -0
  26. package/apps/www/contacts/[uid].tsx +1 -0
  27. package/apps/www/contacts/index.tsx +3 -1
  28. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
  29. package/dist/apps/shared/components/admin/layout/AdminShell.js +3 -2
  30. package/dist/apps/shared/components/calendar/EventDetails.js +2 -1
  31. package/dist/apps/shared/components/contacts/ContactCard.d.ts +19 -0
  32. package/dist/apps/shared/components/contacts/ContactCard.js +148 -0
  33. package/dist/apps/shared/components/contacts/ContactCardProvider.d.ts +25 -0
  34. package/dist/apps/shared/components/contacts/ContactCardProvider.js +41 -0
  35. package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +3 -1
  36. package/dist/apps/shared/components/contacts/ContactDetailPane.js +17 -2
  37. package/dist/apps/shared/components/contacts/ContactForm.js +20 -2
  38. package/dist/apps/shared/components/contacts/ContactPhotoField.d.ts +21 -0
  39. package/dist/apps/shared/components/contacts/ContactPhotoField.js +46 -0
  40. package/dist/apps/shared/components/contacts/FavoriteStarButton.d.ts +9 -0
  41. package/dist/apps/shared/components/contacts/FavoriteStarButton.js +12 -0
  42. package/dist/apps/shared/components/contacts/ParticipantLink.d.ts +18 -0
  43. package/dist/apps/shared/components/contacts/ParticipantLink.js +16 -0
  44. package/dist/apps/shared/components/contacts/contactCardData.d.ts +69 -0
  45. package/dist/apps/shared/components/contacts/contactCardData.js +158 -0
  46. package/dist/apps/shared/components/contacts/participantDetails.d.ts +64 -0
  47. package/dist/apps/shared/components/contacts/participantDetails.js +129 -0
  48. package/dist/apps/shared/components/layout/AppShell.d.ts +3 -2
  49. package/dist/apps/shared/components/layout/AppShell.js +24 -15
  50. package/dist/apps/shared/components/mail/InviteCard.js +5 -3
  51. package/dist/apps/shared/components/mail/MailAddress.d.ts +5 -2
  52. package/dist/apps/shared/components/mail/MailAddress.js +5 -3
  53. package/dist/apps/shared/components/mail/MessageDetailPane.js +19 -7
  54. package/dist/apps/shared/components/mail/VCardAttachmentChip.d.ts +18 -0
  55. package/dist/apps/shared/components/mail/VCardAttachmentChip.js +53 -0
  56. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +81 -4
  57. package/dist/apps/shared/components/mail/compose/RecipientInput.js +1 -0
  58. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +5 -2
  59. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +10 -1
  60. package/dist/apps/shared/components/mail/compose/dropFiles.d.ts +8 -0
  61. package/dist/apps/shared/components/mail/compose/dropFiles.js +18 -0
  62. package/dist/apps/shared/components/settings/layout/SettingsShell.js +4 -3
  63. package/dist/apps/shared/plugins/pluginIcons.d.ts +10 -0
  64. package/dist/apps/shared/plugins/pluginIcons.js +64 -0
  65. package/dist/apps/shared/plugins/pluginNav.d.ts +8 -0
  66. package/dist/apps/shared/plugins/useResolvedRailItems.d.ts +12 -0
  67. package/dist/apps/shared/plugins/useResolvedRailItems.js +47 -0
  68. package/dist/apps/www/contacts/[uid].js +1 -1
  69. package/dist/apps/www/contacts/index.js +2 -2
  70. package/dist/lib/components/avatar/ContactAvatar.d.ts +9 -3
  71. package/dist/lib/components/avatar/ContactAvatar.js +29 -6
  72. package/dist/lib/components/buttons/CopyIconButton.d.ts +13 -0
  73. package/dist/lib/components/buttons/CopyIconButton.js +16 -0
  74. package/dist/lib/contacts/contactsApi.d.ts +15 -0
  75. package/dist/lib/contacts/contactsApi.js +31 -2
  76. package/dist/lib/contacts/gravatar.d.ts +12 -0
  77. package/dist/lib/contacts/gravatar.js +43 -0
  78. package/dist/lib/mail/directoryApi.d.ts +16 -10
  79. package/dist/lib/mail/directoryApi.js +20 -12
  80. package/lib/components/avatar/ContactAvatar.tsx +43 -7
  81. package/lib/components/buttons/CopyIconButton.tsx +49 -0
  82. package/lib/contacts/contactsApi.ts +35 -2
  83. package/lib/contacts/gravatar.ts +47 -0
  84. package/lib/mail/directoryApi.ts +30 -12
  85. package/package.json +1 -1
@@ -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
+ }
@@ -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 { createContext, useContext, useEffect, useMemo, useState } from "react";
7
- import { HiOutlineArrowUturnLeft, HiOutlineBars3, HiOutlineEnvelopeOpen, HiOutlineFunnel, HiOutlineLockClosed, HiOutlineNoSymbol, HiOutlinePaintBrush, HiOutlinePencilSquare, HiOutlinePuzzlePiece, HiOutlineShieldCheck, HiOutlineTag, HiOutlineUserCircle, HiOutlineUsers, } from "react-icons/hi2";
7
+ import { HiOutlineArrowUturnLeft, HiOutlineBars3, HiOutlineEnvelopeOpen, HiOutlineFunnel, HiOutlineLockClosed, HiOutlineNoSymbol, HiOutlinePaintBrush, HiOutlinePencilSquare, HiOutlineShieldCheck, HiOutlineTag, HiOutlineUserCircle, HiOutlineUsers, } from "react-icons/hi2";
8
8
  import { ApiRequestError } from "../../../../../lib/util/api.js";
9
9
  import Drawer from "../../../../../lib/components/overlays/Drawer.js";
10
10
  import { listMailboxes } from "../../../../../lib/mail/mailApi.js";
@@ -15,6 +15,7 @@ import AppShell from "../../layout/AppShell.js";
15
15
  import { useLocation, useNavigate } from "../../../navigation/index.js";
16
16
  import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
17
17
  import { orderMailboxes, primaryMailboxUid } from "../../../mail/primaryMailbox.js";
18
+ import { pluginIcon } from "../../../plugins/pluginIcons.js";
18
19
  import { mergePluginNavItems } from "../../../plugins/pluginNav.js";
19
20
  /** Every settings section with its own sidebar entry. */
20
21
  export const SETTINGS_SECTIONS = [
@@ -33,11 +34,11 @@ export const SETTINGS_SECTIONS = [
33
34
  /** `SETTINGS_SECTIONS` followed by the plugins' `settingsSections` items, core ids winning - see
34
35
  * `mergePluginNavItems`. */
35
36
  export function settingsSections(pluginNav) {
36
- return mergePluginNavItems(SETTINGS_SECTIONS, pluginNav?.settingsSections, ({ id, href, label }) => ({
37
+ return mergePluginNavItems(SETTINGS_SECTIONS, pluginNav?.settingsSections, ({ id, href, label, icon }) => ({
37
38
  id,
38
39
  href,
39
40
  label,
40
- icon: HiOutlinePuzzlePiece,
41
+ icon: pluginIcon(icon),
41
42
  }));
42
43
  }
43
44
  const SettingsShellContext = createContext({ mailboxes: [] });
@@ -0,0 +1,10 @@
1
+ import type { IconType } from "react-icons";
2
+ /** The icon a plugin nav item gets when its manifest names none, or one that isn't in `PLUGIN_ICONS`. */
3
+ export declare const DEFAULT_PLUGIN_ICON: IconType;
4
+ /**
5
+ * The `react-icons/hi2` icons a plugin manifest's nav `icon` can name. A fixed list rather than the whole icon set: importing every
6
+ * icon by name would put all of them in the bundle. A plugin that needs another one gets it added here.
7
+ */
8
+ export declare const PLUGIN_ICONS: Readonly<Record<string, IconType>>;
9
+ /** The icon for a plugin nav item's `icon` name - see `PLUGIN_ICONS` - or `DEFAULT_PLUGIN_ICON`. */
10
+ export declare function pluginIcon(name: string | undefined): IconType;
@@ -0,0 +1,64 @@
1
+ import { HiOutlineAcademicCap, HiOutlineBanknotes, HiOutlineBeaker, HiOutlineBellAlert, HiOutlineBolt, HiOutlineBookOpen, HiOutlineBriefcase, HiOutlineBuildingOffice2, HiOutlineCalendarDays, HiOutlineChartBar, HiOutlineChartPie, HiOutlineChatBubbleLeftRight, HiOutlineClipboardDocumentCheck, HiOutlineCloud, HiOutlineCog6Tooth, HiOutlineCreditCard, HiOutlineCube, HiOutlineCurrencyDollar, HiOutlineDocumentText, HiOutlineEnvelopeOpen, HiOutlineFolderOpen, HiOutlineFunnel, HiOutlineGlobeAlt, HiOutlineHeart, HiOutlineIdentification, HiOutlineInboxStack, HiOutlineKey, HiOutlineLifebuoy, HiOutlineLightBulb, HiOutlineMap, HiOutlineMegaphone, HiOutlineNewspaper, HiOutlinePaperAirplane, HiOutlinePhone, HiOutlinePhoto, HiOutlinePresentationChartLine, HiOutlinePuzzlePiece, HiOutlineQueueList, HiOutlineRectangleGroup, HiOutlineRocketLaunch, HiOutlineShieldCheck, HiOutlineShoppingCart, HiOutlineSparkles, HiOutlineSquares2X2, HiOutlineTag, HiOutlineTicket, HiOutlineTrophy, HiOutlineUserGroup, HiOutlineUsers, HiOutlineVideoCamera, HiOutlineWrenchScrewdriver, } from "react-icons/hi2";
2
+ /** The icon a plugin nav item gets when its manifest names none, or one that isn't in `PLUGIN_ICONS`. */
3
+ export const DEFAULT_PLUGIN_ICON = HiOutlinePuzzlePiece;
4
+ /**
5
+ * The `react-icons/hi2` icons a plugin manifest's nav `icon` can name. A fixed list rather than the whole icon set: importing every
6
+ * icon by name would put all of them in the bundle. A plugin that needs another one gets it added here.
7
+ */
8
+ export const PLUGIN_ICONS = {
9
+ HiOutlineAcademicCap,
10
+ HiOutlineBanknotes,
11
+ HiOutlineBeaker,
12
+ HiOutlineBellAlert,
13
+ HiOutlineBolt,
14
+ HiOutlineBookOpen,
15
+ HiOutlineBriefcase,
16
+ HiOutlineBuildingOffice2,
17
+ HiOutlineCalendarDays,
18
+ HiOutlineChartBar,
19
+ HiOutlineChartPie,
20
+ HiOutlineChatBubbleLeftRight,
21
+ HiOutlineClipboardDocumentCheck,
22
+ HiOutlineCloud,
23
+ HiOutlineCog6Tooth,
24
+ HiOutlineCreditCard,
25
+ HiOutlineCube,
26
+ HiOutlineCurrencyDollar,
27
+ HiOutlineDocumentText,
28
+ HiOutlineEnvelopeOpen,
29
+ HiOutlineFolderOpen,
30
+ HiOutlineFunnel,
31
+ HiOutlineGlobeAlt,
32
+ HiOutlineHeart,
33
+ HiOutlineIdentification,
34
+ HiOutlineInboxStack,
35
+ HiOutlineKey,
36
+ HiOutlineLifebuoy,
37
+ HiOutlineLightBulb,
38
+ HiOutlineMap,
39
+ HiOutlineMegaphone,
40
+ HiOutlineNewspaper,
41
+ HiOutlinePaperAirplane,
42
+ HiOutlinePhone,
43
+ HiOutlinePhoto,
44
+ HiOutlinePresentationChartLine,
45
+ HiOutlinePuzzlePiece,
46
+ HiOutlineQueueList,
47
+ HiOutlineRectangleGroup,
48
+ HiOutlineRocketLaunch,
49
+ HiOutlineShieldCheck,
50
+ HiOutlineShoppingCart,
51
+ HiOutlineSparkles,
52
+ HiOutlineSquares2X2,
53
+ HiOutlineTag,
54
+ HiOutlineTicket,
55
+ HiOutlineTrophy,
56
+ HiOutlineUserGroup,
57
+ HiOutlineUsers,
58
+ HiOutlineVideoCamera,
59
+ HiOutlineWrenchScrewdriver,
60
+ };
61
+ /** The icon for a plugin nav item's `icon` name - see `PLUGIN_ICONS` - or `DEFAULT_PLUGIN_ICON`. */
62
+ export function pluginIcon(name) {
63
+ return (name && Object.prototype.hasOwnProperty.call(PLUGIN_ICONS, name) ? PLUGIN_ICONS[name] : undefined) ?? DEFAULT_PLUGIN_ICON;
64
+ }
@@ -8,6 +8,14 @@ export interface PluginUiNavItem {
8
8
  label: string;
9
9
  /** A same-origin path under the host's prefix (`/settings/...`, `/admin/...`, `/...`). */
10
10
  href: string;
11
+ /** A `react-icons/hi2` icon name from `PLUGIN_ICONS` (`pluginIcons.ts`); anything else shows the generic puzzle piece. */
12
+ icon?: string;
13
+ /**
14
+ * For an app-rail entry that depends on the signed-in user: an API path (without the `/api` prefix, e.g. `/mail/video-meetings/personal-room`)
15
+ * the shell requests for them. The entry is shown only when that answers `200 { href }` with a same-origin path, and links there instead of to
16
+ * `href`; anything else hides it. See `useResolvedRailItems()`.
17
+ */
18
+ resolveFrom?: string;
11
19
  }
12
20
  /**
13
21
  * The navigation every enabled plugin with a successfully built UI contributes, supplied by the server's
@@ -0,0 +1,12 @@
1
+ import { type ApiClient } from "../../../lib/util/api.js";
2
+ import { type PluginUiNavItem } from "./pluginNav.js";
3
+ /** Where each app-rail entry that is worked out per user points, by entry id: only the entries the server answered for are here. */
4
+ export type ResolvedRailLinks = Record<string, string>;
5
+ /**
6
+ * Asks, once per signed-in user, for the entries of a plugin's app rail that are worked out per user (they name an API path in `resolveFrom` - the
7
+ * meet plugin's "Meet" button is one: it shows only for a user who has a personal room, and links to it). Each path is requested through
8
+ * `apiFetch()`; a `200` answering `{ href }` with a same-origin path puts that link in the result, and anything else (no such thing for this user, a
9
+ * server error, an answer that is not a path on this site) leaves the entry out, so a failing plugin never breaks the rail. Entries with no
10
+ * `resolveFrom` are not asked about. Nothing is requested without a signed-in user.
11
+ */
12
+ export declare function useResolvedRailItems(items: PluginUiNavItem[] | undefined, userUid: string | undefined, client?: ApiClient): ResolvedRailLinks;
@@ -0,0 +1,47 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { useEffect, useState } from "react";
6
+ import { withClient } from "../../../lib/util/api.js";
7
+ import { useApiClient } from "../../../lib/util/apiClientContext.js";
8
+ import { isSafePluginHref } from "./pluginNav.js";
9
+ /**
10
+ * Asks, once per signed-in user, for the entries of a plugin's app rail that are worked out per user (they name an API path in `resolveFrom` - the
11
+ * meet plugin's "Meet" button is one: it shows only for a user who has a personal room, and links to it). Each path is requested through
12
+ * `apiFetch()`; a `200` answering `{ href }` with a same-origin path puts that link in the result, and anything else (no such thing for this user, a
13
+ * server error, an answer that is not a path on this site) leaves the entry out, so a failing plugin never breaks the rail. Entries with no
14
+ * `resolveFrom` are not asked about. Nothing is requested without a signed-in user.
15
+ */
16
+ export function useResolvedRailItems(items, userUid, client) {
17
+ const explicitClient = useApiClient();
18
+ const [links, setLinks] = useState({});
19
+ const asked = (items ?? []).filter((item) => typeof item?.resolveFrom === "string" && item.resolveFrom !== "");
20
+ // A list that is the same entries again (a new array on each render) does not start over.
21
+ const key = asked.map((item) => `${item.id}=${item.resolveFrom}`).join("|");
22
+ useEffect(() => {
23
+ if (!userUid || asked.length === 0) {
24
+ setLinks({});
25
+ return undefined;
26
+ }
27
+ let cancelled = false;
28
+ void Promise.all(asked.map(async (item) => {
29
+ try {
30
+ const answer = await withClient(client ?? explicitClient, item.resolveFrom);
31
+ return typeof answer?.href === "string" && isSafePluginHref(answer.href) ? [item.id, answer.href] : null;
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ })).then((results) => {
37
+ if (!cancelled) {
38
+ setLinks(Object.fromEntries(results.filter((entry) => entry !== null)));
39
+ }
40
+ });
41
+ return () => {
42
+ cancelled = true;
43
+ };
44
+ // `asked` is derived from `key`.
45
+ }, [key, userUid, client, explicitClient]);
46
+ return links;
47
+ }
@@ -72,7 +72,7 @@ function ContactDetailContent({ uid }) {
72
72
  setMode("view");
73
73
  }, onCancel: () => setMode("view") })] }));
74
74
  }
75
- return (_jsx("div", { className: "p-6", children: _jsx(ContactDetailPane, { contact: contact, onEdit: () => setMode("edit"), onDelete: () => handleDelete(contact), backHref: backHref, onKeysChanged: handleKeysChanged }) }));
75
+ return (_jsx("div", { className: "p-6", children: _jsx(ContactDetailPane, { contact: contact, onEdit: () => setMode("edit"), onDelete: () => handleDelete(contact), backHref: backHref, onKeysChanged: handleKeysChanged, onChanged: setContact }) }));
76
76
  }
77
77
  export default ContactDetailPage;
78
78
  /** The tab's title: `Brand: Contacts` (see `pageTitle()`). */
@@ -7,7 +7,7 @@ import { pageTitle } from "../../shared/navigation/pageTitle.js";
7
7
  import { useNavigate } from "../../shared/navigation/index.js";
8
8
  import { useEffect, useMemo, useRef, useState } from "react";
9
9
  import { ApiRequestError } from "../../../lib/util/api.js";
10
- import { createContact, deleteContact, listContacts, listDeletedContacts, setContactFavorite, updateContact, } from "../../../lib/contacts/contactsApi.js";
10
+ import { createContact, deleteContact, listContacts, listDeletedContacts, setContactFavorite, contactPhotoUrl, updateContact, } from "../../../lib/contacts/contactsApi.js";
11
11
  import { contactsToVCardFile, contactToVCard, parseVCards } from "../../../lib/contacts/vcard.js";
12
12
  import useIsMobile from "../../../lib/util/useIsMobile.js";
13
13
  import { useApiClient } from "../../../lib/util/apiClientContext.js";
@@ -346,7 +346,7 @@ function ContactsContent({ userUid }) {
346
346
  return (_jsxs("div", { className: "flex-1 min-w-0 min-h-0 flex flex-col lg:flex-row md:flex-none md:h-[calc(100dvh_-_var(--rr-header-h,4rem))] md:overflow-hidden", children: [_jsx(ContactsSidebar, { mailboxUid: mailboxUid, contacts: contacts, active: view, onSelect: handleSelectView, showDeleted: canViewDeleted }), _jsxs("div", { className: "flex-1 min-w-0 min-h-0 flex", children: [_jsxs("div", { className: [
347
347
  "w-full md:w-[clamp(16rem,32vw,26rem)] min-w-0 md:shrink-0 md:border-r border-border md:flex flex-col min-h-0",
348
348
  mode === "new" ? "hidden md:flex" : "flex",
349
- ].join(" "), children: [_jsx(ContactsToolbar, { selectedCount: checkedContacts.length, allSelectedFavorited: checkedContacts.length > 0 && checkedContacts.every((c) => c.favorite), onNewContact: handleNew, onEdit: handleToolbarEdit, onDelete: () => setConfirmingBulkDelete(true), onEmail: handleEmail, onToggleFavorite: handleToggleFavorite, onAddCategory: handleAddCategory, onExportVCard: handleExportVCard, onImportFile: handleImportFile, shortcuts: true, hideNew: isMobile }), _jsx("div", { className: "p-3 border-b border-border", children: _jsx("input", { ref: searchInputRef, type: "search", value: query, onChange: (e) => setQuery(e.target.value), placeholder: "Search contacts", "aria-label": "Search contacts", className: INPUT_CLASS }) }), error && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: error }) })), (isDeletedView ? deletedTruncated : truncated) && (_jsx("div", { className: "p-3", children: _jsxs(Alert, { children: ["This folder has more contacts than can be shown at once - only the first ", LIST_PAGE_SIZE * MAX_LIST_PAGES, " are listed."] }) })), deletedError && isDeletedView && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: deletedError }) })), (isDeletedView ? deletedLoading : loading) ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "Loading\u2026" })) : sorted.length === 0 ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "No contacts found." })) : (_jsx("div", { className: "flex-1 min-h-0 overflow-auto", children: _jsxs("table", { className: "w-full table-fixed text-sm", children: [_jsx("thead", { children: _jsxs("tr", { className: "border-b border-border text-left", children: [!isDeletedView && (_jsx("th", { className: "w-10 px-3 py-2", children: _jsx("input", { type: "checkbox", "aria-label": "Select all contacts", checked: allChecked, onChange: toggleCheckAll }) })), _jsx("th", { className: "w-[58%] px-3 py-2", children: _jsxs("button", { type: "button", onClick: () => handleSort("name"), className: "block max-w-full truncate text-xs font-bold uppercase tracking-wide text-text-muted", children: ["Name", sortColumn === "name" ? (sortDesc ? " ▾" : " ▴") : ""] }) }), _jsx("th", { className: "px-3 py-2", children: _jsxs("button", { type: "button", onClick: () => handleSort("info"), className: "block max-w-full truncate text-xs font-bold uppercase tracking-wide text-text-muted", children: ["Contact info", sortColumn === "info" ? (sortDesc ? " ▾" : " ▴") : ""] }) })] }) }), _jsx("tbody", { children: sorted.map((contact) => (_jsxs("tr", { className: ["border-b border-border", contact.uid === selectedUid ? "bg-primary/10" : "hover:bg-surface-alt"].join(" "), children: [!isDeletedView && (_jsx("td", { className: "px-3 py-2", children: _jsx("input", { type: "checkbox", "aria-label": `Select ${contact.displayName}`, checked: checkedUids.has(contact.uid), onChange: () => toggleChecked(contact.uid) }) })), _jsx("td", { className: "px-3 py-2", children: _jsxs("button", { type: "button", onClick: () => handleSelectRow(contact), className: "flex w-full min-w-0 items-center gap-2 text-left", children: [_jsx(ContactAvatar, { displayName: contact.displayName, size: 28 }), _jsxs("span", { className: "min-w-0 truncate font-medium", children: [contact.displayName, contact.favorite && _jsx("span", { "aria-label": "Favorite", children: " \u2605" })] })] }) }), _jsx("td", { className: "max-w-0 truncate px-3 py-2 text-text-muted", children: primaryInfo(contact) })] }, contact.uid))) })] }) }))] }), _jsxs("div", { className: ["flex-1 min-w-0 min-h-0 flex-col md:flex", mode === "new" ? "flex" : "hidden"].join(" "), children: [savedElsewhere && mode !== "new" && (_jsxs("p", { role: "status", className: "m-6 mb-0 text-sm py-2 px-3 rounded-sm bg-surface-alt text-text", children: [savedElsewhere.displayName, " was added to", " ", mailboxes.find((mb) => mb.uid === savedElsewhere.mailboxUid)?.displayName ?? "another mailbox", ".", " ", _jsx("a", { href: `/contacts?mailboxUid=${encodeURIComponent(savedElsewhere.mailboxUid)}`, "data-router-shallow": "", "data-router-prefetch": "false", className: "font-medium text-primary-dark hover:underline", children: "View that mailbox\u2019s contacts" })] })), mode === "new" && mailboxUid && folderUid ? (_jsx(ContactForm, { mailboxUid: mailboxUid, folderUid: folderUid, mailboxes: writableMailboxes, onSaved: handleSaved, onCancel: handleCancel })) : mode === "edit" && selected ? (_jsx(ContactForm, { contact: selected, onSaved: handleSaved, onCancel: handleCancel })) : selected ? (_jsx("div", { className: "min-h-0 flex-1 overflow-y-auto p-6", children: _jsx(ContactDetailPane, { contact: selected, onEdit: () => setMode("edit"), onDelete: () => handleDelete(selected), onKeysChanged: () => void reload(), canResolveKeys: ownsMailbox || delegateCanUpdate }) })) : (_jsx("div", { className: "flex flex-1 items-center justify-center p-6 text-center", children: _jsx("p", { className: "text-sm text-text-muted", children: "Select a contact, or create a new one." }) }))] })] }), _jsxs(Modal, { open: confirmingBulkDelete, onClose: () => setConfirmingBulkDelete(false), title: "Delete contacts", children: [_jsxs("p", { className: "text-sm mb-5", children: ["Delete ", checkedContacts.length, " selected ", checkedContacts.length === 1 ? "contact" : "contacts", "? They move to Deleted contacts."] }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setConfirmingBulkDelete(false), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", onClick: handleBulkDelete, children: "Delete" })] })] }), isMobile && mode !== "new" && _jsx(FloatingActionButton, { label: "New contact", icon: HiOutlineUserPlus, onClick: handleNew })] }));
349
+ ].join(" "), children: [_jsx(ContactsToolbar, { selectedCount: checkedContacts.length, allSelectedFavorited: checkedContacts.length > 0 && checkedContacts.every((c) => c.favorite), onNewContact: handleNew, onEdit: handleToolbarEdit, onDelete: () => setConfirmingBulkDelete(true), onEmail: handleEmail, onToggleFavorite: handleToggleFavorite, onAddCategory: handleAddCategory, onExportVCard: handleExportVCard, onImportFile: handleImportFile, shortcuts: true, hideNew: isMobile }), _jsx("div", { className: "p-3 border-b border-border", children: _jsx("input", { ref: searchInputRef, type: "search", value: query, onChange: (e) => setQuery(e.target.value), placeholder: "Search contacts", "aria-label": "Search contacts", className: INPUT_CLASS }) }), error && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: error }) })), (isDeletedView ? deletedTruncated : truncated) && (_jsx("div", { className: "p-3", children: _jsxs(Alert, { children: ["This folder has more contacts than can be shown at once - only the first ", LIST_PAGE_SIZE * MAX_LIST_PAGES, " are listed."] }) })), deletedError && isDeletedView && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: deletedError }) })), (isDeletedView ? deletedLoading : loading) ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "Loading\u2026" })) : sorted.length === 0 ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "No contacts found." })) : (_jsx("div", { className: "flex-1 min-h-0 overflow-auto", children: _jsxs("table", { className: "w-full table-fixed text-sm", children: [_jsx("thead", { children: _jsxs("tr", { className: "border-b border-border text-left", children: [!isDeletedView && (_jsx("th", { className: "w-10 px-3 py-2", children: _jsx("input", { type: "checkbox", "aria-label": "Select all contacts", checked: allChecked, onChange: toggleCheckAll }) })), _jsx("th", { className: "w-[58%] px-3 py-2", children: _jsxs("button", { type: "button", onClick: () => handleSort("name"), className: "block max-w-full truncate text-xs font-bold uppercase tracking-wide text-text-muted", children: ["Name", sortColumn === "name" ? (sortDesc ? " ▾" : " ▴") : ""] }) }), _jsx("th", { className: "px-3 py-2", children: _jsxs("button", { type: "button", onClick: () => handleSort("info"), className: "block max-w-full truncate text-xs font-bold uppercase tracking-wide text-text-muted", children: ["Contact info", sortColumn === "info" ? (sortDesc ? " ▾" : " ▴") : ""] }) })] }) }), _jsx("tbody", { children: sorted.map((contact) => (_jsxs("tr", { className: ["border-b border-border", contact.uid === selectedUid ? "bg-primary/10" : "hover:bg-surface-alt"].join(" "), children: [!isDeletedView && (_jsx("td", { className: "px-3 py-2", children: _jsx("input", { type: "checkbox", "aria-label": `Select ${contact.displayName}`, checked: checkedUids.has(contact.uid), onChange: () => toggleChecked(contact.uid) }) })), _jsx("td", { className: "px-3 py-2", children: _jsxs("button", { type: "button", onClick: () => handleSelectRow(contact), className: "flex w-full min-w-0 items-center gap-2 text-left", children: [_jsx(ContactAvatar, { displayName: contact.displayName, size: 28, photoUrl: contactPhotoUrl(contact), email: contact.emails[0]?.address }), _jsxs("span", { className: "min-w-0 truncate font-medium", children: [contact.displayName, contact.favorite && _jsx("span", { "aria-label": "Favorite", children: " \u2605" })] })] }) }), _jsx("td", { className: "max-w-0 truncate px-3 py-2 text-text-muted", children: primaryInfo(contact) })] }, contact.uid))) })] }) }))] }), _jsxs("div", { className: ["flex-1 min-w-0 min-h-0 flex-col md:flex", mode === "new" ? "flex" : "hidden"].join(" "), children: [savedElsewhere && mode !== "new" && (_jsxs("p", { role: "status", className: "m-6 mb-0 text-sm py-2 px-3 rounded-sm bg-surface-alt text-text", children: [savedElsewhere.displayName, " was added to", " ", mailboxes.find((mb) => mb.uid === savedElsewhere.mailboxUid)?.displayName ?? "another mailbox", ".", " ", _jsx("a", { href: `/contacts?mailboxUid=${encodeURIComponent(savedElsewhere.mailboxUid)}`, "data-router-shallow": "", "data-router-prefetch": "false", className: "font-medium text-primary-dark hover:underline", children: "View that mailbox\u2019s contacts" })] })), mode === "new" && mailboxUid && folderUid ? (_jsx(ContactForm, { mailboxUid: mailboxUid, folderUid: folderUid, mailboxes: writableMailboxes, onSaved: handleSaved, onCancel: handleCancel })) : mode === "edit" && selected ? (_jsx(ContactForm, { contact: selected, onSaved: handleSaved, onCancel: handleCancel })) : selected ? (_jsx("div", { className: "min-h-0 flex-1 overflow-y-auto p-6", children: _jsx(ContactDetailPane, { contact: selected, onEdit: () => setMode("edit"), onDelete: () => handleDelete(selected), onKeysChanged: () => void reload(), onChanged: () => void reload(), canResolveKeys: ownsMailbox || delegateCanUpdate }) })) : (_jsx("div", { className: "flex flex-1 items-center justify-center p-6 text-center", children: _jsx("p", { className: "text-sm text-text-muted", children: "Select a contact, or create a new one." }) }))] })] }), _jsxs(Modal, { open: confirmingBulkDelete, onClose: () => setConfirmingBulkDelete(false), title: "Delete contacts", children: [_jsxs("p", { className: "text-sm mb-5", children: ["Delete ", checkedContacts.length, " selected ", checkedContacts.length === 1 ? "contact" : "contacts", "? They move to Deleted contacts."] }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setConfirmingBulkDelete(false), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", onClick: handleBulkDelete, children: "Delete" })] })] }), isMobile && mode !== "new" && _jsx(FloatingActionButton, { label: "New contact", icon: HiOutlineUserPlus, onClick: handleNew })] }));
350
350
  }
351
351
  export default ContactsPage;
352
352
  /** The tab's title: `Brand: Contacts` (see `pageTitle()`). */
@@ -2,7 +2,13 @@ import React from "react";
2
2
  export interface ContactAvatarProps {
3
3
  displayName: string;
4
4
  size?: number;
5
+ /** The contact's own picture (see `contactPhotoUrl()`); shown in preference to anything else. */
6
+ photoUrl?: string;
7
+ /** The contact's address: without a picture of their own, its Gravatar is shown when there is one (and the reader hasn't turned that off). */
8
+ email?: string;
5
9
  }
6
- /** A small circular initials avatar for a contact, matching Outlook People's list-row avatars — deterministic
7
- * per name (same contact always gets the same color/initials), no photo-upload dependency. */
8
- export default function ContactAvatar({ displayName, size }: ContactAvatarProps): React.JSX.Element;
10
+ /**
11
+ * A small circular avatar for a contact, matching Outlook People's: the contact's own picture when they have one, else their Gravatar (when `email`
12
+ * is given), else initials on a color that is deterministic per name. A picture that fails to load falls through to the next.
13
+ */
14
+ export default function ContactAvatar({ displayName, size, photoUrl, email }: ContactAvatarProps): React.JSX.Element;