@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
@@ -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;
@@ -1227,7 +1243,14 @@ function MessageDetailContent({
1227
1243
  <CardShell unread={cardUnread}>
1228
1244
  {/* Header row: who it is from, when, and what can be done with it. Wraps: on a phone the actions drop under the sender. */}
1229
1245
  <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(" ")}>
1230
- <SenderAvatar from={senderRecipient} />
1246
+ {threadHeader ? (
1247
+ // The sender line of a thread's message is the button that collapses it, so its contact card is opened from the avatar beside it.
1248
+ <ParticipantLink participant={{ address: senderAddress, displayName: senderName }} context={cardContext} label={`Contact card for ${senderLabel}`}>
1249
+ <SenderAvatar from={senderRecipient} />
1250
+ </ParticipantLink>
1251
+ ) : (
1252
+ <SenderAvatar from={senderRecipient} />
1253
+ )}
1231
1254
  <div className="flex-1 min-w-[12rem]">
1232
1255
  <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
1233
1256
  {threadHeader ? (
@@ -1250,7 +1273,10 @@ function MessageDetailContent({
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 {
@@ -2,8 +2,8 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- import React, { useEffect, useRef, useState } from "react";
6
- import { EditorContent, useEditor } from "@tiptap/react";
5
+ import React, { MutableRefObject, useEffect, useRef, useState } from "react";
6
+ import { type Editor, EditorContent, useEditor } from "@tiptap/react";
7
7
  import StarterKit from "@tiptap/starter-kit";
8
8
  import { TextStyleKit } from "@tiptap/extension-text-style";
9
9
  import TextAlign from "@tiptap/extension-text-align";
@@ -40,6 +40,8 @@ export interface RichTextEditorProps {
40
40
  appendHtml?: string;
41
41
  /** Called with the document as the editor serializes it, right after `appendHtml` was added. */
42
42
  onAppended?: (value: string) => void;
43
+ /** Set to the editor while it exists (and to `null` before and after), for a caller that has to put content into the message from outside the toolbar - a picture dropped on the window. */
44
+ editorRef?: MutableRefObject<Editor | null>;
43
45
  }
44
46
 
45
47
  /**
@@ -73,6 +75,7 @@ export default function RichTextEditor({
73
75
  onInitialized,
74
76
  appendHtml,
75
77
  onAppended,
78
+ editorRef,
76
79
  }: RichTextEditorProps) {
77
80
  const onChangeRef = useRef(onChange);
78
81
  onChangeRef.current = onChange;
@@ -119,6 +122,16 @@ export default function RichTextEditor({
119
122
  },
120
123
  });
121
124
 
125
+ useEffect(() => {
126
+ if (!editorRef) {
127
+ return undefined;
128
+ }
129
+ editorRef.current = editor;
130
+ return () => {
131
+ editorRef.current = null;
132
+ };
133
+ }, [editor, editorRef]);
134
+
122
135
  // Added once, at the end, whatever the reader has typed meanwhile (the caret stays where it is).
123
136
  useEffect(() => {
124
137
  if (!editor || appendHtml === undefined || appendedRef.current === appendHtml) {
@@ -0,0 +1,21 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+
6
+ /** Picture files by name, for a file the browser gave no type to. */
7
+ const IMAGE_EXTENSION = /\.(jpe?g|png|gif|webp|svg|avif|bmp|ico|apng)$/i;
8
+
9
+ /**
10
+ * Whether a file dropped on the compose window goes into the message itself, at the caret, rather than onto it as an attachment: pictures do (a
11
+ * photo or a screenshot is meant to be seen in the text), everything else is attached. The browser's own idea of the type decides, and the file's name
12
+ * does when it had none.
13
+ */
14
+ export function isInlineImage(file: Pick<File, "name" | "type">): boolean {
15
+ return file.type ? file.type.startsWith("image/") : IMAGE_EXTENSION.test(file.name);
16
+ }
17
+
18
+ /** Whether a drag carries files from outside the page (as opposed to the text or an image being dragged around inside the editor). */
19
+ export function isFileDrag(dataTransfer: Pick<DataTransfer, "types"> | null | undefined): boolean {
20
+ return Array.from(dataTransfer?.types ?? []).includes("Files");
21
+ }
@@ -16,6 +16,7 @@ import { describeTimeZone, deviceTimeZone } from "../../../../lib/util/timeZone.
16
16
  import Alert from "../../../../lib/components/feedback/Alert.js";
17
17
  import Button from "../../../../lib/components/buttons/Button.js";
18
18
  import { joinMeetingUrl } from "../../calendar/calendarReminders.js";
19
+ import ParticipantLink from "../contacts/ParticipantLink.js";
19
20
  import EventDescriptionView from "./EventDescriptionView.js";
20
21
  import RequestChangeForm from "./RequestChangeForm.js";
21
22
  import { BUSY_STATUS_LABEL, RESPONSE_STATUS_LABEL, VISIBILITY_LABEL, describeGuestPermissions, describeReminder, formatStoredWhen, formatStoredWhenInZone } from "./eventFormat.js";
@@ -121,7 +122,7 @@ export default function EventDetails({ occurrence, isInvited, myResponse, isOwnA
121
122
  { response: "tentative", label: "Tentative" },
122
123
  { response: "declined", label: "Decline" },
123
124
  ];
124
- return (_jsxs("div", { className: "flex flex-col", children: [_jsxs("div", { className: "flex items-start gap-3 px-5 pt-4", children: [_jsx("span", { "aria-hidden": "true", className: "w-3.5 h-3.5 rounded-full shrink-0 mt-2", style: { backgroundColor: calendarColor } }), _jsx("h2", { className: "flex-1 min-w-0 text-xl font-semibold break-words", children: occurrence.title || "(no title)" }), _jsx("button", { type: "button", "aria-label": "Close details", onClick: onClose, className: "w-8 h-8 shrink-0 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text", children: _jsx(HiOutlineXMark, { size: 20, "aria-hidden": "true" }) })] }), _jsxs("div", { className: "flex flex-col gap-3 px-5 pt-3 pb-4 text-sm", children: [error && _jsx(Alert, { children: error }), _jsxs(DetailRow, { icon: _jsx(HiOutlineClock, { size: 18 }), children: [!occurrence.allDay && eventZone && eventZone !== deviceTimeZone() ? (_jsxs(_Fragment, { children: [_jsxs("div", { children: [formatStoredWhen(occurrence.startDate, occurrence.endDate, false), " \u2022 ", describeTimeZone(deviceTimeZone(), new Date(occurrence.startDate))] }), _jsxs("div", { className: "text-text-muted", children: [formatStoredWhenInZone(occurrence.startDate, occurrence.endDate, eventZone), " \u2022 ", describeTimeZone(eventZone, new Date(occurrence.startDate))] })] })) : (_jsx("div", { children: formatStoredWhen(occurrence.startDate, occurrence.endDate, occurrence.allDay) })), occurrence.recurrenceRule && _jsxs("div", { className: "text-text-muted", children: ["Repeats ", describeRecurrence(occurrence.recurrenceRule)] })] }), occurrence.location && (_jsx(DetailRow, { icon: _jsx(HiOutlineMapPin, { size: 18 }), children: _jsx("div", { children: occurrence.location }) })), hasDescription && (_jsx(DetailRow, { icon: _jsx(HiOutlineBars3BottomLeft, { size: 18 }), children: _jsx(EventDescriptionView, { html: occurrence.descriptionHtml, text: occurrence.description }) })), videoMeetingUid && (_jsx(DetailRow, { icon: _jsx(HiOutlineVideoCamera, { size: 18 }), children: _jsxs("div", { className: "flex items-center gap-3 flex-wrap", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: !joinUrl, onClick: () => window.open(joinUrl, "_blank", "noopener,noreferrer"), children: "Join video call" }), !joinUrl && (_jsx("span", { className: "text-xs text-text-muted", children: organizerJoinUrl === undefined ? "Loading the join link…" : "This meeting’s join link isn’t available." }))] }) })), _jsxs(DetailRow, { icon: _jsx(HiOutlineUser, { size: 18 }), children: [_jsx("div", { children: organizerName || occurrence.organizer.address }), _jsxs("div", { className: "text-text-muted", children: [organizerName ? `${occurrence.organizer.address} · ` : "", "Organizer"] })] }), (guests.length > 0 || hiddenList) && (_jsxs(DetailRow, { icon: _jsx(HiOutlineUserGroup, { size: 18 }), children: [_jsx("div", { className: "font-medium mb-1", children: hiddenList ? "The organizer has hidden the guest list" : guests.length === 1 ? "1 guest" : `${guests.length} guests` }), _jsx("ul", { className: "flex flex-col gap-1", children: guests.map((guest, i) => (_jsxs("li", { className: "flex items-baseline justify-between gap-3", children: [_jsxs("span", { className: "min-w-0 truncate", children: [guest.displayName || guest.address, ROLE_LABEL[guest.role] && _jsxs("span", { className: "text-text-muted", children: [" (", ROLE_LABEL[guest.role], ")"] })] }), _jsx("span", { className: "shrink-0 text-xs text-text-muted", children: RESPONSE_STATUS_LABEL[guest.responseStatus] })] }, i))) }), !isInvited && _jsx("div", { className: "text-xs text-text-muted mt-1", children: describeGuestPermissions(permissions) })] })), _jsxs(DetailRow, { icon: _jsx(HiOutlineBriefcase, { size: 18 }), children: [_jsx("div", { children: BUSY_STATUS_LABEL[occurrence.busyStatus] }), _jsx("div", { className: "text-text-muted", children: VISIBILITY_LABEL[visibilityOf(occurrence)] }), occurrence.autoReplyEnabled && (_jsx("div", { className: "text-text-muted", children: "Sends an automatic reply while this event is happening" }))] }), _jsx(DetailRow, { icon: _jsx(HiOutlineBell, { size: 18 }), children: _jsx("div", { children: describeReminder(occurrence.reminderMinutesBeforeStart) }) }), calendarName && (_jsxs("div", { className: "flex items-center gap-3 text-text-muted", children: [_jsx("span", { "aria-hidden": "true", className: "w-3 h-3 ml-[3px] rounded-full shrink-0", style: { backgroundColor: calendarColor } }), _jsx("span", { className: "truncate", children: calendarName })] })), isInvited && (_jsxs("p", { className: "text-xs text-text-muted", children: ["You were invited to this event. Only the organizer can change its details", canChange || canInvite ? ", but you can ask them to." : "."] }))] }), requesting && (_jsx("div", { className: "mx-5 mb-3 p-3 rounded-md bg-surface-alt", children: _jsx(RequestChangeForm, { subject: {
125
+ return (_jsxs("div", { className: "flex flex-col", children: [_jsxs("div", { className: "flex items-start gap-3 px-5 pt-4", children: [_jsx("span", { "aria-hidden": "true", className: "w-3.5 h-3.5 rounded-full shrink-0 mt-2", style: { backgroundColor: calendarColor } }), _jsx("h2", { className: "flex-1 min-w-0 text-xl font-semibold break-words", children: occurrence.title || "(no title)" }), _jsx("button", { type: "button", "aria-label": "Close details", onClick: onClose, className: "w-8 h-8 shrink-0 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text", children: _jsx(HiOutlineXMark, { size: 20, "aria-hidden": "true" }) })] }), _jsxs("div", { className: "flex flex-col gap-3 px-5 pt-3 pb-4 text-sm", children: [error && _jsx(Alert, { children: error }), _jsxs(DetailRow, { icon: _jsx(HiOutlineClock, { size: 18 }), children: [!occurrence.allDay && eventZone && eventZone !== deviceTimeZone() ? (_jsxs(_Fragment, { children: [_jsxs("div", { children: [formatStoredWhen(occurrence.startDate, occurrence.endDate, false), " \u2022 ", describeTimeZone(deviceTimeZone(), new Date(occurrence.startDate))] }), _jsxs("div", { className: "text-text-muted", children: [formatStoredWhenInZone(occurrence.startDate, occurrence.endDate, eventZone), " \u2022 ", describeTimeZone(eventZone, new Date(occurrence.startDate))] })] })) : (_jsx("div", { children: formatStoredWhen(occurrence.startDate, occurrence.endDate, occurrence.allDay) })), occurrence.recurrenceRule && _jsxs("div", { className: "text-text-muted", children: ["Repeats ", describeRecurrence(occurrence.recurrenceRule)] })] }), occurrence.location && (_jsx(DetailRow, { icon: _jsx(HiOutlineMapPin, { size: 18 }), children: _jsx("div", { children: occurrence.location }) })), hasDescription && (_jsx(DetailRow, { icon: _jsx(HiOutlineBars3BottomLeft, { size: 18 }), children: _jsx(EventDescriptionView, { html: occurrence.descriptionHtml, text: occurrence.description }) })), videoMeetingUid && (_jsx(DetailRow, { icon: _jsx(HiOutlineVideoCamera, { size: 18 }), children: _jsxs("div", { className: "flex items-center gap-3 flex-wrap", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: !joinUrl, onClick: () => window.open(joinUrl, "_blank", "noopener,noreferrer"), children: "Join video call" }), !joinUrl && (_jsx("span", { className: "text-xs text-text-muted", children: organizerJoinUrl === undefined ? "Loading the join link…" : "This meeting’s join link isn’t available." }))] }) })), _jsxs(DetailRow, { icon: _jsx(HiOutlineUser, { size: 18 }), children: [_jsx("div", { children: _jsx(ParticipantLink, { participant: occurrence.organizer, children: organizerName || occurrence.organizer.address }) }), _jsxs("div", { className: "text-text-muted", children: [organizerName ? `${occurrence.organizer.address} · ` : "", "Organizer"] })] }), (guests.length > 0 || hiddenList) && (_jsxs(DetailRow, { icon: _jsx(HiOutlineUserGroup, { size: 18 }), children: [_jsx("div", { className: "font-medium mb-1", children: hiddenList ? "The organizer has hidden the guest list" : guests.length === 1 ? "1 guest" : `${guests.length} guests` }), _jsx("ul", { className: "flex flex-col gap-1", children: guests.map((guest, i) => (_jsxs("li", { className: "flex items-baseline justify-between gap-3", children: [_jsxs("span", { className: "min-w-0 truncate", children: [_jsx(ParticipantLink, { participant: guest, children: guest.displayName || guest.address }), ROLE_LABEL[guest.role] && _jsxs("span", { className: "text-text-muted", children: [" (", ROLE_LABEL[guest.role], ")"] })] }), _jsx("span", { className: "shrink-0 text-xs text-text-muted", children: RESPONSE_STATUS_LABEL[guest.responseStatus] })] }, i))) }), !isInvited && _jsx("div", { className: "text-xs text-text-muted mt-1", children: describeGuestPermissions(permissions) })] })), _jsxs(DetailRow, { icon: _jsx(HiOutlineBriefcase, { size: 18 }), children: [_jsx("div", { children: BUSY_STATUS_LABEL[occurrence.busyStatus] }), _jsx("div", { className: "text-text-muted", children: VISIBILITY_LABEL[visibilityOf(occurrence)] }), occurrence.autoReplyEnabled && (_jsx("div", { className: "text-text-muted", children: "Sends an automatic reply while this event is happening" }))] }), _jsx(DetailRow, { icon: _jsx(HiOutlineBell, { size: 18 }), children: _jsx("div", { children: describeReminder(occurrence.reminderMinutesBeforeStart) }) }), calendarName && (_jsxs("div", { className: "flex items-center gap-3 text-text-muted", children: [_jsx("span", { "aria-hidden": "true", className: "w-3 h-3 ml-[3px] rounded-full shrink-0", style: { backgroundColor: calendarColor } }), _jsx("span", { className: "truncate", children: calendarName })] })), isInvited && (_jsxs("p", { className: "text-xs text-text-muted", children: ["You were invited to this event. Only the organizer can change its details", canChange || canInvite ? ", but you can ask them to." : "."] }))] }), requesting && (_jsx("div", { className: "mx-5 mb-3 p-3 rounded-md bg-surface-alt", children: _jsx(RequestChangeForm, { subject: {
125
126
  uid: occurrence.uid,
126
127
  title: occurrence.title,
127
128
  location: occurrence.location,
@@ -0,0 +1,19 @@
1
+ import React from "react";
2
+ import { type ContactCardContext } from "./contactCardData.js";
3
+ import { type Participant } from "./participantDetails.js";
4
+ export interface ContactCardProps {
5
+ participant: Participant;
6
+ context?: ContactCardContext;
7
+ onClose: () => void;
8
+ userUid?: string;
9
+ }
10
+ /**
11
+ * A person's contact card, as Outlook's people card: a big avatar, their name, an "External" badge when the address is outside the user's own
12
+ * domains, the actions (email them, copy the address, create the contact or open it), then their contact details, the last messages to or from
13
+ * them and the events coming up that they organize or were invited to. Each section loads on its own, with a skeleton, an empty text and an
14
+ * error that touches nothing else on the card.
15
+ *
16
+ * When the address is not in the user's address book the details are what is known: the name and address, and everything a vCard attached to the
17
+ * message they were met in says. "Create contact" stores that. The lookups are made once, when the card opens.
18
+ */
19
+ export default function ContactCard({ participant, context, onClose, userUid }: ContactCardProps): React.JSX.Element;