@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
@@ -24,6 +24,8 @@ import {
24
24
  HiOutlineUserGroup,
25
25
  HiOutlineWrenchScrewdriver,
26
26
  HiOutlineBars3,
27
+ HiOutlineChevronDoubleLeft,
28
+ HiOutlineChevronDoubleRight,
27
29
  } from "react-icons/hi2";
28
30
  import { apiFetch, ApiRequestError } from "../../../../../lib/util/api.js";
29
31
  import { getSetupStatus } from "../../../../../lib/admin/setupApi.js";
@@ -35,6 +37,7 @@ import Drawer from "../../../../../lib/components/overlays/Drawer.js";
35
37
  import type { NavItem } from "../../../../../lib/components/navigation/BottomTabBar.js";
36
38
  import { FrameBrandingFooter, useBrandingHtml } from "../../layout/BrandingChrome.js";
37
39
  import RailIcon from "../../layout/RailIcon.js";
40
+ import { readRailExpanded, writeRailExpanded } from "./railPreference.js";
38
41
  import AppearanceProvider from "../../../appearance/AppearanceProvider.js";
39
42
  import UserMenu from "../../layout/UserMenu.js";
40
43
  import {
@@ -192,6 +195,15 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
192
195
  const [status, setStatus] = useState<Status>("checking");
193
196
  const [error, setError] = useState<string | null>(null);
194
197
  const [menuOpen, setMenuOpen] = useState(false);
198
+ // 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.
199
+ const [railExpanded, setRailExpanded] = useState(false);
200
+ useEffect(() => setRailExpanded(readRailExpanded()), []);
201
+ function toggleRail() {
202
+ setRailExpanded((expanded) => {
203
+ writeRailExpanded(!expanded);
204
+ return !expanded;
205
+ });
206
+ }
195
207
  // `branding` only feeds the footer below: the admin-configured header is for the webmail and public pages, not the
196
208
  // console. `useBranding()` is still what injects the custom stylesheet and supplies the rail's icon.
197
209
  const { branding, iconSrc } = useBranding();
@@ -309,7 +321,10 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
309
321
  <div className="flex-1 flex min-h-0">
310
322
  <nav
311
323
  aria-label="Admin sections"
312
- className="hidden md:flex w-16 shrink-0 bg-surface border-r border-border flex-col items-center pb-3 gap-1"
324
+ className={[
325
+ "hidden md:flex shrink-0 bg-surface border-r border-border flex-col pb-3 gap-1",
326
+ railExpanded ? "w-56 items-stretch" : "w-16 items-center",
327
+ ].join(" ")}
313
328
  >
314
329
  <RailIcon src={iconSrc} />
315
330
  {navItems.map(({ id, href, label, icon: Icon }) => (
@@ -318,17 +333,37 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
318
333
  href={href}
319
334
  aria-label={label}
320
335
  aria-current={id === active ? "page" : undefined}
321
- title={label}
336
+ title={railExpanded ? undefined : label}
322
337
  className={[
323
- "w-10 h-10 flex items-center justify-center rounded-sm",
338
+ "h-10 flex items-center rounded-sm",
339
+ railExpanded ? "gap-3 px-3 mx-2 text-sm font-medium" : "w-10 justify-center",
324
340
  id === active
325
341
  ? "bg-primary/10 text-primary-dark"
326
342
  : "text-text-muted hover:bg-surface-alt hover:text-text",
327
343
  ].join(" ")}
328
344
  >
329
- <Icon size={20} aria-hidden="true" />
345
+ <Icon size={20} aria-hidden="true" className="shrink-0" />
346
+ {railExpanded && <span className="truncate">{label}</span>}
330
347
  </a>
331
348
  ))}
349
+ <button
350
+ type="button"
351
+ aria-expanded={railExpanded}
352
+ aria-label={railExpanded ? "Collapse the menu" : "Expand the menu"}
353
+ title={railExpanded ? "Collapse the menu" : "Expand the menu"}
354
+ onClick={toggleRail}
355
+ className={[
356
+ "mt-auto h-10 flex items-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text",
357
+ railExpanded ? "gap-3 px-3 mx-2 text-sm font-medium" : "w-10 justify-center",
358
+ ].join(" ")}
359
+ >
360
+ {railExpanded ? (
361
+ <HiOutlineChevronDoubleLeft size={20} aria-hidden="true" className="shrink-0" />
362
+ ) : (
363
+ <HiOutlineChevronDoubleRight size={20} aria-hidden="true" className="shrink-0" />
364
+ )}
365
+ {railExpanded && <span>Collapse</span>}
366
+ </button>
332
367
  </nav>
333
368
  {/* Below `md` the sections are a menu that slides in from the left: ten or so of them don't fit a bar along the bottom. */}
334
369
  <Drawer open={menuOpen} onClose={() => setMenuOpen(false)} title="Admin" fullScreen>
@@ -0,0 +1,25 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+
6
+ /** Where this browser remembers whether the admin console's side rail is showing its labels. */
7
+ export const RAIL_EXPANDED_KEY = "rapidmx:admin-rail-expanded";
8
+
9
+ /** Whether the rail was left expanded. A missing or blocked store means collapsed, as it always was. */
10
+ export function readRailExpanded(): boolean {
11
+ try {
12
+ return localStorage.getItem(RAIL_EXPANDED_KEY) === "true";
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ /** Remembers the rail's state. Best effort: a blocked store only means it starts collapsed the next time. */
19
+ export function writeRailExpanded(expanded: boolean): void {
20
+ try {
21
+ localStorage.setItem(RAIL_EXPANDED_KEY, String(expanded));
22
+ } catch {
23
+ // Nothing to do.
24
+ }
25
+ }
@@ -7,6 +7,7 @@ import { CALENDAR_COLOR_PALETTE, accentColorForMailbox, colorForFolder } from ".
7
7
  import { Folder, Mailbox } from "../../../../lib/mail/mailApi.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
  export interface CalendarListSidebarMailbox {
12
13
  mailbox: Mailbox;
@@ -108,7 +109,7 @@ export default function CalendarListSidebar({
108
109
  return (
109
110
  <nav aria-label="My calendars" className="p-3 border-t border-border flex flex-col gap-3">
110
111
  {mailboxCalendars.map(({ mailbox, calendarFolders, error: mailboxError }) => {
111
- const title = multiple ? `${mailbox.displayName}${mailbox.ownerUserUid ? "" : " (shared)"}` : "My calendars";
112
+ const title = multiple ? mailbox.displayName : "My calendars";
112
113
  return (
113
114
  <div key={mailbox.uid}>
114
115
  <div className="flex items-center justify-between mb-1 gap-2">
@@ -120,7 +121,10 @@ export default function CalendarListSidebar({
120
121
  style={{ backgroundColor: accentColorForMailbox(mailbox.uid) }}
121
122
  />
122
123
  )}
123
- <span className="truncate">{title}</span>
124
+ <span className="truncate">
125
+ {title}
126
+ {multiple && !mailbox.ownerUserUid && <SharedMailboxMark />}
127
+ </span>
124
128
  </h2>
125
129
  <button
126
130
  type="button"
@@ -26,6 +26,7 @@ import { describeTimeZone, deviceTimeZone } from "../../../../lib/util/timeZone.
26
26
  import Alert from "../../../../lib/components/feedback/Alert.js";
27
27
  import Button from "../../../../lib/components/buttons/Button.js";
28
28
  import { joinMeetingUrl } from "../../calendar/calendarReminders.js";
29
+ import ParticipantLink from "../contacts/ParticipantLink.js";
29
30
  import EventDescriptionView from "./EventDescriptionView.js";
30
31
  import RequestChangeForm from "./RequestChangeForm.js";
31
32
  import { BUSY_STATUS_LABEL, RESPONSE_STATUS_LABEL, VISIBILITY_LABEL, describeGuestPermissions, describeReminder, formatStoredWhen, formatStoredWhenInZone } from "./eventFormat.js";
@@ -270,7 +271,9 @@ export default function EventDetails({
270
271
  )}
271
272
 
272
273
  <DetailRow icon={<HiOutlineUser size={18} />}>
273
- <div>{organizerName || occurrence.organizer.address}</div>
274
+ <div>
275
+ <ParticipantLink participant={occurrence.organizer}>{organizerName || occurrence.organizer.address}</ParticipantLink>
276
+ </div>
274
277
  <div className="text-text-muted">
275
278
  {organizerName ? `${occurrence.organizer.address} · ` : ""}Organizer
276
279
  </div>
@@ -285,7 +288,7 @@ export default function EventDetails({
285
288
  {guests.map((guest, i) => (
286
289
  <li key={i} className="flex items-baseline justify-between gap-3">
287
290
  <span className="min-w-0 truncate">
288
- {guest.displayName || guest.address}
291
+ <ParticipantLink participant={guest}>{guest.displayName || guest.address}</ParticipantLink>
289
292
  {ROLE_LABEL[guest.role] && <span className="text-text-muted"> ({ROLE_LABEL[guest.role]})</span>}
290
293
  </span>
291
294
  <span className="shrink-0 text-xs text-text-muted">{RESPONSE_STATUS_LABEL[guest.responseStatus]}</span>
@@ -0,0 +1,330 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { ReactNode, useContext, useEffect, useRef, useState } from "react";
6
+ import type { Contact, ContactAddressKind } from "../../../../lib/contacts/contactsApi.js";
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 type { ParsedVCardContact } from "../../../../lib/contacts/vcard.js";
15
+ import { type Folder, type Mailbox, listMailboxes } from "../../../../lib/mail/mailApi.js";
16
+ import { useApiClient } from "../../../../lib/util/apiClientContext.js";
17
+ import { MAILBOX_LIST_LIMIT, MailConnectionContext } from "../../mail/useMailConnection.js";
18
+ import { notify } from "../../notifications/store.js";
19
+ import { notifyApiError } from "../../notifications/apiErrors.js";
20
+ import { APP_HREFS } from "../../navigation/appHrefs.js";
21
+ import { useCompose } from "../mail/compose/ComposeContext.js";
22
+ import { formatInviteWhen } from "../mail/invite/inviteFormat.js";
23
+ import {
24
+ type ContactCardContext,
25
+ contactScope,
26
+ createContactFor,
27
+ findContact,
28
+ loadFolders,
29
+ loadParticipantCard,
30
+ loadRecentMessages,
31
+ loadUpcomingEvents,
32
+ scopeMailboxes,
33
+ } from "./contactCardData.js";
34
+ import { type Participant, contactInputFor, isExternalAddress, ownDomains, participantName } from "./participantDetails.js";
35
+
36
+ type Section<T> = { status: "loading" } | { status: "error" } | { status: "ready"; data: T };
37
+
38
+ /** What creating the contact is made from. */
39
+ interface CreateWith {
40
+ folders: Folder[];
41
+ mailboxes: Mailbox[];
42
+ card: ParsedVCardContact | undefined;
43
+ }
44
+
45
+ /**
46
+ * 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
47
+ * failure is this section's error and nothing else's. `upstream` is the section this one needs: its error is this one's too.
48
+ */
49
+ function useSection<T>(load: (() => Promise<T>) | undefined, upstream?: Section<unknown>): Section<T> {
50
+ const [state, setState] = useState<Section<T>>({ status: "loading" });
51
+ const ready = !!load;
52
+ useEffect(() => {
53
+ if (!load) {
54
+ return;
55
+ }
56
+ let cancelled = false;
57
+ load().then(
58
+ (data) => !cancelled && setState({ status: "ready", data }),
59
+ () => !cancelled && setState({ status: "error" }),
60
+ );
61
+ return () => {
62
+ cancelled = true;
63
+ };
64
+ }, [ready]);
65
+ return upstream?.status === "error" ? { status: "error" } : state;
66
+ }
67
+
68
+ const KIND_LABEL: Record<ContactAddressKind, string> = { work: "Work", home: "Home", other: "" };
69
+
70
+ function kindLabel(kind: ContactAddressKind): string {
71
+ return KIND_LABEL[kind];
72
+ }
73
+
74
+ function Detail({ label, children }: { label: string; children: ReactNode }) {
75
+ return (
76
+ <div className="flex gap-3 text-sm">
77
+ <dt className="w-24 shrink-0 text-text-muted">{label}</dt>
78
+ <dd className="min-w-0 break-words whitespace-pre-line">{children}</dd>
79
+ </div>
80
+ );
81
+ }
82
+
83
+ /** A card section: its heading, and while loading a skeleton, on failure the reason, when empty the empty text, else what `render` draws. */
84
+ function CardSection<T>({
85
+ title,
86
+ section,
87
+ empty,
88
+ failure,
89
+ isEmpty,
90
+ render,
91
+ }: {
92
+ title: string;
93
+ section: Section<T>;
94
+ empty: string;
95
+ failure: string;
96
+ isEmpty: (data: T) => boolean;
97
+ render: (data: T) => ReactNode;
98
+ }) {
99
+ return (
100
+ <section className="mt-5">
101
+ <h3 className="text-sm font-bold mb-2">{title}</h3>
102
+ {section.status === "loading" ? (
103
+ <div role="status" aria-label={`Loading ${title.toLowerCase()}`} className="flex flex-col gap-2">
104
+ <Skeleton width="w-3/4" />
105
+ <Skeleton width="w-1/2" />
106
+ </div>
107
+ ) : section.status === "error" ? (
108
+ <p role="alert" className="text-sm text-danger">
109
+ {failure}
110
+ </p>
111
+ ) : isEmpty(section.data) ? (
112
+ <p className="text-sm text-text-muted">{empty}</p>
113
+ ) : (
114
+ render(section.data)
115
+ )}
116
+ </section>
117
+ );
118
+ }
119
+
120
+ const ACTION_CLASS = "!w-auto !py-1 !px-3 text-xs";
121
+ 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";
122
+
123
+ export interface ContactCardProps {
124
+ participant: Participant;
125
+ context?: ContactCardContext;
126
+ onClose: () => void;
127
+ userUid?: string;
128
+ }
129
+
130
+ /**
131
+ * 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
132
+ * domains, the actions (email them, copy the address, create the contact or open it), then their contact details, the last messages to or from
133
+ * 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
134
+ * error that touches nothing else on the card.
135
+ *
136
+ * 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
137
+ * message they were met in says. "Create contact" stores that. The lookups are made once, when the card opens.
138
+ */
139
+ export default function ContactCard({ participant, context = {}, onClose, userUid }: ContactCardProps) {
140
+ const client = useApiClient();
141
+ const { openCompose } = useCompose();
142
+ const connection = useContext(MailConnectionContext);
143
+ const [listed, setListed] = useState<Section<Mailbox[]>>({ status: "loading" });
144
+ const [created, setCreated] = useState<Contact | undefined>();
145
+ const [creating, setCreating] = useState(false);
146
+ const mounted = useRef(true);
147
+ useEffect(() => {
148
+ mounted.current = true;
149
+ return () => void (mounted.current = false);
150
+ }, []);
151
+
152
+ // The mailboxes come from the app frame's connection when there is one; otherwise (a page rendered outside it) they are asked for.
153
+ useEffect(() => {
154
+ if (connection) {
155
+ return;
156
+ }
157
+ listMailboxes({ limit: MAILBOX_LIST_LIMIT }, client).then(
158
+ (data) => mounted.current && setListed({ status: "ready", data }),
159
+ () => mounted.current && setListed({ status: "error" }),
160
+ );
161
+ }, []);
162
+ const mailboxes: Section<Mailbox[]> = connection
163
+ ? connection.status === "ready"
164
+ ? { status: "ready", data: connection.mailboxes }
165
+ : connection.status === "error"
166
+ ? { status: "error" }
167
+ : { status: "loading" }
168
+ : listed;
169
+
170
+ const scope = mailboxes.status === "ready" ? scopeMailboxes(mailboxes.data, userUid, context.message) : undefined;
171
+ const now = useRef(new Date());
172
+ const folders = useSection(scope && (() => loadFolders(scope, client)), mailboxes);
173
+ const contact = useSection(
174
+ folders.status === "ready" ? () => findContact(folders.data, participant.address, client) : undefined,
175
+ folders,
176
+ );
177
+ const messages = useSection(scope && (() => loadRecentMessages(scope, participant.address, client)), mailboxes);
178
+ const events = useSection(
179
+ folders.status === "ready" ? () => loadUpcomingEvents(folders.data, participant.address, now.current, client) : undefined,
180
+ folders,
181
+ );
182
+ const card = useSection(() => loadParticipantCard(participant, context, client));
183
+
184
+ const name = participantName(participant);
185
+ const stored = created ?? (contact.status === "ready" ? contact.data : undefined);
186
+ const external = mailboxes.status === "ready" && isExternalAddress(participant.address, ownDomains(mailboxes.data));
187
+ // What is known when there is no contact: the name, the address and the message's vCard.
188
+ const known = stored ?? contactInputFor(participant, card.status === "ready" ? card.data : undefined, { mailboxUid: "", folderUid: "" });
189
+ // What "Create contact" needs, once it is all there.
190
+ const createWith =
191
+ folders.status === "ready" && mailboxes.status === "ready" && card.status === "ready"
192
+ ? { folders: folders.data, mailboxes: mailboxes.data, card: card.data }
193
+ : undefined;
194
+
195
+ async function handleCreate({ folders: folderList, mailboxes: mailboxList, card: parsed }: CreateWith) {
196
+ setCreating(true);
197
+ try {
198
+ const target = contactScope(mailboxList, folderList, userUid, context.message);
199
+ const made = await createContactFor(participant, parsed, target, client);
200
+ clearPinnedSignerCache();
201
+ notify({ kind: "success", title: "Contact created", message: `${made.displayName} was added to your contacts.` });
202
+ if (mounted.current) {
203
+ setCreated(made);
204
+ }
205
+ } catch (err) {
206
+ notifyApiError(err, "Couldn't create this contact");
207
+ } finally {
208
+ if (mounted.current) {
209
+ setCreating(false);
210
+ }
211
+ }
212
+ }
213
+
214
+ function handleEmail() {
215
+ openCompose({ to: formatMailAddress(participant) });
216
+ onClose();
217
+ }
218
+
219
+ return (
220
+ <Modal open onClose={onClose} title={name}>
221
+ <div className="flex items-center gap-4">
222
+ <ContactAvatar displayName={name} size={72} />
223
+ <div className="min-w-0">
224
+ <p className="text-sm text-text-muted break-words">{participant.address}</p>
225
+ {external && (
226
+ <span className="inline-block mt-1 text-xs font-semibold py-0.5 px-2 rounded-pill bg-warning/15 text-text">External</span>
227
+ )}
228
+ {(known.jobTitle || known.company) && (
229
+ <p className="mt-1 text-sm break-words">{[known.jobTitle, known.company].filter(Boolean).join(", ")}</p>
230
+ )}
231
+ </div>
232
+ </div>
233
+ <div className="mt-4 flex flex-wrap items-center gap-2">
234
+ <Button type="button" variant="secondary" className={ACTION_CLASS} onClick={handleEmail}>
235
+ Email
236
+ </Button>
237
+ <CopyButton value={participant.address} label="Copy address">
238
+ Copy address
239
+ </CopyButton>
240
+ {stored ? (
241
+ <a
242
+ href={`${APP_HREFS.contacts}/${encodeURIComponent(stored.uid)}`}
243
+ onClick={onClose}
244
+ 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"
245
+ >
246
+ Open contact
247
+ </a>
248
+ ) : (
249
+ <Button type="button" variant="secondary" className={ACTION_CLASS} disabled={!createWith || creating} loading={creating} onClick={() => void handleCreate(createWith as CreateWith)}>
250
+ Create contact
251
+ </Button>
252
+ )}
253
+ </div>
254
+
255
+ <section className="mt-5">
256
+ <h3 className="text-sm font-bold mb-2">Contact</h3>
257
+ {!stored && contact.status === "loading" && <Skeleton width="w-1/2" className="mb-2" />}
258
+ {!stored && contact.status === "error" && (
259
+ <p role="alert" className="text-sm text-danger mb-2">
260
+ Couldn&rsquo;t check your address book.
261
+ </p>
262
+ )}
263
+ {!stored && contact.status === "ready" && <p className="text-sm text-text-muted mb-2">Not in your address book.</p>}
264
+ <dl className="flex flex-col gap-1.5">
265
+ {known.emails.map((email) => (
266
+ <Detail key={`e:${email.address}`} label={["Email", kindLabel(email.type)].filter(Boolean).join(" ")}>
267
+ {email.address}
268
+ </Detail>
269
+ ))}
270
+ {known.phones.map((phone) => (
271
+ <Detail key={`p:${phone.phoneNumber}`} label={["Phone", kindLabel(phone.type)].filter(Boolean).join(" ")}>
272
+ {phone.phoneNumber}
273
+ </Detail>
274
+ ))}
275
+ {known.company && <Detail label="Company">{known.company}</Detail>}
276
+ {known.jobTitle && <Detail label="Job title">{known.jobTitle}</Detail>}
277
+ {known.addresses.map((address, index) => (
278
+ <Detail key={`a:${index}`} label={["Address", kindLabel(address.type)].filter(Boolean).join(" ")}>
279
+ {[address.street, [address.postalCode, address.city].filter(Boolean).join(" "), address.state, address.country].filter(Boolean).join("\n")}
280
+ </Detail>
281
+ ))}
282
+ {known.notes && <Detail label="Notes">{known.notes}</Detail>}
283
+ </dl>
284
+ </section>
285
+ <CardSection
286
+ title="Recent messages"
287
+ section={messages}
288
+ empty="No recent messages"
289
+ failure="Couldn't load recent messages."
290
+ isEmpty={(list) => list.length === 0}
291
+ render={(list) => (
292
+ <ul className="flex flex-col">
293
+ {list.map((message) => (
294
+ <li key={message.uid}>
295
+ <a href={`/messages/${encodeURIComponent(message.uid)}`} onClick={onClose} className={ROW_LINK_CLASS}>
296
+ <span className="flex items-baseline justify-between gap-3 text-sm">
297
+ <span className="font-medium truncate">{message.subject || "(No subject)"}</span>
298
+ <time dateTime={message.receivedDate} className="text-xs text-text-muted shrink-0">
299
+ {new Date(message.receivedDate).toLocaleDateString()}
300
+ </time>
301
+ </span>
302
+ <span className="block text-xs text-text-muted truncate">{message.bodyPreview}</span>
303
+ </a>
304
+ </li>
305
+ ))}
306
+ </ul>
307
+ )}
308
+ />
309
+ <CardSection
310
+ title="Upcoming events"
311
+ section={events}
312
+ empty="No upcoming events"
313
+ failure="Couldn't load upcoming events."
314
+ isEmpty={(list) => list.length === 0}
315
+ render={(list) => (
316
+ <ul className="flex flex-col">
317
+ {list.map((event) => (
318
+ <li key={event.occurrenceKey}>
319
+ <a href={APP_HREFS.calendar} onClick={onClose} className={ROW_LINK_CLASS}>
320
+ <span className="block text-sm font-medium truncate">{event.title}</span>
321
+ <span className="block text-xs text-text-muted">{formatInviteWhen(event)}</span>
322
+ </a>
323
+ </li>
324
+ ))}
325
+ </ul>
326
+ )}
327
+ />
328
+ </Modal>
329
+ );
330
+ }
@@ -0,0 +1,72 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { PropsWithChildren, createContext, useCallback, useContext, useMemo, useState } from "react";
6
+ import { parseVCards } from "../../../../lib/contacts/vcard.js";
7
+ import { type Message, listMailboxes } from "../../../../lib/mail/mailApi.js";
8
+ import { useApiClient } from "../../../../lib/util/apiClientContext.js";
9
+ import { MAILBOX_LIST_LIMIT, MailConnectionContext } from "../../mail/useMailConnection.js";
10
+ import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
11
+ import ContactCard from "./ContactCard.js";
12
+ import { type AddCardsResult, type ContactCardContext, addCardsToAddressBook, contactScope, loadFolders, scopeMailboxes } from "./contactCardData.js";
13
+ import type { Participant } from "./participantDetails.js";
14
+
15
+ export interface ContactCardValue {
16
+ /** Opens the contact card of `participant`; `context` is the message they were met in, when there is one (its vCard attachments and mailbox). */
17
+ show: (participant: Participant, context?: ContactCardContext) => void;
18
+ /**
19
+ * 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
20
+ * theirs, else of their primary mailbox - skipping the ones whose email is already a contact's. Rejects if a card cannot be stored (an
21
+ * `AddCardsError`) or the folders cannot be read. Does nothing useful outside a provider (`available` is `false`).
22
+ */
23
+ addVCards: (text: string, message?: Message) => Promise<AddCardsResult>;
24
+ /** `false` outside a `ContactCardProvider`, where `show()` does nothing: a caller then draws plain text instead of a button. */
25
+ available: boolean;
26
+ }
27
+
28
+ const CardContext = createContext<ContactCardValue>({ show: () => undefined, addVCards: () => Promise.resolve({ added: 0, skipped: 0 }), available: false });
29
+
30
+ /** Shows a person's contact card from anywhere inside `AppShell` - see `ContactCard`. */
31
+ export function useContactCard(): ContactCardValue {
32
+ return useContext(CardContext);
33
+ }
34
+
35
+ interface Shown {
36
+ participant: Participant;
37
+ context?: ContactCardContext;
38
+ /** A new card each time, so what one card loaded is never shown for the next. */
39
+ id: number;
40
+ }
41
+
42
+ /**
43
+ * 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
44
+ * 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.
45
+ */
46
+ export default function ContactCardProvider({ children, userUid }: PropsWithChildren<{ userUid?: string }>) {
47
+ const client = useApiClient();
48
+ const connection = useContext(MailConnectionContext);
49
+ const [shown, setShown] = useState<Shown | null>(null);
50
+ const show = useCallback<ContactCardValue["show"]>((participant, context) => {
51
+ setShown((previous) => ({ participant, context, id: (previous?.id ?? 0) + 1 }));
52
+ }, []);
53
+ const addVCards = useCallback<ContactCardValue["addVCards"]>(
54
+ async (text, message) => {
55
+ const cards = parseVCards(text);
56
+ // The frame's mailboxes when it has them; a page rendered outside it asks.
57
+ const mailboxes = connection?.status === "ready" ? connection.mailboxes : await listMailboxes({ limit: MAILBOX_LIST_LIMIT }, client);
58
+ const folders = await loadFolders(scopeMailboxes(mailboxes, userUid, message), client);
59
+ const result = await addCardsToAddressBook(cards, contactScope(mailboxes, folders, userUid, message), client);
60
+ clearPinnedSignerCache();
61
+ return result;
62
+ },
63
+ [connection, client, userUid],
64
+ );
65
+ const value = useMemo(() => ({ show, addVCards, available: true }), [show, addVCards]);
66
+ return (
67
+ <CardContext.Provider value={value}>
68
+ {children}
69
+ {shown && <ContactCard key={shown.id} participant={shown.participant} context={shown.context} userUid={userUid} onClose={() => setShown(null)} />}
70
+ </CardContext.Provider>
71
+ );
72
+ }
@@ -0,0 +1,42 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { ReactNode } from "react";
6
+ import { useContactCard } from "./ContactCardProvider.js";
7
+ import type { ContactCardContext } from "./contactCardData.js";
8
+ import type { Participant } from "./participantDetails.js";
9
+
10
+ export interface ParticipantLinkProps {
11
+ participant: Participant;
12
+ /** The message the participant was met in - see `ContactCardValue.show`. */
13
+ context?: ContactCardContext;
14
+ /** The button's accessible name, when what it shows has none of its own (an avatar). */
15
+ label?: string;
16
+ className?: string;
17
+ children: ReactNode;
18
+ }
19
+
20
+ /**
21
+ * 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
22
+ * button, underlined on hover and with a visible focus ring, named by what it shows. Outside a `ContactCardProvider` it is plain text. Never
23
+ * put one inside another button or link.
24
+ */
25
+ export default function ParticipantLink({ participant, context, label, className, children }: ParticipantLinkProps) {
26
+ const { show, available } = useContactCard();
27
+ if (!available) {
28
+ return <span className={className}>{children}</span>;
29
+ }
30
+ return (
31
+ <button
32
+ type="button"
33
+ aria-label={label}
34
+ onClick={() => show(participant, context)}
35
+ className={["text-left rounded-sm hover:underline focus-visible:underline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary", className]
36
+ .filter(Boolean)
37
+ .join(" ")}
38
+ >
39
+ {children}
40
+ </button>
41
+ );
42
+ }