@rapidmx/web-client 0.25.1 → 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 (46) hide show
  1. package/apps/shared/components/calendar/EventDetails.tsx +5 -2
  2. package/apps/shared/components/contacts/ContactCard.tsx +330 -0
  3. package/apps/shared/components/contacts/ContactCardProvider.tsx +72 -0
  4. package/apps/shared/components/contacts/ParticipantLink.tsx +42 -0
  5. package/apps/shared/components/contacts/contactCardData.ts +220 -0
  6. package/apps/shared/components/contacts/participantDetails.ts +169 -0
  7. package/apps/shared/components/layout/AppShell.tsx +4 -0
  8. package/apps/shared/components/mail/InviteCard.tsx +27 -5
  9. package/apps/shared/components/mail/MailAddress.tsx +8 -3
  10. package/apps/shared/components/mail/MessageDetailPane.tsx +63 -18
  11. package/apps/shared/components/mail/VCardAttachmentChip.tsx +85 -0
  12. package/apps/shared/components/mail/compose/ComposeWindow.tsx +112 -1
  13. package/apps/shared/components/mail/compose/RecipientInput.tsx +1 -0
  14. package/apps/shared/components/mail/compose/RichTextEditor.tsx +15 -2
  15. package/apps/shared/components/mail/compose/dropFiles.ts +21 -0
  16. package/dist/apps/shared/components/calendar/EventDetails.js +2 -1
  17. package/dist/apps/shared/components/contacts/ContactCard.d.ts +19 -0
  18. package/dist/apps/shared/components/contacts/ContactCard.js +132 -0
  19. package/dist/apps/shared/components/contacts/ContactCardProvider.d.ts +25 -0
  20. package/dist/apps/shared/components/contacts/ContactCardProvider.js +41 -0
  21. package/dist/apps/shared/components/contacts/ParticipantLink.d.ts +18 -0
  22. package/dist/apps/shared/components/contacts/ParticipantLink.js +16 -0
  23. package/dist/apps/shared/components/contacts/contactCardData.d.ts +69 -0
  24. package/dist/apps/shared/components/contacts/contactCardData.js +158 -0
  25. package/dist/apps/shared/components/contacts/participantDetails.d.ts +64 -0
  26. package/dist/apps/shared/components/contacts/participantDetails.js +129 -0
  27. package/dist/apps/shared/components/layout/AppShell.js +11 -10
  28. package/dist/apps/shared/components/mail/InviteCard.js +5 -3
  29. package/dist/apps/shared/components/mail/MailAddress.d.ts +5 -2
  30. package/dist/apps/shared/components/mail/MailAddress.js +5 -3
  31. package/dist/apps/shared/components/mail/MessageDetailPane.js +19 -4
  32. package/dist/apps/shared/components/mail/VCardAttachmentChip.d.ts +18 -0
  33. package/dist/apps/shared/components/mail/VCardAttachmentChip.js +53 -0
  34. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +81 -4
  35. package/dist/apps/shared/components/mail/compose/RecipientInput.js +1 -0
  36. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +5 -2
  37. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +10 -1
  38. package/dist/apps/shared/components/mail/compose/dropFiles.d.ts +8 -0
  39. package/dist/apps/shared/components/mail/compose/dropFiles.js +18 -0
  40. package/dist/lib/contacts/contactsApi.d.ts +3 -0
  41. package/dist/lib/contacts/contactsApi.js +1 -1
  42. package/dist/lib/mail/directoryApi.d.ts +16 -10
  43. package/dist/lib/mail/directoryApi.js +20 -12
  44. package/lib/contacts/contactsApi.ts +1 -1
  45. package/lib/mail/directoryApi.ts +30 -12
  46. package/package.json +1 -1
@@ -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
+ }