@rapidmx/web-client 0.25.1 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +15 -3
  2. package/apps/shared/components/admin/layout/AdminShell.tsx +3 -2
  3. package/apps/shared/components/calendar/EventDetails.tsx +5 -2
  4. package/apps/shared/components/contacts/ContactCard.tsx +356 -0
  5. package/apps/shared/components/contacts/ContactCardProvider.tsx +72 -0
  6. package/apps/shared/components/contacts/ContactDetailPane.tsx +25 -5
  7. package/apps/shared/components/contacts/ContactForm.tsx +36 -5
  8. package/apps/shared/components/contacts/ContactPhotoField.tsx +86 -0
  9. package/apps/shared/components/contacts/FavoriteStarButton.tsx +36 -0
  10. package/apps/shared/components/contacts/ParticipantLink.tsx +42 -0
  11. package/apps/shared/components/contacts/contactCardData.ts +220 -0
  12. package/apps/shared/components/contacts/participantDetails.ts +169 -0
  13. package/apps/shared/components/layout/AppShell.tsx +18 -6
  14. package/apps/shared/components/mail/InviteCard.tsx +27 -5
  15. package/apps/shared/components/mail/MailAddress.tsx +8 -3
  16. package/apps/shared/components/mail/MessageDetailPane.tsx +72 -27
  17. package/apps/shared/components/mail/VCardAttachmentChip.tsx +85 -0
  18. package/apps/shared/components/mail/compose/ComposeWindow.tsx +112 -1
  19. package/apps/shared/components/mail/compose/RecipientInput.tsx +1 -0
  20. package/apps/shared/components/mail/compose/RichTextEditor.tsx +15 -2
  21. package/apps/shared/components/mail/compose/dropFiles.ts +21 -0
  22. package/apps/shared/components/settings/layout/SettingsShell.tsx +3 -3
  23. package/apps/shared/plugins/pluginIcons.ts +124 -0
  24. package/apps/shared/plugins/pluginNav.ts +8 -0
  25. package/apps/shared/plugins/useResolvedRailItems.ts +54 -0
  26. package/apps/www/contacts/[uid].tsx +1 -0
  27. package/apps/www/contacts/index.tsx +3 -1
  28. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
  29. package/dist/apps/shared/components/admin/layout/AdminShell.js +3 -2
  30. package/dist/apps/shared/components/calendar/EventDetails.js +2 -1
  31. package/dist/apps/shared/components/contacts/ContactCard.d.ts +19 -0
  32. package/dist/apps/shared/components/contacts/ContactCard.js +148 -0
  33. package/dist/apps/shared/components/contacts/ContactCardProvider.d.ts +25 -0
  34. package/dist/apps/shared/components/contacts/ContactCardProvider.js +41 -0
  35. package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +3 -1
  36. package/dist/apps/shared/components/contacts/ContactDetailPane.js +17 -2
  37. package/dist/apps/shared/components/contacts/ContactForm.js +20 -2
  38. package/dist/apps/shared/components/contacts/ContactPhotoField.d.ts +21 -0
  39. package/dist/apps/shared/components/contacts/ContactPhotoField.js +46 -0
  40. package/dist/apps/shared/components/contacts/FavoriteStarButton.d.ts +9 -0
  41. package/dist/apps/shared/components/contacts/FavoriteStarButton.js +12 -0
  42. package/dist/apps/shared/components/contacts/ParticipantLink.d.ts +18 -0
  43. package/dist/apps/shared/components/contacts/ParticipantLink.js +16 -0
  44. package/dist/apps/shared/components/contacts/contactCardData.d.ts +69 -0
  45. package/dist/apps/shared/components/contacts/contactCardData.js +158 -0
  46. package/dist/apps/shared/components/contacts/participantDetails.d.ts +64 -0
  47. package/dist/apps/shared/components/contacts/participantDetails.js +129 -0
  48. package/dist/apps/shared/components/layout/AppShell.d.ts +3 -2
  49. package/dist/apps/shared/components/layout/AppShell.js +24 -15
  50. package/dist/apps/shared/components/mail/InviteCard.js +5 -3
  51. package/dist/apps/shared/components/mail/MailAddress.d.ts +5 -2
  52. package/dist/apps/shared/components/mail/MailAddress.js +5 -3
  53. package/dist/apps/shared/components/mail/MessageDetailPane.js +19 -7
  54. package/dist/apps/shared/components/mail/VCardAttachmentChip.d.ts +18 -0
  55. package/dist/apps/shared/components/mail/VCardAttachmentChip.js +53 -0
  56. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +81 -4
  57. package/dist/apps/shared/components/mail/compose/RecipientInput.js +1 -0
  58. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +5 -2
  59. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +10 -1
  60. package/dist/apps/shared/components/mail/compose/dropFiles.d.ts +8 -0
  61. package/dist/apps/shared/components/mail/compose/dropFiles.js +18 -0
  62. package/dist/apps/shared/components/settings/layout/SettingsShell.js +4 -3
  63. package/dist/apps/shared/plugins/pluginIcons.d.ts +10 -0
  64. package/dist/apps/shared/plugins/pluginIcons.js +64 -0
  65. package/dist/apps/shared/plugins/pluginNav.d.ts +8 -0
  66. package/dist/apps/shared/plugins/useResolvedRailItems.d.ts +12 -0
  67. package/dist/apps/shared/plugins/useResolvedRailItems.js +47 -0
  68. package/dist/apps/www/contacts/[uid].js +1 -1
  69. package/dist/apps/www/contacts/index.js +2 -2
  70. package/dist/lib/components/avatar/ContactAvatar.d.ts +9 -3
  71. package/dist/lib/components/avatar/ContactAvatar.js +29 -6
  72. package/dist/lib/components/buttons/CopyIconButton.d.ts +13 -0
  73. package/dist/lib/components/buttons/CopyIconButton.js +16 -0
  74. package/dist/lib/contacts/contactsApi.d.ts +15 -0
  75. package/dist/lib/contacts/contactsApi.js +31 -2
  76. package/dist/lib/contacts/gravatar.d.ts +12 -0
  77. package/dist/lib/contacts/gravatar.js +43 -0
  78. package/dist/lib/mail/directoryApi.d.ts +16 -10
  79. package/dist/lib/mail/directoryApi.js +20 -12
  80. package/lib/components/avatar/ContactAvatar.tsx +43 -7
  81. package/lib/components/buttons/CopyIconButton.tsx +49 -0
  82. package/lib/contacts/contactsApi.ts +35 -2
  83. package/lib/contacts/gravatar.ts +47 -0
  84. package/lib/mail/directoryApi.ts +30 -12
  85. package/package.json +1 -1
package/README.md CHANGED
@@ -355,10 +355,20 @@ any release.
355
355
  | `shared/components/admin/layout/AdminShell.js` | Admin console chrome, gated on administrator access - an administrator whose session isn't elevated is sent to auth-server's `/auth/elevate` page and returned. `active` is the plugin's `adminNav` item id. |
356
356
  | `shared/components/layout/BrandingChrome.js` | `BrandingHeader` and `BrandingFooter`, for pages that don't use a shell, such as public pages. |
357
357
  | `shared/plugins/pluginNav.js` | The `PluginNav`, `PluginUiNavItem` and `PluginNavProps` types. |
358
+ | `shared/plugins/pluginIcons.js` | `PLUGIN_ICONS`, the icon names a nav entry's `icon` can use, and `pluginIcon()`, which resolves one. |
359
+
360
+ ### Page building blocks
361
+
362
+ | Import | Use |
363
+ | --- | --- |
364
+ | `shared/components/layout/ResponsiveToolbar.js` | A toolbar whose buttons collapse into an overflow menu when space runs out. |
365
+ | `shared/components/admin/usePagedList.js` | Loads a list a page at a time with "Load more", for tables of the plugin's own records. |
366
+ | `shared/notifications/apiErrors.js` | `notifyApiError()`, which shows a failed API call as a notification. |
367
+ | `shared/components/admin/diagnostics/Sparkline.js` and `MetricTile.js` | A small trend line and a labelled figure, for simple dashboards. |
358
368
 
359
369
  Every www and admin page receives a `pluginNav` prop from the server. It lists the settings sections, admin sections and
360
- app rail entries of every enabled plugin whose UI built. The shells append those entries after their own, with a generic
361
- icon. An entry whose id matches a core entry is skipped, and so is one whose `href` isn't a same-origin path. Pass the
370
+ app rail entries of every enabled plugin whose UI built. The shells append those entries after their own, each with the
371
+ icon its manifest names when that name is in `PLUGIN_ICONS`, and a generic puzzle piece otherwise. An entry whose id matches a core entry is skipped, and so is one whose `href` isn't a same-origin path. Pass the
362
372
  page props straight to the shell so the navigation shows:
363
373
 
364
374
  ```tsx
@@ -397,7 +407,9 @@ section above):
397
407
  - `components/buttons/Button.js`, `components/feedback/Alert.js` and `components/feedback/Skeleton.js`;
398
408
  - `components/forms/FormField.js`;
399
409
  - `components/overlays/Modal.js` and `components/overlays/Drawer.js`;
400
- - `components/pickers/MiniDatePicker.js`.
410
+ - `components/pickers/MiniDatePicker.js`;
411
+ - `components/avatar/ContactAvatar.js`;
412
+ - `util/apiQuery.js`: `buildQuery()`, for paging a list route.
401
413
 
402
414
  ## Uninstalling a plugin with its data
403
415
 
@@ -48,6 +48,7 @@ import {
48
48
  recordElevationAttempt,
49
49
  } from "../elevation.js";
50
50
  import { signOutOfConsole } from "../signOut.js";
51
+ import { pluginIcon } from "../../../plugins/pluginIcons.js";
51
52
  import { mergePluginNavItems, PluginNav, PluginNavProps } from "../../../plugins/pluginNav.js";
52
53
 
53
54
  export type AdminSection =
@@ -165,13 +166,13 @@ const SETUP_ITEM: NavItem = { id: "setup", href: "/admin/setup", label: "Setup",
165
166
 
166
167
  const OFF_RAIL_ITEMS: NavItem[] = [...MAILBOX_SCOPED_ITEMS, SETUP_ITEM];
167
168
 
168
- /** `NAV_ITEMS` followed by the plugins' `adminNav` items (generic icon). Core ids win, including the
169
+ /** `NAV_ITEMS` followed by the plugins' `adminNav` items (their manifest `icon`, or a generic one). Core ids win, including the
169
170
  * off-rail sections' - see `mergePluginNavItems`. */
170
171
  export function adminNavItems(pluginNav?: PluginNav): NavItem[] {
171
172
  return mergePluginNavItems<NavItem>(
172
173
  NAV_ITEMS,
173
174
  pluginNav?.adminNav,
174
- ({ id, href, label }) => ({ id, href, label, icon: HiOutlinePuzzlePiece }),
175
+ ({ id, href, label, icon }) => ({ id, href, label, icon: pluginIcon(icon) }),
175
176
  OFF_RAIL_ITEMS.map((item) => item.id),
176
177
  );
177
178
  }
@@ -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,356 @@
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, type ContactAddressKind, contactPhotoUrl, setContactFavorite } 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 CopyIconButton from "../../../../lib/components/buttons/CopyIconButton.js";
11
+ import { HiOutlineEnvelope, HiOutlineUserPlus } from "react-icons/hi2";
12
+ import FavoriteStarButton from "./FavoriteStarButton.js";
13
+ import Skeleton from "../../../../lib/components/feedback/Skeleton.js";
14
+ import Modal from "../../../../lib/components/overlays/Modal.js";
15
+ import { formatMailAddress } from "../../../../lib/mail/mailAddress.js";
16
+ import type { ParsedVCardContact } from "../../../../lib/contacts/vcard.js";
17
+ import { type Folder, type Mailbox, listMailboxes } from "../../../../lib/mail/mailApi.js";
18
+ import { useApiClient } from "../../../../lib/util/apiClientContext.js";
19
+ import { MAILBOX_LIST_LIMIT, MailConnectionContext } from "../../mail/useMailConnection.js";
20
+ import { notify } from "../../notifications/store.js";
21
+ import { notifyApiError } from "../../notifications/apiErrors.js";
22
+ import { APP_HREFS } from "../../navigation/appHrefs.js";
23
+ import { useCompose } from "../mail/compose/ComposeContext.js";
24
+ import { formatInviteWhen } from "../mail/invite/inviteFormat.js";
25
+ import {
26
+ type ContactCardContext,
27
+ contactScope,
28
+ createContactFor,
29
+ findContact,
30
+ loadFolders,
31
+ loadParticipantCard,
32
+ loadRecentMessages,
33
+ loadUpcomingEvents,
34
+ scopeMailboxes,
35
+ } from "./contactCardData.js";
36
+ import { type Participant, contactInputFor, isExternalAddress, ownDomains, participantName } from "./participantDetails.js";
37
+
38
+ type Section<T> = { status: "loading" } | { status: "error" } | { status: "ready"; data: T };
39
+
40
+ /** What creating the contact is made from. */
41
+ interface CreateWith {
42
+ folders: Folder[];
43
+ mailboxes: Mailbox[];
44
+ card: ParsedVCardContact | undefined;
45
+ }
46
+
47
+ /**
48
+ * 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
49
+ * failure is this section's error and nothing else's. `upstream` is the section this one needs: its error is this one's too.
50
+ */
51
+ function useSection<T>(load: (() => Promise<T>) | undefined, upstream?: Section<unknown>): Section<T> {
52
+ const [state, setState] = useState<Section<T>>({ status: "loading" });
53
+ const ready = !!load;
54
+ useEffect(() => {
55
+ if (!load) {
56
+ return;
57
+ }
58
+ let cancelled = false;
59
+ load().then(
60
+ (data) => !cancelled && setState({ status: "ready", data }),
61
+ () => !cancelled && setState({ status: "error" }),
62
+ );
63
+ return () => {
64
+ cancelled = true;
65
+ };
66
+ }, [ready]);
67
+ return upstream?.status === "error" ? { status: "error" } : state;
68
+ }
69
+
70
+ const KIND_LABEL: Record<ContactAddressKind, string> = { work: "Work", home: "Home", other: "" };
71
+
72
+ function kindLabel(kind: ContactAddressKind): string {
73
+ return KIND_LABEL[kind];
74
+ }
75
+
76
+ function Detail({ label, children }: { label: string; children: ReactNode }) {
77
+ return (
78
+ <div className="flex gap-3 text-sm">
79
+ <dt className="w-24 shrink-0 text-text-muted">{label}</dt>
80
+ <dd className="min-w-0 break-words whitespace-pre-line">{children}</dd>
81
+ </div>
82
+ );
83
+ }
84
+
85
+ /** A card section: its heading, and while loading a skeleton, on failure the reason, when empty the empty text, else what `render` draws. */
86
+ function CardSection<T>({
87
+ title,
88
+ section,
89
+ empty,
90
+ failure,
91
+ isEmpty,
92
+ render,
93
+ }: {
94
+ title: string;
95
+ section: Section<T>;
96
+ empty: string;
97
+ failure: string;
98
+ isEmpty: (data: T) => boolean;
99
+ render: (data: T) => ReactNode;
100
+ }) {
101
+ return (
102
+ <section className="mt-5">
103
+ <h3 className="text-sm font-bold mb-2">{title}</h3>
104
+ {section.status === "loading" ? (
105
+ <div role="status" aria-label={`Loading ${title.toLowerCase()}`} className="flex flex-col gap-2">
106
+ <Skeleton width="w-3/4" />
107
+ <Skeleton width="w-1/2" />
108
+ </div>
109
+ ) : section.status === "error" ? (
110
+ <p role="alert" className="text-sm text-danger">
111
+ {failure}
112
+ </p>
113
+ ) : isEmpty(section.data) ? (
114
+ <p className="text-sm text-text-muted">{empty}</p>
115
+ ) : (
116
+ render(section.data)
117
+ )}
118
+ </section>
119
+ );
120
+ }
121
+
122
+ const ACTION_CLASS = "!w-auto !py-1 !px-3 text-xs";
123
+ 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";
124
+
125
+ export interface ContactCardProps {
126
+ participant: Participant;
127
+ context?: ContactCardContext;
128
+ onClose: () => void;
129
+ userUid?: string;
130
+ }
131
+
132
+ /**
133
+ * 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
134
+ * domains, the actions (email them, copy the address, create the contact or open it), then their contact details, the last messages to or from
135
+ * 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
136
+ * error that touches nothing else on the card.
137
+ *
138
+ * 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
139
+ * message they were met in says. "Create contact" stores that. The lookups are made once, when the card opens.
140
+ */
141
+ export default function ContactCard({ participant, context = {}, onClose, userUid }: ContactCardProps) {
142
+ const client = useApiClient();
143
+ const { openCompose } = useCompose();
144
+ const connection = useContext(MailConnectionContext);
145
+ const [listed, setListed] = useState<Section<Mailbox[]>>({ status: "loading" });
146
+ const [created, setCreated] = useState<Contact | undefined>();
147
+ const [creating, setCreating] = useState(false);
148
+ // The contact as last changed from this card (starred or unstarred), which is newer than the one looked up.
149
+ const [changed, setChanged] = useState<Contact | undefined>();
150
+ const mounted = useRef(true);
151
+ useEffect(() => {
152
+ mounted.current = true;
153
+ return () => void (mounted.current = false);
154
+ }, []);
155
+
156
+ // The mailboxes come from the app frame's connection when there is one; otherwise (a page rendered outside it) they are asked for.
157
+ useEffect(() => {
158
+ if (connection) {
159
+ return;
160
+ }
161
+ listMailboxes({ limit: MAILBOX_LIST_LIMIT }, client).then(
162
+ (data) => mounted.current && setListed({ status: "ready", data }),
163
+ () => mounted.current && setListed({ status: "error" }),
164
+ );
165
+ }, []);
166
+ const mailboxes: Section<Mailbox[]> = connection
167
+ ? connection.status === "ready"
168
+ ? { status: "ready", data: connection.mailboxes }
169
+ : connection.status === "error"
170
+ ? { status: "error" }
171
+ : { status: "loading" }
172
+ : listed;
173
+
174
+ const scope = mailboxes.status === "ready" ? scopeMailboxes(mailboxes.data, userUid, context.message) : undefined;
175
+ const now = useRef(new Date());
176
+ const folders = useSection(scope && (() => loadFolders(scope, client)), mailboxes);
177
+ const contact = useSection(
178
+ folders.status === "ready" ? () => findContact(folders.data, participant.address, client) : undefined,
179
+ folders,
180
+ );
181
+ const messages = useSection(scope && (() => loadRecentMessages(scope, participant.address, client)), mailboxes);
182
+ const events = useSection(
183
+ folders.status === "ready" ? () => loadUpcomingEvents(folders.data, participant.address, now.current, client) : undefined,
184
+ folders,
185
+ );
186
+ const card = useSection(() => loadParticipantCard(participant, context, client));
187
+
188
+ const name = participantName(participant);
189
+ const stored = changed ?? created ?? (contact.status === "ready" ? contact.data : undefined);
190
+ const external = mailboxes.status === "ready" && isExternalAddress(participant.address, ownDomains(mailboxes.data));
191
+ // What is known when there is no contact: the name, the address and the message's vCard.
192
+ const known = stored ?? contactInputFor(participant, card.status === "ready" ? card.data : undefined, { mailboxUid: "", folderUid: "" });
193
+ // What "Create contact" needs, once it is all there.
194
+ const createWith =
195
+ folders.status === "ready" && mailboxes.status === "ready" && card.status === "ready"
196
+ ? { folders: folders.data, mailboxes: mailboxes.data, card: card.data }
197
+ : undefined;
198
+
199
+ async function handleCreate({ folders: folderList, mailboxes: mailboxList, card: parsed }: CreateWith) {
200
+ setCreating(true);
201
+ try {
202
+ const target = contactScope(mailboxList, folderList, userUid, context.message);
203
+ const made = await createContactFor(participant, parsed, target, client);
204
+ clearPinnedSignerCache();
205
+ notify({ kind: "success", title: "Contact created", message: `${made.displayName} was added to your contacts.` });
206
+ if (mounted.current) {
207
+ setCreated(made);
208
+ }
209
+ } catch (err) {
210
+ notifyApiError(err, "Couldn't create this contact");
211
+ } finally {
212
+ if (mounted.current) {
213
+ setCreating(false);
214
+ }
215
+ }
216
+ }
217
+
218
+ async function handleToggleFavorite(current: Contact) {
219
+ try {
220
+ const next = await setContactFavorite(current, !current.favorite, client);
221
+ if (mounted.current) {
222
+ setChanged(next);
223
+ }
224
+ } catch (err) {
225
+ notifyApiError(err, "Couldn't update this contact");
226
+ }
227
+ }
228
+
229
+ function handleEmail() {
230
+ openCompose({ to: formatMailAddress(participant) });
231
+ onClose();
232
+ }
233
+
234
+ return (
235
+ <Modal open onClose={onClose} title={name}>
236
+ <div className="flex items-center gap-4">
237
+ <ContactAvatar displayName={name} size={72} photoUrl={stored && contactPhotoUrl(stored)} email={participant.address} />
238
+ <div className="min-w-0 flex-1">
239
+ <p className="text-sm text-text-muted break-words">
240
+ {participant.address}
241
+ <CopyIconButton value={participant.address} label="Copy address" className="ml-1" />
242
+ </p>
243
+ {external && (
244
+ <span className="inline-block mt-1 text-xs font-semibold py-0.5 px-2 rounded-pill bg-warning/15 text-text">External</span>
245
+ )}
246
+ {(known.jobTitle || known.company) && (
247
+ <p className="mt-1 text-sm break-words">{[known.jobTitle, known.company].filter(Boolean).join(", ")}</p>
248
+ )}
249
+ </div>
250
+ {stored && <FavoriteStarButton favorite={!!stored.favorite} onToggle={() => void handleToggleFavorite(stored)} className="shrink-0 self-start" />}
251
+ </div>
252
+ <div className="mt-4 flex flex-wrap items-center gap-2">
253
+ <Button type="button" variant="secondary" className={`${ACTION_CLASS} inline-flex items-center gap-1.5`} onClick={handleEmail}>
254
+ <HiOutlineEnvelope aria-hidden="true" className="size-4" />
255
+ Email
256
+ </Button>
257
+ {stored ? (
258
+ <a
259
+ href={`${APP_HREFS.contacts}/${encodeURIComponent(stored.uid)}`}
260
+ onClick={onClose}
261
+ 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"
262
+ >
263
+ Open contact
264
+ </a>
265
+ ) : (
266
+ <Button
267
+ type="button"
268
+ variant="secondary"
269
+ className={`${ACTION_CLASS} inline-flex items-center gap-1.5`}
270
+ disabled={!createWith || creating}
271
+ loading={creating}
272
+ onClick={() => void handleCreate(createWith as CreateWith)}
273
+ >
274
+ <HiOutlineUserPlus aria-hidden="true" className="size-4" />
275
+ Add to contacts
276
+ </Button>
277
+ )}
278
+ </div>
279
+
280
+ <section className="mt-5">
281
+ <h3 className="text-sm font-bold mb-2">Contact</h3>
282
+ {!stored && contact.status === "loading" && <Skeleton width="w-1/2" className="mb-2" />}
283
+ {!stored && contact.status === "error" && (
284
+ <p role="alert" className="text-sm text-danger mb-2">
285
+ Couldn&rsquo;t check your address book.
286
+ </p>
287
+ )}
288
+ {!stored && contact.status === "ready" && <p className="text-sm text-text-muted mb-2">Not in your address book.</p>}
289
+ <dl className="flex flex-col gap-1.5">
290
+ {known.emails.map((email) => (
291
+ <Detail key={`e:${email.address}`} label={["Email", kindLabel(email.type)].filter(Boolean).join(" ")}>
292
+ {email.address}
293
+ <CopyIconButton value={email.address} label="Copy email address" className="ml-1" />
294
+ </Detail>
295
+ ))}
296
+ {known.phones.map((phone) => (
297
+ <Detail key={`p:${phone.phoneNumber}`} label={["Phone", kindLabel(phone.type)].filter(Boolean).join(" ")}>
298
+ {phone.phoneNumber}
299
+ </Detail>
300
+ ))}
301
+ {known.company && <Detail label="Company">{known.company}</Detail>}
302
+ {known.jobTitle && <Detail label="Job title">{known.jobTitle}</Detail>}
303
+ {known.addresses.map((address, index) => (
304
+ <Detail key={`a:${index}`} label={["Address", kindLabel(address.type)].filter(Boolean).join(" ")}>
305
+ {[address.street, [address.postalCode, address.city].filter(Boolean).join(" "), address.state, address.country].filter(Boolean).join("\n")}
306
+ </Detail>
307
+ ))}
308
+ {known.notes && <Detail label="Notes">{known.notes}</Detail>}
309
+ </dl>
310
+ </section>
311
+ <CardSection
312
+ title="Recent messages"
313
+ section={messages}
314
+ empty="No recent messages"
315
+ failure="Couldn't load recent messages."
316
+ isEmpty={(list) => list.length === 0}
317
+ render={(list) => (
318
+ <ul className="flex flex-col">
319
+ {list.map((message) => (
320
+ <li key={message.uid}>
321
+ <a href={`/messages/${encodeURIComponent(message.uid)}`} onClick={onClose} className={ROW_LINK_CLASS}>
322
+ <span className="flex items-baseline justify-between gap-3 text-sm">
323
+ <span className="font-medium truncate">{message.subject || "(No subject)"}</span>
324
+ <time dateTime={message.receivedDate} className="text-xs text-text-muted shrink-0">
325
+ {new Date(message.receivedDate).toLocaleDateString()}
326
+ </time>
327
+ </span>
328
+ <span className="block text-xs text-text-muted truncate">{message.bodyPreview}</span>
329
+ </a>
330
+ </li>
331
+ ))}
332
+ </ul>
333
+ )}
334
+ />
335
+ <CardSection
336
+ title="Upcoming events"
337
+ section={events}
338
+ empty="No upcoming events"
339
+ failure="Couldn't load upcoming events."
340
+ isEmpty={(list) => list.length === 0}
341
+ render={(list) => (
342
+ <ul className="flex flex-col">
343
+ {list.map((event) => (
344
+ <li key={event.occurrenceKey}>
345
+ <a href={APP_HREFS.calendar} onClick={onClose} className={ROW_LINK_CLASS}>
346
+ <span className="block text-sm font-medium truncate">{event.title}</span>
347
+ <span className="block text-xs text-text-muted">{formatInviteWhen(event)}</span>
348
+ </a>
349
+ </li>
350
+ ))}
351
+ </ul>
352
+ )}
353
+ />
354
+ </Modal>
355
+ );
356
+ }
@@ -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
+ }
@@ -3,7 +3,11 @@
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import React, { useState } from "react";
6
- import { Contact } from "../../../../lib/contacts/contactsApi.js";
6
+ import { Contact, contactPhotoUrl, setContactFavorite } from "../../../../lib/contacts/contactsApi.js";
7
+ import CopyIconButton from "../../../../lib/components/buttons/CopyIconButton.js";
8
+ import { useApiClient } from "../../../../lib/util/apiClientContext.js";
9
+ import { notifyApiError } from "../../notifications/apiErrors.js";
10
+ import FavoriteStarButton from "./FavoriteStarButton.js";
7
11
  import Button from "../../../../lib/components/buttons/Button.js";
8
12
  import Alert from "../../../../lib/components/feedback/Alert.js";
9
13
  import ContactAvatar from "../../../../lib/components/avatar/ContactAvatar.js";
@@ -37,6 +41,8 @@ export interface ContactDetailPaneProps {
37
41
  /** Called after one of the contact's key changes was accepted or kept, or turned out to be stale - the caller
38
42
  * re-reads the contact. */
39
43
  onKeysChanged?: () => void;
44
+ /** Called with the contact after the star was toggled, so the caller shows the changed contact. */
45
+ onChanged?: (contact: Contact) => void;
40
46
  /** `false` when the reader is known not to be able to change this contact's keys (the actions are hidden); `undefined`
41
47
  * when unknown (they're hidden after a 403). */
42
48
  canResolveKeys?: boolean;
@@ -47,13 +53,23 @@ export interface ContactDetailPaneProps {
47
53
  * always visible alongside the contact list) and the mobile detail route
48
54
  * (`apps/www/contacts/[uid].tsx`, a full page on its own reached by tapping a contact row).
49
55
  */
50
- export default function ContactDetailPane({ contact, onEdit, onDelete, backHref, onKeysChanged, canResolveKeys }: ContactDetailPaneProps) {
56
+ export default function ContactDetailPane({ contact, onEdit, onDelete, backHref, onKeysChanged, onChanged, canResolveKeys }: ContactDetailPaneProps) {
57
+ const client = useApiClient();
51
58
  // The outcome of the last key decision, kept per contact so selecting another contact doesn't carry it over.
52
59
  const [keyNotice, setKeyNotice] = useState<{ contactUid: string; text: string } | null>(null);
53
60
  const notice = keyNotice?.contactUid === contact.uid ? keyNotice.text : null;
54
61
  const keyConflicts = contact.keyConflicts ?? [];
55
62
  const previousKeys = contact.previousKeys ?? [];
56
63
 
64
+ async function handleToggleFavorite() {
65
+ try {
66
+ const changed = await setContactFavorite(contact, !contact.favorite, client);
67
+ onChanged?.(changed);
68
+ } catch (err) {
69
+ notifyApiError(err, "Couldn't update this contact");
70
+ }
71
+ }
72
+
57
73
  // Pinned signing keys vouch for signatures, so the pinned-signer cache is dropped along with re-reading the contact.
58
74
  function refresh(text: string) {
59
75
  setKeyNotice({ contactUid: contact.uid, text });
@@ -70,9 +86,12 @@ export default function ContactDetailPane({ contact, onEdit, onDelete, backHref,
70
86
  )}
71
87
  <div className="flex items-start justify-between gap-4">
72
88
  <div className="flex items-center gap-3">
73
- <ContactAvatar displayName={contact.displayName} size={48} />
89
+ <ContactAvatar displayName={contact.displayName} size={48} photoUrl={contactPhotoUrl(contact)} email={contact.emails[0]?.address} />
74
90
  <div>
75
- <h1 className="text-xl font-bold tracking-tight">{contact.displayName}</h1>
91
+ <h1 className="text-xl font-bold tracking-tight">
92
+ {contact.displayName}
93
+ <FavoriteStarButton favorite={!!contact.favorite} onToggle={() => void handleToggleFavorite()} className="ml-1 align-middle" />
94
+ </h1>
76
95
  {contact.jobTitle && contact.company && (
77
96
  <p className="text-sm text-text-muted mt-0.5">
78
97
  {contact.jobTitle} at {contact.company}
@@ -96,7 +115,8 @@ export default function ContactDetailPane({ contact, onEdit, onDelete, backHref,
96
115
  <div className="text-text-muted text-xs font-bold uppercase tracking-wide mb-1">Email</div>
97
116
  {contact.emails.map((e, i) => (
98
117
  <div key={i}>
99
- {e.address} <span className="text-text-muted">({e.type})</span>
118
+ {e.address}
119
+ <CopyIconButton value={e.address} label="Copy email address" className="mx-0.5" /> <span className="text-text-muted">({e.type})</span>
100
120
  </div>
101
121
  ))}
102
122
  </div>