@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,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
@@ -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
  }
@@ -27,6 +27,10 @@ import { loadOriginalMessage, prefetchOriginalMessage } from "./compose/quotedBo
27
27
  import { formatRecipient } from "./compose/recipients.js";
28
28
  import { formatMailAddress } from "../../../../lib/mail/mailAddress.js";
29
29
  import MailAddress, { RecipientLine } from "./MailAddress.js";
30
+ import ParticipantLink from "../contacts/ParticipantLink.js";
31
+ import { fetchVCardText } from "../contacts/contactCardData.js";
32
+ import { isVCardAttachment } from "../contacts/participantDetails.js";
33
+ import VCardAttachmentChip from "./VCardAttachmentChip.js";
30
34
  import { useMailShell } from "./layout/MailShell.js";
31
35
  import { ariaKeyShortcuts, withHint } from "../../keyboard/format.js";
32
36
  import { SHORTCUTS } from "../../keyboard/keymap.js";
@@ -130,6 +134,15 @@ export function formatFingerprint(fingerprint) {
130
134
  const compact = fingerprint.replace(/[\s:]/g, "").toUpperCase();
131
135
  return compact.match(/.{1,4}/g)?.join(" ") ?? compact;
132
136
  }
137
+ /** Saves the file at `url` under `filename`: the browser is handed the link as a download. */
138
+ function downloadFromUrl(url, filename) {
139
+ const a = document.createElement("a");
140
+ a.href = url;
141
+ a.download = filename;
142
+ document.body.appendChild(a);
143
+ a.click();
144
+ a.remove();
145
+ }
133
146
  /** Saves one attachment recovered from inside a signed/encrypted entity. Always handed to the browser as an
134
147
  * opaque download (`application/octet-stream`), never rendered in this origin. */
135
148
  function downloadMimeAttachment(attachment) {
@@ -772,6 +785,8 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
772
785
  const senderNameCheck = checkSenderName(senderName, senderAddress);
773
786
  // Always `Name <address>` (or the bare address): a name alone hides who a message is really from.
774
787
  const senderLabel = formatMailAddress({ displayName: senderName, address: senderAddress });
788
+ // What the contact card of anyone named in this message is given: the message, for its mailbox and any vCard it carries.
789
+ const cardContext = { message, attachments };
775
790
  // Offered only for a valid signature from a certificate nobody pinned for this sender - never to replace a pin.
776
791
  // A recorded signing-key conflict for this sender is resolved from the contact, never by trusting another key.
777
792
  const pendingConflict = senderUnpinned && senderKeyState?.conflict !== undefined;
@@ -913,10 +928,10 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
913
928
  : "This message can't be read, so it can't be printed";
914
929
  // In Junk Email the card's button is the way back (Not junk), which only needs the right to change the message.
915
930
  const reportReason = !writable ? "View-only mailbox" : undefined;
916
- const card = (_jsxs(CardShell, { unread: cardUnread, children: [_jsxs("div", { className: ["flex flex-wrap items-start gap-x-3 gap-y-1 px-4 pt-3 pb-2", cardUnread ? "bg-primary/[0.07]" : ""].join(" "), children: [_jsx(SenderAvatar, { from: senderRecipient }), _jsxs("div", { className: "flex-1 min-w-[12rem]", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-x-2 gap-y-1", children: [threadHeader ? (_jsx("h2", { className: "min-w-0 flex-1", style: BODY_FONT_STYLE, children: _jsxs("button", { type: "button", ref: threadHeader.buttonRef, onClick: threadHeader.onToggle, "aria-expanded": true, "aria-controls": threadHeader.bodyId, className: [
931
+ const card = (_jsxs(CardShell, { unread: cardUnread, children: [_jsxs("div", { className: ["flex flex-wrap items-start gap-x-3 gap-y-1 px-4 pt-3 pb-2", cardUnread ? "bg-primary/[0.07]" : ""].join(" "), children: [threadHeader ? (_jsx(ParticipantLink, { participant: { address: senderAddress, displayName: senderName }, context: cardContext, label: `Contact card for ${senderLabel}`, children: _jsx(SenderAvatar, { from: senderRecipient }) })) : (_jsx(SenderAvatar, { from: senderRecipient })), _jsxs("div", { className: "flex-1 min-w-[12rem]", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-x-2 gap-y-1", children: [threadHeader ? (_jsx("h2", { className: "min-w-0 flex-1", style: BODY_FONT_STYLE, children: _jsxs("button", { type: "button", ref: threadHeader.buttonRef, onClick: threadHeader.onToggle, "aria-expanded": true, "aria-controls": threadHeader.bodyId, className: [
917
932
  "w-full text-left rounded-sm text-sm",
918
933
  ROW_FOCUS_CLASS,
919
- ].join(" "), children: [_jsx(UnreadLabel, { unread: cardUnread }), _jsx(MailAddress, { recipient: senderRecipient, className: senderClass(cardUnread) })] }) })) : (_jsxs("p", { className: "text-sm break-words min-w-0", children: ["From ", _jsx("span", { className: "font-semibold text-text", children: senderLabel })] })), security && _jsx(SecurityIndicator, { security: security })] }), senderNameCheck.misleading && (_jsxs("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: ["The sender\u2019s name \u201C", senderName, "\u201D looks like an email address, but this message was sent from ", _jsx("span", { className: "font-medium", children: senderAddress }), ". Don\u2019t trust it based on the name."] })), _jsx(RecipientLine, { label: "To", recipients: message.recipients.filter((r) => r.type !== "cc" && r.type !== "bcc") }), _jsx(RecipientLine, { label: "Cc", recipients: message.recipients.filter((r) => r.type === "cc") }), _jsx(RecipientLine, { label: "Bcc", recipients: message.recipients.filter((r) => r.type === "bcc") })] }), _jsxs("div", { className: "ml-auto flex flex-wrap items-center justify-end gap-1", children: [_jsx("time", { dateTime: message.receivedDate, className: ["text-xs mr-1", dateClass(cardUnread)].join(" "), children: new Date(message.receivedDate).toLocaleString() }), adaptable && (_jsx("button", { type: "button", "aria-label": "View original", "aria-pressed": viewOriginal, title: viewOriginal ? "Follow the theme" : "View original", onClick: () => setViewOriginal(!viewOriginal), className: [
934
+ ].join(" "), children: [_jsx(UnreadLabel, { unread: cardUnread }), _jsx(MailAddress, { recipient: senderRecipient, className: senderClass(cardUnread) })] }) })) : (_jsxs("p", { className: "text-sm break-words min-w-0", children: ["From", " ", _jsx(ParticipantLink, { participant: { address: senderAddress, displayName: senderName }, context: cardContext, className: "font-semibold text-text", children: senderLabel })] })), security && _jsx(SecurityIndicator, { security: security })] }), senderNameCheck.misleading && (_jsxs("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: ["The sender\u2019s name \u201C", senderName, "\u201D looks like an email address, but this message was sent from ", _jsx("span", { className: "font-medium", children: senderAddress }), ". Don\u2019t trust it based on the name."] })), _jsx(RecipientLine, { label: "To", recipients: message.recipients.filter((r) => r.type !== "cc" && r.type !== "bcc"), context: cardContext }), _jsx(RecipientLine, { label: "Cc", recipients: message.recipients.filter((r) => r.type === "cc"), context: cardContext }), _jsx(RecipientLine, { label: "Bcc", recipients: message.recipients.filter((r) => r.type === "bcc"), context: cardContext })] }), _jsxs("div", { className: "ml-auto flex flex-wrap items-center justify-end gap-1", children: [_jsx("time", { dateTime: message.receivedDate, className: ["text-xs mr-1", dateClass(cardUnread)].join(" "), children: new Date(message.receivedDate).toLocaleString() }), adaptable && (_jsx("button", { type: "button", "aria-label": "View original", "aria-pressed": viewOriginal, title: viewOriginal ? "Follow the theme" : "View original", onClick: () => setViewOriginal(!viewOriginal), className: [
920
935
  "inline-flex items-center justify-center p-1.5 rounded-md border border-border text-sm text-text hover:bg-surface-alt",
921
936
  viewOriginal ? "bg-primary/10" : "",
922
937
  ].join(" "), children: viewOriginal ? _jsx(HiOutlineMoon, { size: 16, "aria-hidden": "true" }) : _jsx(HiOutlineSun, { size: 16, "aria-hidden": "true" }) })), _jsx(IconAction, { icon: _jsx(HiOutlineArrowUturnLeft, { size: 16, "aria-hidden": "true" }), label: "Reply", shortcut: keyboard ? SHORTCUTS.mail.reply : undefined, disabled: preparingCompose, onPrefetch: prefetchReply, onClick: () => void handleReplyOrForward("reply") }), _jsx(IconAction, { icon: _jsxs("span", { className: "inline-flex items-center", "aria-hidden": "true", children: [_jsx(HiOutlineArrowUturnLeft, { size: 16 }), _jsx(HiOutlineArrowUturnLeft, { size: 16, className: "-ml-2.5" })] }), label: "Reply All", shortcut: keyboard ? SHORTCUTS.mail.replyAll : undefined, disabled: preparingCompose, onPrefetch: prefetchReply, onClick: () => void handleReplyOrForward("replyAll") }), _jsx(IconAction, { icon: _jsx(HiOutlineArrowUturnRight, { size: 16, "aria-hidden": "true" }), label: "Forward", shortcut: keyboard ? SHORTCUTS.mail.forward : undefined, disabled: preparingCompose, onPrefetch: prefetchReply, onClick: () => void handleReplyOrForward("forward") }), archivable && (_jsx(IconAction, { icon: _jsx(HiOutlineArchiveBox, { size: 16, "aria-hidden": "true" }), label: "Archive", shortcut: keyboard ? SHORTCUTS.mail.archive : undefined, busy: archiving, disabled: archiving, onClick: handleArchive })), movable && (_jsx(IconAction, { icon: _jsx(HiOutlineFolderArrowDown, { size: 16, "aria-hidden": "true" }), label: "Move to", shortcut: keyboard ? SHORTCUTS.mail.move : undefined, onClick: () => setMovePrompt(true) })), reportable && (_jsx(IconAction, { icon: inJunk ? _jsx(HiOutlineInboxArrowDown, { size: 16, "aria-hidden": "true" }) : _jsx(HiOutlineNoSymbol, { size: 16, "aria-hidden": "true" }), label: inJunk ? "Not junk" : "Report junk", busy: messageActions.busy, disabled: messageActions.busy || reportReason !== undefined, reason: reportReason, onClick: () => void (inJunk ? messageActions.notJunk() : messageActions.reportJunk()) })), labels && labels.length > 0 && (_jsx(LabelMenuButton, { "aria-label": "Labels", label: "Labels", className: "border border-border py-1.5", labels: labels, mailboxUid: message.mailboxUid, onLabelCreated: onLabelCreated, applied: currentLabelsMessage.labelUids ?? [], onCommit: (labelUids) => void handleApplyLabels(labelUids), busy: savingLabels, note: "Ticked labels are applied to this message and unticked ones removed.", emptyNote: "This mailbox has no labels yet.", commit: { label: "Apply" }, clear: { label: "Remove all labels" } })), archivable && (_jsx(MessageMoreMenu, { actions: menuActions, senderAddress: message.from.address, read: currentLabelsMessage.flags.read === true, flagged: currentLabelsMessage.flags.flagged === true, writable: writable, busy: messageActions.busy, inJunk: inJunk, inDeletedItems: inDeletedItems, sent: !!isSentItems, ownSender: readerOwnAddresses.includes(message.from.address.toLowerCase()), canTrustSender: writable, printReason: printReason, composing: preparingCompose, triggerClassName: ICON_BUTTON_CLASS }))] })] }), (answered || forwarded) && (_jsx("p", { className: "px-4 py-1.5 text-xs text-text-muted bg-surface-alt border-y border-border", children: answered && forwarded ? "You replied to and forwarded this message." : answered ? "You replied to this message." : "You forwarded this message." })), _jsxs("div", { id: threadHeader?.bodyId, className: "px-4 pb-3 flex flex-col gap-2", children: [inThread && (subjectDiffers || !sameSubject(shownSubject, threadSubject)) && (_jsx("h3", { className: "text-sm font-semibold break-words", children: shownSubject })), _jsx("div", { className: "flex flex-wrap items-center gap-2 empty:hidden", children: sendingStatus }), inOutbox && message.scheduledSendError && _jsxs(Alert, { children: ["This message wasn\u2019t sent: ", message.scheduledSendError] }), cancelError && _jsx(Alert, { children: cancelError }), keyChangeNotice && (_jsx("p", { role: "status", className: "py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: keyChangeNotice })), security && UNVERIFIED_SIGNER_STATES.has(security.state) && (_jsxs("p", { role: "status", className: "py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: ["Signed, but the signer isn\u2019t a trusted contact key, so the sender isn\u2019t verified.", security.signerEmails && security.signerEmails.length > 0 && _jsxs(_Fragment, { children: [" Certificate for ", security.signerEmails.join(", "), "."] }), security.signerFingerprint && (_jsxs(_Fragment, { children: [" ", "Fingerprint ", _jsx("span", { className: "font-mono text-xs break-all", children: security.signerFingerprint }), "."] }))] })), pendingConflict && (_jsxs("p", { role: "status", className: "py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: ["This sender has a signing key change waiting for your review, so this signer can\u2019t be trusted from here.", " ", _jsx("a", { href: `/contacts/${encodeURIComponent(senderKeyState.contactUid)}`, className: "font-medium text-primary-dark hover:underline", children: "Review it in Contacts" })] })), trustableCertificate && (_jsx("div", { children: _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: trusting, onClick: () => {
@@ -935,7 +950,7 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
935
950
  .map((l) => (_jsxs("span", { className: "inline-flex items-center gap-1.5 text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt", children: [_jsx("span", { className: "w-2 h-2 rounded-full shrink-0", style: { backgroundColor: l.color ?? "#6366f1" } }), l.name] }, l.uid))) })), ["delivery", "read"]
936
951
  .filter((type) => (type === "delivery" ? message.deliveryReceiptPending : message.readReceiptPending))
937
952
  .map((type) => (_jsxs("div", { className: "flex flex-wrap items-center gap-2 py-2 px-3 rounded-sm bg-surface-alt text-sm", children: [_jsxs("span", { children: [senderLabel, " requested a ", type, " receipt for this message."] }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: receiptBusy === type, disabled: receiptBusy !== null, onClick: () => handleReceipt(type, "approve"), children: "Send receipt" }), _jsx(Button, { type: "button", variant: "text", disabled: receiptBusy !== null, onClick: () => handleReceipt(type, "decline"), children: "Decline" })] }, type))), receiptError && _jsx(Alert, { children: receiptError }), mayHoldInvite && _jsx(InviteCard, { messageUid: message.uid, headingLevel: inThread ? 3 : 2 }), security?.state === "signature_failed" && innerAttachments && innerAttachments.length > 0 && (_jsx("p", { role: "status", className: "py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: "These attachments come from a message whose signature couldn\u2019t be verified. Open them only if you trust the sender." })), innerAttachments
938
- ? innerAttachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2", children: innerAttachments.map((attachment, index) => (_jsx("li", { children: _jsx("button", { type: "button", onClick: () => downloadMimeAttachment(attachment), className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark", children: attachment.filename ?? `Unnamed ${attachment.contentType} attachment` }) }, `${index}:${attachment.filename ?? ""}`))) }))
939
- : listedAttachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2", children: listedAttachments.map((attachment) => (_jsx("li", { children: _jsxs("a", { href: attachmentContentUrl(attachment.uid), className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark", children: [attachment.filename, " (", formatBytes(attachment.sizeBytes), ")"] }) }, attachment.uid))) })), _jsx("div", { ref: bodyRegionRef, tabIndex: -1, className: "outline-none", children: message.encrypted && security === null ? (_jsx(BodySkeleton, {})) : security?.decryptError !== undefined ? (_jsx(EncryptedBody, { locked: lockedRef.current, reason: security.decryptError, unlocking: unlocking, onUnlock: () => void handleUnlockToView() })) : (_jsx(MessageBody, { messageUid: message.uid, messageVersion: message.version, title: bodyTitle, content: bodyContent, attachments: attachments, inlineParts: innerAttachments, original: viewOriginal, onAdaptable: setAdaptable })) })] }), showFooter && (_jsxs("div", { className: "flex flex-wrap gap-2 px-4 py-3 border-t border-border print:hidden", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Reply to this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("reply"), children: "Reply" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Reply all to this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("replyAll"), children: "Reply All" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Forward this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("forward"), children: "Forward" })] }))] }));
953
+ ? innerAttachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2", children: innerAttachments.map((attachment, index) => (_jsx("li", { children: isVCardAttachment({ filename: attachment.filename, mimeType: attachment.contentType }) ? (_jsx(VCardAttachmentChip, { label: attachment.filename ?? `Unnamed ${attachment.contentType} attachment`, message: message, loadText: async () => new TextDecoder().decode(attachment.decode() ?? new Uint8Array(0)), onDownload: () => downloadMimeAttachment(attachment) })) : (_jsx("button", { type: "button", onClick: () => downloadMimeAttachment(attachment), className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark", children: attachment.filename ?? `Unnamed ${attachment.contentType} attachment` })) }, `${index}:${attachment.filename ?? ""}`))) }))
954
+ : listedAttachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2", children: listedAttachments.map((attachment) => (_jsx("li", { children: isVCardAttachment(attachment) ? (_jsx(VCardAttachmentChip, { label: `${attachment.filename} (${formatBytes(attachment.sizeBytes)})`, message: message, loadText: () => fetchVCardText(attachment.uid), onDownload: () => downloadFromUrl(attachmentContentUrl(attachment.uid), attachment.filename) })) : (_jsxs("a", { href: attachmentContentUrl(attachment.uid), className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark", children: [attachment.filename, " (", formatBytes(attachment.sizeBytes), ")"] })) }, attachment.uid))) })), _jsx("div", { ref: bodyRegionRef, tabIndex: -1, className: "outline-none", children: message.encrypted && security === null ? (_jsx(BodySkeleton, {})) : security?.decryptError !== undefined ? (_jsx(EncryptedBody, { locked: lockedRef.current, reason: security.decryptError, unlocking: unlocking, onUnlock: () => void handleUnlockToView() })) : (_jsx(MessageBody, { messageUid: message.uid, messageVersion: message.version, title: bodyTitle, content: bodyContent, attachments: attachments, inlineParts: innerAttachments, original: viewOriginal, onAdaptable: setAdaptable })) })] }), showFooter && (_jsxs("div", { className: "flex flex-wrap gap-2 px-4 py-3 border-t border-border print:hidden", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Reply to this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("reply"), children: "Reply" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Reply all to this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("replyAll"), children: "Reply All" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Forward this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("forward"), children: "Forward" })] }))] }));
940
955
  return (_jsxs("div", { className: inThread ? "min-w-0" : "flex-1 min-w-0 min-h-0 flex flex-col h-full", children: [inThread ? (card) : (_jsxs(_Fragment, { children: [backHref && (_jsx("a", { href: backHref, className: "text-sm text-primary-dark hover:underline block px-4 pt-3", children: "\u2190 Back to messages" })), _jsx(SubjectCard, { subject: shownSubject }), _jsx("div", { className: "flex-1 min-h-0 overflow-y-auto p-2 sm:p-3 flex flex-col gap-3", children: card })] })), _jsx(MoveToFolderDialog, { open: movePrompt, onClose: () => setMovePrompt(false), mailboxUid: message.mailboxUid, folders: folders ?? [], currentFolderUid: message.folderUid, onMove: handleMove, onFolderCreated: onFolderCreated }), messageActions.dialog, _jsx(MessageSourceDialog, { open: sourceView !== null, onClose: closeSource, mode: sourceView?.mode ?? "source", source: sourceView?.source ?? { status: "loading" }, encrypted: !!message.encrypted }), _jsxs(Modal, { open: confirming, onClose: () => setConfirming(false), title: "Recall this message?", children: [_jsx("p", { className: "text-sm text-text-muted mb-4", children: "This asks every original recipient's mail system to delete their copy, but only if it's still unread there \u2014 there's no way to guarantee it, and no confirmation once it either succeeds or fails. Recipients who already read the message will keep it." }), error && _jsx(Alert, { children: error }), _jsxs("div", { className: "flex gap-3", children: [_jsx(Button, { type: "button", loading: recalling, disabled: recalling, onClick: handleRecall, className: "!w-auto", children: "Recall message" }), _jsx(Button, { type: "button", variant: "secondary", disabled: recalling, onClick: () => setConfirming(false), className: "!w-auto", children: "Cancel" })] })] }), _jsx(Modal, { open: trustConfirmOpen && !!trustableCertificate, onClose: () => !trusting && setTrustConfirmOpen(false), title: "Trust this signer?", children: _jsxs("div", { className: "flex flex-col gap-3 text-sm", children: [_jsxs("p", { children: ["Mail from ", _jsx("span", { className: "font-medium", children: senderAddress }), " signed with this certificate will show as verified."] }), _jsxs("dl", { className: "flex flex-col gap-1", children: [_jsx("dt", { className: "text-xs text-text-muted", children: "Certificate issued to" }), _jsx("dd", { children: security?.signerEmails?.length ? security.signerEmails.join(", ") : "No email address" }), _jsx("dt", { className: "text-xs text-text-muted", children: "Fingerprint" }), _jsx("dd", { className: "font-mono text-xs break-all", children: security?.signerFingerprint ? formatFingerprint(security.signerFingerprint) : "Unknown" })] }), _jsx("p", { className: "text-text-muted", children: "Anyone can create a certificate naming any address. Before trusting it, confirm this fingerprint with the sender through another channel, such as a phone call." }), trustError && _jsx(Alert, { children: trustError }), _jsxs("div", { className: "flex gap-3", children: [_jsx(Button, { type: "button", className: "!w-auto", loading: trusting, disabled: trusting, onClick: () => handleTrustSigner(senderAddress, trustableCertificate), children: "Trust" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: trusting, onClick: () => setTrustConfirmOpen(false), children: "Cancel" })] })] }) })] }));
941
956
  }
@@ -0,0 +1,18 @@
1
+ import React from "react";
2
+ import type { Message } from "../../../../lib/mail/mailApi.js";
3
+ export interface VCardAttachmentChipProps {
4
+ /** The file's name, as the chip shows it (with the size, when known). */
5
+ label: string;
6
+ /** The message the file is attached to: a new contact goes to its mailbox's address book when that is the user's own. */
7
+ message: Message;
8
+ /** The vCard file's text. */
9
+ loadText: () => Promise<string>;
10
+ /** Saves the file to the device. */
11
+ onDownload: () => void;
12
+ }
13
+ /**
14
+ * A vCard attachment (`.vcf`, `text/vcard`) in a message's attachment list. Clicking it opens a menu - "Add to address book" (every card in the file
15
+ * becomes a contact, except the ones already there) and "Download" - instead of downloading at once. Outside a `ContactCardProvider` there is no
16
+ * address book to add to, and the chip is the plain download it always was.
17
+ */
18
+ export default function VCardAttachmentChip({ label, message, loadText, onDownload }: VCardAttachmentChipProps): React.JSX.Element;
@@ -0,0 +1,53 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { notify } from "../../notifications/store.js";
3
+ import { notifyApiError } from "../../notifications/apiErrors.js";
4
+ import { useContactCard } from "../contacts/ContactCardProvider.js";
5
+ import { AddCardsError } from "../contacts/contactCardData.js";
6
+ import MenuButton from "./MenuButton.js";
7
+ const CHIP_CLASS = "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark";
8
+ /** "1 contact", "3 contacts". */
9
+ function contacts(count) {
10
+ return `${count} ${count === 1 ? "contact" : "contacts"}`;
11
+ }
12
+ /** What adding a vCard file ended with, for the pop-up that says so. */
13
+ function addedMessage(added, skipped) {
14
+ if (added + skipped === 0) {
15
+ return "This file has no contacts in it.";
16
+ }
17
+ const already = skipped === 0 ? "" : `${added === 0 ? "" : " "}${contacts(skipped)} ${skipped === 1 ? "was" : "were"} already in your address book.`;
18
+ return `${added === 0 ? "" : `${contacts(added)} added to your address book.`}${already}`;
19
+ }
20
+ /**
21
+ * A vCard attachment (`.vcf`, `text/vcard`) in a message's attachment list. Clicking it opens a menu - "Add to address book" (every card in the file
22
+ * becomes a contact, except the ones already there) and "Download" - instead of downloading at once. Outside a `ContactCardProvider` there is no
23
+ * address book to add to, and the chip is the plain download it always was.
24
+ */
25
+ export default function VCardAttachmentChip({ label, message, loadText, onDownload }) {
26
+ const { addVCards, available } = useContactCard();
27
+ async function handleAdd() {
28
+ try {
29
+ const { added, skipped } = await addVCards(await loadText(), message);
30
+ notify({
31
+ kind: added + skipped === 0 ? "warning" : "success",
32
+ title: added + skipped === 0 ? "No contacts found" : "Address book updated",
33
+ message: addedMessage(added, skipped),
34
+ });
35
+ }
36
+ catch (err) {
37
+ const stored = err instanceof AddCardsError && err.added > 0 ? ` ${contacts(err.added)} could be added before it failed.` : "";
38
+ notifyApiError(err, `Couldn't add to your address book.${stored}`);
39
+ }
40
+ }
41
+ if (!available) {
42
+ return (_jsx("button", { type: "button", onClick: onDownload, className: CHIP_CLASS, children: label }));
43
+ }
44
+ return (_jsx(MenuButton, { label: label, "aria-label": `${label}, contact card actions`, className: CHIP_CLASS, sections: [
45
+ {
46
+ key: "vcard",
47
+ items: [
48
+ { key: "add", label: "Add to address book", onSelect: () => void handleAdd() },
49
+ { key: "download", label: "Download", onSelect: onDownload },
50
+ ],
51
+ },
52
+ ] }));
53
+ }