@rapidmx/web-client 0.24.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/apps/shared/components/admin/layout/AdminShell.tsx +39 -4
  2. package/apps/shared/components/admin/layout/railPreference.ts +25 -0
  3. package/apps/shared/components/calendar/CalendarListSidebar.tsx +6 -2
  4. package/apps/shared/components/calendar/EventDetails.tsx +5 -2
  5. package/apps/shared/components/contacts/ContactCard.tsx +330 -0
  6. package/apps/shared/components/contacts/ContactCardProvider.tsx +72 -0
  7. package/apps/shared/components/contacts/ParticipantLink.tsx +42 -0
  8. package/apps/shared/components/contacts/contactCardData.ts +220 -0
  9. package/apps/shared/components/contacts/participantDetails.ts +169 -0
  10. package/apps/shared/components/layout/AppShell.tsx +4 -0
  11. package/apps/shared/components/layout/ThemeSwitch.tsx +3 -2
  12. package/apps/shared/components/layout/UserMenu.tsx +30 -10
  13. package/apps/shared/components/mail/InviteCard.tsx +27 -5
  14. package/apps/shared/components/mail/MailAddress.tsx +8 -3
  15. package/apps/shared/components/mail/MessageDetailPane.tsx +63 -18
  16. package/apps/shared/components/mail/VCardAttachmentChip.tsx +85 -0
  17. package/apps/shared/components/mail/compose/ComposeWindow.tsx +112 -1
  18. package/apps/shared/components/mail/compose/RecipientInput.tsx +1 -0
  19. package/apps/shared/components/mail/compose/RichTextEditor.tsx +15 -2
  20. package/apps/shared/components/mail/compose/dropFiles.ts +21 -0
  21. package/apps/shared/components/mail/layout/MailShell.tsx +13 -5
  22. package/apps/shared/components/mail/layout/folderIcons.tsx +54 -0
  23. package/apps/shared/components/settings/layout/SettingsShell.tsx +32 -13
  24. package/apps/shared/navigation/appHrefs.ts +3 -3
  25. package/dist/apps/shared/components/admin/layout/AdminShell.js +21 -4
  26. package/dist/apps/shared/components/admin/layout/railPreference.d.ts +6 -0
  27. package/dist/apps/shared/components/admin/layout/railPreference.js +24 -0
  28. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +3 -2
  29. package/dist/apps/shared/components/calendar/EventDetails.js +2 -1
  30. package/dist/apps/shared/components/contacts/ContactCard.d.ts +19 -0
  31. package/dist/apps/shared/components/contacts/ContactCard.js +132 -0
  32. package/dist/apps/shared/components/contacts/ContactCardProvider.d.ts +25 -0
  33. package/dist/apps/shared/components/contacts/ContactCardProvider.js +41 -0
  34. package/dist/apps/shared/components/contacts/ParticipantLink.d.ts +18 -0
  35. package/dist/apps/shared/components/contacts/ParticipantLink.js +16 -0
  36. package/dist/apps/shared/components/contacts/contactCardData.d.ts +69 -0
  37. package/dist/apps/shared/components/contacts/contactCardData.js +158 -0
  38. package/dist/apps/shared/components/contacts/participantDetails.d.ts +64 -0
  39. package/dist/apps/shared/components/contacts/participantDetails.js +129 -0
  40. package/dist/apps/shared/components/layout/AppShell.js +11 -10
  41. package/dist/apps/shared/components/layout/ThemeSwitch.js +2 -2
  42. package/dist/apps/shared/components/layout/UserMenu.d.ts +1 -2
  43. package/dist/apps/shared/components/layout/UserMenu.js +4 -4
  44. package/dist/apps/shared/components/mail/InviteCard.js +5 -3
  45. package/dist/apps/shared/components/mail/MailAddress.d.ts +5 -2
  46. package/dist/apps/shared/components/mail/MailAddress.js +5 -3
  47. package/dist/apps/shared/components/mail/MessageDetailPane.js +19 -4
  48. package/dist/apps/shared/components/mail/VCardAttachmentChip.d.ts +18 -0
  49. package/dist/apps/shared/components/mail/VCardAttachmentChip.js +53 -0
  50. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +81 -4
  51. package/dist/apps/shared/components/mail/compose/RecipientInput.js +1 -0
  52. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +5 -2
  53. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +10 -1
  54. package/dist/apps/shared/components/mail/compose/dropFiles.d.ts +8 -0
  55. package/dist/apps/shared/components/mail/compose/dropFiles.js +18 -0
  56. package/dist/apps/shared/components/mail/layout/MailShell.js +5 -4
  57. package/dist/apps/shared/components/mail/layout/folderIcons.d.ts +8 -0
  58. package/dist/apps/shared/components/mail/layout/folderIcons.js +25 -0
  59. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
  60. package/dist/apps/shared/components/settings/layout/SettingsShell.js +16 -15
  61. package/dist/apps/shared/navigation/appHrefs.d.ts +3 -3
  62. package/dist/apps/shared/navigation/appHrefs.js +3 -3
  63. package/dist/lib/components/pickers/TimeZonePicker.js +14 -5
  64. package/dist/lib/contacts/contactsApi.d.ts +3 -0
  65. package/dist/lib/contacts/contactsApi.js +1 -1
  66. package/dist/lib/mail/directoryApi.d.ts +16 -10
  67. package/dist/lib/mail/directoryApi.js +20 -12
  68. package/dist/lib/util/timeZone.d.ts +1 -1
  69. package/dist/lib/util/timeZone.js +22 -9
  70. package/lib/components/pickers/TimeZonePicker.tsx +14 -9
  71. package/lib/contacts/contactsApi.ts +1 -1
  72. package/lib/mail/directoryApi.ts +30 -12
  73. package/lib/util/timeZone.ts +24 -9
  74. package/package.json +1 -1
@@ -0,0 +1,158 @@
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 { listCalendarEvents } from "../../../../lib/calendar/calendarApi.js";
12
+ import { expandAllOccurrences } from "../../../../lib/calendar/recurrence.js";
13
+ import { createContact, listContactsInFolders } from "../../../../lib/contacts/contactsApi.js";
14
+ import { parseVCards } from "../../../../lib/contacts/vcard.js";
15
+ import { attachmentContentUrl, getMessage, listAttachments, listFolders, } from "../../../../lib/mail/mailApi.js";
16
+ import { search } from "../../../../lib/search/searchApi.js";
17
+ import { ApiRequestError } from "../../../../lib/util/api.js";
18
+ import { ownMailboxes, primaryMailbox } from "../../mail/primaryMailbox.js";
19
+ import { cardForAddress, contactInputFor, contactInputFromCard, findContactByAddress, isVCardAttachment, normalizeAddress, ownContactCard, withoutDuplicates, } from "./participantDetails.js";
20
+ /** How many recent messages and upcoming events the card lists. */
21
+ export const CARD_LIST_SIZE = 5;
22
+ /** How far ahead "upcoming" looks, in days. */
23
+ export const UPCOMING_DAYS = 60;
24
+ /** The most vCard attachments and the most bytes of one that are read to fill a contact in. */
25
+ export const MAX_VCARD_FILES = 3;
26
+ export const MAX_VCARD_BYTES = 256 * 1024;
27
+ /** 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. */
28
+ export function scopeMailboxes(mailboxes, userUid, message) {
29
+ const own = ownMailboxes(mailboxes, userUid);
30
+ const scope = own.length > 0 ? own : [primaryMailbox(mailboxes, userUid)].filter((mailbox) => !!mailbox);
31
+ const messageMailbox = mailboxes.find((mailbox) => mailbox.uid === message?.mailboxUid);
32
+ return messageMailbox && !scope.includes(messageMailbox) ? [...scope, messageMailbox] : scope;
33
+ }
34
+ /** Every folder of `mailboxes`. A mailbox whose folders cannot be listed is left out rather than failing the rest. */
35
+ export async function loadFolders(mailboxes, client) {
36
+ const lists = await Promise.all(mailboxes.map((mailbox) => listFolders(mailbox.uid, client).catch(() => [])));
37
+ return lists.flat();
38
+ }
39
+ /** The contact the address belongs to in `folders`' contacts folders, if any. */
40
+ export async function findContact(folders, address, client) {
41
+ const uids = folders.filter((folder) => folder.type === "contacts").map((folder) => folder.uid);
42
+ return findContactByAddress(await listContactsInFolders(uids, client), address);
43
+ }
44
+ /** The last `CARD_LIST_SIZE` messages to or from `address` in `mailboxes`, newest first. */
45
+ export async function loadRecentMessages(mailboxes, address, client) {
46
+ const pages = await Promise.all(mailboxes.flatMap((mailbox) => ["from", "to"].map((role) => search("", { types: ["message"], [role]: address, limit: CARD_LIST_SIZE, mailboxUid: mailbox.uid }, client))));
47
+ const uids = [...new Set(pages.flatMap((page) => page.results.map((result) => result.entityUid)))];
48
+ // A hit can briefly outlive its message: one that no longer resolves is dropped.
49
+ const messages = (await Promise.all(uids.map((uid) => getMessage(uid, client).catch(() => null)))).filter((message) => !!message);
50
+ return messages.sort((a, b) => Date.parse(b.receivedDate) - Date.parse(a.receivedDate)).slice(0, CARD_LIST_SIZE);
51
+ }
52
+ /** The next `CARD_LIST_SIZE` events within `UPCOMING_DAYS` from `now` that `address` organizes or is invited to, soonest first. */
53
+ export async function loadUpcomingEvents(folders, address, now, client) {
54
+ const wanted = normalizeAddress(address);
55
+ const calendars = folders.filter((folder) => folder.type === "calendar");
56
+ const lists = await Promise.all(calendars.map((folder) => listCalendarEvents(folder.uid, client)));
57
+ const involved = lists
58
+ .flat()
59
+ .filter((event) => event.status !== "cancelled" &&
60
+ (normalizeAddress(event.organizer.address) === wanted ||
61
+ (event.attendees ?? []).some((attendee) => normalizeAddress(attendee.address) === wanted)));
62
+ return expandAllOccurrences(involved, now, addDays(now, UPCOMING_DAYS))
63
+ .sort((a, b) => Date.parse(a.startDate) - Date.parse(b.startDate))
64
+ .slice(0, CARD_LIST_SIZE);
65
+ }
66
+ /** A vCard attachment's text. */
67
+ export async function fetchVCardText(attachmentUid) {
68
+ const res = await fetch(attachmentContentUrl(attachmentUid), { credentials: "include" });
69
+ if (!res.ok) {
70
+ throw new ApiRequestError(res.statusText || "Could not load this contact card.", res.status);
71
+ }
72
+ return res.text();
73
+ }
74
+ /** The message's vCard attachments: `context.attachments` when given, else the message's own list. */
75
+ async function vCardAttachments(context, client) {
76
+ const { message } = context;
77
+ const attachments = context.attachments ?? (message?.hasAttachments ? await listAttachments(message.folderUid, message.uid, client) : []);
78
+ return attachments.filter((attachment) => isVCardAttachment(attachment) && attachment.sizeBytes <= MAX_VCARD_BYTES).slice(0, MAX_VCARD_FILES);
79
+ }
80
+ /**
81
+ * The vCard of the context message that describes `participant`: the first attachment holding a card with their address, else - only for the
82
+ * 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:
83
+ * a vCard only adds detail, it is never needed.
84
+ */
85
+ export async function loadParticipantCard(participant, context, client) {
86
+ try {
87
+ const isSender = !!context.message && normalizeAddress(context.message.from.address) === normalizeAddress(participant.address);
88
+ let fallback;
89
+ for (const attachment of await vCardAttachments(context, client)) {
90
+ const cards = parseVCards(await fetchVCardText(attachment.uid));
91
+ const match = cardForAddress(cards, participant.address, false);
92
+ if (match) {
93
+ return match;
94
+ }
95
+ fallback ?? (fallback = cards[0]);
96
+ }
97
+ return isSender ? fallback : undefined;
98
+ }
99
+ catch {
100
+ return undefined;
101
+ }
102
+ }
103
+ /** 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. */
104
+ export function contactScope(mailboxes, folders, userUid, message) {
105
+ const own = ownMailboxes(mailboxes, userUid);
106
+ const mailbox = own.find((candidate) => candidate.uid === message?.mailboxUid) ?? primaryMailbox(mailboxes, userUid);
107
+ const folder = folders.find((candidate) => candidate.mailboxUid === mailbox?.uid && candidate.type === "contacts");
108
+ if (!mailbox || !folder) {
109
+ throw new Error("There is no contacts folder to add this contact to.");
110
+ }
111
+ return { mailboxUid: mailbox.uid, folderUid: folder.uid };
112
+ }
113
+ /** Stores a contact for `participant` with everything known - the name, the address and whatever `card` says. */
114
+ export function createContactFor(participant, card, scope, client) {
115
+ return createContact(contactInputFor(participant, card, scope), client);
116
+ }
117
+ /**
118
+ * 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
119
+ * 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.
120
+ */
121
+ export async function buildOwnCardFile(mailbox, mailboxes, userUid, client) {
122
+ let own;
123
+ try {
124
+ const mine = ownMailboxes(mailboxes, userUid);
125
+ own = await findContact(await loadFolders(mine.length > 0 ? mine : [mailbox], client), mailbox.primarySmtpAddress, client);
126
+ }
127
+ catch {
128
+ // The card is still worth sending without them.
129
+ }
130
+ const { displayName, vcard } = ownContactCard(mailbox, own);
131
+ return new File([vcard], `${displayName.replace(/[\\/:*?"<>|]/g, "").trim() || "Contact"}.vcf`, { type: "text/vcard" });
132
+ }
133
+ /**
134
+ * 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
135
+ * 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.
136
+ */
137
+ export async function addCardsToAddressBook(cards, scope, client) {
138
+ const { fresh, skipped } = withoutDuplicates(cards, await listContactsInFolders([scope.folderUid], client));
139
+ let added = 0;
140
+ for (const card of fresh) {
141
+ try {
142
+ await createContact(contactInputFromCard(card, scope), client);
143
+ added += 1;
144
+ }
145
+ catch (err) {
146
+ throw new AddCardsError(err, added);
147
+ }
148
+ }
149
+ return { added, skipped };
150
+ }
151
+ /** A card failed to store: the message is the failure's and `added` how many cards were stored before it. */
152
+ export class AddCardsError extends Error {
153
+ constructor(failure, added) {
154
+ super(failure instanceof Error ? failure.message : "Could not add the contact.");
155
+ this.name = "AddCardsError";
156
+ this.added = added;
157
+ }
158
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The pure rules behind the contact card (`ContactCard`), the "Add to address book" action of a vCard attachment and the compose window's
3
+ * "Attach my contact card": who a participant is, whether they are external, which contact (or vCard) describes them and what a new contact
4
+ * made from them holds. No network and no React - the calls that use these are in `contactCardData.ts`.
5
+ */
6
+ import type { Contact, ContactEmail, ContactInput, ContactPhone, ContactPostalAddress } from "../../../../lib/contacts/contactsApi.js";
7
+ import { type ParsedVCardContact } from "../../../../lib/contacts/vcard.js";
8
+ import type { Mailbox } from "../../../../lib/mail/mailApi.js";
9
+ /** Someone who took part in a message or an event: a sender, a recipient, an organizer, a guest. */
10
+ export interface Participant {
11
+ address: string;
12
+ displayName?: string;
13
+ }
14
+ /** Whether an attachment is a vCard: by its MIME type (parameters such as `; charset=utf-8` ignored), else by a `.vcf`/`.vcard` name. */
15
+ export declare function isVCardAttachment(attachment: {
16
+ filename?: string;
17
+ mimeType?: string;
18
+ }): boolean;
19
+ /** An address in the form two of them are compared by. */
20
+ export declare function normalizeAddress(address: string): string;
21
+ /** The name to show for a participant: their own when distinct from the address, else the address's local part. */
22
+ export declare function participantName(participant: Participant): string;
23
+ /** 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. */
24
+ export declare function splitPersonName(displayName: string): {
25
+ givenName?: string;
26
+ surname?: string;
27
+ };
28
+ /** The first of `contacts` with an email equal (case-insensitively) to `address`. */
29
+ export declare function findContactByAddress(contacts: readonly Contact[], address: string): Contact | undefined;
30
+ /** The domains (lower case) of every address of `mailboxes` - primary and aliases. */
31
+ export declare function ownDomains(mailboxes: readonly Pick<Mailbox, "primarySmtpAddress" | "aliasAddresses">[]): Set<string>;
32
+ /** Whether `address` is outside the user's own domains. An address with no domain at all is not called external. */
33
+ export declare function isExternalAddress(address: string, domains: ReadonlySet<string>): boolean;
34
+ /** 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. */
35
+ export declare function cardForAddress(cards: readonly ParsedVCardContact[], address: string, orFirst: boolean): ParsedVCardContact | undefined;
36
+ /** A `ContactInput` whose lists are always there. */
37
+ export type KnownPerson = ContactInput & {
38
+ emails: ContactEmail[];
39
+ phones: ContactPhone[];
40
+ addresses: ContactPostalAddress[];
41
+ };
42
+ /**
43
+ * What a new contact for `participant` holds: everything the vCard `card` says (when there is one) and always the participant's address. The
44
+ * 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.
45
+ */
46
+ export declare function contactInputFor(participant: Participant, card: ParsedVCardContact | undefined, scope: {
47
+ mailboxUid: string;
48
+ folderUid: string;
49
+ }): KnownPerson;
50
+ /** 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. */
51
+ export declare function contactInputFromCard(card: ParsedVCardContact, scope: {
52
+ mailboxUid: string;
53
+ folderUid: string;
54
+ }): KnownPerson;
55
+ /** 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. */
56
+ export declare function withoutDuplicates(cards: readonly ParsedVCardContact[], existing: readonly Contact[]): {
57
+ fresh: ParsedVCardContact[];
58
+ skipped: number;
59
+ };
60
+ /** 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). */
61
+ export declare function ownContactCard(mailbox: Pick<Mailbox, "displayName" | "primarySmtpAddress">, own: Contact | undefined): {
62
+ displayName: string;
63
+ vcard: string;
64
+ };
@@ -0,0 +1,129 @@
1
+ import { contactToVCard } from "../../../../lib/contacts/vcard.js";
2
+ import { splitMailAddress } from "../../../../lib/mail/mailAddress.js";
3
+ /** The MIME types a vCard file travels as. */
4
+ const VCARD_MIME_TYPES = new Set(["text/vcard", "text/x-vcard", "text/directory", "application/vcard"]);
5
+ /** Whether an attachment is a vCard: by its MIME type (parameters such as `; charset=utf-8` ignored), else by a `.vcf`/`.vcard` name. */
6
+ export function isVCardAttachment(attachment) {
7
+ const mimeType = (attachment.mimeType ?? "").split(";")[0].trim().toLowerCase();
8
+ return VCARD_MIME_TYPES.has(mimeType) || /\.(vcf|vcard)$/i.test(attachment.filename ?? "");
9
+ }
10
+ /** An address in the form two of them are compared by. */
11
+ export function normalizeAddress(address) {
12
+ return address.trim().toLowerCase();
13
+ }
14
+ /** The name to show for a participant: their own when distinct from the address, else the address's local part. */
15
+ export function participantName(participant) {
16
+ const { name, address } = splitMailAddress(participant);
17
+ if (name) {
18
+ return name;
19
+ }
20
+ const at = address.lastIndexOf("@");
21
+ return at > 0 ? address.slice(0, at) : address;
22
+ }
23
+ /** 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. */
24
+ export function splitPersonName(displayName) {
25
+ const name = displayName.trim();
26
+ const comma = name.indexOf(",");
27
+ if (comma > 0) {
28
+ return { givenName: name.slice(comma + 1).trim() || undefined, surname: name.slice(0, comma).trim() };
29
+ }
30
+ const parts = name.split(/\s+/).filter(Boolean);
31
+ if (parts.length < 2) {
32
+ return { givenName: parts[0] };
33
+ }
34
+ return { givenName: parts.slice(0, -1).join(" "), surname: parts[parts.length - 1] };
35
+ }
36
+ /** The first of `contacts` with an email equal (case-insensitively) to `address`. */
37
+ export function findContactByAddress(contacts, address) {
38
+ const wanted = normalizeAddress(address);
39
+ return contacts.find((contact) => contact.emails.some((email) => normalizeAddress(email.address) === wanted));
40
+ }
41
+ /** The domains (lower case) of every address of `mailboxes` - primary and aliases. */
42
+ export function ownDomains(mailboxes) {
43
+ const domains = new Set();
44
+ for (const mailbox of mailboxes) {
45
+ for (const address of [mailbox.primarySmtpAddress, ...(mailbox.aliasAddresses ?? [])]) {
46
+ const at = address.lastIndexOf("@");
47
+ if (at > 0) {
48
+ domains.add(address.slice(at + 1).trim().toLowerCase());
49
+ }
50
+ }
51
+ }
52
+ return domains;
53
+ }
54
+ /** Whether `address` is outside the user's own domains. An address with no domain at all is not called external. */
55
+ export function isExternalAddress(address, domains) {
56
+ const at = address.lastIndexOf("@");
57
+ return at > 0 && !domains.has(address.slice(at + 1).trim().toLowerCase());
58
+ }
59
+ /** 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. */
60
+ export function cardForAddress(cards, address, orFirst) {
61
+ const wanted = normalizeAddress(address);
62
+ return cards.find((card) => (card.emails ?? []).some((email) => normalizeAddress(email.address) === wanted)) ?? (orFirst ? cards[0] : undefined);
63
+ }
64
+ /**
65
+ * What a new contact for `participant` holds: everything the vCard `card` says (when there is one) and always the participant's address. The
66
+ * 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.
67
+ */
68
+ export function contactInputFor(participant, card, scope) {
69
+ // A vCard with no name at all is parsed as "Unnamed contact": not a name worth keeping over the participant's own.
70
+ const cardName = card && (card.givenName || card.surname || card.displayName !== "Unnamed contact") ? card.displayName : "";
71
+ const displayName = cardName || participantName(participant);
72
+ const emails = [...(card?.emails ?? [])];
73
+ if (!emails.some((email) => normalizeAddress(email.address) === normalizeAddress(participant.address))) {
74
+ emails.push({ address: participant.address.trim(), type: "other" });
75
+ }
76
+ const names = card?.givenName || card?.surname ? { givenName: card.givenName, surname: card.surname } : splitPersonName(displayName);
77
+ return {
78
+ ...scope,
79
+ displayName,
80
+ givenName: names.givenName,
81
+ surname: names.surname,
82
+ emails,
83
+ phones: card?.phones ?? [],
84
+ addresses: card?.addresses ?? [],
85
+ company: card?.company,
86
+ jobTitle: card?.jobTitle,
87
+ notes: card?.notes,
88
+ };
89
+ }
90
+ /** 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. */
91
+ export function contactInputFromCard(card, scope) {
92
+ const names = card.givenName || card.surname ? { givenName: card.givenName, surname: card.surname } : splitPersonName(card.displayName);
93
+ return { ...card, ...scope, ...names, emails: card.emails ?? [], phones: card.phones ?? [], addresses: card.addresses ?? [] };
94
+ }
95
+ /** 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. */
96
+ export function withoutDuplicates(cards, existing) {
97
+ const known = new Set(existing.flatMap((contact) => contact.emails.map((email) => normalizeAddress(email.address))));
98
+ const fresh = [];
99
+ for (const card of cards) {
100
+ const addresses = (card.emails ?? []).map((email) => normalizeAddress(email.address));
101
+ if (addresses.some((address) => known.has(address))) {
102
+ continue;
103
+ }
104
+ addresses.forEach((address) => known.add(address));
105
+ fresh.push(card);
106
+ }
107
+ return { fresh, skipped: cards.length - fresh.length };
108
+ }
109
+ /** 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). */
110
+ export function ownContactCard(mailbox, own) {
111
+ const displayName = own?.displayName || mailbox.displayName || participantName({ address: mailbox.primarySmtpAddress });
112
+ const names = own?.givenName || own?.surname ? { givenName: own.givenName, surname: own.surname } : splitPersonName(displayName);
113
+ const vcard = contactToVCard({
114
+ uid: "",
115
+ version: 0,
116
+ dateCreated: "",
117
+ dateModified: "",
118
+ mailboxUid: "",
119
+ folderUid: "",
120
+ displayName,
121
+ ...names,
122
+ emails: [{ address: mailbox.primarySmtpAddress, type: "work" }],
123
+ phones: own?.phones ?? [],
124
+ addresses: [],
125
+ company: own?.company,
126
+ jobTitle: own?.jobTitle,
127
+ });
128
+ return { displayName, vcard };
129
+ }
@@ -14,6 +14,7 @@ import { useApiClient } from "../../../../lib/util/apiClientContext.js";
14
14
  import useBranding from "../../../../lib/branding/useBranding.js";
15
15
  import { useIdleKeyTimeout } from "../../../../lib/crypto/useIdleKeyTimeout.js";
16
16
  import ComposeProvider from "../mail/compose/ComposeContext.js";
17
+ import ContactCardProvider from "../contacts/ContactCardProvider.js";
17
18
  import { flushComposeDrafts, markSigningOut } from "../mail/compose/composeFlushRegistry.js";
18
19
  import BottomTabBar from "../../../../lib/components/navigation/BottomTabBar.js";
19
20
  import { FrameBrandingFooter, FrameBrandingHeader, useBrandingHtml } from "./BrandingChrome.js";
@@ -246,16 +247,16 @@ export function AppChrome({ active, userUid, authServerUrl, impersonating, imper
246
247
  const renderUserMenu = (placement) => (_jsx(UserMenu, { userUid: userUid, authServerUrl: authServerUrl, onSignOut: handleSignOut,
247
248
  // An impersonating administrator acts as the impersonated user, so the console link is hidden then.
248
249
  showAdminLink: !!trusted && !impersonating, detectAdmin: !trusted && !impersonating, trustedRoles: trustedRoles, showSettingsLink: true, showNotificationSettings: true, onShowShortcuts: () => setShortcutsOpen(true), onShowNotifications: () => setHistoryOpen(true), unseenErrors: unseenErrors, placement: placement }));
249
- return (_jsx(AppearanceProvider, { userUid: userUid, initial: appearance, lookUp: inFrame, children: _jsx(ShortcutProvider, { children: _jsxs(MailConnectionContext.Provider, { value: inFrame ? mail : null, children: [_jsx(GlobalShortcuts, { authServerUrl: authServerUrl, onToggleHelp: () => setShortcutsOpen((open) => !open) }), _jsx(ShortcutsDialog, { open: shortcutsOpen, onClose: () => setShortcutsOpen(false) }), _jsxs(UnlockPromptProvider, { children: [_jsx(UnlockBridge, {}), _jsx(ComposeProvider, { userUid: userUid, trusted: !!trusted && !impersonating, children: _jsxs("div", { className: "rr-frame-bg min-h-screen flex flex-col", children: [!hideChrome && customHeader && (_jsx(FrameBrandingHeader, { parsed: header, userMenu: menuInHeader ? renderUserMenu("down") : undefined, fallbackMenu: header && !menuInHeader && !menuInFooter ? renderUserMenu("down") : undefined, appTitle: activeLabel })), impersonating && !hideChrome && (_jsxs("div", { className: "h-10 shrink-0 bg-warning text-warning-contrast flex items-center justify-center gap-3 text-sm font-medium px-4", children: [_jsxs("span", { children: ["You are viewing as ", _jsx("strong", { children: userUid }), "."] }), _jsx("button", { type: "button", onClick: handleStopImpersonating, disabled: stoppingImpersonation, className: "underline hover:no-underline disabled:opacity-60", children: stoppingImpersonation ? "Returning to admin…" : "Return to admin" })] })), _jsxs("div", { className: "flex-1 flex min-h-0", children: [!hideChrome && (_jsxs("nav", { "aria-label": "Apps", className: [
250
- "hidden md:flex w-16 shrink-0 bg-surface border-r border-border flex-col items-center gap-1",
251
- // Under a custom header the rail starts with the app icons; without one its own icon is flush with the top of the window.
252
- customHeader ? "py-3" : "pb-3",
253
- ].join(" "), children: [!customHeader && (_jsx(RailIcon, { src: iconSrc })), apps.map(({ id, href, label, icon: Icon }) => (_jsx("a", { href: href, "aria-label": label, "aria-current": id === active ? "page" : undefined, title: label, className: [
254
- "w-10 h-10 flex items-center justify-center rounded-sm",
255
- id === active
256
- ? "bg-primary/10 text-primary-dark"
257
- : "text-text-muted hover:bg-surface-alt hover:text-text",
258
- ].join(" "), children: _jsx(Icon, { size: 20, "aria-hidden": "true" }) }, id)))] })), !hideChrome && _jsx(BottomTabBar, { apps: apps, active: active }), _jsxs("div", { className: "flex-1 flex flex-col min-w-0", children: [!hideChrome && !customHeader && (_jsxs("header", { ref: headerRef, className: "rr-solid sticky top-0 z-30 h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-6", children: [_jsx("span", { className: "font-display font-bold text-lg uppercase tracking-wide", children: activeLabel }), renderUserMenu("down")] })), _jsx(NotificationCenter, {}), _jsx("div", { id: "app-content", tabIndex: -1, "aria-busy": busy || undefined, className: ["flex-1 flex min-h-0 outline-none", hideChrome ? "" : "pb-14 md:pb-0"].join(" "), children: children })] })] }), !hideChrome && _jsx(FrameBrandingFooter, { parsed: footer, userMenu: menuInFooter ? renderUserMenu("up") : undefined, appTitle: activeLabel })] }) })] }), _jsx(NotificationHistoryDialog, { open: historyOpen, onClose: () => setHistoryOpen(false) })] }) }) }));
250
+ return (_jsx(AppearanceProvider, { userUid: userUid, initial: appearance, lookUp: inFrame, children: _jsx(ShortcutProvider, { children: _jsxs(MailConnectionContext.Provider, { value: inFrame ? mail : null, children: [_jsx(GlobalShortcuts, { authServerUrl: authServerUrl, onToggleHelp: () => setShortcutsOpen((open) => !open) }), _jsx(ShortcutsDialog, { open: shortcutsOpen, onClose: () => setShortcutsOpen(false) }), _jsxs(UnlockPromptProvider, { children: [_jsx(UnlockBridge, {}), _jsx(ComposeProvider, { userUid: userUid, trusted: !!trusted && !impersonating, children: _jsx(ContactCardProvider, { userUid: userUid, children: _jsxs("div", { className: "rr-frame-bg min-h-screen flex flex-col", children: [!hideChrome && customHeader && (_jsx(FrameBrandingHeader, { parsed: header, userMenu: menuInHeader ? renderUserMenu("down") : undefined, fallbackMenu: header && !menuInHeader && !menuInFooter ? renderUserMenu("down") : undefined, appTitle: activeLabel })), impersonating && !hideChrome && (_jsxs("div", { className: "h-10 shrink-0 bg-warning text-warning-contrast flex items-center justify-center gap-3 text-sm font-medium px-4", children: [_jsxs("span", { children: ["You are viewing as ", _jsx("strong", { children: userUid }), "."] }), _jsx("button", { type: "button", onClick: handleStopImpersonating, disabled: stoppingImpersonation, className: "underline hover:no-underline disabled:opacity-60", children: stoppingImpersonation ? "Returning to admin…" : "Return to admin" })] })), _jsxs("div", { className: "flex-1 flex min-h-0", children: [!hideChrome && (_jsxs("nav", { "aria-label": "Apps", className: [
251
+ "hidden md:flex w-16 shrink-0 bg-surface border-r border-border flex-col items-center gap-1",
252
+ // Under a custom header the rail starts with the app icons; without one its own icon is flush with the top of the window.
253
+ customHeader ? "py-3" : "pb-3",
254
+ ].join(" "), children: [!customHeader && (_jsx(RailIcon, { src: iconSrc })), apps.map(({ id, href, label, icon: Icon }) => (_jsx("a", { href: href, "aria-label": label, "aria-current": id === active ? "page" : undefined, title: label, className: [
255
+ "w-10 h-10 flex items-center justify-center rounded-sm",
256
+ id === active
257
+ ? "bg-primary/10 text-primary-dark"
258
+ : "text-text-muted hover:bg-surface-alt hover:text-text",
259
+ ].join(" "), children: _jsx(Icon, { size: 20, "aria-hidden": "true" }) }, id)))] })), !hideChrome && _jsx(BottomTabBar, { apps: apps, active: active }), _jsxs("div", { className: "flex-1 flex flex-col min-w-0", children: [!hideChrome && !customHeader && (_jsxs("header", { ref: headerRef, className: "rr-solid sticky top-0 z-30 h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-6", children: [_jsx("span", { className: "font-display font-bold text-lg uppercase tracking-wide", children: activeLabel }), renderUserMenu("down")] })), _jsx(NotificationCenter, {}), _jsx("div", { id: "app-content", tabIndex: -1, "aria-busy": busy || undefined, className: ["flex-1 flex min-h-0 outline-none", hideChrome ? "" : "pb-14 md:pb-0"].join(" "), children: children })] })] }), !hideChrome && _jsx(FrameBrandingFooter, { parsed: footer, userMenu: menuInFooter ? renderUserMenu("up") : undefined, appTitle: activeLabel })] }) }) })] }), _jsx(NotificationHistoryDialog, { open: historyOpen, onClose: () => setHistoryOpen(false) })] }) }) }));
259
260
  }
260
261
  /**
261
262
  * What a page's own shell (`MailShell`, `CalendarShell`, ...) renders around its content. Outside the client-side router it
@@ -4,7 +4,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import { useContext, useId, useRef } from "react";
7
- import { HiOutlineComputerDesktop, HiOutlineMoon, HiOutlineSun } from "react-icons/hi2";
7
+ import { HiOutlineComputerDesktop, HiOutlineMoon, HiOutlinePaintBrush, HiOutlineSun } from "react-icons/hi2";
8
8
  import { AppearanceContext, INERT_APPEARANCE } from "../../appearance/appearanceContext.js";
9
9
  const CHOICES = [
10
10
  { mode: "system", label: "System", hint: "System - follow this device", Icon: HiOutlineComputerDesktop },
@@ -42,7 +42,7 @@ export default function ThemeSwitch() {
42
42
  choose(target, true);
43
43
  }
44
44
  }
45
- return (_jsxs("div", { className: "flex items-center justify-between gap-3 px-3.5 py-2 border-b border-border", children: [_jsx("span", { id: labelId, className: "text-sm text-text", children: "Theme" }), _jsx("div", { role: "radiogroup", "aria-labelledby": labelId, className: "inline-flex rounded-sm border border-border bg-surface-alt p-0.5", children: CHOICES.map(({ mode, label, hint, Icon }, index) => {
45
+ return (_jsxs("div", { className: "flex items-center justify-between gap-3 px-3.5 py-2 border-b border-border", children: [_jsxs("span", { id: labelId, className: "flex items-center gap-2 text-sm text-text", children: [_jsx(HiOutlinePaintBrush, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Theme"] }), _jsx("div", { role: "radiogroup", "aria-labelledby": labelId, className: "inline-flex rounded-sm border border-border bg-surface-alt p-0.5", children: CHOICES.map(({ mode, label, hint, Icon }, index) => {
46
46
  const selected = mode === current;
47
47
  return (_jsx("button", { ref: (element) => {
48
48
  buttons.current[index] = element;
@@ -18,8 +18,7 @@ export interface UserMenuProps {
18
18
  trustedRoles?: readonly string[];
19
19
  /** Shows a "Settings" item, above "Admin"/"Sign Out" — an account-scoped surface (like "Admin"), not
20
20
  * a content-scoped one, so it lives here rather than as a 5th `AppShell` rail icon. Links straight to
21
- * `/settings/auto-reply`, the only settings section that exists today; repoint this at a real
22
- * `/settings` landing page once a second section (Mail Filters, Signatures) makes one worth building. */
21
+ * `/settings/profile`, the first settings section; repoint this at a real `/settings` landing page once one is worth building. */
23
22
  showSettingsLink?: boolean;
24
23
  /** Shows the notification controls, above "Admin Console"/"Sign Out": a "Notifications" on/off switch for every pop-up and,
25
24
  * while the browser hasn't been asked, "Turn on desktop notifications". Both are per browser (see `notifications/preferences.ts`
@@ -6,7 +6,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
6
6
  import { useContext, useEffect, useLayoutEffect, useRef, useState } from "react";
7
7
  import { createPortal } from "react-dom";
8
8
  import useIsMobile from "../../../../lib/util/useIsMobile.js";
9
- import { HiOutlineArrowUturnLeft, HiOutlineShieldCheck } from "react-icons/hi2";
9
+ import { HiOutlineArrowRightOnRectangle, HiOutlineArrowUturnLeft, HiOutlineBell, HiOutlineBellAlert, HiOutlineClock, HiOutlineCog6Tooth, HiOutlineCommandLine, HiOutlineShieldCheck, HiOutlineUserCircle, } from "react-icons/hi2";
10
10
  import { formatProfileName, getMyProfile, getMyUsername, profileInitials } from "../../../../lib/auth/profileApi.js";
11
11
  import { listMailboxes } from "../../../../lib/mail/mailApi.js";
12
12
  import { useApiClient } from "../../../../lib/util/apiClientContext.js";
@@ -206,11 +206,11 @@ export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminL
206
206
  const accountUrl = accountUrlOf(authServerUrl);
207
207
  return (_jsxs("div", { className: "relative", ref: containerRef, children: [_jsxs("button", { type: "button", onClick: () => setOpen((o) => !o), "aria-haspopup": "true", "aria-expanded": open, "aria-label": "Account menu", className: "relative flex items-center gap-1.5 rounded-pill py-1 pl-1 pr-2 hover:bg-surface-alt", children: [_jsx(Avatar, { profile: profile, initials: initials }), unseenErrors > 0 && (_jsx("span", { "data-testid": "unseen-errors-dot", "aria-hidden": "true", className: "absolute right-0.5 top-0.5 h-2.5 w-2.5 rounded-full border-2 border-surface bg-danger" })), _jsx("svg", { width: "14", height: "14", viewBox: "0 0 20 20", fill: "none", className: `text-text-muted transition-transform ${open ? "rotate-180" : ""}`, "aria-hidden": "true", children: _jsx("path", { d: "M5 7.5L10 12.5L15 7.5", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round" }) })] }), open &&
208
208
  anchor &&
209
- createPortal(_jsxs("div", { ref: menuRef, role: "menu", style: { position: "fixed", right: anchor.right, top: anchor.top, bottom: anchor.bottom }, className: "w-60 max-w-[calc(100vw-1rem)] max-h-[calc(100vh-5rem)] overflow-y-auto bg-surface border border-border rounded-md shadow-modal py-1.5 z-[70]", children: [_jsxs("div", { className: "flex items-center gap-3 px-3.5 py-2.5 border-b border-border", children: [_jsx(Avatar, { profile: profile, initials: initials, large: true }), _jsx("span", { className: "text-sm font-semibold text-text truncate", children: name })] }), showMailLink && (_jsxs("a", { role: "menuitem", href: APP_HREFS.mail, className: "flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineArrowUturnLeft, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Back to mail"] })), _jsx(ThemeSwitch, {}), accountUrl && (_jsx("a", { role: "menuitem", href: accountUrl, className: "block px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: "Account" })), showSettingsLink && (_jsx("a", { role: "menuitem", href: SETTINGS_HREF, className: "block px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: "Settings" })), showNotificationSettings && (_jsxs("button", { role: "menuitemcheckbox", "aria-checked": popups, type: "button", onClick: togglePopups, className: "flex w-full items-center justify-between gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: [_jsx("span", { children: "Notifications" }), _jsx("span", { className: "text-xs font-semibold text-text-muted", children: popups ? "On" : "Off" })] })), showNotificationSettings && permission === "default" && (_jsx("button", { role: "menuitem", type: "button", onClick: () => void enableDesktopNotifications(), className: "block w-full px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: "Turn on desktop notifications" })), onShowNotifications && (_jsxs("button", { role: "menuitem", type: "button", onClick: () => {
209
+ createPortal(_jsxs("div", { ref: menuRef, role: "menu", style: { position: "fixed", right: anchor.right, top: anchor.top, bottom: anchor.bottom }, className: "w-60 max-w-[calc(100vw-1rem)] max-h-[calc(100vh-5rem)] overflow-y-auto bg-surface border border-border rounded-md shadow-modal py-1.5 z-[70]", children: [_jsxs("div", { className: "flex items-center gap-3 px-3.5 py-2.5 border-b border-border", children: [_jsx(Avatar, { profile: profile, initials: initials, large: true }), _jsx("span", { className: "text-sm font-semibold text-text truncate", children: name })] }), showMailLink && (_jsxs("a", { role: "menuitem", href: APP_HREFS.mail, className: "flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineArrowUturnLeft, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Back to mail"] })), _jsx(ThemeSwitch, {}), accountUrl && (_jsxs("a", { role: "menuitem", href: accountUrl, className: "flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineUserCircle, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Account"] })), showSettingsLink && (_jsxs("a", { role: "menuitem", href: SETTINGS_HREF, className: "flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineCog6Tooth, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Settings"] })), showNotificationSettings && (_jsxs("button", { role: "menuitemcheckbox", "aria-checked": popups, type: "button", onClick: togglePopups, className: "flex w-full items-center justify-between gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: [_jsxs("span", { className: "flex items-center gap-2", children: [_jsx(HiOutlineBell, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Notifications"] }), _jsx("span", { className: "text-xs font-semibold text-text-muted", children: popups ? "On" : "Off" })] })), showNotificationSettings && permission === "default" && (_jsxs("button", { role: "menuitem", type: "button", onClick: () => void enableDesktopNotifications(), className: "flex w-full items-center gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineBellAlert, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Turn on desktop notifications"] })), onShowNotifications && (_jsxs("button", { role: "menuitem", type: "button", onClick: () => {
210
210
  setOpen(false);
211
211
  onShowNotifications();
212
- }, className: "flex w-full items-center justify-between gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: [_jsx("span", { children: "Recent notifications" }), unseenErrors > 0 && (_jsxs("span", { className: "rounded-pill bg-danger px-1.5 text-xs font-bold text-white", children: [_jsx("span", { "aria-hidden": "true", children: unseenErrors }), _jsxs("span", { className: "sr-only", children: [unseenErrors, " unseen ", unseenErrors === 1 ? "error" : "errors"] })] }))] })), onShowShortcuts && !isMobile && (_jsx("button", { role: "menuitem", type: "button", "aria-keyshortcuts": ariaKeyShortcuts(SHORTCUTS.global.help, env), onClick: () => {
212
+ }, className: "flex w-full items-center justify-between gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: [_jsxs("span", { className: "flex items-center gap-2", children: [_jsx(HiOutlineClock, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Recent notifications"] }), unseenErrors > 0 && (_jsxs("span", { className: "rounded-pill bg-danger px-1.5 text-xs font-bold text-white", children: [_jsx("span", { "aria-hidden": "true", children: unseenErrors }), _jsxs("span", { className: "sr-only", children: [unseenErrors, " unseen ", unseenErrors === 1 ? "error" : "errors"] })] }))] })), onShowShortcuts && !isMobile && (_jsxs("button", { role: "menuitem", type: "button", "aria-keyshortcuts": ariaKeyShortcuts(SHORTCUTS.global.help, env), onClick: () => {
213
213
  setOpen(false);
214
214
  onShowShortcuts();
215
- }, className: "block w-full px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: "Keyboard shortcuts" })), (showAdminLink || detectedAdmin) && (_jsxs("a", { role: "menuitem", href: "/admin", className: "flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineShieldCheck, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Admin Console"] })), _jsx("button", { role: "menuitem", type: "button", onClick: onSignOut, className: "block w-full text-left px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: "Sign Out" })] }), document.body)] }));
215
+ }, className: "flex w-full items-center gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineCommandLine, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Keyboard shortcuts"] })), (showAdminLink || detectedAdmin) && (_jsxs("a", { role: "menuitem", href: "/admin", className: "flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineShieldCheck, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Admin Console"] })), _jsxs("button", { role: "menuitem", type: "button", onClick: onSignOut, className: "flex w-full items-center gap-2 text-left px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineArrowRightOnRectangle, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Sign Out"] })] }), document.body)] }));
216
216
  }
@@ -3,13 +3,14 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
3
3
  // Copyright (C) 2026 Jean-Philippe Steinmetz
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
- import { useId, useState } from "react";
6
+ import React, { useId, useState } from "react";
7
7
  import { HiOutlineCalendarDays, HiOutlineCheck } from "react-icons/hi2";
8
8
  import { acceptProposal, proposeNewTime, removeMessageInvite, respondToMessageInvite, } from "../../../../lib/calendar/inviteApi.js";
9
9
  import { guestPermissionsOf } from "../../../../lib/calendar/calendarApi.js";
10
10
  import { formatMailAddress } from "../../../../lib/mail/mailAddress.js";
11
11
  import { useApiClient } from "../../../../lib/util/apiClientContext.js";
12
12
  import Button from "../../../../lib/components/buttons/Button.js";
13
+ import ParticipantLink from "../contacts/ParticipantLink.js";
13
14
  import EventDescriptionView from "../calendar/EventDescriptionView.js";
14
15
  import RequestChangeForm from "../calendar/RequestChangeForm.js";
15
16
  import { VISIBILITY_LABEL, describeGuestPermissions } from "../calendar/eventFormat.js";
@@ -55,7 +56,7 @@ function attendeeSummary(attendees) {
55
56
  return undefined;
56
57
  }
57
58
  const count = `${attendees.length} ${attendees.length === 1 ? "attendee" : "attendees"}`;
58
- return attendees.length <= MAX_NAMED_ATTENDEES ? `${count}: ${attendees.map((a) => a.displayName || a.address).join(", ")}` : count;
59
+ return attendees.length <= MAX_NAMED_ATTENDEES ? (_jsxs(_Fragment, { children: [count, ":", " ", attendees.map((attendee, index) => (_jsxs(React.Fragment, { children: [index > 0 && ", ", _jsx(ParticipantLink, { participant: attendee, children: attendee.displayName || attendee.address })] }, index)))] })) : (count);
59
60
  }
60
61
  /**
61
62
  * The calendar invitation a message carries, as Outlook and Gmail draw it: what the meeting is, when and where, who organizes it, and the buttons
@@ -120,6 +121,7 @@ export default function InviteCard({ messageUid, headingLevel = 2 }) {
120
121
  const visibility = invite.visibility && invite.visibility !== "default" ? VISIBILITY_LABEL[invite.visibility] : undefined;
121
122
  const sender = invite.reply ?? invite.attendees[0];
122
123
  const senderName = sender ? sender.displayName || sender.address : "Someone";
124
+ const senderNode = sender ? (_jsx(ParticipantLink, { participant: sender, className: "font-semibold", children: senderName })) : (_jsx("span", { className: "font-semibold", children: senderName }));
123
125
  const showResponses = invite.canRespond && !invite.isOrganizer;
124
126
  const conflicts = !isReply && !isCounter && method !== "CANCEL" && invite.conflicts.length > 0 ? conflictSummary(invite.conflicts) : undefined;
125
127
  const hasActions = showResponses ||
@@ -129,7 +131,7 @@ export default function InviteCard({ messageUid, headingLevel = 2 }) {
129
131
  invite.canRemove ||
130
132
  canRequest ||
131
133
  (invite.onCalendar && !!invite.calendarEventUid);
132
- return (_jsxs("section", { "aria-labelledby": headingId, className: "rounded-sm border border-border bg-surface-alt px-3 py-3 flex flex-col gap-2 text-sm text-text", children: [_jsxs(Heading, { id: headingId, className: "flex items-center gap-2 text-sm font-semibold", children: [_jsx(HiOutlineCalendarDays, { size: 16, "aria-hidden": "true", className: "shrink-0" }), changeRequest ? CHANGE_REQUEST_HEADING : (HEADINGS[method] ?? "Calendar event")] }), isReply && (_jsxs("p", { className: "break-words", children: [_jsx("span", { className: "font-semibold", children: senderName }), " ", (sender?.responseStatus && REPLY_VERBS[sender.responseStatus]) ?? "responded", "."] })), isCounter && !changeRequest && (_jsxs("p", { className: "break-words", children: [_jsx("span", { className: "font-semibold", children: senderName }), " proposed a new time", when ? _jsxs(_Fragment, { children: [": ", when] }) : "."] })), changeRequest && (_jsx("p", { className: "break-words", children: changeRequest.applied ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "font-semibold", children: senderName }), "\u2019s change was applied."] })) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "font-semibold", children: senderName }), " requested a change."] })) })), _jsx("p", { className: "font-medium break-words", children: title }), !isReply && (_jsxs("dl", { className: "grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1", children: [when && (!isCounter || !!changeRequest) && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "When" }), _jsxs("dd", { className: "break-words", children: [when, invite.recurring && _jsx("span", { className: "text-text-muted", children: " (Repeats)" })] })] })), invite.location && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Where" }), _jsx("dd", { className: "break-words", children: invite.location })] })), hasDescription && (!isCounter || !!changeRequest) && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Description" }), _jsx("dd", { className: "break-words", children: _jsx(EventDescriptionView, { html: invite.descriptionHtml, text: invite.description }) })] })), invite.organizer && !isCounter && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Organizer" }), _jsx("dd", { className: "break-words", children: formatMailAddress(invite.organizer) })] })), attendees && (!isCounter || !!changeRequest) && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Attendees" }), _jsx("dd", { className: "break-words", children: attendees })] })), visibility && !isCounter && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Visibility" }), _jsx("dd", { className: "break-words", children: visibility })] }))] })), isReply && when && _jsx("p", { className: "text-text-muted break-words", children: when }), conflicts && (_jsxs("p", { className: "text-danger break-words", children: [_jsx("span", { className: "font-medium", children: "Conflicts with:" }), " ", conflicts] })), invite.guestPermissions && !isReply && !isCounter && method !== "CANCEL" && (_jsx("p", { className: "text-xs text-text-muted break-words", children: describeGuestPermissions(permissions) })), invite.isOrganizer && !isReply && !isCounter && _jsx("p", { className: "text-text-muted", children: "You are the organizer of this meeting." }), method === "CANCEL" && _jsx("p", { className: "font-medium text-danger", children: "This meeting was canceled." }), invite.response && !invite.isOrganizer && !isReply && !isCounter && (_jsx("p", { role: "status", className: `font-medium ${ANSWER_CLASS[invite.response]}`, children: ANSWER_TEXT[invite.response] })), isCounter && invite.response === "accepted" && !changeRequest?.applied && (_jsx("p", { role: "status", className: "font-medium text-success", children: "You accepted the proposed time." })), proposed && (_jsx("p", { role: "status", className: "font-medium text-text", children: "Your proposed time was sent to the organizer." })), requested && (_jsx("p", { role: "status", className: "font-medium text-text", children: "Your change was sent to the organizer." })), invite.outdated && _jsx("p", { className: "text-xs text-text-muted", children: "A newer version of this meeting is already on your calendar." }), hasActions && (_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [showResponses && (_jsx("div", { role: "group", "aria-label": "Respond to this meeting", "aria-busy": busy || undefined, className: "flex flex-wrap gap-2", children: RESPONSES.map(({ response, label, failure }) => {
134
+ return (_jsxs("section", { "aria-labelledby": headingId, className: "rounded-sm border border-border bg-surface-alt px-3 py-3 flex flex-col gap-2 text-sm text-text", children: [_jsxs(Heading, { id: headingId, className: "flex items-center gap-2 text-sm font-semibold", children: [_jsx(HiOutlineCalendarDays, { size: 16, "aria-hidden": "true", className: "shrink-0" }), changeRequest ? CHANGE_REQUEST_HEADING : (HEADINGS[method] ?? "Calendar event")] }), isReply && (_jsxs("p", { className: "break-words", children: [senderNode, " ", (sender?.responseStatus && REPLY_VERBS[sender.responseStatus]) ?? "responded", "."] })), isCounter && !changeRequest && (_jsxs("p", { className: "break-words", children: [senderNode, " proposed a new time", when ? _jsxs(_Fragment, { children: [": ", when] }) : "."] })), changeRequest && (_jsx("p", { className: "break-words", children: changeRequest.applied ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "font-semibold", children: senderName }), "\u2019s change was applied."] })) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "font-semibold", children: senderName }), " requested a change."] })) })), _jsx("p", { className: "font-medium break-words", children: title }), !isReply && (_jsxs("dl", { className: "grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1", children: [when && (!isCounter || !!changeRequest) && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "When" }), _jsxs("dd", { className: "break-words", children: [when, invite.recurring && _jsx("span", { className: "text-text-muted", children: " (Repeats)" })] })] })), invite.location && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Where" }), _jsx("dd", { className: "break-words", children: invite.location })] })), hasDescription && (!isCounter || !!changeRequest) && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Description" }), _jsx("dd", { className: "break-words", children: _jsx(EventDescriptionView, { html: invite.descriptionHtml, text: invite.description }) })] })), invite.organizer && !isCounter && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Organizer" }), _jsx("dd", { className: "break-words", children: _jsx(ParticipantLink, { participant: invite.organizer, children: formatMailAddress(invite.organizer) }) })] })), attendees && (!isCounter || !!changeRequest) && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Attendees" }), _jsx("dd", { className: "break-words", children: attendees })] })), visibility && !isCounter && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Visibility" }), _jsx("dd", { className: "break-words", children: visibility })] }))] })), isReply && when && _jsx("p", { className: "text-text-muted break-words", children: when }), conflicts && (_jsxs("p", { className: "text-danger break-words", children: [_jsx("span", { className: "font-medium", children: "Conflicts with:" }), " ", conflicts] })), invite.guestPermissions && !isReply && !isCounter && method !== "CANCEL" && (_jsx("p", { className: "text-xs text-text-muted break-words", children: describeGuestPermissions(permissions) })), invite.isOrganizer && !isReply && !isCounter && _jsx("p", { className: "text-text-muted", children: "You are the organizer of this meeting." }), method === "CANCEL" && _jsx("p", { className: "font-medium text-danger", children: "This meeting was canceled." }), invite.response && !invite.isOrganizer && !isReply && !isCounter && (_jsx("p", { role: "status", className: `font-medium ${ANSWER_CLASS[invite.response]}`, children: ANSWER_TEXT[invite.response] })), isCounter && invite.response === "accepted" && !changeRequest?.applied && (_jsx("p", { role: "status", className: "font-medium text-success", children: "You accepted the proposed time." })), proposed && (_jsx("p", { role: "status", className: "font-medium text-text", children: "Your proposed time was sent to the organizer." })), requested && (_jsx("p", { role: "status", className: "font-medium text-text", children: "Your change was sent to the organizer." })), invite.outdated && _jsx("p", { className: "text-xs text-text-muted", children: "A newer version of this meeting is already on your calendar." }), hasActions && (_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [showResponses && (_jsx("div", { role: "group", "aria-label": "Respond to this meeting", "aria-busy": busy || undefined, className: "flex flex-wrap gap-2", children: RESPONSES.map(({ response, label, failure }) => {
133
135
  const current = invite.response === response;
134
136
  return (_jsxs(Button, { type: "button", variant: "secondary", className: ["!w-auto", current ? "!bg-primary/10 !border-primary disabled:!opacity-100" : ""].join(" "), "aria-pressed": current, "aria-busy": pending === response || undefined, loading: pending === response, disabled: busy || current, onClick: () => void run(response, failure, () => respondToMessageInvite(messageUid, response, client)), children: [current && _jsx(HiOutlineCheck, { size: 14, "aria-hidden": "true" }), label] }, response));
135
137
  }) })), invite.canPropose && !proposing && (_jsx(Button, { type: "button", variant: "text", disabled: busy, onClick: () => setProposing(true), children: "Propose new time" })), canRequest && !requesting && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: busy, onClick: () => setRequesting(true), children: invite.canRequestChange ? "Request a change" : "Add guests" })), invite.canAcceptProposal && invite.response !== "accepted" && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-busy": pending === "acceptProposal" || undefined, loading: pending === "acceptProposal", disabled: busy, onClick: () => void run("acceptProposal", "Couldn't accept the proposed time", () => acceptProposal(messageUid, client)), children: "Accept proposal" })), invite.canAdd && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-busy": pending === "add" || undefined, loading: pending === "add", disabled: busy,
@@ -1,5 +1,6 @@
1
1
  import React from "react";
2
2
  import { MailAddressLike } from "../../../../lib/mail/mailAddress.js";
3
+ import type { ContactCardContext } from "../contacts/contactCardData.js";
3
4
  export interface MailAddressProps {
4
5
  /** A message's `from`, a recipient, a conversation participant. */
5
6
  recipient: MailAddressLike;
@@ -19,9 +20,11 @@ export declare const RECIPIENT_LINE_LIMIT = 3;
19
20
  /**
20
21
  * A `To`/`Cc` line of a message header: every recipient as `Name <address>` in the text itself (never hover-only), wrapping
21
22
  * onto more lines as needed. A long list shows its first `RECIPIENT_LINE_LIMIT` and folds the rest behind a button -
22
- * "and 12 more" / "Show fewer" - so a message to a hundred people doesn't take the whole header. Nothing when empty.
23
+ * "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
24
+ * contact card when clicked (see `ParticipantLink`); `context` is the message they are listed on.
23
25
  */
24
- export declare function RecipientLine({ label, recipients }: {
26
+ export declare function RecipientLine({ label, recipients, context }: {
25
27
  label: string;
26
28
  recipients: MailAddressLike[];
29
+ context?: ContactCardContext;
27
30
  }): React.JSX.Element | null;
@@ -5,6 +5,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import React, { useState } from "react";
7
7
  import { formatMailAddress, splitMailAddress } from "../../../../lib/mail/mailAddress.js";
8
+ import ParticipantLink from "../contacts/ParticipantLink.js";
8
9
  /**
9
10
  * A sender or recipient as one compact line for a dense row: the name, when there is one, with the real address in muted
10
11
  * type after it - `Jean-Philippe <jp@example.com>` - or the bare address without a name. The address is never left out,
@@ -26,14 +27,15 @@ export const RECIPIENT_LINE_LIMIT = 3;
26
27
  /**
27
28
  * A `To`/`Cc` line of a message header: every recipient as `Name <address>` in the text itself (never hover-only), wrapping
28
29
  * onto more lines as needed. A long list shows its first `RECIPIENT_LINE_LIMIT` and folds the rest behind a button -
29
- * "and 12 more" / "Show fewer" - so a message to a hundred people doesn't take the whole header. Nothing when empty.
30
+ * "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
31
+ * contact card when clicked (see `ParticipantLink`); `context` is the message they are listed on.
30
32
  */
31
- export function RecipientLine({ label, recipients }) {
33
+ export function RecipientLine({ label, recipients, context }) {
32
34
  const [expanded, setExpanded] = useState(false);
33
35
  if (recipients.length === 0) {
34
36
  return null;
35
37
  }
36
38
  const folded = recipients.length > RECIPIENT_LINE_LIMIT;
37
39
  const shown = folded && !expanded ? recipients.slice(0, RECIPIENT_LINE_LIMIT) : recipients;
38
- return (_jsxs("p", { className: "text-sm text-text-muted break-words", children: [_jsx("span", { children: label }), " ", shown.map((recipient, index) => (_jsxs(React.Fragment, { children: [index > 0 && ", ", _jsx("span", { children: formatMailAddress(recipient) })] }, index))), folded && (_jsxs(_Fragment, { children: [" ", _jsx("button", { type: "button", "aria-expanded": expanded, onClick: () => setExpanded((open) => !open), className: "text-xs font-semibold text-primary-dark underline hover:no-underline", children: expanded ? "Show fewer" : `and ${recipients.length - RECIPIENT_LINE_LIMIT} more` })] }))] }));
40
+ return (_jsxs("p", { className: "text-sm text-text-muted break-words", children: [_jsx("span", { children: label }), " ", shown.map((recipient, index) => (_jsxs(React.Fragment, { children: [index > 0 && ", ", _jsx(ParticipantLink, { participant: recipient, context: context, children: formatMailAddress(recipient) })] }, index))), folded && (_jsxs(_Fragment, { children: [" ", _jsx("button", { type: "button", "aria-expanded": expanded, onClick: () => setExpanded((open) => !open), className: "text-xs font-semibold text-primary-dark underline hover:no-underline", children: expanded ? "Show fewer" : `and ${recipients.length - RECIPIENT_LINE_LIMIT} more` })] }))] }));
39
41
  }