@rapidmx/web-client 0.24.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/apps/shared/components/admin/layout/AdminShell.tsx +39 -4
  2. package/apps/shared/components/admin/layout/railPreference.ts +25 -0
  3. package/apps/shared/components/calendar/CalendarListSidebar.tsx +6 -2
  4. package/apps/shared/components/calendar/EventDetails.tsx +5 -2
  5. package/apps/shared/components/contacts/ContactCard.tsx +330 -0
  6. package/apps/shared/components/contacts/ContactCardProvider.tsx +72 -0
  7. package/apps/shared/components/contacts/ParticipantLink.tsx +42 -0
  8. package/apps/shared/components/contacts/contactCardData.ts +220 -0
  9. package/apps/shared/components/contacts/participantDetails.ts +169 -0
  10. package/apps/shared/components/layout/AppShell.tsx +4 -0
  11. package/apps/shared/components/layout/ThemeSwitch.tsx +3 -2
  12. package/apps/shared/components/layout/UserMenu.tsx +30 -10
  13. package/apps/shared/components/mail/InviteCard.tsx +27 -5
  14. package/apps/shared/components/mail/MailAddress.tsx +8 -3
  15. package/apps/shared/components/mail/MessageDetailPane.tsx +63 -18
  16. package/apps/shared/components/mail/VCardAttachmentChip.tsx +85 -0
  17. package/apps/shared/components/mail/compose/ComposeWindow.tsx +112 -1
  18. package/apps/shared/components/mail/compose/RecipientInput.tsx +1 -0
  19. package/apps/shared/components/mail/compose/RichTextEditor.tsx +15 -2
  20. package/apps/shared/components/mail/compose/dropFiles.ts +21 -0
  21. package/apps/shared/components/mail/layout/MailShell.tsx +13 -5
  22. package/apps/shared/components/mail/layout/folderIcons.tsx +54 -0
  23. package/apps/shared/components/settings/layout/SettingsShell.tsx +32 -13
  24. package/apps/shared/navigation/appHrefs.ts +3 -3
  25. package/dist/apps/shared/components/admin/layout/AdminShell.js +21 -4
  26. package/dist/apps/shared/components/admin/layout/railPreference.d.ts +6 -0
  27. package/dist/apps/shared/components/admin/layout/railPreference.js +24 -0
  28. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +3 -2
  29. package/dist/apps/shared/components/calendar/EventDetails.js +2 -1
  30. package/dist/apps/shared/components/contacts/ContactCard.d.ts +19 -0
  31. package/dist/apps/shared/components/contacts/ContactCard.js +132 -0
  32. package/dist/apps/shared/components/contacts/ContactCardProvider.d.ts +25 -0
  33. package/dist/apps/shared/components/contacts/ContactCardProvider.js +41 -0
  34. package/dist/apps/shared/components/contacts/ParticipantLink.d.ts +18 -0
  35. package/dist/apps/shared/components/contacts/ParticipantLink.js +16 -0
  36. package/dist/apps/shared/components/contacts/contactCardData.d.ts +69 -0
  37. package/dist/apps/shared/components/contacts/contactCardData.js +158 -0
  38. package/dist/apps/shared/components/contacts/participantDetails.d.ts +64 -0
  39. package/dist/apps/shared/components/contacts/participantDetails.js +129 -0
  40. package/dist/apps/shared/components/layout/AppShell.js +11 -10
  41. package/dist/apps/shared/components/layout/ThemeSwitch.js +2 -2
  42. package/dist/apps/shared/components/layout/UserMenu.d.ts +1 -2
  43. package/dist/apps/shared/components/layout/UserMenu.js +4 -4
  44. package/dist/apps/shared/components/mail/InviteCard.js +5 -3
  45. package/dist/apps/shared/components/mail/MailAddress.d.ts +5 -2
  46. package/dist/apps/shared/components/mail/MailAddress.js +5 -3
  47. package/dist/apps/shared/components/mail/MessageDetailPane.js +19 -4
  48. package/dist/apps/shared/components/mail/VCardAttachmentChip.d.ts +18 -0
  49. package/dist/apps/shared/components/mail/VCardAttachmentChip.js +53 -0
  50. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +81 -4
  51. package/dist/apps/shared/components/mail/compose/RecipientInput.js +1 -0
  52. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +5 -2
  53. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +10 -1
  54. package/dist/apps/shared/components/mail/compose/dropFiles.d.ts +8 -0
  55. package/dist/apps/shared/components/mail/compose/dropFiles.js +18 -0
  56. package/dist/apps/shared/components/mail/layout/MailShell.js +5 -4
  57. package/dist/apps/shared/components/mail/layout/folderIcons.d.ts +8 -0
  58. package/dist/apps/shared/components/mail/layout/folderIcons.js +25 -0
  59. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
  60. package/dist/apps/shared/components/settings/layout/SettingsShell.js +16 -15
  61. package/dist/apps/shared/navigation/appHrefs.d.ts +3 -3
  62. package/dist/apps/shared/navigation/appHrefs.js +3 -3
  63. package/dist/lib/components/pickers/TimeZonePicker.js +14 -5
  64. package/dist/lib/contacts/contactsApi.d.ts +3 -0
  65. package/dist/lib/contacts/contactsApi.js +1 -1
  66. package/dist/lib/mail/directoryApi.d.ts +16 -10
  67. package/dist/lib/mail/directoryApi.js +20 -12
  68. package/dist/lib/util/timeZone.d.ts +1 -1
  69. package/dist/lib/util/timeZone.js +22 -9
  70. package/lib/components/pickers/TimeZonePicker.tsx +14 -9
  71. package/lib/contacts/contactsApi.ts +1 -1
  72. package/lib/mail/directoryApi.ts +30 -12
  73. package/lib/util/timeZone.ts +24 -9
  74. package/package.json +1 -1
@@ -3,7 +3,22 @@
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import React, { createContext, PropsWithChildren, ReactNode, useContext, useEffect, useMemo, useState } from "react";
6
- import { HiOutlineBars3 } from "react-icons/hi2";
6
+ import type { IconType } from "react-icons";
7
+ import {
8
+ HiOutlineArrowUturnLeft,
9
+ HiOutlineBars3,
10
+ HiOutlineEnvelopeOpen,
11
+ HiOutlineFunnel,
12
+ HiOutlineLockClosed,
13
+ HiOutlineNoSymbol,
14
+ HiOutlinePaintBrush,
15
+ HiOutlinePencilSquare,
16
+ HiOutlinePuzzlePiece,
17
+ HiOutlineShieldCheck,
18
+ HiOutlineTag,
19
+ HiOutlineUserCircle,
20
+ HiOutlineUsers,
21
+ } from "react-icons/hi2";
7
22
  import { ApiRequestError } from "../../../../../lib/util/api.js";
8
23
  import Drawer from "../../../../../lib/components/overlays/Drawer.js";
9
24
  import { Mailbox, listMailboxes } from "../../../../../lib/mail/mailApi.js";
@@ -20,21 +35,23 @@ export interface SettingsSectionDef {
20
35
  id: string;
21
36
  href: string;
22
37
  label: string;
38
+ /** Drawn beside the label in the sidebar. */
39
+ icon: IconType;
23
40
  }
24
41
 
25
42
  /** Every settings section with its own sidebar entry. */
26
43
  export const SETTINGS_SECTIONS: SettingsSectionDef[] = [
27
- { id: "profile", href: "/settings/profile", label: "Profile" },
28
- { id: "appearance", href: "/settings/appearance", label: "Appearance" },
29
- { id: "auto-reply", href: "/settings/auto-reply", label: "Automatic Replies" },
30
- { id: "filters", href: "/settings/filters", label: "Mail Filters" },
31
- { id: "blocked-senders", href: "/settings/blocked-senders", label: "Blocked & Safe Senders" },
32
- { id: "signatures", href: "/settings/signatures", label: "Signatures" },
33
- { id: "labels", href: "/settings/labels", label: "Labels" },
34
- { id: "read-receipts", href: "/settings/read-receipts", label: "Read Receipts" },
35
- { id: "encryption", href: "/settings/encryption", label: "Encryption" },
36
- { id: "sharing", href: "/settings/sharing", label: "Sharing" },
37
- { id: "privacy", href: "/settings/privacy", label: "Privacy & Data" },
44
+ { id: "profile", href: "/settings/profile", label: "Profile", icon: HiOutlineUserCircle },
45
+ { id: "appearance", href: "/settings/appearance", label: "Appearance", icon: HiOutlinePaintBrush },
46
+ { id: "auto-reply", href: "/settings/auto-reply", label: "Automatic Replies", icon: HiOutlineArrowUturnLeft },
47
+ { id: "filters", href: "/settings/filters", label: "Mail Filters", icon: HiOutlineFunnel },
48
+ { id: "blocked-senders", href: "/settings/blocked-senders", label: "Blocked & Safe Senders", icon: HiOutlineNoSymbol },
49
+ { id: "signatures", href: "/settings/signatures", label: "Signatures", icon: HiOutlinePencilSquare },
50
+ { id: "labels", href: "/settings/labels", label: "Labels", icon: HiOutlineTag },
51
+ { id: "read-receipts", href: "/settings/read-receipts", label: "Read Receipts", icon: HiOutlineEnvelopeOpen },
52
+ { id: "encryption", href: "/settings/encryption", label: "Encryption", icon: HiOutlineLockClosed },
53
+ { id: "sharing", href: "/settings/sharing", label: "Sharing", icon: HiOutlineUsers },
54
+ { id: "privacy", href: "/settings/privacy", label: "Privacy & Data", icon: HiOutlineShieldCheck },
38
55
  ];
39
56
 
40
57
  /** A `SETTINGS_SECTIONS` id, or a plugin's `settingsSections` item id (see `PluginNav`). */
@@ -47,6 +64,7 @@ export function settingsSections(pluginNav?: PluginNav): SettingsSectionDef[] {
47
64
  id,
48
65
  href,
49
66
  label,
67
+ icon: HiOutlinePuzzlePiece,
50
68
  }));
51
69
  }
52
70
 
@@ -207,10 +225,11 @@ export default function SettingsShell({
207
225
  href={`${section.href}?mailboxUid=${encodeURIComponent(mailboxUid!)}`}
208
226
  aria-current={section.id === active ? "page" : undefined}
209
227
  className={[
210
- "block px-2.5 py-1.5 rounded-sm text-sm",
228
+ "flex items-center gap-2.5 px-2.5 py-1.5 rounded-sm text-sm",
211
229
  section.id === active ? "bg-primary/10 text-primary-dark font-medium" : "text-text hover:bg-surface-alt",
212
230
  ].join(" ")}
213
231
  >
232
+ <section.icon size={18} aria-hidden="true" className="shrink-0" />
214
233
  {section.label}
215
234
  </a>
216
235
  ))}
@@ -12,10 +12,10 @@ export const APP_HREFS = {
12
12
  } as const;
13
13
 
14
14
  /**
15
- * Settings is reached from the account menu, not the rail. It links straight to the first settings section, the only one that always
16
- * exists - repoint this at a real `/settings` landing page once there is one.
15
+ * Settings is reached from the account menu, not the rail. It links straight to the Profile section, the first of them and the one that
16
+ * always exists - repoint this at a real `/settings` landing page once there is one.
17
17
  */
18
- export const SETTINGS_HREF = "/settings/auto-reply";
18
+ export const SETTINGS_HREF = "/settings/profile";
19
19
 
20
20
  /** Whether `pathname` is a page of Settings (any section), which is what "already there" means for it. */
21
21
  export function isSettingsPath(pathname: string): boolean {
@@ -6,7 +6,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
6
6
  import "../../../styles/app.css";
7
7
  import { useEffect, useState } from "react";
8
8
  import { useRouter } from "@rapidrest/react/client";
9
- import { HiOutlineClipboardDocumentList, HiOutlineClock, HiOutlineCpuChip, HiOutlineDocumentArrowDown, HiOutlineDocumentCheck, HiOutlineGlobeAlt, HiOutlineInboxStack, HiOutlineKey, HiOutlineLockClosed, HiOutlinePaintBrush, HiOutlinePuzzlePiece, HiOutlineQueueList, HiOutlineRocketLaunch, HiOutlineShieldCheck, HiOutlineShieldExclamation, HiOutlineUserGroup, HiOutlineWrenchScrewdriver, HiOutlineBars3, } from "react-icons/hi2";
9
+ import { HiOutlineClipboardDocumentList, HiOutlineClock, HiOutlineCpuChip, HiOutlineDocumentArrowDown, HiOutlineDocumentCheck, HiOutlineGlobeAlt, HiOutlineInboxStack, HiOutlineKey, HiOutlineLockClosed, HiOutlinePaintBrush, HiOutlinePuzzlePiece, HiOutlineQueueList, HiOutlineRocketLaunch, HiOutlineShieldCheck, HiOutlineShieldExclamation, HiOutlineUserGroup, HiOutlineWrenchScrewdriver, HiOutlineBars3, HiOutlineChevronDoubleLeft, HiOutlineChevronDoubleRight, } from "react-icons/hi2";
10
10
  import { apiFetch, ApiRequestError } from "../../../../../lib/util/api.js";
11
11
  import { getSetupStatus } from "../../../../../lib/admin/setupApi.js";
12
12
  import { useSessionRefresh } from "../../../../../lib/auth/session.js";
@@ -16,6 +16,7 @@ import Button from "../../../../../lib/components/buttons/Button.js";
16
16
  import Drawer from "../../../../../lib/components/overlays/Drawer.js";
17
17
  import { FrameBrandingFooter, useBrandingHtml } from "../../layout/BrandingChrome.js";
18
18
  import RailIcon from "../../layout/RailIcon.js";
19
+ import { readRailExpanded, writeRailExpanded } from "./railPreference.js";
19
20
  import AppearanceProvider from "../../../appearance/AppearanceProvider.js";
20
21
  import UserMenu from "../../layout/UserMenu.js";
21
22
  import { clearElevationAttempt, elevationAttemptedRecently, elevationUrl, isElevationRequired, recordElevationAttempt, } from "../elevation.js";
@@ -114,6 +115,15 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
114
115
  const [status, setStatus] = useState("checking");
115
116
  const [error, setError] = useState(null);
116
117
  const [menuOpen, setMenuOpen] = useState(false);
118
+ // The side rail shows icons alone, or icons with their labels. Read after the first render: the page is drawn on the server first, which cannot know.
119
+ const [railExpanded, setRailExpanded] = useState(false);
120
+ useEffect(() => setRailExpanded(readRailExpanded()), []);
121
+ function toggleRail() {
122
+ setRailExpanded((expanded) => {
123
+ writeRailExpanded(!expanded);
124
+ return !expanded;
125
+ });
126
+ }
117
127
  // `branding` only feeds the footer below: the admin-configured header is for the webmail and public pages, not the
118
128
  // console. `useBranding()` is still what injects the custom stylesheet and supplies the rail's icon.
119
129
  const { branding, iconSrc } = useBranding();
@@ -198,12 +208,19 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
198
208
  else {
199
209
  const navItems = adminNavItems(pluginNav);
200
210
  const activeItem = [...navItems, ...OFF_RAIL_ITEMS].find((item) => item.id === active);
201
- content = (_jsx("div", { className: "rr-frame-bg min-h-screen flex flex-col", children: _jsxs("div", { className: "flex-1 flex min-h-0", children: [_jsxs("nav", { "aria-label": "Admin sections", className: "hidden md:flex w-16 shrink-0 bg-surface border-r border-border flex-col items-center pb-3 gap-1", children: [_jsx(RailIcon, { src: iconSrc }), navItems.map(({ id, href, label, icon: Icon }) => (_jsx("a", { href: href, "aria-label": label, "aria-current": id === active ? "page" : undefined, title: label, className: [
202
- "w-10 h-10 flex items-center justify-center rounded-sm",
211
+ content = (_jsx("div", { className: "rr-frame-bg min-h-screen flex flex-col", children: _jsxs("div", { className: "flex-1 flex min-h-0", children: [_jsxs("nav", { "aria-label": "Admin sections", className: [
212
+ "hidden md:flex shrink-0 bg-surface border-r border-border flex-col pb-3 gap-1",
213
+ railExpanded ? "w-56 items-stretch" : "w-16 items-center",
214
+ ].join(" "), children: [_jsx(RailIcon, { src: iconSrc }), navItems.map(({ id, href, label, icon: Icon }) => (_jsxs("a", { href: href, "aria-label": label, "aria-current": id === active ? "page" : undefined, title: railExpanded ? undefined : label, className: [
215
+ "h-10 flex items-center rounded-sm",
216
+ railExpanded ? "gap-3 px-3 mx-2 text-sm font-medium" : "w-10 justify-center",
203
217
  id === active
204
218
  ? "bg-primary/10 text-primary-dark"
205
219
  : "text-text-muted hover:bg-surface-alt hover:text-text",
206
- ].join(" "), children: _jsx(Icon, { size: 20, "aria-hidden": "true" }) }, id)))] }), _jsx(Drawer, { open: menuOpen, onClose: () => setMenuOpen(false), title: "Admin", fullScreen: true, children: _jsx("nav", { "aria-label": "Admin menu", className: "flex flex-col gap-1 -mx-2", children: navItems.map(({ id, href, label, icon: Icon }) => (_jsxs("a", { href: href, "aria-current": id === active ? "page" : undefined, onClick: () => setMenuOpen(false), className: [
220
+ ].join(" "), children: [_jsx(Icon, { size: 20, "aria-hidden": "true", className: "shrink-0" }), railExpanded && _jsx("span", { className: "truncate", children: label })] }, id))), _jsxs("button", { type: "button", "aria-expanded": railExpanded, "aria-label": railExpanded ? "Collapse the menu" : "Expand the menu", title: railExpanded ? "Collapse the menu" : "Expand the menu", onClick: toggleRail, className: [
221
+ "mt-auto h-10 flex items-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text",
222
+ railExpanded ? "gap-3 px-3 mx-2 text-sm font-medium" : "w-10 justify-center",
223
+ ].join(" "), children: [railExpanded ? (_jsx(HiOutlineChevronDoubleLeft, { size: 20, "aria-hidden": "true", className: "shrink-0" })) : (_jsx(HiOutlineChevronDoubleRight, { size: 20, "aria-hidden": "true", className: "shrink-0" })), railExpanded && _jsx("span", { children: "Collapse" })] })] }), _jsx(Drawer, { open: menuOpen, onClose: () => setMenuOpen(false), title: "Admin", fullScreen: true, children: _jsx("nav", { "aria-label": "Admin menu", className: "flex flex-col gap-1 -mx-2", children: navItems.map(({ id, href, label, icon: Icon }) => (_jsxs("a", { href: href, "aria-current": id === active ? "page" : undefined, onClick: () => setMenuOpen(false), className: [
207
224
  "flex items-center gap-3 px-3 py-2.5 rounded-sm text-sm font-medium",
208
225
  id === active ? "bg-primary/10 text-primary-dark" : "text-text hover:bg-surface-alt",
209
226
  ].join(" "), children: [_jsx(Icon, { size: 20, "aria-hidden": "true" }), label] }, id))) }) }), _jsxs("div", { className: "flex-1 flex flex-col min-w-0", children: [_jsxs("header", { className: "rr-solid sticky top-0 z-30 h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-4 md:px-6", children: [_jsxs("div", { className: "flex items-center gap-2 min-w-0", children: [_jsx("button", { type: "button", className: "md:hidden shrink-0 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open menu", onClick: () => setMenuOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx("span", { className: "font-display font-bold text-lg uppercase tracking-wide truncate", children: activeItem?.label })] }), _jsx(UserMenu, { userUid: userUid, authServerUrl: authServerUrl, onSignOut: handleSignOut, showMailLink: true })] }), _jsx("div", { id: "app-content", className: "flex-1", children: _jsx("main", { className: "max-w-6xl mx-auto px-6 py-8", children: children }) })] })] }) }));
@@ -0,0 +1,6 @@
1
+ /** Where this browser remembers whether the admin console's side rail is showing its labels. */
2
+ export declare const RAIL_EXPANDED_KEY = "rapidmx:admin-rail-expanded";
3
+ /** Whether the rail was left expanded. A missing or blocked store means collapsed, as it always was. */
4
+ export declare function readRailExpanded(): boolean;
5
+ /** Remembers the rail's state. Best effort: a blocked store only means it starts collapsed the next time. */
6
+ export declare function writeRailExpanded(expanded: boolean): void;
@@ -0,0 +1,24 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /** Where this browser remembers whether the admin console's side rail is showing its labels. */
6
+ export const RAIL_EXPANDED_KEY = "rapidmx:admin-rail-expanded";
7
+ /** Whether the rail was left expanded. A missing or blocked store means collapsed, as it always was. */
8
+ export function readRailExpanded() {
9
+ try {
10
+ return localStorage.getItem(RAIL_EXPANDED_KEY) === "true";
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ }
16
+ /** Remembers the rail's state. Best effort: a blocked store only means it starts collapsed the next time. */
17
+ export function writeRailExpanded(expanded) {
18
+ try {
19
+ localStorage.setItem(RAIL_EXPANDED_KEY, String(expanded));
20
+ }
21
+ catch {
22
+ // Nothing to do.
23
+ }
24
+ }
@@ -7,6 +7,7 @@ import { useState } from "react";
7
7
  import { CALENDAR_COLOR_PALETTE, accentColorForMailbox, colorForFolder } from "../../../../lib/calendar/calendarColors.js";
8
8
  import Alert from "../../../../lib/components/feedback/Alert.js";
9
9
  import { notifyApiError } from "../../notifications/apiErrors.js";
10
+ import { SharedMailboxMark } from "../mail/layout/folderIcons.js";
10
11
  /**
11
12
  * Calendar's left-sidebar checklist of calendars — one row per `calendar`-type folder, a color swatch,
12
13
  * and a checkbox controlling whether that calendar's events are currently shown (the checked set is owned
@@ -49,7 +50,7 @@ export default function CalendarListSidebar({ mailboxCalendars, checkedFolderUid
49
50
  }
50
51
  const addForm = (_jsxs("form", { onSubmit: handleAdd, className: "flex flex-col gap-1.5 mt-2", children: [_jsx("input", { type: "text", autoFocus: true, "aria-label": "New calendar name", value: name, onChange: (e) => setName(e.target.value), className: "text-xs py-1 px-1.5 border border-border rounded-sm bg-surface" }), _jsx("div", { className: "flex items-center gap-1", role: "radiogroup", "aria-label": "Calendar color", children: CALENDAR_COLOR_PALETTE.map((swatch) => (_jsx("button", { type: "button", "aria-label": `Color ${swatch}`, "aria-pressed": color === swatch, onClick: () => setColor(swatch), className: ["w-4 h-4 rounded-full shrink-0", color === swatch ? "ring-2 ring-offset-1 ring-text" : ""].join(" "), style: { backgroundColor: swatch } }, swatch))) }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("button", { type: "submit", disabled: saving, className: "text-xs font-semibold text-primary-dark", children: "Add" }), _jsx("button", { type: "button", onClick: () => setAddingFor(null), className: "text-xs text-text-muted", children: "Cancel" })] })] }));
51
52
  return (_jsx("nav", { "aria-label": "My calendars", className: "p-3 border-t border-border flex flex-col gap-3", children: mailboxCalendars.map(({ mailbox, calendarFolders, error: mailboxError }) => {
52
- const title = multiple ? `${mailbox.displayName}${mailbox.ownerUserUid ? "" : " (shared)"}` : "My calendars";
53
- return (_jsxs("div", { children: [_jsxs("div", { className: "flex items-center justify-between mb-1 gap-2", children: [_jsxs("h2", { className: "flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-text-muted min-w-0", children: [multiple && (_jsx("span", { "aria-hidden": "true", className: "w-2 h-2 rounded-full shrink-0", style: { backgroundColor: accentColorForMailbox(mailbox.uid) } })), _jsx("span", { className: "truncate", children: title })] }), _jsx("button", { type: "button", onClick: () => startAdding(mailbox.uid), "aria-label": multiple ? `Add calendar to ${mailbox.displayName}` : "Add calendar", className: "text-xs font-bold text-primary-dark hover:underline shrink-0", children: "+" })] }), mailboxError && (_jsx("div", { className: "mb-1", children: _jsx(Alert, { children: mailboxError }) })), _jsx("div", { className: "flex flex-col gap-0.5", children: calendarFolders.map((folder) => (_jsxs("label", { className: "flex items-center gap-2 text-sm px-1 py-1 rounded-sm hover:bg-surface-alt cursor-pointer", children: [_jsx("input", { type: "checkbox", checked: checkedFolderUids.has(folder.uid), onChange: () => onToggle(folder.uid), style: { accentColor: colorFor(folder) } }), _jsx("span", { "aria-hidden": "true", className: "w-2.5 h-2.5 rounded-full shrink-0", style: { backgroundColor: colorFor(folder) } }), _jsx("span", { className: "truncate", children: folder.name })] }, folder.uid))) }), addingFor === mailbox.uid && addForm] }, mailbox.uid));
53
+ const title = multiple ? mailbox.displayName : "My calendars";
54
+ return (_jsxs("div", { children: [_jsxs("div", { className: "flex items-center justify-between mb-1 gap-2", children: [_jsxs("h2", { className: "flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-text-muted min-w-0", children: [multiple && (_jsx("span", { "aria-hidden": "true", className: "w-2 h-2 rounded-full shrink-0", style: { backgroundColor: accentColorForMailbox(mailbox.uid) } })), _jsxs("span", { className: "truncate", children: [title, multiple && !mailbox.ownerUserUid && _jsx(SharedMailboxMark, {})] })] }), _jsx("button", { type: "button", onClick: () => startAdding(mailbox.uid), "aria-label": multiple ? `Add calendar to ${mailbox.displayName}` : "Add calendar", className: "text-xs font-bold text-primary-dark hover:underline shrink-0", children: "+" })] }), mailboxError && (_jsx("div", { className: "mb-1", children: _jsx(Alert, { children: mailboxError }) })), _jsx("div", { className: "flex flex-col gap-0.5", children: calendarFolders.map((folder) => (_jsxs("label", { className: "flex items-center gap-2 text-sm px-1 py-1 rounded-sm hover:bg-surface-alt cursor-pointer", children: [_jsx("input", { type: "checkbox", checked: checkedFolderUids.has(folder.uid), onChange: () => onToggle(folder.uid), style: { accentColor: colorFor(folder) } }), _jsx("span", { "aria-hidden": "true", className: "w-2.5 h-2.5 rounded-full shrink-0", style: { backgroundColor: colorFor(folder) } }), _jsx("span", { className: "truncate", children: folder.name })] }, folder.uid))) }), addingFor === mailbox.uid && addForm] }, mailbox.uid));
54
55
  }) }));
55
56
  }
@@ -16,6 +16,7 @@ import { describeTimeZone, deviceTimeZone } from "../../../../lib/util/timeZone.
16
16
  import Alert from "../../../../lib/components/feedback/Alert.js";
17
17
  import Button from "../../../../lib/components/buttons/Button.js";
18
18
  import { joinMeetingUrl } from "../../calendar/calendarReminders.js";
19
+ import ParticipantLink from "../contacts/ParticipantLink.js";
19
20
  import EventDescriptionView from "./EventDescriptionView.js";
20
21
  import RequestChangeForm from "./RequestChangeForm.js";
21
22
  import { BUSY_STATUS_LABEL, RESPONSE_STATUS_LABEL, VISIBILITY_LABEL, describeGuestPermissions, describeReminder, formatStoredWhen, formatStoredWhenInZone } from "./eventFormat.js";
@@ -121,7 +122,7 @@ export default function EventDetails({ occurrence, isInvited, myResponse, isOwnA
121
122
  { response: "tentative", label: "Tentative" },
122
123
  { response: "declined", label: "Decline" },
123
124
  ];
124
- return (_jsxs("div", { className: "flex flex-col", children: [_jsxs("div", { className: "flex items-start gap-3 px-5 pt-4", children: [_jsx("span", { "aria-hidden": "true", className: "w-3.5 h-3.5 rounded-full shrink-0 mt-2", style: { backgroundColor: calendarColor } }), _jsx("h2", { className: "flex-1 min-w-0 text-xl font-semibold break-words", children: occurrence.title || "(no title)" }), _jsx("button", { type: "button", "aria-label": "Close details", onClick: onClose, className: "w-8 h-8 shrink-0 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text", children: _jsx(HiOutlineXMark, { size: 20, "aria-hidden": "true" }) })] }), _jsxs("div", { className: "flex flex-col gap-3 px-5 pt-3 pb-4 text-sm", children: [error && _jsx(Alert, { children: error }), _jsxs(DetailRow, { icon: _jsx(HiOutlineClock, { size: 18 }), children: [!occurrence.allDay && eventZone && eventZone !== deviceTimeZone() ? (_jsxs(_Fragment, { children: [_jsxs("div", { children: [formatStoredWhen(occurrence.startDate, occurrence.endDate, false), " \u2022 ", describeTimeZone(deviceTimeZone(), new Date(occurrence.startDate))] }), _jsxs("div", { className: "text-text-muted", children: [formatStoredWhenInZone(occurrence.startDate, occurrence.endDate, eventZone), " \u2022 ", describeTimeZone(eventZone, new Date(occurrence.startDate))] })] })) : (_jsx("div", { children: formatStoredWhen(occurrence.startDate, occurrence.endDate, occurrence.allDay) })), occurrence.recurrenceRule && _jsxs("div", { className: "text-text-muted", children: ["Repeats ", describeRecurrence(occurrence.recurrenceRule)] })] }), occurrence.location && (_jsx(DetailRow, { icon: _jsx(HiOutlineMapPin, { size: 18 }), children: _jsx("div", { children: occurrence.location }) })), hasDescription && (_jsx(DetailRow, { icon: _jsx(HiOutlineBars3BottomLeft, { size: 18 }), children: _jsx(EventDescriptionView, { html: occurrence.descriptionHtml, text: occurrence.description }) })), videoMeetingUid && (_jsx(DetailRow, { icon: _jsx(HiOutlineVideoCamera, { size: 18 }), children: _jsxs("div", { className: "flex items-center gap-3 flex-wrap", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: !joinUrl, onClick: () => window.open(joinUrl, "_blank", "noopener,noreferrer"), children: "Join video call" }), !joinUrl && (_jsx("span", { className: "text-xs text-text-muted", children: organizerJoinUrl === undefined ? "Loading the join link…" : "This meeting’s join link isn’t available." }))] }) })), _jsxs(DetailRow, { icon: _jsx(HiOutlineUser, { size: 18 }), children: [_jsx("div", { children: organizerName || occurrence.organizer.address }), _jsxs("div", { className: "text-text-muted", children: [organizerName ? `${occurrence.organizer.address} · ` : "", "Organizer"] })] }), (guests.length > 0 || hiddenList) && (_jsxs(DetailRow, { icon: _jsx(HiOutlineUserGroup, { size: 18 }), children: [_jsx("div", { className: "font-medium mb-1", children: hiddenList ? "The organizer has hidden the guest list" : guests.length === 1 ? "1 guest" : `${guests.length} guests` }), _jsx("ul", { className: "flex flex-col gap-1", children: guests.map((guest, i) => (_jsxs("li", { className: "flex items-baseline justify-between gap-3", children: [_jsxs("span", { className: "min-w-0 truncate", children: [guest.displayName || guest.address, ROLE_LABEL[guest.role] && _jsxs("span", { className: "text-text-muted", children: [" (", ROLE_LABEL[guest.role], ")"] })] }), _jsx("span", { className: "shrink-0 text-xs text-text-muted", children: RESPONSE_STATUS_LABEL[guest.responseStatus] })] }, i))) }), !isInvited && _jsx("div", { className: "text-xs text-text-muted mt-1", children: describeGuestPermissions(permissions) })] })), _jsxs(DetailRow, { icon: _jsx(HiOutlineBriefcase, { size: 18 }), children: [_jsx("div", { children: BUSY_STATUS_LABEL[occurrence.busyStatus] }), _jsx("div", { className: "text-text-muted", children: VISIBILITY_LABEL[visibilityOf(occurrence)] }), occurrence.autoReplyEnabled && (_jsx("div", { className: "text-text-muted", children: "Sends an automatic reply while this event is happening" }))] }), _jsx(DetailRow, { icon: _jsx(HiOutlineBell, { size: 18 }), children: _jsx("div", { children: describeReminder(occurrence.reminderMinutesBeforeStart) }) }), calendarName && (_jsxs("div", { className: "flex items-center gap-3 text-text-muted", children: [_jsx("span", { "aria-hidden": "true", className: "w-3 h-3 ml-[3px] rounded-full shrink-0", style: { backgroundColor: calendarColor } }), _jsx("span", { className: "truncate", children: calendarName })] })), isInvited && (_jsxs("p", { className: "text-xs text-text-muted", children: ["You were invited to this event. Only the organizer can change its details", canChange || canInvite ? ", but you can ask them to." : "."] }))] }), requesting && (_jsx("div", { className: "mx-5 mb-3 p-3 rounded-md bg-surface-alt", children: _jsx(RequestChangeForm, { subject: {
125
+ return (_jsxs("div", { className: "flex flex-col", children: [_jsxs("div", { className: "flex items-start gap-3 px-5 pt-4", children: [_jsx("span", { "aria-hidden": "true", className: "w-3.5 h-3.5 rounded-full shrink-0 mt-2", style: { backgroundColor: calendarColor } }), _jsx("h2", { className: "flex-1 min-w-0 text-xl font-semibold break-words", children: occurrence.title || "(no title)" }), _jsx("button", { type: "button", "aria-label": "Close details", onClick: onClose, className: "w-8 h-8 shrink-0 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text", children: _jsx(HiOutlineXMark, { size: 20, "aria-hidden": "true" }) })] }), _jsxs("div", { className: "flex flex-col gap-3 px-5 pt-3 pb-4 text-sm", children: [error && _jsx(Alert, { children: error }), _jsxs(DetailRow, { icon: _jsx(HiOutlineClock, { size: 18 }), children: [!occurrence.allDay && eventZone && eventZone !== deviceTimeZone() ? (_jsxs(_Fragment, { children: [_jsxs("div", { children: [formatStoredWhen(occurrence.startDate, occurrence.endDate, false), " \u2022 ", describeTimeZone(deviceTimeZone(), new Date(occurrence.startDate))] }), _jsxs("div", { className: "text-text-muted", children: [formatStoredWhenInZone(occurrence.startDate, occurrence.endDate, eventZone), " \u2022 ", describeTimeZone(eventZone, new Date(occurrence.startDate))] })] })) : (_jsx("div", { children: formatStoredWhen(occurrence.startDate, occurrence.endDate, occurrence.allDay) })), occurrence.recurrenceRule && _jsxs("div", { className: "text-text-muted", children: ["Repeats ", describeRecurrence(occurrence.recurrenceRule)] })] }), occurrence.location && (_jsx(DetailRow, { icon: _jsx(HiOutlineMapPin, { size: 18 }), children: _jsx("div", { children: occurrence.location }) })), hasDescription && (_jsx(DetailRow, { icon: _jsx(HiOutlineBars3BottomLeft, { size: 18 }), children: _jsx(EventDescriptionView, { html: occurrence.descriptionHtml, text: occurrence.description }) })), videoMeetingUid && (_jsx(DetailRow, { icon: _jsx(HiOutlineVideoCamera, { size: 18 }), children: _jsxs("div", { className: "flex items-center gap-3 flex-wrap", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: !joinUrl, onClick: () => window.open(joinUrl, "_blank", "noopener,noreferrer"), children: "Join video call" }), !joinUrl && (_jsx("span", { className: "text-xs text-text-muted", children: organizerJoinUrl === undefined ? "Loading the join link…" : "This meeting’s join link isn’t available." }))] }) })), _jsxs(DetailRow, { icon: _jsx(HiOutlineUser, { size: 18 }), children: [_jsx("div", { children: _jsx(ParticipantLink, { participant: occurrence.organizer, children: organizerName || occurrence.organizer.address }) }), _jsxs("div", { className: "text-text-muted", children: [organizerName ? `${occurrence.organizer.address} · ` : "", "Organizer"] })] }), (guests.length > 0 || hiddenList) && (_jsxs(DetailRow, { icon: _jsx(HiOutlineUserGroup, { size: 18 }), children: [_jsx("div", { className: "font-medium mb-1", children: hiddenList ? "The organizer has hidden the guest list" : guests.length === 1 ? "1 guest" : `${guests.length} guests` }), _jsx("ul", { className: "flex flex-col gap-1", children: guests.map((guest, i) => (_jsxs("li", { className: "flex items-baseline justify-between gap-3", children: [_jsxs("span", { className: "min-w-0 truncate", children: [_jsx(ParticipantLink, { participant: guest, children: guest.displayName || guest.address }), ROLE_LABEL[guest.role] && _jsxs("span", { className: "text-text-muted", children: [" (", ROLE_LABEL[guest.role], ")"] })] }), _jsx("span", { className: "shrink-0 text-xs text-text-muted", children: RESPONSE_STATUS_LABEL[guest.responseStatus] })] }, i))) }), !isInvited && _jsx("div", { className: "text-xs text-text-muted mt-1", children: describeGuestPermissions(permissions) })] })), _jsxs(DetailRow, { icon: _jsx(HiOutlineBriefcase, { size: 18 }), children: [_jsx("div", { children: BUSY_STATUS_LABEL[occurrence.busyStatus] }), _jsx("div", { className: "text-text-muted", children: VISIBILITY_LABEL[visibilityOf(occurrence)] }), occurrence.autoReplyEnabled && (_jsx("div", { className: "text-text-muted", children: "Sends an automatic reply while this event is happening" }))] }), _jsx(DetailRow, { icon: _jsx(HiOutlineBell, { size: 18 }), children: _jsx("div", { children: describeReminder(occurrence.reminderMinutesBeforeStart) }) }), calendarName && (_jsxs("div", { className: "flex items-center gap-3 text-text-muted", children: [_jsx("span", { "aria-hidden": "true", className: "w-3 h-3 ml-[3px] rounded-full shrink-0", style: { backgroundColor: calendarColor } }), _jsx("span", { className: "truncate", children: calendarName })] })), isInvited && (_jsxs("p", { className: "text-xs text-text-muted", children: ["You were invited to this event. Only the organizer can change its details", canChange || canInvite ? ", but you can ask them to." : "."] }))] }), requesting && (_jsx("div", { className: "mx-5 mb-3 p-3 rounded-md bg-surface-alt", children: _jsx(RequestChangeForm, { subject: {
125
126
  uid: occurrence.uid,
126
127
  title: occurrence.title,
127
128
  location: occurrence.location,
@@ -0,0 +1,19 @@
1
+ import React from "react";
2
+ import { type ContactCardContext } from "./contactCardData.js";
3
+ import { type Participant } from "./participantDetails.js";
4
+ export interface ContactCardProps {
5
+ participant: Participant;
6
+ context?: ContactCardContext;
7
+ onClose: () => void;
8
+ userUid?: string;
9
+ }
10
+ /**
11
+ * A person's contact card, as Outlook's people card: a big avatar, their name, an "External" badge when the address is outside the user's own
12
+ * domains, the actions (email them, copy the address, create the contact or open it), then their contact details, the last messages to or from
13
+ * them and the events coming up that they organize or were invited to. Each section loads on its own, with a skeleton, an empty text and an
14
+ * error that touches nothing else on the card.
15
+ *
16
+ * When the address is not in the user's address book the details are what is known: the name and address, and everything a vCard attached to the
17
+ * message they were met in says. "Create contact" stores that. The lookups are made once, when the card opens.
18
+ */
19
+ export default function ContactCard({ participant, context, onClose, userUid }: ContactCardProps): React.JSX.Element;
@@ -0,0 +1,132 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useContext, useEffect, useRef, useState } from "react";
7
+ import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
8
+ import ContactAvatar from "../../../../lib/components/avatar/ContactAvatar.js";
9
+ import Button from "../../../../lib/components/buttons/Button.js";
10
+ import CopyButton from "../../../../lib/components/buttons/CopyButton.js";
11
+ import Skeleton from "../../../../lib/components/feedback/Skeleton.js";
12
+ import Modal from "../../../../lib/components/overlays/Modal.js";
13
+ import { formatMailAddress } from "../../../../lib/mail/mailAddress.js";
14
+ import { listMailboxes } from "../../../../lib/mail/mailApi.js";
15
+ import { useApiClient } from "../../../../lib/util/apiClientContext.js";
16
+ import { MAILBOX_LIST_LIMIT, MailConnectionContext } from "../../mail/useMailConnection.js";
17
+ import { notify } from "../../notifications/store.js";
18
+ import { notifyApiError } from "../../notifications/apiErrors.js";
19
+ import { APP_HREFS } from "../../navigation/appHrefs.js";
20
+ import { useCompose } from "../mail/compose/ComposeContext.js";
21
+ import { formatInviteWhen } from "../mail/invite/inviteFormat.js";
22
+ import { contactScope, createContactFor, findContact, loadFolders, loadParticipantCard, loadRecentMessages, loadUpcomingEvents, scopeMailboxes, } from "./contactCardData.js";
23
+ import { contactInputFor, isExternalAddress, ownDomains, participantName } from "./participantDetails.js";
24
+ /**
25
+ * One piece of the card's data. `load` is `undefined` while what it needs is still coming (it stays loading), and runs once when it is there; a
26
+ * failure is this section's error and nothing else's. `upstream` is the section this one needs: its error is this one's too.
27
+ */
28
+ function useSection(load, upstream) {
29
+ const [state, setState] = useState({ status: "loading" });
30
+ const ready = !!load;
31
+ useEffect(() => {
32
+ if (!load) {
33
+ return;
34
+ }
35
+ let cancelled = false;
36
+ load().then((data) => !cancelled && setState({ status: "ready", data }), () => !cancelled && setState({ status: "error" }));
37
+ return () => {
38
+ cancelled = true;
39
+ };
40
+ }, [ready]);
41
+ return upstream?.status === "error" ? { status: "error" } : state;
42
+ }
43
+ const KIND_LABEL = { work: "Work", home: "Home", other: "" };
44
+ function kindLabel(kind) {
45
+ return KIND_LABEL[kind];
46
+ }
47
+ function Detail({ label, children }) {
48
+ return (_jsxs("div", { className: "flex gap-3 text-sm", children: [_jsx("dt", { className: "w-24 shrink-0 text-text-muted", children: label }), _jsx("dd", { className: "min-w-0 break-words whitespace-pre-line", children: children })] }));
49
+ }
50
+ /** A card section: its heading, and while loading a skeleton, on failure the reason, when empty the empty text, else what `render` draws. */
51
+ function CardSection({ title, section, empty, failure, isEmpty, render, }) {
52
+ return (_jsxs("section", { className: "mt-5", children: [_jsx("h3", { className: "text-sm font-bold mb-2", children: title }), section.status === "loading" ? (_jsxs("div", { role: "status", "aria-label": `Loading ${title.toLowerCase()}`, className: "flex flex-col gap-2", children: [_jsx(Skeleton, { width: "w-3/4" }), _jsx(Skeleton, { width: "w-1/2" })] })) : section.status === "error" ? (_jsx("p", { role: "alert", className: "text-sm text-danger", children: failure })) : isEmpty(section.data) ? (_jsx("p", { className: "text-sm text-text-muted", children: empty })) : (render(section.data))] }));
53
+ }
54
+ const ACTION_CLASS = "!w-auto !py-1 !px-3 text-xs";
55
+ const ROW_LINK_CLASS = "block rounded-sm px-2 py-1.5 -mx-2 hover:bg-surface-alt focus-visible:outline-2 focus-visible:outline-primary";
56
+ /**
57
+ * A person's contact card, as Outlook's people card: a big avatar, their name, an "External" badge when the address is outside the user's own
58
+ * domains, the actions (email them, copy the address, create the contact or open it), then their contact details, the last messages to or from
59
+ * them and the events coming up that they organize or were invited to. Each section loads on its own, with a skeleton, an empty text and an
60
+ * error that touches nothing else on the card.
61
+ *
62
+ * When the address is not in the user's address book the details are what is known: the name and address, and everything a vCard attached to the
63
+ * message they were met in says. "Create contact" stores that. The lookups are made once, when the card opens.
64
+ */
65
+ export default function ContactCard({ participant, context = {}, onClose, userUid }) {
66
+ const client = useApiClient();
67
+ const { openCompose } = useCompose();
68
+ const connection = useContext(MailConnectionContext);
69
+ const [listed, setListed] = useState({ status: "loading" });
70
+ const [created, setCreated] = useState();
71
+ const [creating, setCreating] = useState(false);
72
+ const mounted = useRef(true);
73
+ useEffect(() => {
74
+ mounted.current = true;
75
+ return () => void (mounted.current = false);
76
+ }, []);
77
+ // The mailboxes come from the app frame's connection when there is one; otherwise (a page rendered outside it) they are asked for.
78
+ useEffect(() => {
79
+ if (connection) {
80
+ return;
81
+ }
82
+ listMailboxes({ limit: MAILBOX_LIST_LIMIT }, client).then((data) => mounted.current && setListed({ status: "ready", data }), () => mounted.current && setListed({ status: "error" }));
83
+ }, []);
84
+ const mailboxes = connection
85
+ ? connection.status === "ready"
86
+ ? { status: "ready", data: connection.mailboxes }
87
+ : connection.status === "error"
88
+ ? { status: "error" }
89
+ : { status: "loading" }
90
+ : listed;
91
+ const scope = mailboxes.status === "ready" ? scopeMailboxes(mailboxes.data, userUid, context.message) : undefined;
92
+ const now = useRef(new Date());
93
+ const folders = useSection(scope && (() => loadFolders(scope, client)), mailboxes);
94
+ const contact = useSection(folders.status === "ready" ? () => findContact(folders.data, participant.address, client) : undefined, folders);
95
+ const messages = useSection(scope && (() => loadRecentMessages(scope, participant.address, client)), mailboxes);
96
+ const events = useSection(folders.status === "ready" ? () => loadUpcomingEvents(folders.data, participant.address, now.current, client) : undefined, folders);
97
+ const card = useSection(() => loadParticipantCard(participant, context, client));
98
+ const name = participantName(participant);
99
+ const stored = created ?? (contact.status === "ready" ? contact.data : undefined);
100
+ const external = mailboxes.status === "ready" && isExternalAddress(participant.address, ownDomains(mailboxes.data));
101
+ // What is known when there is no contact: the name, the address and the message's vCard.
102
+ const known = stored ?? contactInputFor(participant, card.status === "ready" ? card.data : undefined, { mailboxUid: "", folderUid: "" });
103
+ // What "Create contact" needs, once it is all there.
104
+ const createWith = folders.status === "ready" && mailboxes.status === "ready" && card.status === "ready"
105
+ ? { folders: folders.data, mailboxes: mailboxes.data, card: card.data }
106
+ : undefined;
107
+ async function handleCreate({ folders: folderList, mailboxes: mailboxList, card: parsed }) {
108
+ setCreating(true);
109
+ try {
110
+ const target = contactScope(mailboxList, folderList, userUid, context.message);
111
+ const made = await createContactFor(participant, parsed, target, client);
112
+ clearPinnedSignerCache();
113
+ notify({ kind: "success", title: "Contact created", message: `${made.displayName} was added to your contacts.` });
114
+ if (mounted.current) {
115
+ setCreated(made);
116
+ }
117
+ }
118
+ catch (err) {
119
+ notifyApiError(err, "Couldn't create this contact");
120
+ }
121
+ finally {
122
+ if (mounted.current) {
123
+ setCreating(false);
124
+ }
125
+ }
126
+ }
127
+ function handleEmail() {
128
+ openCompose({ to: formatMailAddress(participant) });
129
+ onClose();
130
+ }
131
+ return (_jsxs(Modal, { open: true, onClose: onClose, title: name, children: [_jsxs("div", { className: "flex items-center gap-4", children: [_jsx(ContactAvatar, { displayName: name, size: 72 }), _jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "text-sm text-text-muted break-words", children: participant.address }), external && (_jsx("span", { className: "inline-block mt-1 text-xs font-semibold py-0.5 px-2 rounded-pill bg-warning/15 text-text", children: "External" })), (known.jobTitle || known.company) && (_jsx("p", { className: "mt-1 text-sm break-words", children: [known.jobTitle, known.company].filter(Boolean).join(", ") }))] })] }), _jsxs("div", { className: "mt-4 flex flex-wrap items-center gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", className: ACTION_CLASS, onClick: handleEmail, children: "Email" }), _jsx(CopyButton, { value: participant.address, label: "Copy address", children: "Copy address" }), stored ? (_jsx("a", { href: `${APP_HREFS.contacts}/${encodeURIComponent(stored.uid)}`, onClick: onClose, className: "inline-flex items-center text-xs font-semibold py-1 px-3 rounded-sm border border-border hover:border-accent hover:text-accent-dark focus-visible:outline-2 focus-visible:outline-primary", children: "Open contact" })) : (_jsx(Button, { type: "button", variant: "secondary", className: ACTION_CLASS, disabled: !createWith || creating, loading: creating, onClick: () => void handleCreate(createWith), children: "Create contact" }))] }), _jsxs("section", { className: "mt-5", children: [_jsx("h3", { className: "text-sm font-bold mb-2", children: "Contact" }), !stored && contact.status === "loading" && _jsx(Skeleton, { width: "w-1/2", className: "mb-2" }), !stored && contact.status === "error" && (_jsx("p", { role: "alert", className: "text-sm text-danger mb-2", children: "Couldn\u2019t check your address book." })), !stored && contact.status === "ready" && _jsx("p", { className: "text-sm text-text-muted mb-2", children: "Not in your address book." }), _jsxs("dl", { className: "flex flex-col gap-1.5", children: [known.emails.map((email) => (_jsx(Detail, { label: ["Email", kindLabel(email.type)].filter(Boolean).join(" "), children: email.address }, `e:${email.address}`))), known.phones.map((phone) => (_jsx(Detail, { label: ["Phone", kindLabel(phone.type)].filter(Boolean).join(" "), children: phone.phoneNumber }, `p:${phone.phoneNumber}`))), known.company && _jsx(Detail, { label: "Company", children: known.company }), known.jobTitle && _jsx(Detail, { label: "Job title", children: known.jobTitle }), known.addresses.map((address, index) => (_jsx(Detail, { label: ["Address", kindLabel(address.type)].filter(Boolean).join(" "), children: [address.street, [address.postalCode, address.city].filter(Boolean).join(" "), address.state, address.country].filter(Boolean).join("\n") }, `a:${index}`))), known.notes && _jsx(Detail, { label: "Notes", children: known.notes })] })] }), _jsx(CardSection, { title: "Recent messages", section: messages, empty: "No recent messages", failure: "Couldn't load recent messages.", isEmpty: (list) => list.length === 0, render: (list) => (_jsx("ul", { className: "flex flex-col", children: list.map((message) => (_jsx("li", { children: _jsxs("a", { href: `/messages/${encodeURIComponent(message.uid)}`, onClick: onClose, className: ROW_LINK_CLASS, children: [_jsxs("span", { className: "flex items-baseline justify-between gap-3 text-sm", children: [_jsx("span", { className: "font-medium truncate", children: message.subject || "(No subject)" }), _jsx("time", { dateTime: message.receivedDate, className: "text-xs text-text-muted shrink-0", children: new Date(message.receivedDate).toLocaleDateString() })] }), _jsx("span", { className: "block text-xs text-text-muted truncate", children: message.bodyPreview })] }) }, message.uid))) })) }), _jsx(CardSection, { title: "Upcoming events", section: events, empty: "No upcoming events", failure: "Couldn't load upcoming events.", isEmpty: (list) => list.length === 0, render: (list) => (_jsx("ul", { className: "flex flex-col", children: list.map((event) => (_jsx("li", { children: _jsxs("a", { href: APP_HREFS.calendar, onClick: onClose, className: ROW_LINK_CLASS, children: [_jsx("span", { className: "block text-sm font-medium truncate", children: event.title }), _jsx("span", { className: "block text-xs text-text-muted", children: formatInviteWhen(event) })] }) }, event.occurrenceKey))) })) })] }));
132
+ }
@@ -0,0 +1,25 @@
1
+ import React, { PropsWithChildren } from "react";
2
+ import { type Message } from "../../../../lib/mail/mailApi.js";
3
+ import { type AddCardsResult, type ContactCardContext } from "./contactCardData.js";
4
+ import type { Participant } from "./participantDetails.js";
5
+ export interface ContactCardValue {
6
+ /** Opens the contact card of `participant`; `context` is the message they were met in, when there is one (its vCard attachments and mailbox). */
7
+ show: (participant: Participant, context?: ContactCardContext) => void;
8
+ /**
9
+ * Adds every card of a vCard file's `text` to the user's address book - in the default contacts folder of `message`'s mailbox when it is one of
10
+ * theirs, else of their primary mailbox - skipping the ones whose email is already a contact's. Rejects if a card cannot be stored (an
11
+ * `AddCardsError`) or the folders cannot be read. Does nothing useful outside a provider (`available` is `false`).
12
+ */
13
+ addVCards: (text: string, message?: Message) => Promise<AddCardsResult>;
14
+ /** `false` outside a `ContactCardProvider`, where `show()` does nothing: a caller then draws plain text instead of a button. */
15
+ available: boolean;
16
+ }
17
+ /** Shows a person's contact card from anywhere inside `AppShell` - see `ContactCard`. */
18
+ export declare function useContactCard(): ContactCardValue;
19
+ /**
20
+ * Owns the one contact card that can be open. Mounted once in `AppShell` (inside the compose provider, whose windows the card's "Email" opens), so
21
+ * a participant is clickable in every app. The card is a modal: focus goes into it, and back to the participant that was clicked when it closes.
22
+ */
23
+ export default function ContactCardProvider({ children, userUid }: PropsWithChildren<{
24
+ userUid?: string;
25
+ }>): React.JSX.Element;
@@ -0,0 +1,41 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { createContext, useCallback, useContext, useMemo, useState } from "react";
7
+ import { parseVCards } from "../../../../lib/contacts/vcard.js";
8
+ import { listMailboxes } from "../../../../lib/mail/mailApi.js";
9
+ import { useApiClient } from "../../../../lib/util/apiClientContext.js";
10
+ import { MAILBOX_LIST_LIMIT, MailConnectionContext } from "../../mail/useMailConnection.js";
11
+ import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
12
+ import ContactCard from "./ContactCard.js";
13
+ import { addCardsToAddressBook, contactScope, loadFolders, scopeMailboxes } from "./contactCardData.js";
14
+ const CardContext = createContext({ show: () => undefined, addVCards: () => Promise.resolve({ added: 0, skipped: 0 }), available: false });
15
+ /** Shows a person's contact card from anywhere inside `AppShell` - see `ContactCard`. */
16
+ export function useContactCard() {
17
+ return useContext(CardContext);
18
+ }
19
+ /**
20
+ * Owns the one contact card that can be open. Mounted once in `AppShell` (inside the compose provider, whose windows the card's "Email" opens), so
21
+ * a participant is clickable in every app. The card is a modal: focus goes into it, and back to the participant that was clicked when it closes.
22
+ */
23
+ export default function ContactCardProvider({ children, userUid }) {
24
+ const client = useApiClient();
25
+ const connection = useContext(MailConnectionContext);
26
+ const [shown, setShown] = useState(null);
27
+ const show = useCallback((participant, context) => {
28
+ setShown((previous) => ({ participant, context, id: (previous?.id ?? 0) + 1 }));
29
+ }, []);
30
+ const addVCards = useCallback(async (text, message) => {
31
+ const cards = parseVCards(text);
32
+ // The frame's mailboxes when it has them; a page rendered outside it asks.
33
+ const mailboxes = connection?.status === "ready" ? connection.mailboxes : await listMailboxes({ limit: MAILBOX_LIST_LIMIT }, client);
34
+ const folders = await loadFolders(scopeMailboxes(mailboxes, userUid, message), client);
35
+ const result = await addCardsToAddressBook(cards, contactScope(mailboxes, folders, userUid, message), client);
36
+ clearPinnedSignerCache();
37
+ return result;
38
+ }, [connection, client, userUid]);
39
+ const value = useMemo(() => ({ show, addVCards, available: true }), [show, addVCards]);
40
+ return (_jsxs(CardContext.Provider, { value: value, children: [children, shown && _jsx(ContactCard, { participant: shown.participant, context: shown.context, userUid: userUid, onClose: () => setShown(null) }, shown.id)] }));
41
+ }
@@ -0,0 +1,18 @@
1
+ import React, { ReactNode } from "react";
2
+ import type { ContactCardContext } from "./contactCardData.js";
3
+ import type { Participant } from "./participantDetails.js";
4
+ export interface ParticipantLinkProps {
5
+ participant: Participant;
6
+ /** The message the participant was met in - see `ContactCardValue.show`. */
7
+ context?: ContactCardContext;
8
+ /** The button's accessible name, when what it shows has none of its own (an avatar). */
9
+ label?: string;
10
+ className?: string;
11
+ children: ReactNode;
12
+ }
13
+ /**
14
+ * A participant of a message or an event (a sender, a recipient, an organizer, a guest) that opens their contact card when clicked: a real
15
+ * button, underlined on hover and with a visible focus ring, named by what it shows. Outside a `ContactCardProvider` it is plain text. Never
16
+ * put one inside another button or link.
17
+ */
18
+ export default function ParticipantLink({ participant, context, label, className, children }: ParticipantLinkProps): React.JSX.Element;
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useContactCard } from "./ContactCardProvider.js";
3
+ /**
4
+ * A participant of a message or an event (a sender, a recipient, an organizer, a guest) that opens their contact card when clicked: a real
5
+ * button, underlined on hover and with a visible focus ring, named by what it shows. Outside a `ContactCardProvider` it is plain text. Never
6
+ * put one inside another button or link.
7
+ */
8
+ export default function ParticipantLink({ participant, context, label, className, children }) {
9
+ const { show, available } = useContactCard();
10
+ if (!available) {
11
+ return _jsx("span", { className: className, children: children });
12
+ }
13
+ return (_jsx("button", { type: "button", "aria-label": label, onClick: () => show(participant, context), className: ["text-left rounded-sm hover:underline focus-visible:underline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary", className]
14
+ .filter(Boolean)
15
+ .join(" "), children: children }));
16
+ }
@@ -0,0 +1,69 @@
1
+ import { type CalendarOccurrence } from "../../../../lib/calendar/recurrence.js";
2
+ import { type Contact } from "../../../../lib/contacts/contactsApi.js";
3
+ import { type ParsedVCardContact } from "../../../../lib/contacts/vcard.js";
4
+ import { type Attachment, type Folder, type Mailbox, type Message } from "../../../../lib/mail/mailApi.js";
5
+ import { type ApiClient } from "../../../../lib/util/api.js";
6
+ import { type Participant } from "./participantDetails.js";
7
+ /** What the card is opened for besides who: the message the participant was met in, and that message's attachments when the caller already has them. */
8
+ export interface ContactCardContext {
9
+ message?: Message;
10
+ attachments?: Attachment[];
11
+ }
12
+ /** How many recent messages and upcoming events the card lists. */
13
+ export declare const CARD_LIST_SIZE = 5;
14
+ /** How far ahead "upcoming" looks, in days. */
15
+ export declare const UPCOMING_DAYS = 60;
16
+ /** The most vCard attachments and the most bytes of one that are read to fill a contact in. */
17
+ export declare const MAX_VCARD_FILES = 3;
18
+ export declare const MAX_VCARD_BYTES: number;
19
+ /** The mailboxes whose data the card reads: the user's own, else the primary/first one, plus the message's mailbox when it is one they can reach. */
20
+ export declare function scopeMailboxes(mailboxes: readonly Mailbox[], userUid: string | undefined, message?: Message): Mailbox[];
21
+ /** Every folder of `mailboxes`. A mailbox whose folders cannot be listed is left out rather than failing the rest. */
22
+ export declare function loadFolders(mailboxes: readonly Mailbox[], client?: ApiClient): Promise<Folder[]>;
23
+ /** The contact the address belongs to in `folders`' contacts folders, if any. */
24
+ export declare function findContact(folders: readonly Folder[], address: string, client?: ApiClient): Promise<Contact | undefined>;
25
+ /** The last `CARD_LIST_SIZE` messages to or from `address` in `mailboxes`, newest first. */
26
+ export declare function loadRecentMessages(mailboxes: readonly Mailbox[], address: string, client?: ApiClient): Promise<Message[]>;
27
+ /** The next `CARD_LIST_SIZE` events within `UPCOMING_DAYS` from `now` that `address` organizes or is invited to, soonest first. */
28
+ export declare function loadUpcomingEvents(folders: readonly Folder[], address: string, now: Date, client?: ApiClient): Promise<CalendarOccurrence[]>;
29
+ /** A vCard attachment's text. */
30
+ export declare function fetchVCardText(attachmentUid: string): Promise<string>;
31
+ /**
32
+ * The vCard of the context message that describes `participant`: the first attachment holding a card with their address, else - only for the
33
+ * message's sender, whose own vCard it presumably is - the first card there is. `undefined` when there is none, and for any failure to read one:
34
+ * a vCard only adds detail, it is never needed.
35
+ */
36
+ export declare function loadParticipantCard(participant: Participant, context: ContactCardContext, client?: ApiClient): Promise<ParsedVCardContact | undefined>;
37
+ /** Where a new contact goes: the message's mailbox when it is one of the user's own, else their primary mailbox - in that mailbox's contacts folder. */
38
+ export declare function contactScope(mailboxes: readonly Mailbox[], folders: readonly Folder[], userUid: string | undefined, message?: Message): {
39
+ mailboxUid: string;
40
+ folderUid: string;
41
+ };
42
+ /** Stores a contact for `participant` with everything known - the name, the address and whatever `card` says. */
43
+ export declare function createContactFor(participant: Participant, card: ParsedVCardContact | undefined, scope: {
44
+ mailboxUid: string;
45
+ folderUid: string;
46
+ }, client?: ApiClient): Promise<Contact>;
47
+ /**
48
+ * The sender's own contact card as a file to attach, `<display name>.vcf`: the mailbox being sent from (its name and primary address) with the phones, company and
49
+ * title of the user's own contact entry for that address, when they have one in their own mailboxes' address books. A lookup that fails just leaves those out.
50
+ */
51
+ export declare function buildOwnCardFile(mailbox: Mailbox, mailboxes: readonly Mailbox[], userUid: string | undefined, client?: ApiClient): Promise<File>;
52
+ /** The outcome of adding a vCard file's cards to the address book. */
53
+ export interface AddCardsResult {
54
+ added: number;
55
+ skipped: number;
56
+ }
57
+ /**
58
+ * Adds every card of a vCard file to the contacts folder `scope` names, skipping the ones whose email is already a stored contact's (or an earlier
59
+ * card's in the same file). A card that cannot be stored fails the whole call after the earlier ones were added - `AddCardsError` says how many.
60
+ */
61
+ export declare function addCardsToAddressBook(cards: readonly ParsedVCardContact[], scope: {
62
+ mailboxUid: string;
63
+ folderUid: string;
64
+ }, client?: ApiClient): Promise<AddCardsResult>;
65
+ /** A card failed to store: the message is the failure's and `added` how many cards were stored before it. */
66
+ export declare class AddCardsError extends Error {
67
+ readonly added: number;
68
+ constructor(failure: unknown, added: number);
69
+ }