@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
@@ -0,0 +1,220 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * What the contact card (`ContactCard`) and the vCard attachment actions read and write: the user's contact and calendar folders, the contact
7
+ * behind an address, the last messages to or from it, its upcoming events, and the calls that make a contact from a participant or a vCard.
8
+ * Every function takes the `ApiClient` to use (`useApiClient()`), like the API modules under `lib/`.
9
+ */
10
+ import { addDays } from "date-fns";
11
+ import { CalendarEvent, listCalendarEvents } from "../../../../lib/calendar/calendarApi.js";
12
+ import { type CalendarOccurrence, expandAllOccurrences } from "../../../../lib/calendar/recurrence.js";
13
+ import { type Contact, createContact, listContactsInFolders } from "../../../../lib/contacts/contactsApi.js";
14
+ import { type ParsedVCardContact, parseVCards } from "../../../../lib/contacts/vcard.js";
15
+ import {
16
+ type Attachment,
17
+ type Folder,
18
+ type Mailbox,
19
+ type Message,
20
+ attachmentContentUrl,
21
+ getMessage,
22
+ listAttachments,
23
+ listFolders,
24
+ } from "../../../../lib/mail/mailApi.js";
25
+ import { search } from "../../../../lib/search/searchApi.js";
26
+ import { ApiRequestError, type ApiClient } from "../../../../lib/util/api.js";
27
+ import { ownMailboxes, primaryMailbox } from "../../mail/primaryMailbox.js";
28
+ import {
29
+ type Participant,
30
+ cardForAddress,
31
+ contactInputFor,
32
+ contactInputFromCard,
33
+ findContactByAddress,
34
+ isVCardAttachment,
35
+ normalizeAddress,
36
+ ownContactCard,
37
+ withoutDuplicates,
38
+ } from "./participantDetails.js";
39
+
40
+ /** What the card is opened for besides who: the message the participant was met in, and that message's attachments when the caller already has them. */
41
+ export interface ContactCardContext {
42
+ message?: Message;
43
+ attachments?: Attachment[];
44
+ }
45
+
46
+ /** How many recent messages and upcoming events the card lists. */
47
+ export const CARD_LIST_SIZE = 5;
48
+ /** How far ahead "upcoming" looks, in days. */
49
+ export const UPCOMING_DAYS = 60;
50
+ /** The most vCard attachments and the most bytes of one that are read to fill a contact in. */
51
+ export const MAX_VCARD_FILES = 3;
52
+ export const MAX_VCARD_BYTES = 256 * 1024;
53
+
54
+ /** The mailboxes whose data the card reads: the user's own, else the primary/first one, plus the message's mailbox when it is one they can reach. */
55
+ export function scopeMailboxes(mailboxes: readonly Mailbox[], userUid: string | undefined, message?: Message): Mailbox[] {
56
+ const own = ownMailboxes(mailboxes, userUid);
57
+ const scope = own.length > 0 ? own : [primaryMailbox(mailboxes, userUid)].filter((mailbox): mailbox is Mailbox => !!mailbox);
58
+ const messageMailbox = mailboxes.find((mailbox) => mailbox.uid === message?.mailboxUid);
59
+ return messageMailbox && !scope.includes(messageMailbox) ? [...scope, messageMailbox] : scope;
60
+ }
61
+
62
+ /** Every folder of `mailboxes`. A mailbox whose folders cannot be listed is left out rather than failing the rest. */
63
+ export async function loadFolders(mailboxes: readonly Mailbox[], client?: ApiClient): Promise<Folder[]> {
64
+ const lists = await Promise.all(mailboxes.map((mailbox) => listFolders(mailbox.uid, client).catch(() => [] as Folder[])));
65
+ return lists.flat();
66
+ }
67
+
68
+ /** The contact the address belongs to in `folders`' contacts folders, if any. */
69
+ export async function findContact(folders: readonly Folder[], address: string, client?: ApiClient): Promise<Contact | undefined> {
70
+ const uids = folders.filter((folder) => folder.type === "contacts").map((folder) => folder.uid);
71
+ return findContactByAddress(await listContactsInFolders(uids, client), address);
72
+ }
73
+
74
+ /** The last `CARD_LIST_SIZE` messages to or from `address` in `mailboxes`, newest first. */
75
+ export async function loadRecentMessages(mailboxes: readonly Mailbox[], address: string, client?: ApiClient): Promise<Message[]> {
76
+ const pages = await Promise.all(
77
+ mailboxes.flatMap((mailbox) =>
78
+ (["from", "to"] as const).map((role) =>
79
+ search("", { types: ["message"], [role]: address, limit: CARD_LIST_SIZE, mailboxUid: mailbox.uid }, client),
80
+ ),
81
+ ),
82
+ );
83
+ const uids = [...new Set(pages.flatMap((page) => page.results.map((result) => result.entityUid)))];
84
+ // A hit can briefly outlive its message: one that no longer resolves is dropped.
85
+ const messages = (await Promise.all(uids.map((uid) => getMessage(uid, client).catch(() => null)))).filter((message): message is Message => !!message);
86
+ return messages.sort((a, b) => Date.parse(b.receivedDate) - Date.parse(a.receivedDate)).slice(0, CARD_LIST_SIZE);
87
+ }
88
+
89
+ /** The next `CARD_LIST_SIZE` events within `UPCOMING_DAYS` from `now` that `address` organizes or is invited to, soonest first. */
90
+ export async function loadUpcomingEvents(folders: readonly Folder[], address: string, now: Date, client?: ApiClient): Promise<CalendarOccurrence[]> {
91
+ const wanted = normalizeAddress(address);
92
+ const calendars = folders.filter((folder) => folder.type === "calendar");
93
+ const lists = await Promise.all(calendars.map((folder) => listCalendarEvents(folder.uid, client)));
94
+ const involved = lists
95
+ .flat()
96
+ .filter(
97
+ (event: CalendarEvent) =>
98
+ event.status !== "cancelled" &&
99
+ (normalizeAddress(event.organizer.address) === wanted ||
100
+ (event.attendees ?? []).some((attendee) => normalizeAddress(attendee.address) === wanted)),
101
+ );
102
+ return expandAllOccurrences(involved, now, addDays(now, UPCOMING_DAYS))
103
+ .sort((a, b) => Date.parse(a.startDate) - Date.parse(b.startDate))
104
+ .slice(0, CARD_LIST_SIZE);
105
+ }
106
+
107
+ /** A vCard attachment's text. */
108
+ export async function fetchVCardText(attachmentUid: string): Promise<string> {
109
+ const res = await fetch(attachmentContentUrl(attachmentUid), { credentials: "include" });
110
+ if (!res.ok) {
111
+ throw new ApiRequestError(res.statusText || "Could not load this contact card.", res.status);
112
+ }
113
+ return res.text();
114
+ }
115
+
116
+ /** The message's vCard attachments: `context.attachments` when given, else the message's own list. */
117
+ async function vCardAttachments(context: ContactCardContext, client?: ApiClient): Promise<Attachment[]> {
118
+ const { message } = context;
119
+ const attachments = context.attachments ?? (message?.hasAttachments ? await listAttachments(message.folderUid, message.uid, client) : []);
120
+ return attachments.filter((attachment) => isVCardAttachment(attachment) && attachment.sizeBytes <= MAX_VCARD_BYTES).slice(0, MAX_VCARD_FILES);
121
+ }
122
+
123
+ /**
124
+ * The vCard of the context message that describes `participant`: the first attachment holding a card with their address, else - only for the
125
+ * message's sender, whose own vCard it presumably is - the first card there is. `undefined` when there is none, and for any failure to read one:
126
+ * a vCard only adds detail, it is never needed.
127
+ */
128
+ export async function loadParticipantCard(participant: Participant, context: ContactCardContext, client?: ApiClient): Promise<ParsedVCardContact | undefined> {
129
+ try {
130
+ const isSender = !!context.message && normalizeAddress(context.message.from.address) === normalizeAddress(participant.address);
131
+ let fallback: ParsedVCardContact | undefined;
132
+ for (const attachment of await vCardAttachments(context, client)) {
133
+ const cards = parseVCards(await fetchVCardText(attachment.uid));
134
+ const match = cardForAddress(cards, participant.address, false);
135
+ if (match) {
136
+ return match;
137
+ }
138
+ fallback ??= cards[0];
139
+ }
140
+ return isSender ? fallback : undefined;
141
+ } catch {
142
+ return undefined;
143
+ }
144
+ }
145
+
146
+ /** Where a new contact goes: the message's mailbox when it is one of the user's own, else their primary mailbox - in that mailbox's contacts folder. */
147
+ export function contactScope(mailboxes: readonly Mailbox[], folders: readonly Folder[], userUid: string | undefined, message?: Message): { mailboxUid: string; folderUid: string } {
148
+ const own = ownMailboxes(mailboxes, userUid);
149
+ const mailbox = own.find((candidate) => candidate.uid === message?.mailboxUid) ?? primaryMailbox(mailboxes, userUid);
150
+ const folder = folders.find((candidate) => candidate.mailboxUid === mailbox?.uid && candidate.type === "contacts");
151
+ if (!mailbox || !folder) {
152
+ throw new Error("There is no contacts folder to add this contact to.");
153
+ }
154
+ return { mailboxUid: mailbox.uid, folderUid: folder.uid };
155
+ }
156
+
157
+ /** Stores a contact for `participant` with everything known - the name, the address and whatever `card` says. */
158
+ export function createContactFor(
159
+ participant: Participant,
160
+ card: ParsedVCardContact | undefined,
161
+ scope: { mailboxUid: string; folderUid: string },
162
+ client?: ApiClient,
163
+ ): Promise<Contact> {
164
+ return createContact(contactInputFor(participant, card, scope), client);
165
+ }
166
+
167
+ /**
168
+ * The sender's own contact card as a file to attach, `<display name>.vcf`: the mailbox being sent from (its name and primary address) with the phones, company and
169
+ * title of the user's own contact entry for that address, when they have one in their own mailboxes' address books. A lookup that fails just leaves those out.
170
+ */
171
+ export async function buildOwnCardFile(mailbox: Mailbox, mailboxes: readonly Mailbox[], userUid: string | undefined, client?: ApiClient): Promise<File> {
172
+ let own: Contact | undefined;
173
+ try {
174
+ const mine = ownMailboxes(mailboxes, userUid);
175
+ own = await findContact(await loadFolders(mine.length > 0 ? mine : [mailbox], client), mailbox.primarySmtpAddress, client);
176
+ } catch {
177
+ // The card is still worth sending without them.
178
+ }
179
+ const { displayName, vcard } = ownContactCard(mailbox, own);
180
+ return new File([vcard], `${displayName.replace(/[\\/:*?"<>|]/g, "").trim() || "Contact"}.vcf`, { type: "text/vcard" });
181
+ }
182
+
183
+ /** The outcome of adding a vCard file's cards to the address book. */
184
+ export interface AddCardsResult {
185
+ added: number;
186
+ skipped: number;
187
+ }
188
+
189
+ /**
190
+ * Adds every card of a vCard file to the contacts folder `scope` names, skipping the ones whose email is already a stored contact's (or an earlier
191
+ * card's in the same file). A card that cannot be stored fails the whole call after the earlier ones were added - `AddCardsError` says how many.
192
+ */
193
+ export async function addCardsToAddressBook(
194
+ cards: readonly ParsedVCardContact[],
195
+ scope: { mailboxUid: string; folderUid: string },
196
+ client?: ApiClient,
197
+ ): Promise<AddCardsResult> {
198
+ const { fresh, skipped } = withoutDuplicates(cards, await listContactsInFolders([scope.folderUid], client));
199
+ let added = 0;
200
+ for (const card of fresh) {
201
+ try {
202
+ await createContact(contactInputFromCard(card, scope), client);
203
+ added += 1;
204
+ } catch (err) {
205
+ throw new AddCardsError(err, added);
206
+ }
207
+ }
208
+ return { added, skipped };
209
+ }
210
+
211
+ /** A card failed to store: the message is the failure's and `added` how many cards were stored before it. */
212
+ export class AddCardsError extends Error {
213
+ readonly added: number;
214
+
215
+ constructor(failure: unknown, added: number) {
216
+ super(failure instanceof Error ? failure.message : "Could not add the contact.");
217
+ this.name = "AddCardsError";
218
+ this.added = added;
219
+ }
220
+ }
@@ -0,0 +1,169 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * The pure rules behind the contact card (`ContactCard`), the "Add to address book" action of a vCard attachment and the compose window's
7
+ * "Attach my contact card": who a participant is, whether they are external, which contact (or vCard) describes them and what a new contact
8
+ * made from them holds. No network and no React - the calls that use these are in `contactCardData.ts`.
9
+ */
10
+ import type { Contact, ContactEmail, ContactInput, ContactPhone, ContactPostalAddress } from "../../../../lib/contacts/contactsApi.js";
11
+ import { contactToVCard, type ParsedVCardContact } from "../../../../lib/contacts/vcard.js";
12
+ import { splitMailAddress } from "../../../../lib/mail/mailAddress.js";
13
+ import type { Mailbox } from "../../../../lib/mail/mailApi.js";
14
+
15
+ /** Someone who took part in a message or an event: a sender, a recipient, an organizer, a guest. */
16
+ export interface Participant {
17
+ address: string;
18
+ displayName?: string;
19
+ }
20
+
21
+ /** The MIME types a vCard file travels as. */
22
+ const VCARD_MIME_TYPES = new Set(["text/vcard", "text/x-vcard", "text/directory", "application/vcard"]);
23
+
24
+ /** Whether an attachment is a vCard: by its MIME type (parameters such as `; charset=utf-8` ignored), else by a `.vcf`/`.vcard` name. */
25
+ export function isVCardAttachment(attachment: { filename?: string; mimeType?: string }): boolean {
26
+ const mimeType = (attachment.mimeType ?? "").split(";")[0].trim().toLowerCase();
27
+ return VCARD_MIME_TYPES.has(mimeType) || /\.(vcf|vcard)$/i.test(attachment.filename ?? "");
28
+ }
29
+
30
+ /** An address in the form two of them are compared by. */
31
+ export function normalizeAddress(address: string): string {
32
+ return address.trim().toLowerCase();
33
+ }
34
+
35
+ /** The name to show for a participant: their own when distinct from the address, else the address's local part. */
36
+ export function participantName(participant: Participant): string {
37
+ const { name, address } = splitMailAddress(participant);
38
+ if (name) {
39
+ return name;
40
+ }
41
+ const at = address.lastIndexOf("@");
42
+ return at > 0 ? address.slice(0, at) : address;
43
+ }
44
+
45
+ /** A display name as given name + surname: `Doe, Jane` and `Jane Doe` are both Jane Doe; a single word is only a given name, and a middle name stays with the given. */
46
+ export function splitPersonName(displayName: string): { givenName?: string; surname?: string } {
47
+ const name = displayName.trim();
48
+ const comma = name.indexOf(",");
49
+ if (comma > 0) {
50
+ return { givenName: name.slice(comma + 1).trim() || undefined, surname: name.slice(0, comma).trim() };
51
+ }
52
+ const parts = name.split(/\s+/).filter(Boolean);
53
+ if (parts.length < 2) {
54
+ return { givenName: parts[0] };
55
+ }
56
+ return { givenName: parts.slice(0, -1).join(" "), surname: parts[parts.length - 1] };
57
+ }
58
+
59
+ /** The first of `contacts` with an email equal (case-insensitively) to `address`. */
60
+ export function findContactByAddress(contacts: readonly Contact[], address: string): Contact | undefined {
61
+ const wanted = normalizeAddress(address);
62
+ return contacts.find((contact) => contact.emails.some((email) => normalizeAddress(email.address) === wanted));
63
+ }
64
+
65
+ /** The domains (lower case) of every address of `mailboxes` - primary and aliases. */
66
+ export function ownDomains(mailboxes: readonly Pick<Mailbox, "primarySmtpAddress" | "aliasAddresses">[]): Set<string> {
67
+ const domains = new Set<string>();
68
+ for (const mailbox of mailboxes) {
69
+ for (const address of [mailbox.primarySmtpAddress, ...(mailbox.aliasAddresses ?? [])]) {
70
+ const at = address.lastIndexOf("@");
71
+ if (at > 0) {
72
+ domains.add(address.slice(at + 1).trim().toLowerCase());
73
+ }
74
+ }
75
+ }
76
+ return domains;
77
+ }
78
+
79
+ /** Whether `address` is outside the user's own domains. An address with no domain at all is not called external. */
80
+ export function isExternalAddress(address: string, domains: ReadonlySet<string>): boolean {
81
+ const at = address.lastIndexOf("@");
82
+ return at > 0 && !domains.has(address.slice(at + 1).trim().toLowerCase());
83
+ }
84
+
85
+ /** The card of `cards` that carries `address`; when none does and `orFirst` (the message's sender - a vCard a person attaches is their own), the first card. */
86
+ export function cardForAddress(cards: readonly ParsedVCardContact[], address: string, orFirst: boolean): ParsedVCardContact | undefined {
87
+ const wanted = normalizeAddress(address);
88
+ return cards.find((card) => (card.emails ?? []).some((email) => normalizeAddress(email.address) === wanted)) ?? (orFirst ? cards[0] : undefined);
89
+ }
90
+
91
+ /** A `ContactInput` whose lists are always there. */
92
+ export type KnownPerson = ContactInput & { emails: ContactEmail[]; phones: ContactPhone[]; addresses: ContactPostalAddress[] };
93
+
94
+ /**
95
+ * What a new contact for `participant` holds: everything the vCard `card` says (when there is one) and always the participant's address. The
96
+ * name is the vCard's, else the participant's, else the address's local part; given name and surname are the vCard's `N`, else split from the name.
97
+ */
98
+ export function contactInputFor(
99
+ participant: Participant,
100
+ card: ParsedVCardContact | undefined,
101
+ scope: { mailboxUid: string; folderUid: string },
102
+ ): KnownPerson {
103
+ // A vCard with no name at all is parsed as "Unnamed contact": not a name worth keeping over the participant's own.
104
+ const cardName = card && (card.givenName || card.surname || card.displayName !== "Unnamed contact") ? card.displayName : "";
105
+ const displayName = cardName || participantName(participant);
106
+ const emails: ContactEmail[] = [...(card?.emails ?? [])];
107
+ if (!emails.some((email) => normalizeAddress(email.address) === normalizeAddress(participant.address))) {
108
+ emails.push({ address: participant.address.trim(), type: "other" });
109
+ }
110
+ const names = card?.givenName || card?.surname ? { givenName: card.givenName, surname: card.surname } : splitPersonName(displayName);
111
+ return {
112
+ ...scope,
113
+ displayName,
114
+ givenName: names.givenName,
115
+ surname: names.surname,
116
+ emails,
117
+ phones: card?.phones ?? [],
118
+ addresses: card?.addresses ?? [],
119
+ company: card?.company,
120
+ jobTitle: card?.jobTitle,
121
+ notes: card?.notes,
122
+ };
123
+ }
124
+
125
+ /** A contact made from a vCard on its own, for "Add to address book": the card's own name and details, with the given/surname split when it lacks them. */
126
+ export function contactInputFromCard(card: ParsedVCardContact, scope: { mailboxUid: string; folderUid: string }): KnownPerson {
127
+ const names = card.givenName || card.surname ? { givenName: card.givenName, surname: card.surname } : splitPersonName(card.displayName);
128
+ return { ...card, ...scope, ...names, emails: card.emails ?? [], phones: card.phones ?? [], addresses: card.addresses ?? [] };
129
+ }
130
+
131
+ /** The cards that are not yet in the address book, and how many were: a card is a duplicate when one of its emails is a stored contact's or an earlier card's. */
132
+ export function withoutDuplicates(
133
+ cards: readonly ParsedVCardContact[],
134
+ existing: readonly Contact[],
135
+ ): { fresh: ParsedVCardContact[]; skipped: number } {
136
+ const known = new Set(existing.flatMap((contact) => contact.emails.map((email) => normalizeAddress(email.address))));
137
+ const fresh: ParsedVCardContact[] = [];
138
+ for (const card of cards) {
139
+ const addresses = (card.emails ?? []).map((email) => normalizeAddress(email.address));
140
+ if (addresses.some((address) => known.has(address))) {
141
+ continue;
142
+ }
143
+ addresses.forEach((address) => known.add(address));
144
+ fresh.push(card);
145
+ }
146
+ return { fresh, skipped: cards.length - fresh.length };
147
+ }
148
+
149
+ /** The sender's own vCard, and the name it is filed under. It is built from - the mailbox's name and primary address, with the phones, company and title of the user's own contact entry when they have one (never its other emails or postal addresses: a card that is sent out says only what it should). */
150
+ export function ownContactCard(mailbox: Pick<Mailbox, "displayName" | "primarySmtpAddress">, own: Contact | undefined): { displayName: string; vcard: string } {
151
+ const displayName = own?.displayName || mailbox.displayName || participantName({ address: mailbox.primarySmtpAddress });
152
+ const names = own?.givenName || own?.surname ? { givenName: own.givenName, surname: own.surname } : splitPersonName(displayName);
153
+ const vcard = contactToVCard({
154
+ uid: "",
155
+ version: 0,
156
+ dateCreated: "",
157
+ dateModified: "",
158
+ mailboxUid: "",
159
+ folderUid: "",
160
+ displayName,
161
+ ...names,
162
+ emails: [{ address: mailbox.primarySmtpAddress, type: "work" }],
163
+ phones: own?.phones ?? [],
164
+ addresses: [],
165
+ company: own?.company,
166
+ jobTitle: own?.jobTitle,
167
+ });
168
+ return { displayName, vcard };
169
+ }
@@ -20,6 +20,7 @@ import { useApiClient } from "../../../../lib/util/apiClientContext.js";
20
20
  import useBranding from "../../../../lib/branding/useBranding.js";
21
21
  import { useIdleKeyTimeout } from "../../../../lib/crypto/useIdleKeyTimeout.js";
22
22
  import ComposeProvider from "../mail/compose/ComposeContext.js";
23
+ import ContactCardProvider from "../contacts/ContactCardProvider.js";
23
24
  import { flushComposeDrafts, markSigningOut } from "../mail/compose/composeFlushRegistry.js";
24
25
  import BottomTabBar, { NavItem } from "../../../../lib/components/navigation/BottomTabBar.js";
25
26
  import { FrameBrandingFooter, FrameBrandingHeader, useBrandingHtml } from "./BrandingChrome.js";
@@ -366,6 +367,8 @@ export function AppChrome({
366
367
  {/* An impersonating admin acts with the impersonated user's access, so their own trusted role
367
368
  mustn't skip the per-mailbox checks. */}
368
369
  <ComposeProvider userUid={userUid} trusted={!!trusted && !impersonating}>
370
+ {/* Inside the compose provider: the contact card's Email action opens a compose window. */}
371
+ <ContactCardProvider userUid={userUid}>
369
372
  <div className="rr-frame-bg min-h-screen flex flex-col">
370
373
  {!hideChrome && customHeader && (
371
374
  <FrameBrandingHeader
@@ -444,6 +447,7 @@ export function AppChrome({
444
447
  </div>
445
448
  {!hideChrome && <FrameBrandingFooter parsed={footer} userMenu={menuInFooter ? renderUserMenu("up") : undefined} appTitle={activeLabel} />}
446
449
  </div>
450
+ </ContactCardProvider>
447
451
  </ComposeProvider>
448
452
  </UnlockPromptProvider>
449
453
  <NotificationHistoryDialog open={historyOpen} onClose={() => setHistoryOpen(false)} />
@@ -18,6 +18,7 @@ import { guestPermissionsOf } from "../../../../lib/calendar/calendarApi.js";
18
18
  import { formatMailAddress } from "../../../../lib/mail/mailAddress.js";
19
19
  import { useApiClient } from "../../../../lib/util/apiClientContext.js";
20
20
  import Button from "../../../../lib/components/buttons/Button.js";
21
+ import ParticipantLink from "../contacts/ParticipantLink.js";
21
22
  import EventDescriptionView from "../calendar/EventDescriptionView.js";
22
23
  import RequestChangeForm from "../calendar/RequestChangeForm.js";
23
24
  import { VISIBILITY_LABEL, describeGuestPermissions } from "../calendar/eventFormat.js";
@@ -70,12 +71,24 @@ const MAX_NAMED_ATTENDEES = 5;
70
71
  /** What the card is doing for the reader right now: an answer being sent, an add/remove, a proposal, an accepted proposal. */
71
72
  type Pending = InviteResponse | "add" | "remove" | "propose" | "acceptProposal";
72
73
 
73
- function attendeeSummary(attendees: InviteParticipant[]): string | undefined {
74
+ function attendeeSummary(attendees: InviteParticipant[]): React.ReactNode {
74
75
  if (attendees.length === 0) {
75
76
  return undefined;
76
77
  }
77
78
  const count = `${attendees.length} ${attendees.length === 1 ? "attendee" : "attendees"}`;
78
- return attendees.length <= MAX_NAMED_ATTENDEES ? `${count}: ${attendees.map((a) => a.displayName || a.address).join(", ")}` : count;
79
+ return attendees.length <= MAX_NAMED_ATTENDEES ? (
80
+ <>
81
+ {count}:{" "}
82
+ {attendees.map((attendee, index) => (
83
+ <React.Fragment key={index}>
84
+ {index > 0 && ", "}
85
+ <ParticipantLink participant={attendee}>{attendee.displayName || attendee.address}</ParticipantLink>
86
+ </React.Fragment>
87
+ ))}
88
+ </>
89
+ ) : (
90
+ count
91
+ );
79
92
  }
80
93
 
81
94
  export interface InviteCardProps {
@@ -150,6 +163,13 @@ export default function InviteCard({ messageUid, headingLevel = 2 }: InviteCardP
150
163
  const visibility = invite.visibility && invite.visibility !== "default" ? VISIBILITY_LABEL[invite.visibility] : undefined;
151
164
  const sender = invite.reply ?? invite.attendees[0];
152
165
  const senderName = sender ? sender.displayName || sender.address : "Someone";
166
+ const senderNode = sender ? (
167
+ <ParticipantLink participant={sender} className="font-semibold">
168
+ {senderName}
169
+ </ParticipantLink>
170
+ ) : (
171
+ <span className="font-semibold">{senderName}</span>
172
+ );
153
173
  const showResponses = invite.canRespond && !invite.isOrganizer;
154
174
  const conflicts = !isReply && !isCounter && method !== "CANCEL" && invite.conflicts.length > 0 ? conflictSummary(invite.conflicts) : undefined;
155
175
  const hasActions =
@@ -170,12 +190,12 @@ export default function InviteCard({ messageUid, headingLevel = 2 }: InviteCardP
170
190
  {isReply && (
171
191
  // Someone answering the reader's own invitation: a line of news, nothing to do.
172
192
  <p className="break-words">
173
- <span className="font-semibold">{senderName}</span> {(sender?.responseStatus && REPLY_VERBS[sender.responseStatus]) ?? "responded"}.
193
+ {senderNode} {(sender?.responseStatus && REPLY_VERBS[sender.responseStatus]) ?? "responded"}.
174
194
  </p>
175
195
  )}
176
196
  {isCounter && !changeRequest && (
177
197
  <p className="break-words">
178
- <span className="font-semibold">{senderName}</span> proposed a new time{when ? <>: {when}</> : "."}
198
+ {senderNode} proposed a new time{when ? <>: {when}</> : "."}
179
199
  </p>
180
200
  )}
181
201
  {changeRequest && (
@@ -220,7 +240,9 @@ export default function InviteCard({ messageUid, headingLevel = 2 }: InviteCardP
220
240
  {invite.organizer && !isCounter && (
221
241
  <>
222
242
  <dt className="text-text-muted">Organizer</dt>
223
- <dd className="break-words">{formatMailAddress(invite.organizer)}</dd>
243
+ <dd className="break-words">
244
+ <ParticipantLink participant={invite.organizer}>{formatMailAddress(invite.organizer)}</ParticipantLink>
245
+ </dd>
224
246
  </>
225
247
  )}
226
248
  {attendees && (!isCounter || !!changeRequest) && (
@@ -4,6 +4,8 @@
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import React, { useState } from "react";
6
6
  import { MailAddressLike, formatMailAddress, splitMailAddress } from "../../../../lib/mail/mailAddress.js";
7
+ import type { ContactCardContext } from "../contacts/contactCardData.js";
8
+ import ParticipantLink from "../contacts/ParticipantLink.js";
7
9
 
8
10
  export interface MailAddressProps {
9
11
  /** A message's `from`, a recipient, a conversation participant. */
@@ -52,9 +54,10 @@ export const RECIPIENT_LINE_LIMIT = 3;
52
54
  /**
53
55
  * A `To`/`Cc` line of a message header: every recipient as `Name <address>` in the text itself (never hover-only), wrapping
54
56
  * onto more lines as needed. A long list shows its first `RECIPIENT_LINE_LIMIT` and folds the rest behind a button -
55
- * "and 12 more" / "Show fewer" - so a message to a hundred people doesn't take the whole header. Nothing when empty.
57
+ * "and 12 more" / "Show fewer" - so a message to a hundred people doesn't take the whole header. Nothing when empty. Each recipient opens their
58
+ * contact card when clicked (see `ParticipantLink`); `context` is the message they are listed on.
56
59
  */
57
- export function RecipientLine({ label, recipients }: { label: string; recipients: MailAddressLike[] }) {
60
+ export function RecipientLine({ label, recipients, context }: { label: string; recipients: MailAddressLike[]; context?: ContactCardContext }) {
58
61
  const [expanded, setExpanded] = useState(false);
59
62
  if (recipients.length === 0) {
60
63
  return null;
@@ -67,7 +70,9 @@ export function RecipientLine({ label, recipients }: { label: string; recipients
67
70
  {shown.map((recipient, index) => (
68
71
  <React.Fragment key={index}>
69
72
  {index > 0 && ", "}
70
- <span>{formatMailAddress(recipient)}</span>
73
+ <ParticipantLink participant={recipient} context={context}>
74
+ {formatMailAddress(recipient)}
75
+ </ParticipantLink>
71
76
  </React.Fragment>
72
77
  ))}
73
78
  {folded && (