@rapidmx/web-client 0.26.0 → 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 (49) hide show
  1. package/README.md +15 -3
  2. package/apps/shared/components/admin/layout/AdminShell.tsx +3 -2
  3. package/apps/shared/components/contacts/ContactCard.tsx +38 -12
  4. package/apps/shared/components/contacts/ContactDetailPane.tsx +25 -5
  5. package/apps/shared/components/contacts/ContactForm.tsx +36 -5
  6. package/apps/shared/components/contacts/ContactPhotoField.tsx +86 -0
  7. package/apps/shared/components/contacts/FavoriteStarButton.tsx +36 -0
  8. package/apps/shared/components/layout/AppShell.tsx +14 -6
  9. package/apps/shared/components/mail/MessageDetailPane.tsx +18 -18
  10. package/apps/shared/components/settings/layout/SettingsShell.tsx +3 -3
  11. package/apps/shared/plugins/pluginIcons.ts +124 -0
  12. package/apps/shared/plugins/pluginNav.ts +8 -0
  13. package/apps/shared/plugins/useResolvedRailItems.ts +54 -0
  14. package/apps/www/contacts/[uid].tsx +1 -0
  15. package/apps/www/contacts/index.tsx +3 -1
  16. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
  17. package/dist/apps/shared/components/admin/layout/AdminShell.js +3 -2
  18. package/dist/apps/shared/components/contacts/ContactCard.js +19 -3
  19. package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +3 -1
  20. package/dist/apps/shared/components/contacts/ContactDetailPane.js +17 -2
  21. package/dist/apps/shared/components/contacts/ContactForm.js +20 -2
  22. package/dist/apps/shared/components/contacts/ContactPhotoField.d.ts +21 -0
  23. package/dist/apps/shared/components/contacts/ContactPhotoField.js +46 -0
  24. package/dist/apps/shared/components/contacts/FavoriteStarButton.d.ts +9 -0
  25. package/dist/apps/shared/components/contacts/FavoriteStarButton.js +12 -0
  26. package/dist/apps/shared/components/layout/AppShell.d.ts +3 -2
  27. package/dist/apps/shared/components/layout/AppShell.js +13 -5
  28. package/dist/apps/shared/components/mail/MessageDetailPane.js +2 -5
  29. package/dist/apps/shared/components/settings/layout/SettingsShell.js +4 -3
  30. package/dist/apps/shared/plugins/pluginIcons.d.ts +10 -0
  31. package/dist/apps/shared/plugins/pluginIcons.js +64 -0
  32. package/dist/apps/shared/plugins/pluginNav.d.ts +8 -0
  33. package/dist/apps/shared/plugins/useResolvedRailItems.d.ts +12 -0
  34. package/dist/apps/shared/plugins/useResolvedRailItems.js +47 -0
  35. package/dist/apps/www/contacts/[uid].js +1 -1
  36. package/dist/apps/www/contacts/index.js +2 -2
  37. package/dist/lib/components/avatar/ContactAvatar.d.ts +9 -3
  38. package/dist/lib/components/avatar/ContactAvatar.js +29 -6
  39. package/dist/lib/components/buttons/CopyIconButton.d.ts +13 -0
  40. package/dist/lib/components/buttons/CopyIconButton.js +16 -0
  41. package/dist/lib/contacts/contactsApi.d.ts +12 -0
  42. package/dist/lib/contacts/contactsApi.js +30 -1
  43. package/dist/lib/contacts/gravatar.d.ts +12 -0
  44. package/dist/lib/contacts/gravatar.js +43 -0
  45. package/lib/components/avatar/ContactAvatar.tsx +43 -7
  46. package/lib/components/buttons/CopyIconButton.tsx +49 -0
  47. package/lib/contacts/contactsApi.ts +34 -1
  48. package/lib/contacts/gravatar.ts +47 -0
  49. package/package.json +1 -1
@@ -5,7 +5,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import "../../styles/app.css";
7
7
  import { useEffect, useLayoutEffect, useRef, useState } from "react";
8
- import { HiOutlineCalendarDays, HiOutlineClipboardDocumentList, HiOutlineEnvelope, HiOutlinePuzzlePiece, HiOutlineUsers, } from "react-icons/hi2";
8
+ import { HiOutlineCalendarDays, HiOutlineClipboardDocumentList, HiOutlineEnvelope, HiOutlineUsers, } from "react-icons/hi2";
9
9
  import { useRouter } from "@rapidrest/react/client";
10
10
  import { useSessionRefresh } from "../../../../lib/auth/session.js";
11
11
  import { getSetupStatus } from "../../../../lib/admin/setupApi.js";
@@ -27,7 +27,9 @@ import { SIGN_OUT_CHANNEL, destroyAllLocalIndexes } from "../../search/localInde
27
27
  import { authApiFetch, setApiUnauthorizedObserver } from "../../../../lib/util/api.js";
28
28
  import { destroyUnlockedKeys } from "../../../../lib/crypto/keySession.js";
29
29
  import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
30
+ import { pluginIcon } from "../../plugins/pluginIcons.js";
30
31
  import { mergePluginNavItems } from "../../plugins/pluginNav.js";
32
+ import { useResolvedRailItems } from "../../plugins/useResolvedRailItems.js";
31
33
  import { useInAppFrame } from "../../navigation/frameContext.js";
32
34
  import { APP_HREFS } from "../../navigation/appHrefs.js";
33
35
  import { useNavigate } from "../../navigation/index.js";
@@ -77,9 +79,13 @@ export const APPS = [
77
79
  /** Ids plugin `appRail` items can't take besides `APPS`' own - `"settings"` has no rail icon but is still a
78
80
  * core `active` value. */
79
81
  const RESERVED_APP_IDS = ["settings"];
80
- /** `APPS` followed by the plugins' `appRail` items (generic icon), core ids winning - see `mergePluginNavItems`. */
81
- export function appRailItems(pluginNav) {
82
- return mergePluginNavItems(APPS, pluginNav?.appRail, ({ id, href, label }) => ({ id, href, label, icon: HiOutlinePuzzlePiece }), RESERVED_APP_IDS);
82
+ /** `APPS` followed by the plugins' `appRail` items (their manifest `icon`, or a generic one), core ids winning - see `mergePluginNavItems`. */
83
+ export function appRailItems(pluginNav, resolved = {}) {
84
+ // An entry worked out per user (`resolveFrom`) is there only for a user it was answered for, and links where the answer said; the others are as declared.
85
+ const shown = (pluginNav?.appRail ?? [])
86
+ .filter((item) => !item.resolveFrom || resolved[item.id] !== undefined)
87
+ .map((item) => (item.resolveFrom ? { ...item, href: resolved[item.id] } : item));
88
+ return mergePluginNavItems(APPS, shown, ({ id, href, label, icon }) => ({ id, href, label, icon: pluginIcon(icon) }), RESERVED_APP_IDS);
83
89
  }
84
90
  /**
85
91
  * The persistent chrome shared by every webmail app (Mail, Calendar, Contacts, Tasks): a left icon rail for
@@ -232,10 +238,12 @@ export function AppChrome({ active, userUid, authServerUrl, impersonating, imper
232
238
  // The router sets the document's title to the page's own whenever the page changes, which drops the unread count this puts in front
233
239
  // of it, so a new pathname is what puts the count back. (A folder change is shallow and keeps the title.)
234
240
  useUnreadTitle(inboxUnreadTotal(mail.mailboxFolders, mail.folderCounts.counts), { enabled: inFrame, resetKey: pathname });
241
+ // Plugin rail entries that depend on the user (the meet plugin's personal room) are asked for once they are signed in.
242
+ const resolvedRail = useResolvedRailItems(pluginNav?.appRail, userUid);
235
243
  if (!userUid) {
236
244
  return _jsx("div", { className: "min-h-screen" });
237
245
  }
238
- const apps = appRailItems(pluginNav);
246
+ const apps = appRailItems(pluginNav, resolvedRail);
239
247
  // The header title: "settings" has no rail item, everything else is labelled by its own rail item.
240
248
  const activeLabel = active === "settings" ? "Settings" : apps.find((app) => app.id === active)?.label;
241
249
  // A custom header (`Branding.headerHtml`) replaces the app's own title bar and the icon at the top of the rail: it is the top of the app, and the
@@ -59,7 +59,7 @@ import { buildConversationPrintDocument, printDocument } from "./reading/printMe
59
59
  import { olderConversationMessages, printHeaders, printableOlderMessage } from "./reading/conversationPrint.js";
60
60
  import { fetchBodyContent } from "./reading/bodyContent.js";
61
61
  import { useViewOriginal } from "./reading/viewOriginal.js";
62
- import { ROW_FOCUS_CLASS, UnreadLabel, dateClass, senderClass } from "./unreadStyle.js";
62
+ import { ROW_FOCUS_CLASS, dateClass, senderClass } from "./unreadStyle.js";
63
63
  /** Labels/styling for `specs/end-to-end_encryption.md`'s "Message Security Indicators" table - kept as
64
64
  * plain data (not JSX) so `SecurityIndicator` below stays a trivial lookup. "Signature failed" MUST NOT
65
65
  * read as a muted variant of "verified" (a failed signature is a stronger negative than no signature at
@@ -928,10 +928,7 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
928
928
  : "This message can't be read, so it can't be printed";
929
929
  // In Junk Email the card's button is the way back (Not junk), which only needs the right to change the message.
930
930
  const reportReason = !writable ? "View-only mailbox" : undefined;
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: [
932
- "w-full text-left rounded-sm text-sm",
933
- ROW_FOCUS_CLASS,
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: [
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: [_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 ? (_jsxs("h2", { className: "relative min-w-0 flex-1", style: BODY_FONT_STYLE, children: [_jsx("button", { type: "button", ref: threadHeader.buttonRef, onClick: threadHeader.onToggle, "aria-expanded": true, "aria-controls": threadHeader.bodyId, "aria-label": `${cardUnread ? "Unread. " : ""}${senderLabel}`, className: ["absolute inset-0 w-full rounded-sm", ROW_FOCUS_CLASS].join(" ") }), _jsx("span", { className: "pointer-events-none relative flex text-sm", children: _jsx(ParticipantLink, { participant: { address: senderAddress, displayName: senderName }, context: cardContext, label: `Contact card for ${senderLabel}`, className: "pointer-events-auto min-w-0 max-w-full", children: _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: [
935
932
  "inline-flex items-center justify-center p-1.5 rounded-md border border-border text-sm text-text hover:bg-surface-alt",
936
933
  viewOriginal ? "bg-primary/10" : "",
937
934
  ].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: () => {
@@ -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;
@@ -1,7 +1,11 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- /** A fixed palette of background colors, cycled by a hash of the contact's name — no photo upload feature
3
- * exists yet (`Contact.photoBlobKey` is modeled server-side but nothing in this app sets/reads it), so every
4
- * contact gets one of these deterministically rather than a plain generic gray circle for everyone. */
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useEffect, useState } from "react";
7
+ import { gravatarEnabled, gravatarUrl } from "../../contacts/gravatar.js";
8
+ /** A fixed palette of background colors, cycled by a hash of the contact's name, for the initials shown when there is no picture. */
5
9
  const PALETTE = ["#7c3aed", "#2563eb", "#0891b2", "#059669", "#d97706", "#dc2626", "#db2777", "#4f46e5"];
6
10
  function initialsOf(displayName) {
7
11
  const parts = displayName.trim().split(/\s+/).filter(Boolean);
@@ -19,8 +23,27 @@ function colorOf(displayName) {
19
23
  }
20
24
  return PALETTE[Math.abs(hash) % PALETTE.length];
21
25
  }
22
- /** A small circular initials avatar for a contact, matching Outlook People's list-row avatars — deterministic
23
- * per name (same contact always gets the same color/initials), no photo-upload dependency. */
24
- export default function ContactAvatar({ displayName, size = 32 }) {
26
+ /**
27
+ * 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`
28
+ * is given), else initials on a color that is deterministic per name. A picture that fails to load falls through to the next.
29
+ */
30
+ export default function ContactAvatar({ displayName, size = 32, photoUrl, email }) {
31
+ const [gravatar, setGravatar] = useState();
32
+ const [failed, setFailed] = useState([]);
33
+ useEffect(() => {
34
+ setGravatar(undefined);
35
+ if (!email || !gravatarEnabled()) {
36
+ return;
37
+ }
38
+ let cancelled = false;
39
+ void gravatarUrl(email, size * 2).then((url) => !cancelled && setGravatar(url));
40
+ return () => {
41
+ cancelled = true;
42
+ };
43
+ }, [email, size]);
44
+ const src = [photoUrl, gravatar].find((url) => url && !failed.includes(url));
45
+ if (src) {
46
+ return (_jsx("img", { src: src, alt: "", "aria-hidden": "true", width: size, height: size, referrerPolicy: "no-referrer", onError: () => setFailed((list) => [...list, src]), className: "inline-block rounded-full object-cover shrink-0", style: { width: size, height: size } }));
47
+ }
25
48
  return (_jsx("span", { "aria-hidden": "true", style: { width: size, height: size, backgroundColor: colorOf(displayName), fontSize: size * 0.4 }, className: "inline-flex items-center justify-center rounded-full text-white font-semibold shrink-0", children: initialsOf(displayName) }));
26
49
  }
@@ -0,0 +1,13 @@
1
+ import React from "react";
2
+ export interface CopyIconButtonProps {
3
+ /** The text put on the clipboard. */
4
+ value: string;
5
+ /** The button's accessible name and tooltip - say what is copied, e.g. "Copy address". */
6
+ label: string;
7
+ className?: string;
8
+ }
9
+ /**
10
+ * A small icon-only "copy" button to sit right after a value (an email address). The icon turns into a check mark for a moment once the value is on
11
+ * the clipboard, and a polite live region says "Copied" (or "Couldn't copy") for screen readers.
12
+ */
13
+ export default function CopyIconButton({ value, label, className }: CopyIconButtonProps): React.JSX.Element;
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { HiCheck, HiOutlineClipboardDocument } from "react-icons/hi2";
3
+ import useCopyToClipboard from "../../util/useCopyToClipboard.js";
4
+ /**
5
+ * A small icon-only "copy" button to sit right after a value (an email address). The icon turns into a check mark for a moment once the value is on
6
+ * the clipboard, and a polite live region says "Copied" (or "Couldn't copy") for screen readers.
7
+ */
8
+ export default function CopyIconButton({ value, label, className }) {
9
+ const { status, copy } = useCopyToClipboard();
10
+ return (_jsxs("span", { className: "inline-flex items-center align-middle", children: [_jsx("button", { type: "button", "aria-label": label, title: label, onClick: () => void copy(value), className: [
11
+ "inline-flex items-center justify-center rounded-sm p-1 text-text-muted hover:text-text hover:bg-surface-alt focus-visible:outline-2 focus-visible:outline-primary",
12
+ className,
13
+ ]
14
+ .filter(Boolean)
15
+ .join(" "), children: status === "copied" ? (_jsx(HiCheck, { "aria-hidden": "true", className: "size-4 text-success" })) : (_jsx(HiOutlineClipboardDocument, { "aria-hidden": "true", className: "size-4" })) }), _jsx("span", { role: "status", "aria-live": "polite", className: "sr-only", children: status === "copied" ? "Copied" : status === "failed" ? "Couldn’t copy" : "" })] }));
16
+ }
@@ -154,6 +154,18 @@ export declare function updateContact(patch: ContactPatch, client?: ApiClient):
154
154
  * `setMessageRead()`/`tasksApi.ts`'s `setTaskCompleted()`. Sends only `uid`/`version`/`favorite`, never
155
155
  * the rest of `contact` (the server rejects its managed fields). */
156
156
  export declare function setContactFavorite(contact: Contact, favorite: boolean, client?: ApiClient): Promise<Contact>;
157
+ /**
158
+ * The URL of a contact's own picture, or `undefined` when they have none. The version is part of it so a replaced picture is fetched again rather than
159
+ * served from the browser's cache.
160
+ */
161
+ export declare function contactPhotoUrl(contact: Pick<Contact, "uid" | "version" | "photoBlobKey">): string | undefined;
162
+ /** The picture types the server accepts for a contact, and the most bytes it takes. */
163
+ export declare const CONTACT_PHOTO_TYPES: string[];
164
+ export declare const CONTACT_PHOTO_MAX_BYTES: number;
165
+ /** Sets a contact's own picture from an image file (the file's own bytes go up, not JSON) and returns the updated contact. */
166
+ export declare function uploadContactPhoto(uid: string, version: number, file: Blob): Promise<Contact>;
167
+ /** Removes a contact's own picture and returns the updated contact. */
168
+ export declare function deleteContactPhoto(uid: string, version: number, client?: ApiClient): Promise<Contact>;
157
169
  export declare function deleteContact(uid: string, version: number, client?: ApiClient): Promise<void>;
158
170
  export interface ContactList {
159
171
  uid: string;
@@ -4,7 +4,7 @@
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  /** Typed wrappers over `@rapidmx/restapi`'s `/mail/contacts` REST surface — see `mailApi.ts`'s own header
6
6
  * comment for the shared ACL/authorization model every wrapper file here follows. */
7
- import { withClient } from "../util/api.js";
7
+ import { ApiRequestError, apiUrl, withClient, withCsrfHeader } from "../util/api.js";
8
8
  import { buildQuery } from "../util/apiQuery.js";
9
9
  import { signingKeyFingerprints, } from "../crypto/keyvaultApi.js";
10
10
  /** Lists a folder's contacts, alphabetically by display name. Never includes soft-deleted contacts — see
@@ -118,6 +118,35 @@ export function updateContact(patch, client) {
118
118
  export function setContactFavorite(contact, favorite, client) {
119
119
  return updateContact({ uid: contact.uid, version: contact.version, favorite }, client);
120
120
  }
121
+ /**
122
+ * The URL of a contact's own picture, or `undefined` when they have none. The version is part of it so a replaced picture is fetched again rather than
123
+ * served from the browser's cache.
124
+ */
125
+ export function contactPhotoUrl(contact) {
126
+ return contact.photoBlobKey ? apiUrl(`/mail/contacts/${encodeURIComponent(contact.uid)}/photo?v=${contact.version}`) : undefined;
127
+ }
128
+ /** The picture types the server accepts for a contact, and the most bytes it takes. */
129
+ export const CONTACT_PHOTO_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
130
+ export const CONTACT_PHOTO_MAX_BYTES = 1024 * 1024;
131
+ /** Sets a contact's own picture from an image file (the file's own bytes go up, not JSON) and returns the updated contact. */
132
+ export async function uploadContactPhoto(uid, version, file) {
133
+ const res = await fetch(apiUrl(`/mail/contacts/${encodeURIComponent(uid)}/photo?version=${version}`), {
134
+ method: "PUT",
135
+ credentials: "include",
136
+ headers: withCsrfHeader({ "Content-Type": file.type }, "PUT"),
137
+ body: file,
138
+ });
139
+ const contentType = res.headers.get("content-type") ?? "";
140
+ const body = contentType.includes("application/json") ? await res.json().catch(() => undefined) : undefined;
141
+ if (!res.ok) {
142
+ throw new ApiRequestError((body && (body.message || body.error)) || res.statusText || "Upload failed.", res.status, body?.code);
143
+ }
144
+ return body;
145
+ }
146
+ /** Removes a contact's own picture and returns the updated contact. */
147
+ export function deleteContactPhoto(uid, version, client) {
148
+ return withClient(client, `/mail/contacts/${encodeURIComponent(uid)}/photo?version=${version}`, { method: "DELETE" });
149
+ }
121
150
  export function deleteContact(uid, version, client) {
122
151
  return withClient(client, `/mail/contacts/${encodeURIComponent(uid)}?version=${version}`, { method: "DELETE" });
123
152
  }
@@ -0,0 +1,12 @@
1
+ /** The `localStorage` key of the privacy preference: `"off"` stops contact avatars from asking Gravatar for a picture. */
2
+ export declare const GRAVATAR_PREFERENCE_KEY = "rapidmx:gravatar";
3
+ /**
4
+ * Whether avatars may look a person up at Gravatar by the SHA-256 hash of their address (never the address itself). On unless the reader turned
5
+ * it off (`localStorage["rapidmx:gravatar"] = "off"`); a browser without storage keeps the default.
6
+ */
7
+ export declare function gravatarEnabled(): boolean;
8
+ /**
9
+ * The Gravatar picture URL for `email`, `size` pixels square. It answers `404` (`d=404`) for a person with no Gravatar, so an `<img>` fails to load
10
+ * and the caller falls back to initials rather than showing Gravatar's placeholder. `undefined` for an empty address or without Web Crypto.
11
+ */
12
+ export declare function gravatarUrl(email: string, size: number): Promise<string | undefined>;
@@ -0,0 +1,43 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /** The `localStorage` key of the privacy preference: `"off"` stops contact avatars from asking Gravatar for a picture. */
6
+ export const GRAVATAR_PREFERENCE_KEY = "rapidmx:gravatar";
7
+ /**
8
+ * Whether avatars may look a person up at Gravatar by the SHA-256 hash of their address (never the address itself). On unless the reader turned
9
+ * it off (`localStorage["rapidmx:gravatar"] = "off"`); a browser without storage keeps the default.
10
+ */
11
+ export function gravatarEnabled() {
12
+ try {
13
+ return globalThis.localStorage?.getItem(GRAVATAR_PREFERENCE_KEY) !== "off";
14
+ }
15
+ catch {
16
+ return true;
17
+ }
18
+ }
19
+ const hashes = new Map();
20
+ /** The lowercase-hex SHA-256 of a trimmed, lowercased `email`, or `undefined` where the browser has no Web Crypto (an insecure page). */
21
+ function hashOf(email) {
22
+ const normalized = email.trim().toLowerCase();
23
+ let hash = hashes.get(normalized);
24
+ if (!hash) {
25
+ hash = (async () => {
26
+ const digest = await globalThis.crypto?.subtle?.digest("SHA-256", new TextEncoder().encode(normalized));
27
+ return digest && Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
28
+ })().catch(() => undefined);
29
+ hashes.set(normalized, hash);
30
+ }
31
+ return hash;
32
+ }
33
+ /**
34
+ * The Gravatar picture URL for `email`, `size` pixels square. It answers `404` (`d=404`) for a person with no Gravatar, so an `<img>` fails to load
35
+ * and the caller falls back to initials rather than showing Gravatar's placeholder. `undefined` for an empty address or without Web Crypto.
36
+ */
37
+ export async function gravatarUrl(email, size) {
38
+ if (!email.trim()) {
39
+ return undefined;
40
+ }
41
+ const hash = await hashOf(email);
42
+ return hash && `https://gravatar.com/avatar/${hash}?s=${Math.max(1, Math.round(size))}&d=404`;
43
+ }