@rapidmx/web-client 0.25.1 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +15 -3
  2. package/apps/shared/components/admin/layout/AdminShell.tsx +3 -2
  3. package/apps/shared/components/calendar/EventDetails.tsx +5 -2
  4. package/apps/shared/components/contacts/ContactCard.tsx +356 -0
  5. package/apps/shared/components/contacts/ContactCardProvider.tsx +72 -0
  6. package/apps/shared/components/contacts/ContactDetailPane.tsx +25 -5
  7. package/apps/shared/components/contacts/ContactForm.tsx +36 -5
  8. package/apps/shared/components/contacts/ContactPhotoField.tsx +86 -0
  9. package/apps/shared/components/contacts/FavoriteStarButton.tsx +36 -0
  10. package/apps/shared/components/contacts/ParticipantLink.tsx +42 -0
  11. package/apps/shared/components/contacts/contactCardData.ts +220 -0
  12. package/apps/shared/components/contacts/participantDetails.ts +169 -0
  13. package/apps/shared/components/layout/AppShell.tsx +18 -6
  14. package/apps/shared/components/mail/InviteCard.tsx +27 -5
  15. package/apps/shared/components/mail/MailAddress.tsx +8 -3
  16. package/apps/shared/components/mail/MessageDetailPane.tsx +72 -27
  17. package/apps/shared/components/mail/VCardAttachmentChip.tsx +85 -0
  18. package/apps/shared/components/mail/compose/ComposeWindow.tsx +112 -1
  19. package/apps/shared/components/mail/compose/RecipientInput.tsx +1 -0
  20. package/apps/shared/components/mail/compose/RichTextEditor.tsx +15 -2
  21. package/apps/shared/components/mail/compose/dropFiles.ts +21 -0
  22. package/apps/shared/components/settings/layout/SettingsShell.tsx +3 -3
  23. package/apps/shared/plugins/pluginIcons.ts +124 -0
  24. package/apps/shared/plugins/pluginNav.ts +8 -0
  25. package/apps/shared/plugins/useResolvedRailItems.ts +54 -0
  26. package/apps/www/contacts/[uid].tsx +1 -0
  27. package/apps/www/contacts/index.tsx +3 -1
  28. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
  29. package/dist/apps/shared/components/admin/layout/AdminShell.js +3 -2
  30. package/dist/apps/shared/components/calendar/EventDetails.js +2 -1
  31. package/dist/apps/shared/components/contacts/ContactCard.d.ts +19 -0
  32. package/dist/apps/shared/components/contacts/ContactCard.js +148 -0
  33. package/dist/apps/shared/components/contacts/ContactCardProvider.d.ts +25 -0
  34. package/dist/apps/shared/components/contacts/ContactCardProvider.js +41 -0
  35. package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +3 -1
  36. package/dist/apps/shared/components/contacts/ContactDetailPane.js +17 -2
  37. package/dist/apps/shared/components/contacts/ContactForm.js +20 -2
  38. package/dist/apps/shared/components/contacts/ContactPhotoField.d.ts +21 -0
  39. package/dist/apps/shared/components/contacts/ContactPhotoField.js +46 -0
  40. package/dist/apps/shared/components/contacts/FavoriteStarButton.d.ts +9 -0
  41. package/dist/apps/shared/components/contacts/FavoriteStarButton.js +12 -0
  42. package/dist/apps/shared/components/contacts/ParticipantLink.d.ts +18 -0
  43. package/dist/apps/shared/components/contacts/ParticipantLink.js +16 -0
  44. package/dist/apps/shared/components/contacts/contactCardData.d.ts +69 -0
  45. package/dist/apps/shared/components/contacts/contactCardData.js +158 -0
  46. package/dist/apps/shared/components/contacts/participantDetails.d.ts +64 -0
  47. package/dist/apps/shared/components/contacts/participantDetails.js +129 -0
  48. package/dist/apps/shared/components/layout/AppShell.d.ts +3 -2
  49. package/dist/apps/shared/components/layout/AppShell.js +24 -15
  50. package/dist/apps/shared/components/mail/InviteCard.js +5 -3
  51. package/dist/apps/shared/components/mail/MailAddress.d.ts +5 -2
  52. package/dist/apps/shared/components/mail/MailAddress.js +5 -3
  53. package/dist/apps/shared/components/mail/MessageDetailPane.js +19 -7
  54. package/dist/apps/shared/components/mail/VCardAttachmentChip.d.ts +18 -0
  55. package/dist/apps/shared/components/mail/VCardAttachmentChip.js +53 -0
  56. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +81 -4
  57. package/dist/apps/shared/components/mail/compose/RecipientInput.js +1 -0
  58. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +5 -2
  59. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +10 -1
  60. package/dist/apps/shared/components/mail/compose/dropFiles.d.ts +8 -0
  61. package/dist/apps/shared/components/mail/compose/dropFiles.js +18 -0
  62. package/dist/apps/shared/components/settings/layout/SettingsShell.js +4 -3
  63. package/dist/apps/shared/plugins/pluginIcons.d.ts +10 -0
  64. package/dist/apps/shared/plugins/pluginIcons.js +64 -0
  65. package/dist/apps/shared/plugins/pluginNav.d.ts +8 -0
  66. package/dist/apps/shared/plugins/useResolvedRailItems.d.ts +12 -0
  67. package/dist/apps/shared/plugins/useResolvedRailItems.js +47 -0
  68. package/dist/apps/www/contacts/[uid].js +1 -1
  69. package/dist/apps/www/contacts/index.js +2 -2
  70. package/dist/lib/components/avatar/ContactAvatar.d.ts +9 -3
  71. package/dist/lib/components/avatar/ContactAvatar.js +29 -6
  72. package/dist/lib/components/buttons/CopyIconButton.d.ts +13 -0
  73. package/dist/lib/components/buttons/CopyIconButton.js +16 -0
  74. package/dist/lib/contacts/contactsApi.d.ts +15 -0
  75. package/dist/lib/contacts/contactsApi.js +31 -2
  76. package/dist/lib/contacts/gravatar.d.ts +12 -0
  77. package/dist/lib/contacts/gravatar.js +43 -0
  78. package/dist/lib/mail/directoryApi.d.ts +16 -10
  79. package/dist/lib/mail/directoryApi.js +20 -12
  80. package/lib/components/avatar/ContactAvatar.tsx +43 -7
  81. package/lib/components/buttons/CopyIconButton.tsx +49 -0
  82. package/lib/contacts/contactsApi.ts +35 -2
  83. package/lib/contacts/gravatar.ts +47 -0
  84. package/lib/mail/directoryApi.ts +30 -12
  85. package/package.json +1 -1
@@ -9,7 +9,6 @@ import {
9
9
  HiOutlineCalendarDays,
10
10
  HiOutlineClipboardDocumentList,
11
11
  HiOutlineEnvelope,
12
- HiOutlinePuzzlePiece,
13
12
  HiOutlineUsers,
14
13
  } from "react-icons/hi2";
15
14
  import { useRouter } from "@rapidrest/react/client";
@@ -20,6 +19,7 @@ import { useApiClient } from "../../../../lib/util/apiClientContext.js";
20
19
  import useBranding from "../../../../lib/branding/useBranding.js";
21
20
  import { useIdleKeyTimeout } from "../../../../lib/crypto/useIdleKeyTimeout.js";
22
21
  import ComposeProvider from "../mail/compose/ComposeContext.js";
22
+ import ContactCardProvider from "../contacts/ContactCardProvider.js";
23
23
  import { flushComposeDrafts, markSigningOut } from "../mail/compose/composeFlushRegistry.js";
24
24
  import BottomTabBar, { NavItem } from "../../../../lib/components/navigation/BottomTabBar.js";
25
25
  import { FrameBrandingFooter, FrameBrandingHeader, useBrandingHtml } from "./BrandingChrome.js";
@@ -33,7 +33,9 @@ import { SIGN_OUT_CHANNEL, destroyAllLocalIndexes } from "../../search/localInde
33
33
  import { authApiFetch, setApiUnauthorizedObserver } from "../../../../lib/util/api.js";
34
34
  import { destroyUnlockedKeys } from "../../../../lib/crypto/keySession.js";
35
35
  import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
36
+ import { pluginIcon } from "../../plugins/pluginIcons.js";
36
37
  import { mergePluginNavItems, PluginNav, PluginNavProps } from "../../plugins/pluginNav.js";
38
+ import { type ResolvedRailLinks, useResolvedRailItems } from "../../plugins/useResolvedRailItems.js";
37
39
  import { useInAppFrame } from "../../navigation/frameContext.js";
38
40
  import { APP_HREFS } from "../../navigation/appHrefs.js";
39
41
  import { useNavigate } from "../../navigation/index.js";
@@ -130,12 +132,16 @@ export const APPS: AppDef[] = [
130
132
  * core `active` value. */
131
133
  const RESERVED_APP_IDS = ["settings"];
132
134
 
133
- /** `APPS` followed by the plugins' `appRail` items (generic icon), core ids winning - see `mergePluginNavItems`. */
134
- export function appRailItems(pluginNav?: PluginNav): NavItem[] {
135
+ /** `APPS` followed by the plugins' `appRail` items (their manifest `icon`, or a generic one), core ids winning - see `mergePluginNavItems`. */
136
+ export function appRailItems(pluginNav?: PluginNav, resolved: ResolvedRailLinks = {}): NavItem[] {
137
+ // An entry worked out per user (`resolveFrom`) is there only for a user it was answered for, and links where the answer said; the others are as declared.
138
+ const shown = (pluginNav?.appRail ?? [])
139
+ .filter((item) => !item.resolveFrom || resolved[item.id] !== undefined)
140
+ .map((item) => (item.resolveFrom ? { ...item, href: resolved[item.id] } : item));
135
141
  return mergePluginNavItems<NavItem>(
136
142
  APPS,
137
- pluginNav?.appRail,
138
- ({ id, href, label }) => ({ id, href, label, icon: HiOutlinePuzzlePiece }),
143
+ shown,
144
+ ({ id, href, label, icon }) => ({ id, href, label, icon: pluginIcon(icon) }),
139
145
  RESERVED_APP_IDS,
140
146
  );
141
147
  }
@@ -319,11 +325,14 @@ export function AppChrome({
319
325
  // of it, so a new pathname is what puts the count back. (A folder change is shallow and keeps the title.)
320
326
  useUnreadTitle(inboxUnreadTotal(mail.mailboxFolders, mail.folderCounts.counts), { enabled: inFrame, resetKey: pathname });
321
327
 
328
+ // Plugin rail entries that depend on the user (the meet plugin's personal room) are asked for once they are signed in.
329
+ const resolvedRail = useResolvedRailItems(pluginNav?.appRail, userUid);
330
+
322
331
  if (!userUid) {
323
332
  return <div className="min-h-screen" />;
324
333
  }
325
334
 
326
- const apps = appRailItems(pluginNav);
335
+ const apps = appRailItems(pluginNav, resolvedRail);
327
336
  // The header title: "settings" has no rail item, everything else is labelled by its own rail item.
328
337
  const activeLabel = active === "settings" ? "Settings" : apps.find((app) => app.id === active)?.label;
329
338
  // A custom header (`Branding.headerHtml`) replaces the app's own title bar and the icon at the top of the rail: it is the top of the app, and the
@@ -366,6 +375,8 @@ export function AppChrome({
366
375
  {/* An impersonating admin acts with the impersonated user's access, so their own trusted role
367
376
  mustn't skip the per-mailbox checks. */}
368
377
  <ComposeProvider userUid={userUid} trusted={!!trusted && !impersonating}>
378
+ {/* Inside the compose provider: the contact card's Email action opens a compose window. */}
379
+ <ContactCardProvider userUid={userUid}>
369
380
  <div className="rr-frame-bg min-h-screen flex flex-col">
370
381
  {!hideChrome && customHeader && (
371
382
  <FrameBrandingHeader
@@ -444,6 +455,7 @@ export function AppChrome({
444
455
  </div>
445
456
  {!hideChrome && <FrameBrandingFooter parsed={footer} userMenu={menuInFooter ? renderUserMenu("up") : undefined} appTitle={activeLabel} />}
446
457
  </div>
458
+ </ContactCardProvider>
447
459
  </ComposeProvider>
448
460
  </UnlockPromptProvider>
449
461
  <NotificationHistoryDialog open={historyOpen} onClose={() => setHistoryOpen(false)} />
@@ -18,6 +18,7 @@ import { guestPermissionsOf } from "../../../../lib/calendar/calendarApi.js";
18
18
  import { formatMailAddress } from "../../../../lib/mail/mailAddress.js";
19
19
  import { useApiClient } from "../../../../lib/util/apiClientContext.js";
20
20
  import Button from "../../../../lib/components/buttons/Button.js";
21
+ import ParticipantLink from "../contacts/ParticipantLink.js";
21
22
  import EventDescriptionView from "../calendar/EventDescriptionView.js";
22
23
  import RequestChangeForm from "../calendar/RequestChangeForm.js";
23
24
  import { VISIBILITY_LABEL, describeGuestPermissions } from "../calendar/eventFormat.js";
@@ -70,12 +71,24 @@ const MAX_NAMED_ATTENDEES = 5;
70
71
  /** What the card is doing for the reader right now: an answer being sent, an add/remove, a proposal, an accepted proposal. */
71
72
  type Pending = InviteResponse | "add" | "remove" | "propose" | "acceptProposal";
72
73
 
73
- function attendeeSummary(attendees: InviteParticipant[]): string | undefined {
74
+ function attendeeSummary(attendees: InviteParticipant[]): React.ReactNode {
74
75
  if (attendees.length === 0) {
75
76
  return undefined;
76
77
  }
77
78
  const count = `${attendees.length} ${attendees.length === 1 ? "attendee" : "attendees"}`;
78
- return attendees.length <= MAX_NAMED_ATTENDEES ? `${count}: ${attendees.map((a) => a.displayName || a.address).join(", ")}` : count;
79
+ return attendees.length <= MAX_NAMED_ATTENDEES ? (
80
+ <>
81
+ {count}:{" "}
82
+ {attendees.map((attendee, index) => (
83
+ <React.Fragment key={index}>
84
+ {index > 0 && ", "}
85
+ <ParticipantLink participant={attendee}>{attendee.displayName || attendee.address}</ParticipantLink>
86
+ </React.Fragment>
87
+ ))}
88
+ </>
89
+ ) : (
90
+ count
91
+ );
79
92
  }
80
93
 
81
94
  export interface InviteCardProps {
@@ -150,6 +163,13 @@ export default function InviteCard({ messageUid, headingLevel = 2 }: InviteCardP
150
163
  const visibility = invite.visibility && invite.visibility !== "default" ? VISIBILITY_LABEL[invite.visibility] : undefined;
151
164
  const sender = invite.reply ?? invite.attendees[0];
152
165
  const senderName = sender ? sender.displayName || sender.address : "Someone";
166
+ const senderNode = sender ? (
167
+ <ParticipantLink participant={sender} className="font-semibold">
168
+ {senderName}
169
+ </ParticipantLink>
170
+ ) : (
171
+ <span className="font-semibold">{senderName}</span>
172
+ );
153
173
  const showResponses = invite.canRespond && !invite.isOrganizer;
154
174
  const conflicts = !isReply && !isCounter && method !== "CANCEL" && invite.conflicts.length > 0 ? conflictSummary(invite.conflicts) : undefined;
155
175
  const hasActions =
@@ -170,12 +190,12 @@ export default function InviteCard({ messageUid, headingLevel = 2 }: InviteCardP
170
190
  {isReply && (
171
191
  // Someone answering the reader's own invitation: a line of news, nothing to do.
172
192
  <p className="break-words">
173
- <span className="font-semibold">{senderName}</span> {(sender?.responseStatus && REPLY_VERBS[sender.responseStatus]) ?? "responded"}.
193
+ {senderNode} {(sender?.responseStatus && REPLY_VERBS[sender.responseStatus]) ?? "responded"}.
174
194
  </p>
175
195
  )}
176
196
  {isCounter && !changeRequest && (
177
197
  <p className="break-words">
178
- <span className="font-semibold">{senderName}</span> proposed a new time{when ? <>: {when}</> : "."}
198
+ {senderNode} proposed a new time{when ? <>: {when}</> : "."}
179
199
  </p>
180
200
  )}
181
201
  {changeRequest && (
@@ -220,7 +240,9 @@ export default function InviteCard({ messageUid, headingLevel = 2 }: InviteCardP
220
240
  {invite.organizer && !isCounter && (
221
241
  <>
222
242
  <dt className="text-text-muted">Organizer</dt>
223
- <dd className="break-words">{formatMailAddress(invite.organizer)}</dd>
243
+ <dd className="break-words">
244
+ <ParticipantLink participant={invite.organizer}>{formatMailAddress(invite.organizer)}</ParticipantLink>
245
+ </dd>
224
246
  </>
225
247
  )}
226
248
  {attendees && (!isCounter || !!changeRequest) && (
@@ -4,6 +4,8 @@
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import React, { useState } from "react";
6
6
  import { MailAddressLike, formatMailAddress, splitMailAddress } from "../../../../lib/mail/mailAddress.js";
7
+ import type { ContactCardContext } from "../contacts/contactCardData.js";
8
+ import ParticipantLink from "../contacts/ParticipantLink.js";
7
9
 
8
10
  export interface MailAddressProps {
9
11
  /** A message's `from`, a recipient, a conversation participant. */
@@ -52,9 +54,10 @@ export const RECIPIENT_LINE_LIMIT = 3;
52
54
  /**
53
55
  * A `To`/`Cc` line of a message header: every recipient as `Name <address>` in the text itself (never hover-only), wrapping
54
56
  * onto more lines as needed. A long list shows its first `RECIPIENT_LINE_LIMIT` and folds the rest behind a button -
55
- * "and 12 more" / "Show fewer" - so a message to a hundred people doesn't take the whole header. Nothing when empty.
57
+ * "and 12 more" / "Show fewer" - so a message to a hundred people doesn't take the whole header. Nothing when empty. Each recipient opens their
58
+ * contact card when clicked (see `ParticipantLink`); `context` is the message they are listed on.
56
59
  */
57
- export function RecipientLine({ label, recipients }: { label: string; recipients: MailAddressLike[] }) {
60
+ export function RecipientLine({ label, recipients, context }: { label: string; recipients: MailAddressLike[]; context?: ContactCardContext }) {
58
61
  const [expanded, setExpanded] = useState(false);
59
62
  if (recipients.length === 0) {
60
63
  return null;
@@ -67,7 +70,9 @@ export function RecipientLine({ label, recipients }: { label: string; recipients
67
70
  {shown.map((recipient, index) => (
68
71
  <React.Fragment key={index}>
69
72
  {index > 0 && ", "}
70
- <span>{formatMailAddress(recipient)}</span>
73
+ <ParticipantLink participant={recipient} context={context}>
74
+ {formatMailAddress(recipient)}
75
+ </ParticipantLink>
71
76
  </React.Fragment>
72
77
  ))}
73
78
  {folded && (
@@ -64,6 +64,10 @@ import { loadOriginalMessage, prefetchOriginalMessage } from "./compose/quotedBo
64
64
  import { formatRecipient } from "./compose/recipients.js";
65
65
  import { formatMailAddress } from "../../../../lib/mail/mailAddress.js";
66
66
  import MailAddress, { RecipientLine } from "./MailAddress.js";
67
+ import ParticipantLink from "../contacts/ParticipantLink.js";
68
+ import { fetchVCardText } from "../contacts/contactCardData.js";
69
+ import { isVCardAttachment } from "../contacts/participantDetails.js";
70
+ import VCardAttachmentChip from "./VCardAttachmentChip.js";
67
71
  import { useMailShell } from "./layout/MailShell.js";
68
72
  import { ariaKeyShortcuts, withHint } from "../../keyboard/format.js";
69
73
  import { SHORTCUTS, ShortcutDef } from "../../keyboard/keymap.js";
@@ -186,6 +190,16 @@ export function formatFingerprint(fingerprint: string): string {
186
190
  return compact.match(/.{1,4}/g)?.join(" ") ?? compact;
187
191
  }
188
192
 
193
+ /** Saves the file at `url` under `filename`: the browser is handed the link as a download. */
194
+ function downloadFromUrl(url: string, filename: string): void {
195
+ const a = document.createElement("a");
196
+ a.href = url;
197
+ a.download = filename;
198
+ document.body.appendChild(a);
199
+ a.click();
200
+ a.remove();
201
+ }
202
+
189
203
  /** Saves one attachment recovered from inside a signed/encrypted entity. Always handed to the browser as an
190
204
  * opaque download (`application/octet-stream`), never rendered in this origin. */
191
205
  function downloadMimeAttachment(attachment: MimeAttachment): void {
@@ -1030,6 +1044,8 @@ function MessageDetailContent({
1030
1044
  const senderNameCheck = checkSenderName(senderName, senderAddress);
1031
1045
  // Always `Name <address>` (or the bare address): a name alone hides who a message is really from.
1032
1046
  const senderLabel = formatMailAddress({ displayName: senderName, address: senderAddress });
1047
+ // What the contact card of anyone named in this message is given: the message, for its mailbox and any vCard it carries.
1048
+ const cardContext = { message, attachments };
1033
1049
  // Offered only for a valid signature from a certificate nobody pinned for this sender - never to replace a pin.
1034
1050
  // A recorded signing-key conflict for this sender is resolved from the contact, never by trusting another key.
1035
1051
  const pendingConflict = senderUnpinned && senderKeyState?.conflict !== undefined;
@@ -1231,26 +1247,36 @@ function MessageDetailContent({
1231
1247
  <div className="flex-1 min-w-[12rem]">
1232
1248
  <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
1233
1249
  {threadHeader ? (
1234
- // The sender line of a message in a thread is the button that collapses it again.
1235
- <h2 className="min-w-0 flex-1" style={BODY_FONT_STYLE}>
1250
+ // The sender line of a message in a thread collapses it again. A button cannot hold another, so the collapse button is laid
1251
+ // under the line (it fills the heading) and the sender's name, which opens their contact card, sits over it: a click anywhere
1252
+ // else on the line still reaches the collapse button.
1253
+ <h2 className="relative min-w-0 flex-1" style={BODY_FONT_STYLE}>
1236
1254
  <button
1237
1255
  type="button"
1238
1256
  ref={threadHeader.buttonRef}
1239
1257
  onClick={threadHeader.onToggle}
1240
1258
  aria-expanded={true}
1241
1259
  aria-controls={threadHeader.bodyId}
1242
- className={[
1243
- "w-full text-left rounded-sm text-sm",
1244
- ROW_FOCUS_CLASS,
1245
- ].join(" ")}
1246
- >
1247
- <UnreadLabel unread={cardUnread} />
1248
- <MailAddress recipient={senderRecipient} className={senderClass(cardUnread)} />
1249
- </button>
1260
+ aria-label={`${cardUnread ? "Unread. " : ""}${senderLabel}`}
1261
+ className={["absolute inset-0 w-full rounded-sm", ROW_FOCUS_CLASS].join(" ")}
1262
+ />
1263
+ <span className="pointer-events-none relative flex text-sm">
1264
+ <ParticipantLink
1265
+ participant={{ address: senderAddress, displayName: senderName }}
1266
+ context={cardContext}
1267
+ label={`Contact card for ${senderLabel}`}
1268
+ className="pointer-events-auto min-w-0 max-w-full"
1269
+ >
1270
+ <MailAddress recipient={senderRecipient} className={senderClass(cardUnread)} />
1271
+ </ParticipantLink>
1272
+ </span>
1250
1273
  </h2>
1251
1274
  ) : (
1252
1275
  <p className="text-sm break-words min-w-0">
1253
- From <span className="font-semibold text-text">{senderLabel}</span>
1276
+ From{" "}
1277
+ <ParticipantLink participant={{ address: senderAddress, displayName: senderName }} context={cardContext} className="font-semibold text-text">
1278
+ {senderLabel}
1279
+ </ParticipantLink>
1254
1280
  </p>
1255
1281
  )}
1256
1282
  {security && <SecurityIndicator security={security} />}
@@ -1262,9 +1288,9 @@ function MessageDetailContent({
1262
1288
  </p>
1263
1289
  )}
1264
1290
  {/* Every recipient with their address, grouped as the sender addressed them; a long list folds. */}
1265
- <RecipientLine label="To" recipients={message.recipients.filter((r) => r.type !== "cc" && r.type !== "bcc")} />
1266
- <RecipientLine label="Cc" recipients={message.recipients.filter((r) => r.type === "cc")} />
1267
- <RecipientLine label="Bcc" recipients={message.recipients.filter((r) => r.type === "bcc")} />
1291
+ <RecipientLine label="To" recipients={message.recipients.filter((r) => r.type !== "cc" && r.type !== "bcc")} context={cardContext} />
1292
+ <RecipientLine label="Cc" recipients={message.recipients.filter((r) => r.type === "cc")} context={cardContext} />
1293
+ <RecipientLine label="Bcc" recipients={message.recipients.filter((r) => r.type === "bcc")} context={cardContext} />
1268
1294
  </div>
1269
1295
  <div className="ml-auto flex flex-wrap items-center justify-end gap-1">
1270
1296
  <time dateTime={message.receivedDate} className={["text-xs mr-1", dateClass(cardUnread)].join(" ")}>
@@ -1582,13 +1608,22 @@ function MessageDetailContent({
1582
1608
  <ul className="flex flex-wrap gap-2">
1583
1609
  {innerAttachments.map((attachment, index) => (
1584
1610
  <li key={`${index}:${attachment.filename ?? ""}`}>
1585
- <button
1586
- type="button"
1587
- onClick={() => downloadMimeAttachment(attachment)}
1588
- className="text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark"
1589
- >
1590
- {attachment.filename ?? `Unnamed ${attachment.contentType} attachment`}
1591
- </button>
1611
+ {isVCardAttachment({ filename: attachment.filename, mimeType: attachment.contentType }) ? (
1612
+ <VCardAttachmentChip
1613
+ label={attachment.filename ?? `Unnamed ${attachment.contentType} attachment`}
1614
+ message={message}
1615
+ loadText={async () => new TextDecoder().decode(attachment.decode() ?? new Uint8Array(0))}
1616
+ onDownload={() => downloadMimeAttachment(attachment)}
1617
+ />
1618
+ ) : (
1619
+ <button
1620
+ type="button"
1621
+ onClick={() => downloadMimeAttachment(attachment)}
1622
+ className="text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark"
1623
+ >
1624
+ {attachment.filename ?? `Unnamed ${attachment.contentType} attachment`}
1625
+ </button>
1626
+ )}
1592
1627
  </li>
1593
1628
  ))}
1594
1629
  </ul>
@@ -1597,12 +1632,22 @@ function MessageDetailContent({
1597
1632
  <ul className="flex flex-wrap gap-2">
1598
1633
  {listedAttachments.map((attachment) => (
1599
1634
  <li key={attachment.uid}>
1600
- <a
1601
- href={attachmentContentUrl(attachment.uid)}
1602
- className="text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark"
1603
- >
1604
- {attachment.filename} ({formatBytes(attachment.sizeBytes)})
1605
- </a>
1635
+ {isVCardAttachment(attachment) ? (
1636
+ // A contact card opens a menu (add it to the address book, or download it) instead of downloading at once.
1637
+ <VCardAttachmentChip
1638
+ label={`${attachment.filename} (${formatBytes(attachment.sizeBytes)})`}
1639
+ message={message}
1640
+ loadText={() => fetchVCardText(attachment.uid)}
1641
+ onDownload={() => downloadFromUrl(attachmentContentUrl(attachment.uid), attachment.filename)}
1642
+ />
1643
+ ) : (
1644
+ <a
1645
+ href={attachmentContentUrl(attachment.uid)}
1646
+ className="text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark"
1647
+ >
1648
+ {attachment.filename} ({formatBytes(attachment.sizeBytes)})
1649
+ </a>
1650
+ )}
1606
1651
  </li>
1607
1652
  ))}
1608
1653
  </ul>
@@ -0,0 +1,85 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import type { Message } from "../../../../lib/mail/mailApi.js";
7
+ import { notify } from "../../notifications/store.js";
8
+ import { notifyApiError } from "../../notifications/apiErrors.js";
9
+ import { useContactCard } from "../contacts/ContactCardProvider.js";
10
+ import { AddCardsError } from "../contacts/contactCardData.js";
11
+ import MenuButton from "./MenuButton.js";
12
+
13
+ const CHIP_CLASS = "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark";
14
+
15
+ /** "1 contact", "3 contacts". */
16
+ function contacts(count: number): string {
17
+ return `${count} ${count === 1 ? "contact" : "contacts"}`;
18
+ }
19
+
20
+ /** What adding a vCard file ended with, for the pop-up that says so. */
21
+ function addedMessage(added: number, skipped: number): string {
22
+ if (added + skipped === 0) {
23
+ return "This file has no contacts in it.";
24
+ }
25
+ const already = skipped === 0 ? "" : `${added === 0 ? "" : " "}${contacts(skipped)} ${skipped === 1 ? "was" : "were"} already in your address book.`;
26
+ return `${added === 0 ? "" : `${contacts(added)} added to your address book.`}${already}`;
27
+ }
28
+
29
+ export interface VCardAttachmentChipProps {
30
+ /** The file's name, as the chip shows it (with the size, when known). */
31
+ label: string;
32
+ /** The message the file is attached to: a new contact goes to its mailbox's address book when that is the user's own. */
33
+ message: Message;
34
+ /** The vCard file's text. */
35
+ loadText: () => Promise<string>;
36
+ /** Saves the file to the device. */
37
+ onDownload: () => void;
38
+ }
39
+
40
+ /**
41
+ * 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
42
+ * becomes a contact, except the ones already there) and "Download" - instead of downloading at once. Outside a `ContactCardProvider` there is no
43
+ * address book to add to, and the chip is the plain download it always was.
44
+ */
45
+ export default function VCardAttachmentChip({ label, message, loadText, onDownload }: VCardAttachmentChipProps) {
46
+ const { addVCards, available } = useContactCard();
47
+
48
+ async function handleAdd() {
49
+ try {
50
+ const { added, skipped } = await addVCards(await loadText(), message);
51
+ notify({
52
+ kind: added + skipped === 0 ? "warning" : "success",
53
+ title: added + skipped === 0 ? "No contacts found" : "Address book updated",
54
+ message: addedMessage(added, skipped),
55
+ });
56
+ } catch (err) {
57
+ const stored = err instanceof AddCardsError && err.added > 0 ? ` ${contacts(err.added)} could be added before it failed.` : "";
58
+ notifyApiError(err, `Couldn't add to your address book.${stored}`);
59
+ }
60
+ }
61
+
62
+ if (!available) {
63
+ return (
64
+ <button type="button" onClick={onDownload} className={CHIP_CLASS}>
65
+ {label}
66
+ </button>
67
+ );
68
+ }
69
+ return (
70
+ <MenuButton
71
+ label={label}
72
+ aria-label={`${label}, contact card actions`}
73
+ className={CHIP_CLASS}
74
+ sections={[
75
+ {
76
+ key: "vcard",
77
+ items: [
78
+ { key: "add", label: "Add to address book", onSelect: () => void handleAdd() },
79
+ { key: "download", label: "Download", onSelect: onDownload },
80
+ ],
81
+ },
82
+ ]}
83
+ />
84
+ );
85
+ }
@@ -2,11 +2,13 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- import React, { ChangeEvent, useEffect, useRef, useState } from "react";
5
+ import React, { ChangeEvent, DragEvent, useEffect, useRef, useState } from "react";
6
+ import type { Editor } from "@tiptap/react";
6
7
  import {
7
8
  HiOutlineArrowsPointingIn,
8
9
  HiOutlineArrowsPointingOut,
9
10
  HiOutlineChevronDown,
11
+ HiOutlineIdentification,
10
12
  HiOutlineLockClosed,
11
13
  HiOutlineMinus,
12
14
  HiOutlinePaperClip,
@@ -37,8 +39,10 @@ import { resolveRecipientEncryption, RecipientEncryptionStatus } from "../../../
37
39
  import { getUnlockedKeys, subscribeKeySession } from "../../../../../lib/crypto/keySession.js";
38
40
  import { EncryptionPolicy, findActivePublicKey, getEncryptionPolicy, lookupKeys } from "../../../../../lib/crypto/keyvaultApi.js";
39
41
  import { useUnlockPrompt } from "../../layout/UnlockPromptProvider.js";
42
+ import { buildOwnCardFile } from "../../contacts/contactCardData.js";
40
43
  import useIsMobile from "../../../../../lib/util/useIsMobile.js";
41
44
  import { notify } from "../../../notifications/store.js";
45
+ import { isFileDrag, isInlineImage } from "./dropFiles.js";
42
46
  import { evaluateEncryptionRequirement } from "./encryptionRequirement.js";
43
47
  import { decideSend, SendBlock } from "../../../mail/outbox/sendDecision.js";
44
48
  import { SendRequest, sendDecisionInput, startSend } from "../../../mail/outbox/sendJob.js";
@@ -250,7 +254,14 @@ export default function ComposeWindow({
250
254
  const [html, setHtml] = useState(resume?.html ?? "");
251
255
  const [contentReady, setContentReady] = useState(!!resume);
252
256
  const [attachments, setAttachments] = useState<Attachment[]>(resume?.attachments ?? []);
257
+ // The sender's own contact card is on the message (or on its way): "Attach my contact card" then has nothing more to do.
258
+ const [ownCardAttached, setOwnCardAttached] = useState(false);
253
259
  const [attachError, setAttachError] = useState<string | null>(null);
260
+ // The message editor, for putting a dropped picture into the text; and whether files are being dragged over the window right now.
261
+ const editorRef = useRef<Editor | null>(null);
262
+ const [dropActive, setDropActive] = useState(false);
263
+ // Dragging over a child fires leave on the parent, then enter on the child: only when the count is back to zero has the drag left the window.
264
+ const dragDepthRef = useRef(0);
254
265
  // A problem with the message as composed (no recipient, an attachment that can't be signed): shown here, and the window stays open. What
255
266
  // goes wrong after Send was accepted is a pop-up instead (see `sendJob.ts`).
256
267
  const [sendError, setSendError] = useState<string | null>(null);
@@ -761,6 +772,12 @@ export default function ComposeWindow({
761
772
  const files = Array.from(e.target.files ?? []);
762
773
  e.target.value = "";
763
774
  setAttachError(null);
775
+ await attachFiles(files);
776
+ }
777
+
778
+ /** Uploads each of `files` as an attachment on the draft, one after another, saying why any that could not be. Resolves whether they all were. */
779
+ async function attachFiles(files: File[]): Promise<boolean> {
780
+ let allAttached = true;
764
781
  uploadStarted();
765
782
  try {
766
783
  for (const file of files) {
@@ -770,12 +787,81 @@ export default function ComposeWindow({
770
787
  setAttachments((prev) => [...prev, attachment]);
771
788
  void refreshDraftVersion(draft!.uid);
772
789
  } catch (err) {
790
+ allAttached = false;
773
791
  setAttachError(err instanceof ApiRequestError ? err.message : "Could not upload attachment.");
774
792
  }
775
793
  }
776
794
  } finally {
777
795
  uploadFinished();
778
796
  }
797
+ return allAttached;
798
+ }
799
+
800
+ /** Attaches the sender's own vCard ("Attach my contact card"), once: the button is off from the click on, and back on if the upload failed. */
801
+ async function handleAttachOwnCard() {
802
+ setOwnCardAttached(true);
803
+ setAttachError(null);
804
+ const file = await buildOwnCardFile(mailbox as Mailbox, mailboxes, userUid, client);
805
+ if (!(await attachFiles([file]))) {
806
+ setOwnCardAttached(false);
807
+ }
808
+ }
809
+
810
+ /**
811
+ * Files dropped on the window: a picture goes into the message, at the caret, the way "Insert image" would put it; anything else is attached,
812
+ * as "Attach files" would. Done in the order they were dropped.
813
+ */
814
+ async function handleDroppedFiles(files: File[]) {
815
+ if (!draft) {
816
+ setAttachError("Please wait for the draft to finish loading before adding files.");
817
+ return;
818
+ }
819
+ setAttachError(null);
820
+ const toAttach: File[] = [];
821
+ for (const file of files) {
822
+ if (!isInlineImage(file)) {
823
+ toAttach.push(file);
824
+ continue;
825
+ }
826
+ const url = await handleUploadImage(file);
827
+ if (url) {
828
+ editorRef.current?.chain().focus().setImage({ src: url, alt: file.name }).run();
829
+ }
830
+ }
831
+ await attachFiles(toAttach);
832
+ }
833
+
834
+ function handleDragEnter(e: DragEvent) {
835
+ if (isFileDrag(e.dataTransfer)) {
836
+ dragDepthRef.current += 1;
837
+ setDropActive(true);
838
+ }
839
+ }
840
+
841
+ function handleDragOver(e: DragEvent) {
842
+ if (isFileDrag(e.dataTransfer)) {
843
+ // What makes this a place a file may be dropped (otherwise the browser opens it instead).
844
+ e.preventDefault();
845
+ e.dataTransfer.dropEffect = "copy";
846
+ }
847
+ }
848
+
849
+ function handleDragLeave(e: DragEvent) {
850
+ if (isFileDrag(e.dataTransfer)) {
851
+ dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
852
+ if (dragDepthRef.current === 0) {
853
+ setDropActive(false);
854
+ }
855
+ }
856
+ }
857
+
858
+ function handleDrop(e: DragEvent) {
859
+ if (isFileDrag(e.dataTransfer)) {
860
+ e.preventDefault();
861
+ dragDepthRef.current = 0;
862
+ setDropActive(false);
863
+ void handleDroppedFiles(Array.from(e.dataTransfer.files));
864
+ }
779
865
  }
780
866
 
781
867
  /**
@@ -1372,6 +1458,10 @@ export default function ComposeWindow({
1372
1458
  role="dialog"
1373
1459
  data-shortcut-scope="compose"
1374
1460
  aria-labelledby={titleId}
1461
+ onDragEnter={handleDragEnter}
1462
+ onDragOver={handleDragOver}
1463
+ onDragLeave={handleDragLeave}
1464
+ onDrop={handleDrop}
1375
1465
  style={!isMobile && manualSize ? { width: manualSize.width, height: manualSize.height } : undefined}
1376
1466
  className={[
1377
1467
  "shrink-0 flex flex-col bg-surface border border-border shadow-modal overflow-hidden",
@@ -1382,6 +1472,15 @@ export default function ComposeWindow({
1382
1472
  : ["relative border-b-0 rounded-t-md", manualSize ? "" : expanded ? "w-[720px] h-[85vh]" : "w-[480px] h-[520px]"].join(" "),
1383
1473
  ].join(" ")}
1384
1474
  >
1475
+ {dropActive && (
1476
+ <div
1477
+ data-drop-overlay=""
1478
+ className="pointer-events-none absolute inset-0 z-30 flex flex-col items-center justify-center gap-1 border-2 border-dashed border-primary bg-primary/10 text-center text-primary-dark"
1479
+ >
1480
+ <span className="text-base font-semibold">Drop files to attach them</span>
1481
+ <span className="text-sm">Pictures go into the message where the cursor is</span>
1482
+ </div>
1483
+ )}
1385
1484
  {!isMobile && (
1386
1485
  <>
1387
1486
  <div
@@ -1590,6 +1689,7 @@ export default function ComposeWindow({
1590
1689
  onUploadImage={handleUploadImage}
1591
1690
  autoFocusStart={focusBodyOnMount}
1592
1691
  onInitialized={handleEditorInitialized}
1692
+ editorRef={editorRef}
1593
1693
  />
1594
1694
  )}
1595
1695
  </div>
@@ -1685,6 +1785,17 @@ export default function ComposeWindow({
1685
1785
  <HiOutlinePaperClip size={18} />
1686
1786
  <input type="file" multiple disabled={!draft} onChange={handleFilesSelected} className="sr-only" />
1687
1787
  </label>
1788
+ <button
1789
+ type="button"
1790
+ aria-label="Attach my contact card"
1791
+ title={ownCardAttached ? "Your contact card is attached" : "Attach my contact card"}
1792
+ aria-pressed={ownCardAttached}
1793
+ disabled={!draft || !mailbox || ownCardAttached}
1794
+ onClick={() => void handleAttachOwnCard()}
1795
+ className="w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:not-disabled:bg-surface-alt hover:not-disabled:text-text disabled:opacity-40 disabled:cursor-not-allowed"
1796
+ >
1797
+ <HiOutlineIdentification size={18} />
1798
+ </button>
1688
1799
 
1689
1800
  <span role="status" aria-live="polite" className="ml-auto text-xs text-text-muted">
1690
1801
  {waitingForUploads ? "Waiting for attachments to finish uploading…" : SAVE_STATUS_LABEL[saveStatus]}
@@ -24,6 +24,7 @@ const KIND_LABELS: Record<RecipientSuggestionKind, string> = {
24
24
  room: "Room",
25
25
  equipment: "Equipment",
26
26
  list: "Group",
27
+ correspondent: "Recent",
27
28
  };
28
29
 
29
30
  export interface RecipientInputProps {