@rapidmx/web-client 0.26.0 → 0.28.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 (57) hide show
  1. package/README.md +15 -3
  2. package/apps/shared/components/admin/layout/AdminShell.tsx +3 -2
  3. package/apps/shared/components/contacts/ContactCard.tsx +38 -12
  4. package/apps/shared/components/contacts/ContactDetailPane.tsx +25 -5
  5. package/apps/shared/components/contacts/ContactForm.tsx +36 -5
  6. package/apps/shared/components/contacts/ContactPhotoField.tsx +86 -0
  7. package/apps/shared/components/contacts/FavoriteStarButton.tsx +36 -0
  8. package/apps/shared/components/layout/AppShell.tsx +14 -6
  9. package/apps/shared/components/mail/ConversationList.tsx +3 -3
  10. package/apps/shared/components/mail/MessageDetailPane.tsx +18 -18
  11. package/apps/shared/components/settings/layout/SettingsShell.tsx +3 -3
  12. package/apps/shared/mail/usePermanentDelete.tsx +14 -7
  13. package/apps/shared/plugins/pluginIcons.ts +124 -0
  14. package/apps/shared/plugins/pluginNav.ts +8 -0
  15. package/apps/shared/plugins/useResolvedRailItems.ts +54 -0
  16. package/apps/www/contacts/[uid].tsx +1 -0
  17. package/apps/www/contacts/index.tsx +3 -1
  18. package/apps/www/index.tsx +43 -35
  19. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
  20. package/dist/apps/shared/components/admin/layout/AdminShell.js +3 -2
  21. package/dist/apps/shared/components/contacts/ContactCard.js +19 -3
  22. package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +3 -1
  23. package/dist/apps/shared/components/contacts/ContactDetailPane.js +17 -2
  24. package/dist/apps/shared/components/contacts/ContactForm.js +20 -2
  25. package/dist/apps/shared/components/contacts/ContactPhotoField.d.ts +21 -0
  26. package/dist/apps/shared/components/contacts/ContactPhotoField.js +46 -0
  27. package/dist/apps/shared/components/contacts/FavoriteStarButton.d.ts +9 -0
  28. package/dist/apps/shared/components/contacts/FavoriteStarButton.js +12 -0
  29. package/dist/apps/shared/components/layout/AppShell.d.ts +3 -2
  30. package/dist/apps/shared/components/layout/AppShell.js +13 -5
  31. package/dist/apps/shared/components/mail/ConversationList.d.ts +2 -2
  32. package/dist/apps/shared/components/mail/ConversationList.js +1 -1
  33. package/dist/apps/shared/components/mail/MessageDetailPane.js +2 -5
  34. package/dist/apps/shared/components/settings/layout/SettingsShell.js +4 -3
  35. package/dist/apps/shared/mail/usePermanentDelete.d.ts +2 -0
  36. package/dist/apps/shared/mail/usePermanentDelete.js +13 -8
  37. package/dist/apps/shared/plugins/pluginIcons.d.ts +10 -0
  38. package/dist/apps/shared/plugins/pluginIcons.js +64 -0
  39. package/dist/apps/shared/plugins/pluginNav.d.ts +8 -0
  40. package/dist/apps/shared/plugins/useResolvedRailItems.d.ts +12 -0
  41. package/dist/apps/shared/plugins/useResolvedRailItems.js +47 -0
  42. package/dist/apps/www/contacts/[uid].js +1 -1
  43. package/dist/apps/www/contacts/index.js +2 -2
  44. package/dist/apps/www/index.js +38 -29
  45. package/dist/lib/components/avatar/ContactAvatar.d.ts +9 -3
  46. package/dist/lib/components/avatar/ContactAvatar.js +29 -6
  47. package/dist/lib/components/buttons/CopyIconButton.d.ts +13 -0
  48. package/dist/lib/components/buttons/CopyIconButton.js +16 -0
  49. package/dist/lib/contacts/contactsApi.d.ts +12 -0
  50. package/dist/lib/contacts/contactsApi.js +30 -1
  51. package/dist/lib/contacts/gravatar.d.ts +12 -0
  52. package/dist/lib/contacts/gravatar.js +43 -0
  53. package/lib/components/avatar/ContactAvatar.tsx +43 -7
  54. package/lib/components/buttons/CopyIconButton.tsx +49 -0
  55. package/lib/contacts/contactsApi.ts +34 -1
  56. package/lib/contacts/gravatar.ts +47 -0
  57. package/package.json +1 -1
@@ -7,7 +7,7 @@ import { pageTitle } from "../../shared/navigation/pageTitle.js";
7
7
  import { useNavigate } from "../../shared/navigation/index.js";
8
8
  import { useEffect, useMemo, useRef, useState } from "react";
9
9
  import { ApiRequestError } from "../../../lib/util/api.js";
10
- import { createContact, deleteContact, listContacts, listDeletedContacts, setContactFavorite, updateContact, } from "../../../lib/contacts/contactsApi.js";
10
+ import { createContact, deleteContact, listContacts, listDeletedContacts, setContactFavorite, contactPhotoUrl, updateContact, } from "../../../lib/contacts/contactsApi.js";
11
11
  import { contactsToVCardFile, contactToVCard, parseVCards } from "../../../lib/contacts/vcard.js";
12
12
  import useIsMobile from "../../../lib/util/useIsMobile.js";
13
13
  import { useApiClient } from "../../../lib/util/apiClientContext.js";
@@ -346,7 +346,7 @@ function ContactsContent({ userUid }) {
346
346
  return (_jsxs("div", { className: "flex-1 min-w-0 min-h-0 flex flex-col lg:flex-row md:flex-none md:h-[calc(100dvh_-_var(--rr-header-h,4rem))] md:overflow-hidden", children: [_jsx(ContactsSidebar, { mailboxUid: mailboxUid, contacts: contacts, active: view, onSelect: handleSelectView, showDeleted: canViewDeleted }), _jsxs("div", { className: "flex-1 min-w-0 min-h-0 flex", children: [_jsxs("div", { className: [
347
347
  "w-full md:w-[clamp(16rem,32vw,26rem)] min-w-0 md:shrink-0 md:border-r border-border md:flex flex-col min-h-0",
348
348
  mode === "new" ? "hidden md:flex" : "flex",
349
- ].join(" "), children: [_jsx(ContactsToolbar, { selectedCount: checkedContacts.length, allSelectedFavorited: checkedContacts.length > 0 && checkedContacts.every((c) => c.favorite), onNewContact: handleNew, onEdit: handleToolbarEdit, onDelete: () => setConfirmingBulkDelete(true), onEmail: handleEmail, onToggleFavorite: handleToggleFavorite, onAddCategory: handleAddCategory, onExportVCard: handleExportVCard, onImportFile: handleImportFile, shortcuts: true, hideNew: isMobile }), _jsx("div", { className: "p-3 border-b border-border", children: _jsx("input", { ref: searchInputRef, type: "search", value: query, onChange: (e) => setQuery(e.target.value), placeholder: "Search contacts", "aria-label": "Search contacts", className: INPUT_CLASS }) }), error && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: error }) })), (isDeletedView ? deletedTruncated : truncated) && (_jsx("div", { className: "p-3", children: _jsxs(Alert, { children: ["This folder has more contacts than can be shown at once - only the first ", LIST_PAGE_SIZE * MAX_LIST_PAGES, " are listed."] }) })), deletedError && isDeletedView && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: deletedError }) })), (isDeletedView ? deletedLoading : loading) ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "Loading\u2026" })) : sorted.length === 0 ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "No contacts found." })) : (_jsx("div", { className: "flex-1 min-h-0 overflow-auto", children: _jsxs("table", { className: "w-full table-fixed text-sm", children: [_jsx("thead", { children: _jsxs("tr", { className: "border-b border-border text-left", children: [!isDeletedView && (_jsx("th", { className: "w-10 px-3 py-2", children: _jsx("input", { type: "checkbox", "aria-label": "Select all contacts", checked: allChecked, onChange: toggleCheckAll }) })), _jsx("th", { className: "w-[58%] px-3 py-2", children: _jsxs("button", { type: "button", onClick: () => handleSort("name"), className: "block max-w-full truncate text-xs font-bold uppercase tracking-wide text-text-muted", children: ["Name", sortColumn === "name" ? (sortDesc ? " ▾" : " ▴") : ""] }) }), _jsx("th", { className: "px-3 py-2", children: _jsxs("button", { type: "button", onClick: () => handleSort("info"), className: "block max-w-full truncate text-xs font-bold uppercase tracking-wide text-text-muted", children: ["Contact info", sortColumn === "info" ? (sortDesc ? " ▾" : " ▴") : ""] }) })] }) }), _jsx("tbody", { children: sorted.map((contact) => (_jsxs("tr", { className: ["border-b border-border", contact.uid === selectedUid ? "bg-primary/10" : "hover:bg-surface-alt"].join(" "), children: [!isDeletedView && (_jsx("td", { className: "px-3 py-2", children: _jsx("input", { type: "checkbox", "aria-label": `Select ${contact.displayName}`, checked: checkedUids.has(contact.uid), onChange: () => toggleChecked(contact.uid) }) })), _jsx("td", { className: "px-3 py-2", children: _jsxs("button", { type: "button", onClick: () => handleSelectRow(contact), className: "flex w-full min-w-0 items-center gap-2 text-left", children: [_jsx(ContactAvatar, { displayName: contact.displayName, size: 28 }), _jsxs("span", { className: "min-w-0 truncate font-medium", children: [contact.displayName, contact.favorite && _jsx("span", { "aria-label": "Favorite", children: " \u2605" })] })] }) }), _jsx("td", { className: "max-w-0 truncate px-3 py-2 text-text-muted", children: primaryInfo(contact) })] }, contact.uid))) })] }) }))] }), _jsxs("div", { className: ["flex-1 min-w-0 min-h-0 flex-col md:flex", mode === "new" ? "flex" : "hidden"].join(" "), children: [savedElsewhere && mode !== "new" && (_jsxs("p", { role: "status", className: "m-6 mb-0 text-sm py-2 px-3 rounded-sm bg-surface-alt text-text", children: [savedElsewhere.displayName, " was added to", " ", mailboxes.find((mb) => mb.uid === savedElsewhere.mailboxUid)?.displayName ?? "another mailbox", ".", " ", _jsx("a", { href: `/contacts?mailboxUid=${encodeURIComponent(savedElsewhere.mailboxUid)}`, "data-router-shallow": "", "data-router-prefetch": "false", className: "font-medium text-primary-dark hover:underline", children: "View that mailbox\u2019s contacts" })] })), mode === "new" && mailboxUid && folderUid ? (_jsx(ContactForm, { mailboxUid: mailboxUid, folderUid: folderUid, mailboxes: writableMailboxes, onSaved: handleSaved, onCancel: handleCancel })) : mode === "edit" && selected ? (_jsx(ContactForm, { contact: selected, onSaved: handleSaved, onCancel: handleCancel })) : selected ? (_jsx("div", { className: "min-h-0 flex-1 overflow-y-auto p-6", children: _jsx(ContactDetailPane, { contact: selected, onEdit: () => setMode("edit"), onDelete: () => handleDelete(selected), onKeysChanged: () => void reload(), canResolveKeys: ownsMailbox || delegateCanUpdate }) })) : (_jsx("div", { className: "flex flex-1 items-center justify-center p-6 text-center", children: _jsx("p", { className: "text-sm text-text-muted", children: "Select a contact, or create a new one." }) }))] })] }), _jsxs(Modal, { open: confirmingBulkDelete, onClose: () => setConfirmingBulkDelete(false), title: "Delete contacts", children: [_jsxs("p", { className: "text-sm mb-5", children: ["Delete ", checkedContacts.length, " selected ", checkedContacts.length === 1 ? "contact" : "contacts", "? They move to Deleted contacts."] }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setConfirmingBulkDelete(false), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", onClick: handleBulkDelete, children: "Delete" })] })] }), isMobile && mode !== "new" && _jsx(FloatingActionButton, { label: "New contact", icon: HiOutlineUserPlus, onClick: handleNew })] }));
349
+ ].join(" "), children: [_jsx(ContactsToolbar, { selectedCount: checkedContacts.length, allSelectedFavorited: checkedContacts.length > 0 && checkedContacts.every((c) => c.favorite), onNewContact: handleNew, onEdit: handleToolbarEdit, onDelete: () => setConfirmingBulkDelete(true), onEmail: handleEmail, onToggleFavorite: handleToggleFavorite, onAddCategory: handleAddCategory, onExportVCard: handleExportVCard, onImportFile: handleImportFile, shortcuts: true, hideNew: isMobile }), _jsx("div", { className: "p-3 border-b border-border", children: _jsx("input", { ref: searchInputRef, type: "search", value: query, onChange: (e) => setQuery(e.target.value), placeholder: "Search contacts", "aria-label": "Search contacts", className: INPUT_CLASS }) }), error && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: error }) })), (isDeletedView ? deletedTruncated : truncated) && (_jsx("div", { className: "p-3", children: _jsxs(Alert, { children: ["This folder has more contacts than can be shown at once - only the first ", LIST_PAGE_SIZE * MAX_LIST_PAGES, " are listed."] }) })), deletedError && isDeletedView && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: deletedError }) })), (isDeletedView ? deletedLoading : loading) ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "Loading\u2026" })) : sorted.length === 0 ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "No contacts found." })) : (_jsx("div", { className: "flex-1 min-h-0 overflow-auto", children: _jsxs("table", { className: "w-full table-fixed text-sm", children: [_jsx("thead", { children: _jsxs("tr", { className: "border-b border-border text-left", children: [!isDeletedView && (_jsx("th", { className: "w-10 px-3 py-2", children: _jsx("input", { type: "checkbox", "aria-label": "Select all contacts", checked: allChecked, onChange: toggleCheckAll }) })), _jsx("th", { className: "w-[58%] px-3 py-2", children: _jsxs("button", { type: "button", onClick: () => handleSort("name"), className: "block max-w-full truncate text-xs font-bold uppercase tracking-wide text-text-muted", children: ["Name", sortColumn === "name" ? (sortDesc ? " ▾" : " ▴") : ""] }) }), _jsx("th", { className: "px-3 py-2", children: _jsxs("button", { type: "button", onClick: () => handleSort("info"), className: "block max-w-full truncate text-xs font-bold uppercase tracking-wide text-text-muted", children: ["Contact info", sortColumn === "info" ? (sortDesc ? " ▾" : " ▴") : ""] }) })] }) }), _jsx("tbody", { children: sorted.map((contact) => (_jsxs("tr", { className: ["border-b border-border", contact.uid === selectedUid ? "bg-primary/10" : "hover:bg-surface-alt"].join(" "), children: [!isDeletedView && (_jsx("td", { className: "px-3 py-2", children: _jsx("input", { type: "checkbox", "aria-label": `Select ${contact.displayName}`, checked: checkedUids.has(contact.uid), onChange: () => toggleChecked(contact.uid) }) })), _jsx("td", { className: "px-3 py-2", children: _jsxs("button", { type: "button", onClick: () => handleSelectRow(contact), className: "flex w-full min-w-0 items-center gap-2 text-left", children: [_jsx(ContactAvatar, { displayName: contact.displayName, size: 28, photoUrl: contactPhotoUrl(contact), email: contact.emails[0]?.address }), _jsxs("span", { className: "min-w-0 truncate font-medium", children: [contact.displayName, contact.favorite && _jsx("span", { "aria-label": "Favorite", children: " \u2605" })] })] }) }), _jsx("td", { className: "max-w-0 truncate px-3 py-2 text-text-muted", children: primaryInfo(contact) })] }, contact.uid))) })] }) }))] }), _jsxs("div", { className: ["flex-1 min-w-0 min-h-0 flex-col md:flex", mode === "new" ? "flex" : "hidden"].join(" "), children: [savedElsewhere && mode !== "new" && (_jsxs("p", { role: "status", className: "m-6 mb-0 text-sm py-2 px-3 rounded-sm bg-surface-alt text-text", children: [savedElsewhere.displayName, " was added to", " ", mailboxes.find((mb) => mb.uid === savedElsewhere.mailboxUid)?.displayName ?? "another mailbox", ".", " ", _jsx("a", { href: `/contacts?mailboxUid=${encodeURIComponent(savedElsewhere.mailboxUid)}`, "data-router-shallow": "", "data-router-prefetch": "false", className: "font-medium text-primary-dark hover:underline", children: "View that mailbox\u2019s contacts" })] })), mode === "new" && mailboxUid && folderUid ? (_jsx(ContactForm, { mailboxUid: mailboxUid, folderUid: folderUid, mailboxes: writableMailboxes, onSaved: handleSaved, onCancel: handleCancel })) : mode === "edit" && selected ? (_jsx(ContactForm, { contact: selected, onSaved: handleSaved, onCancel: handleCancel })) : selected ? (_jsx("div", { className: "min-h-0 flex-1 overflow-y-auto p-6", children: _jsx(ContactDetailPane, { contact: selected, onEdit: () => setMode("edit"), onDelete: () => handleDelete(selected), onKeysChanged: () => void reload(), onChanged: () => void reload(), canResolveKeys: ownsMailbox || delegateCanUpdate }) })) : (_jsx("div", { className: "flex flex-1 items-center justify-center p-6 text-center", children: _jsx("p", { className: "text-sm text-text-muted", children: "Select a contact, or create a new one." }) }))] })] }), _jsxs(Modal, { open: confirmingBulkDelete, onClose: () => setConfirmingBulkDelete(false), title: "Delete contacts", children: [_jsxs("p", { className: "text-sm mb-5", children: ["Delete ", checkedContacts.length, " selected ", checkedContacts.length === 1 ? "contact" : "contacts", "? They move to Deleted contacts."] }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setConfirmingBulkDelete(false), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", onClick: handleBulkDelete, children: "Delete" })] })] }), isMobile && mode !== "new" && _jsx(FloatingActionButton, { label: "New contact", icon: HiOutlineUserPlus, onClick: handleNew })] }));
350
350
  }
351
351
  export default ContactsPage;
352
352
  /** The tab's title: `Brand: Contacts` (see `pageTitle()`). */
@@ -804,12 +804,6 @@ function InboxContent({ userUid }) {
804
804
  setSearchAllMailboxes(false);
805
805
  }
806
806
  }, [searchQuery]);
807
- // Rows can be ticked in the results of a search over every mailbox, but not in the listing they came out of: clearing the search leaves select mode.
808
- useEffect(() => {
809
- if (aggregateFolderType && !isSearching) {
810
- setSelectMode(false);
811
- }
812
- }, [aggregateFolderType, isSearching]);
813
807
  // Which listing this is, for the short-lived snapshot of each folder that makes going back to one instant (see
814
808
  // `listSnapshots.ts`). A search, an aggregate view and a folder that hasn't resolved have no snapshot.
815
809
  const listKey = listSnapshotKey({
@@ -1582,7 +1576,7 @@ function InboxContent({ userUid }) {
1582
1576
  * that is in the folder being listed. A conversation spans folders (an Inbox message and the Sent
1583
1577
  * Items copy of its reply), and the list only ever showed this folder's half of it, so a bulk action
1584
1578
  * from here must not reach into the other folders' copies either. */
1585
- const selectedConversationMessages = [...selectedConversationIds].flatMap((id) => (conversationMessagesById[id] ?? []).filter((m) => !folderUid || m.folderUid === folderUid));
1579
+ const selectedConversationMessages = [...selectedConversationIds].flatMap((id) => (conversationMessagesById[id] ?? []).filter((m) => (aggregateFolderType ? folderTypeOf(m.folderUid) === aggregateFolderType : !folderUid || m.folderUid === folderUid)));
1586
1580
  const selectedMessages = asConversations
1587
1581
  ? selectedConversationMessages
1588
1582
  : messages.filter((m) => selectedUids.has(m.uid));
@@ -1622,22 +1616,24 @@ function InboxContent({ userUid }) {
1622
1616
  /** Loads (once) the messages behind each of `ids`, so a ticked conversation resolves to the messages
1623
1617
  * every bulk action below acts on. If any of them fails to load, none of that batch stays ticked and
1624
1618
  * a pop-up says why - better than acting on the part of a selection that happened to arrive. */
1625
- async function resolveConversations(ids) {
1626
- const missing = ids.filter((id) => !conversationMessagesById[id]);
1619
+ async function resolveConversations(rows) {
1620
+ const missing = rows.filter((row) => !conversationMessagesById[conversationKey(row)]);
1627
1621
  if (missing.length === 0) {
1628
1622
  return;
1629
1623
  }
1630
1624
  setResolvingSelection((n) => n + 1);
1631
1625
  try {
1632
- const loaded = await Promise.all(missing.map(async (id) => [id, await listConversationMessages(activeMailboxUid, id, {}, client)]));
1626
+ const loaded = await Promise.all(
1627
+ // A row of the "All mailboxes" view names its own mailbox; any other list is the open mailbox's.
1628
+ missing.map(async (row) => [conversationKey(row), await listConversationMessages(row.mailboxUid ?? activeMailboxUid, row.conversationId, {}, client)]));
1633
1629
  setConversationMessagesById((prev) => ({ ...prev, ...Object.fromEntries(loaded) }));
1634
1630
  }
1635
1631
  catch (err) {
1636
1632
  notifyApiError(err, "Couldn't load the messages in one of those conversations");
1637
1633
  setSelectedConversationIds((prev) => {
1638
1634
  const next = new Set(prev);
1639
- for (const id of missing) {
1640
- next.delete(id);
1635
+ for (const row of missing) {
1636
+ next.delete(conversationKey(row));
1641
1637
  }
1642
1638
  return next;
1643
1639
  });
@@ -1647,7 +1643,7 @@ function InboxContent({ userUid }) {
1647
1643
  }
1648
1644
  }
1649
1645
  function toggleConversationSelected(conversation) {
1650
- const id = conversation.conversationId;
1646
+ const id = conversationKey(conversation);
1651
1647
  const ticking = !selectedConversationIds.has(id);
1652
1648
  setSelectedConversationIds((prev) => {
1653
1649
  const next = new Set(prev);
@@ -1660,13 +1656,12 @@ function InboxContent({ userUid }) {
1660
1656
  return next;
1661
1657
  });
1662
1658
  if (ticking) {
1663
- void resolveConversations([id]);
1659
+ void resolveConversations([conversation]);
1664
1660
  }
1665
1661
  }
1666
1662
  function selectAllConversations() {
1667
- const ids = conversations.map(conversationKey);
1668
- setSelectedConversationIds(new Set(ids));
1669
- void resolveConversations(ids);
1663
+ setSelectedConversationIds(new Set(conversations.map(conversationKey)));
1664
+ void resolveConversations(conversations);
1670
1665
  }
1671
1666
  /**
1672
1667
  * Runs one bulk action over the current selection, then either patches the affected rows in place or
@@ -1951,7 +1946,7 @@ function InboxContent({ userUid }) {
1951
1946
  const inConversations = asConversations;
1952
1947
  /** The selection bar isn't offered in the aggregate listing (its rows belong to several mailboxes), and neither are these - but the results of a
1953
1948
  * search over them are, each hit acted on in its own mailbox (see `inEachMailbox()`). */
1954
- const keyboardActions = !aggregateFolderType || isSearching;
1949
+ const keyboardActions = true;
1955
1950
  /** What the keyboard acts on exists: the ticked rows in select mode, else the message (or conversation) open in the reading pane. */
1956
1951
  const keyboardTargetExists = selectMode ? selectedMessages.length > 0 : threadPane ? openThread !== null : selected !== null;
1957
1952
  const rowCount = inConversations ? listedConversations.length : messages.length;
@@ -2132,13 +2127,31 @@ function InboxContent({ userUid }) {
2132
2127
  : undefined;
2133
2128
  // Delete is the permanent one for what is in Deleted Items: for the selection when there is one (search results can come from any folder),
2134
2129
  // else for the folder being viewed, so the button says so before anything is ticked.
2135
- const deletesPermanently = selectedMessages.length > 0 ? selectedMessages.every(inDeletedItems) : currentFolders.find((f) => f.uid === folderUid)?.type === "deleted_items";
2136
- // Empty folder is offered at the top of the list of a mailbox's own Deleted Items or Junk Email - not for search results, nor for the "All
2137
- // mailboxes" views, which have no one folder to empty.
2138
- const emptiableFolder = !isSearching && !aggregateFolderType ? currentFolders.find((f) => f.uid === folderUid && EMPTIABLE_FOLDER_TYPES.includes(f.type)) : undefined;
2130
+ const deletesPermanently = selectedMessages.length > 0
2131
+ ? selectedMessages.every(inDeletedItems)
2132
+ : aggregateFolderType
2133
+ ? aggregateFolderType === "deleted_items"
2134
+ : currentFolders.find((f) => f.uid === folderUid)?.type === "deleted_items";
2135
+ // Empty folder is offered at the top of the list of a mailbox's own Deleted Items or Junk Email, and of the "All mailboxes" view of either, which
2136
+ // empties each mailbox's own (a share the reader may not delete in refuses, and says so) - not for search results.
2137
+ const emptiableFolders = isSearching
2138
+ ? []
2139
+ : aggregateFolderType
2140
+ ? EMPTIABLE_FOLDER_TYPES.includes(aggregateFolderType)
2141
+ ? mailboxFolders.flatMap((entry) => entry.folders.filter((f) => f.type === aggregateFolderType))
2142
+ : []
2143
+ : currentFolders.filter((f) => f.uid === folderUid && EMPTIABLE_FOLDER_TYPES.includes(f.type));
2144
+ const emptiableFolder = emptiableFolders.length === 0
2145
+ ? undefined
2146
+ : {
2147
+ uid: emptiableFolders[0].uid,
2148
+ name: aggregateFolderType ? (emptiableFolders[0].type === "junk" ? "Junk Email" : "Deleted Items") : emptiableFolders[0].name,
2149
+ uids: aggregateFolderType ? emptiableFolders.map((f) => f.uid) : undefined,
2150
+ };
2139
2151
  // How many it holds as the sidebar's counts have it (kept right as messages leave), unless that says none while the list shows some.
2140
- const emptiableCount = emptiableFolder && folderCountOf(emptiableFolder).total > 0 ? folderCountOf(emptiableFolder).total : undefined;
2141
- const emptyDisabledReason = !mailboxWritable
2152
+ const emptiableTotal = emptiableFolders.reduce((sum, f) => sum + folderCountOf(f).total, 0);
2153
+ const emptiableCount = emptiableFolder && emptiableTotal > 0 ? emptiableTotal : undefined;
2154
+ const emptyDisabledReason = !(aggregateFolderType ? emptiableFolders.length > 0 : mailboxWritable)
2142
2155
  ? "This mailbox is shared with you view-only"
2143
2156
  : listedRowCount === 0 && emptiableCount === undefined
2144
2157
  ? "This folder is already empty"
@@ -2174,11 +2187,7 @@ function InboxContent({ userUid }) {
2174
2187
  ? "This view merges the newest mail from every mailbox and is always listed by date."
2175
2188
  : asConversations
2176
2189
  ? CONVERSATION_SORT_NOTE
2177
- : undefined, selectDisabled: (!!aggregateFolderType && !isSearching) || loading || listedRowCount === 0, selectDisabledReason: aggregateFolderType && !isSearching
2178
- ? "Open a mailbox's own folder to select messages"
2179
- : loading
2180
- ? "Wait for this folder to finish loading"
2181
- : "There is nothing here to select" })), !isMobile && _jsx("div", { className: "p-2 border-b border-border", children: searchField }), isMobile && mobileSearchSlot && createPortal(searchField, mobileSearchSlot), emptiableFolder && !selectMode && (_jsx(EmptyFolderBar, { folderName: emptiableFolder.name, count: emptiableCount, disabled: !!emptyDisabledReason || permanent.busy || bulkBusy, disabledReason: emptyDisabledReason, onEmpty: () => void emptyListedFolder(emptiableFolder, emptiableCount) })), permanent.dialog, offerClassificationFilters && (_jsx("div", { className: "flex border-b border-border text-xs", children: MAIL_LIST_CLASSIFICATION_FILTERS.map(({ value, label }) => (_jsx("button", { type: "button", "aria-pressed": preferences.filter === value, onClick: () => updatePreferences({ filter: value }), className: [
2190
+ : undefined, selectDisabled: loading || listedRowCount === 0, selectDisabledReason: loading ? "Wait for this folder to finish loading" : "There is nothing here to select" })), !isMobile && _jsx("div", { className: "p-2 border-b border-border", children: searchField }), isMobile && mobileSearchSlot && createPortal(searchField, mobileSearchSlot), emptiableFolder && !selectMode && (_jsx(EmptyFolderBar, { folderName: emptiableFolder.name, count: emptiableCount, disabled: !!emptyDisabledReason || permanent.busy || bulkBusy, disabledReason: emptyDisabledReason, onEmpty: () => void emptyListedFolder(emptiableFolder, emptiableCount) })), permanent.dialog, offerClassificationFilters && (_jsx("div", { className: "flex border-b border-border text-xs", children: MAIL_LIST_CLASSIFICATION_FILTERS.map(({ value, label }) => (_jsx("button", { type: "button", "aria-pressed": preferences.filter === value, onClick: () => updatePreferences({ filter: value }), className: [
2182
2191
  "flex-1 py-1.5 font-semibold",
2183
2192
  preferences.filter === value
2184
2193
  ? "text-primary-dark border-b-2 border-primary-dark"
@@ -2,7 +2,13 @@ import React from "react";
2
2
  export interface ContactAvatarProps {
3
3
  displayName: string;
4
4
  size?: number;
5
+ /** The contact's own picture (see `contactPhotoUrl()`); shown in preference to anything else. */
6
+ photoUrl?: string;
7
+ /** The contact's address: without a picture of their own, its Gravatar is shown when there is one (and the reader hasn't turned that off). */
8
+ email?: string;
5
9
  }
6
- /** A small circular initials avatar for a contact, matching Outlook People's list-row avatars — deterministic
7
- * per name (same contact always gets the same color/initials), no photo-upload dependency. */
8
- export default function ContactAvatar({ displayName, size }: ContactAvatarProps): React.JSX.Element;
10
+ /**
11
+ * A small circular avatar for a contact, matching Outlook People's: the contact's own picture when they have one, else their Gravatar (when `email`
12
+ * is given), else initials on a color that is deterministic per name. A picture that fails to load falls through to the next.
13
+ */
14
+ export default function ContactAvatar({ displayName, size, photoUrl, email }: ContactAvatarProps): React.JSX.Element;
@@ -1,7 +1,11 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- /** A fixed palette of background colors, cycled by a hash of the contact's name — no photo upload feature
3
- * exists yet (`Contact.photoBlobKey` is modeled server-side but nothing in this app sets/reads it), so every
4
- * contact gets one of these deterministically rather than a plain generic gray circle for everyone. */
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useEffect, useState } from "react";
7
+ import { gravatarEnabled, gravatarUrl } from "../../contacts/gravatar.js";
8
+ /** A fixed palette of background colors, cycled by a hash of the contact's name, for the initials shown when there is no picture. */
5
9
  const PALETTE = ["#7c3aed", "#2563eb", "#0891b2", "#059669", "#d97706", "#dc2626", "#db2777", "#4f46e5"];
6
10
  function initialsOf(displayName) {
7
11
  const parts = displayName.trim().split(/\s+/).filter(Boolean);
@@ -19,8 +23,27 @@ function colorOf(displayName) {
19
23
  }
20
24
  return PALETTE[Math.abs(hash) % PALETTE.length];
21
25
  }
22
- /** A small circular initials avatar for a contact, matching Outlook People's list-row avatars — deterministic
23
- * per name (same contact always gets the same color/initials), no photo-upload dependency. */
24
- export default function ContactAvatar({ displayName, size = 32 }) {
26
+ /**
27
+ * A small circular avatar for a contact, matching Outlook People's: the contact's own picture when they have one, else their Gravatar (when `email`
28
+ * is given), else initials on a color that is deterministic per name. A picture that fails to load falls through to the next.
29
+ */
30
+ export default function ContactAvatar({ displayName, size = 32, photoUrl, email }) {
31
+ const [gravatar, setGravatar] = useState();
32
+ const [failed, setFailed] = useState([]);
33
+ useEffect(() => {
34
+ setGravatar(undefined);
35
+ if (!email || !gravatarEnabled()) {
36
+ return;
37
+ }
38
+ let cancelled = false;
39
+ void gravatarUrl(email, size * 2).then((url) => !cancelled && setGravatar(url));
40
+ return () => {
41
+ cancelled = true;
42
+ };
43
+ }, [email, size]);
44
+ const src = [photoUrl, gravatar].find((url) => url && !failed.includes(url));
45
+ if (src) {
46
+ return (_jsx("img", { src: src, alt: "", "aria-hidden": "true", width: size, height: size, referrerPolicy: "no-referrer", onError: () => setFailed((list) => [...list, src]), className: "inline-block rounded-full object-cover shrink-0", style: { width: size, height: size } }));
47
+ }
25
48
  return (_jsx("span", { "aria-hidden": "true", style: { width: size, height: size, backgroundColor: colorOf(displayName), fontSize: size * 0.4 }, className: "inline-flex items-center justify-center rounded-full text-white font-semibold shrink-0", children: initialsOf(displayName) }));
26
49
  }
@@ -0,0 +1,13 @@
1
+ import React from "react";
2
+ export interface CopyIconButtonProps {
3
+ /** The text put on the clipboard. */
4
+ value: string;
5
+ /** The button's accessible name and tooltip - say what is copied, e.g. "Copy address". */
6
+ label: string;
7
+ className?: string;
8
+ }
9
+ /**
10
+ * A small icon-only "copy" button to sit right after a value (an email address). The icon turns into a check mark for a moment once the value is on
11
+ * the clipboard, and a polite live region says "Copied" (or "Couldn't copy") for screen readers.
12
+ */
13
+ export default function CopyIconButton({ value, label, className }: CopyIconButtonProps): React.JSX.Element;
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { HiCheck, HiOutlineClipboardDocument } from "react-icons/hi2";
3
+ import useCopyToClipboard from "../../util/useCopyToClipboard.js";
4
+ /**
5
+ * A small icon-only "copy" button to sit right after a value (an email address). The icon turns into a check mark for a moment once the value is on
6
+ * the clipboard, and a polite live region says "Copied" (or "Couldn't copy") for screen readers.
7
+ */
8
+ export default function CopyIconButton({ value, label, className }) {
9
+ const { status, copy } = useCopyToClipboard();
10
+ return (_jsxs("span", { className: "inline-flex items-center align-middle", children: [_jsx("button", { type: "button", "aria-label": label, title: label, onClick: () => void copy(value), className: [
11
+ "inline-flex items-center justify-center rounded-sm p-1 text-text-muted hover:text-text hover:bg-surface-alt focus-visible:outline-2 focus-visible:outline-primary",
12
+ className,
13
+ ]
14
+ .filter(Boolean)
15
+ .join(" "), children: status === "copied" ? (_jsx(HiCheck, { "aria-hidden": "true", className: "size-4 text-success" })) : (_jsx(HiOutlineClipboardDocument, { "aria-hidden": "true", className: "size-4" })) }), _jsx("span", { role: "status", "aria-live": "polite", className: "sr-only", children: status === "copied" ? "Copied" : status === "failed" ? "Couldn’t copy" : "" })] }));
16
+ }
@@ -154,6 +154,18 @@ export declare function updateContact(patch: ContactPatch, client?: ApiClient):
154
154
  * `setMessageRead()`/`tasksApi.ts`'s `setTaskCompleted()`. Sends only `uid`/`version`/`favorite`, never
155
155
  * the rest of `contact` (the server rejects its managed fields). */
156
156
  export declare function setContactFavorite(contact: Contact, favorite: boolean, client?: ApiClient): Promise<Contact>;
157
+ /**
158
+ * The URL of a contact's own picture, or `undefined` when they have none. The version is part of it so a replaced picture is fetched again rather than
159
+ * served from the browser's cache.
160
+ */
161
+ export declare function contactPhotoUrl(contact: Pick<Contact, "uid" | "version" | "photoBlobKey">): string | undefined;
162
+ /** The picture types the server accepts for a contact, and the most bytes it takes. */
163
+ export declare const CONTACT_PHOTO_TYPES: string[];
164
+ export declare const CONTACT_PHOTO_MAX_BYTES: number;
165
+ /** Sets a contact's own picture from an image file (the file's own bytes go up, not JSON) and returns the updated contact. */
166
+ export declare function uploadContactPhoto(uid: string, version: number, file: Blob): Promise<Contact>;
167
+ /** Removes a contact's own picture and returns the updated contact. */
168
+ export declare function deleteContactPhoto(uid: string, version: number, client?: ApiClient): Promise<Contact>;
157
169
  export declare function deleteContact(uid: string, version: number, client?: ApiClient): Promise<void>;
158
170
  export interface ContactList {
159
171
  uid: string;
@@ -4,7 +4,7 @@
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  /** Typed wrappers over `@rapidmx/restapi`'s `/mail/contacts` REST surface — see `mailApi.ts`'s own header
6
6
  * comment for the shared ACL/authorization model every wrapper file here follows. */
7
- import { withClient } from "../util/api.js";
7
+ import { ApiRequestError, apiUrl, withClient, withCsrfHeader } from "../util/api.js";
8
8
  import { buildQuery } from "../util/apiQuery.js";
9
9
  import { signingKeyFingerprints, } from "../crypto/keyvaultApi.js";
10
10
  /** Lists a folder's contacts, alphabetically by display name. Never includes soft-deleted contacts — see
@@ -118,6 +118,35 @@ export function updateContact(patch, client) {
118
118
  export function setContactFavorite(contact, favorite, client) {
119
119
  return updateContact({ uid: contact.uid, version: contact.version, favorite }, client);
120
120
  }
121
+ /**
122
+ * The URL of a contact's own picture, or `undefined` when they have none. The version is part of it so a replaced picture is fetched again rather than
123
+ * served from the browser's cache.
124
+ */
125
+ export function contactPhotoUrl(contact) {
126
+ return contact.photoBlobKey ? apiUrl(`/mail/contacts/${encodeURIComponent(contact.uid)}/photo?v=${contact.version}`) : undefined;
127
+ }
128
+ /** The picture types the server accepts for a contact, and the most bytes it takes. */
129
+ export const CONTACT_PHOTO_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
130
+ export const CONTACT_PHOTO_MAX_BYTES = 1024 * 1024;
131
+ /** Sets a contact's own picture from an image file (the file's own bytes go up, not JSON) and returns the updated contact. */
132
+ export async function uploadContactPhoto(uid, version, file) {
133
+ const res = await fetch(apiUrl(`/mail/contacts/${encodeURIComponent(uid)}/photo?version=${version}`), {
134
+ method: "PUT",
135
+ credentials: "include",
136
+ headers: withCsrfHeader({ "Content-Type": file.type }, "PUT"),
137
+ body: file,
138
+ });
139
+ const contentType = res.headers.get("content-type") ?? "";
140
+ const body = contentType.includes("application/json") ? await res.json().catch(() => undefined) : undefined;
141
+ if (!res.ok) {
142
+ throw new ApiRequestError((body && (body.message || body.error)) || res.statusText || "Upload failed.", res.status, body?.code);
143
+ }
144
+ return body;
145
+ }
146
+ /** Removes a contact's own picture and returns the updated contact. */
147
+ export function deleteContactPhoto(uid, version, client) {
148
+ return withClient(client, `/mail/contacts/${encodeURIComponent(uid)}/photo?version=${version}`, { method: "DELETE" });
149
+ }
121
150
  export function deleteContact(uid, version, client) {
122
151
  return withClient(client, `/mail/contacts/${encodeURIComponent(uid)}?version=${version}`, { method: "DELETE" });
123
152
  }
@@ -0,0 +1,12 @@
1
+ /** The `localStorage` key of the privacy preference: `"off"` stops contact avatars from asking Gravatar for a picture. */
2
+ export declare const GRAVATAR_PREFERENCE_KEY = "rapidmx:gravatar";
3
+ /**
4
+ * Whether avatars may look a person up at Gravatar by the SHA-256 hash of their address (never the address itself). On unless the reader turned
5
+ * it off (`localStorage["rapidmx:gravatar"] = "off"`); a browser without storage keeps the default.
6
+ */
7
+ export declare function gravatarEnabled(): boolean;
8
+ /**
9
+ * The Gravatar picture URL for `email`, `size` pixels square. It answers `404` (`d=404`) for a person with no Gravatar, so an `<img>` fails to load
10
+ * and the caller falls back to initials rather than showing Gravatar's placeholder. `undefined` for an empty address or without Web Crypto.
11
+ */
12
+ export declare function gravatarUrl(email: string, size: number): Promise<string | undefined>;
@@ -0,0 +1,43 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /** The `localStorage` key of the privacy preference: `"off"` stops contact avatars from asking Gravatar for a picture. */
6
+ export const GRAVATAR_PREFERENCE_KEY = "rapidmx:gravatar";
7
+ /**
8
+ * Whether avatars may look a person up at Gravatar by the SHA-256 hash of their address (never the address itself). On unless the reader turned
9
+ * it off (`localStorage["rapidmx:gravatar"] = "off"`); a browser without storage keeps the default.
10
+ */
11
+ export function gravatarEnabled() {
12
+ try {
13
+ return globalThis.localStorage?.getItem(GRAVATAR_PREFERENCE_KEY) !== "off";
14
+ }
15
+ catch {
16
+ return true;
17
+ }
18
+ }
19
+ const hashes = new Map();
20
+ /** The lowercase-hex SHA-256 of a trimmed, lowercased `email`, or `undefined` where the browser has no Web Crypto (an insecure page). */
21
+ function hashOf(email) {
22
+ const normalized = email.trim().toLowerCase();
23
+ let hash = hashes.get(normalized);
24
+ if (!hash) {
25
+ hash = (async () => {
26
+ const digest = await globalThis.crypto?.subtle?.digest("SHA-256", new TextEncoder().encode(normalized));
27
+ return digest && Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
28
+ })().catch(() => undefined);
29
+ hashes.set(normalized, hash);
30
+ }
31
+ return hash;
32
+ }
33
+ /**
34
+ * The Gravatar picture URL for `email`, `size` pixels square. It answers `404` (`d=404`) for a person with no Gravatar, so an `<img>` fails to load
35
+ * and the caller falls back to initials rather than showing Gravatar's placeholder. `undefined` for an empty address or without Web Crypto.
36
+ */
37
+ export async function gravatarUrl(email, size) {
38
+ if (!email.trim()) {
39
+ return undefined;
40
+ }
41
+ const hash = await hashOf(email);
42
+ return hash && `https://gravatar.com/avatar/${hash}?s=${Math.max(1, Math.round(size))}&d=404`;
43
+ }
@@ -2,16 +2,19 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- import React from "react";
5
+ import React, { useEffect, useState } from "react";
6
+ import { gravatarEnabled, gravatarUrl } from "../../contacts/gravatar.js";
6
7
 
7
8
  export interface ContactAvatarProps {
8
9
  displayName: string;
9
10
  size?: number;
11
+ /** The contact's own picture (see `contactPhotoUrl()`); shown in preference to anything else. */
12
+ photoUrl?: string;
13
+ /** The contact's address: without a picture of their own, its Gravatar is shown when there is one (and the reader hasn't turned that off). */
14
+ email?: string;
10
15
  }
11
16
 
12
- /** A fixed palette of background colors, cycled by a hash of the contact's name — no photo upload feature
13
- * exists yet (`Contact.photoBlobKey` is modeled server-side but nothing in this app sets/reads it), so every
14
- * contact gets one of these deterministically rather than a plain generic gray circle for everyone. */
17
+ /** A fixed palette of background colors, cycled by a hash of the contact's name, for the initials shown when there is no picture. */
15
18
  const PALETTE = ["#7c3aed", "#2563eb", "#0891b2", "#059669", "#d97706", "#dc2626", "#db2777", "#4f46e5"];
16
19
 
17
20
  function initialsOf(displayName: string): string {
@@ -32,9 +35,42 @@ function colorOf(displayName: string): string {
32
35
  return PALETTE[Math.abs(hash) % PALETTE.length];
33
36
  }
34
37
 
35
- /** A small circular initials avatar for a contact, matching Outlook People's list-row avatars — deterministic
36
- * per name (same contact always gets the same color/initials), no photo-upload dependency. */
37
- export default function ContactAvatar({ displayName, size = 32 }: ContactAvatarProps) {
38
+ /**
39
+ * A small circular avatar for a contact, matching Outlook People's: the contact's own picture when they have one, else their Gravatar (when `email`
40
+ * is given), else initials on a color that is deterministic per name. A picture that fails to load falls through to the next.
41
+ */
42
+ export default function ContactAvatar({ displayName, size = 32, photoUrl, email }: ContactAvatarProps) {
43
+ const [gravatar, setGravatar] = useState<string | undefined>();
44
+ const [failed, setFailed] = useState<string[]>([]);
45
+
46
+ useEffect(() => {
47
+ setGravatar(undefined);
48
+ if (!email || !gravatarEnabled()) {
49
+ return;
50
+ }
51
+ let cancelled = false;
52
+ void gravatarUrl(email, size * 2).then((url) => !cancelled && setGravatar(url));
53
+ return () => {
54
+ cancelled = true;
55
+ };
56
+ }, [email, size]);
57
+
58
+ const src = [photoUrl, gravatar].find((url) => url && !failed.includes(url));
59
+ if (src) {
60
+ return (
61
+ <img
62
+ src={src}
63
+ alt=""
64
+ aria-hidden="true"
65
+ width={size}
66
+ height={size}
67
+ referrerPolicy="no-referrer"
68
+ onError={() => setFailed((list) => [...list, src])}
69
+ className="inline-block rounded-full object-cover shrink-0"
70
+ style={{ width: size, height: size }}
71
+ />
72
+ );
73
+ }
38
74
  return (
39
75
  <span
40
76
  aria-hidden="true"
@@ -0,0 +1,49 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import { HiCheck, HiOutlineClipboardDocument } from "react-icons/hi2";
7
+ import useCopyToClipboard from "../../util/useCopyToClipboard.js";
8
+
9
+ export interface CopyIconButtonProps {
10
+ /** The text put on the clipboard. */
11
+ value: string;
12
+ /** The button's accessible name and tooltip - say what is copied, e.g. "Copy address". */
13
+ label: string;
14
+ className?: string;
15
+ }
16
+
17
+ /**
18
+ * A small icon-only "copy" button to sit right after a value (an email address). The icon turns into a check mark for a moment once the value is on
19
+ * the clipboard, and a polite live region says "Copied" (or "Couldn't copy") for screen readers.
20
+ */
21
+ export default function CopyIconButton({ value, label, className }: CopyIconButtonProps) {
22
+ const { status, copy } = useCopyToClipboard();
23
+
24
+ return (
25
+ <span className="inline-flex items-center align-middle">
26
+ <button
27
+ type="button"
28
+ aria-label={label}
29
+ title={label}
30
+ onClick={() => void copy(value)}
31
+ className={[
32
+ "inline-flex items-center justify-center rounded-sm p-1 text-text-muted hover:text-text hover:bg-surface-alt focus-visible:outline-2 focus-visible:outline-primary",
33
+ className,
34
+ ]
35
+ .filter(Boolean)
36
+ .join(" ")}
37
+ >
38
+ {status === "copied" ? (
39
+ <HiCheck aria-hidden="true" className="size-4 text-success" />
40
+ ) : (
41
+ <HiOutlineClipboardDocument aria-hidden="true" className="size-4" />
42
+ )}
43
+ </button>
44
+ <span role="status" aria-live="polite" className="sr-only">
45
+ {status === "copied" ? "Copied" : status === "failed" ? "Couldn’t copy" : ""}
46
+ </span>
47
+ </span>
48
+ );
49
+ }
@@ -5,7 +5,7 @@
5
5
  /** Typed wrappers over `@rapidmx/restapi`'s `/mail/contacts` REST surface — see `mailApi.ts`'s own header
6
6
  * comment for the shared ACL/authorization model every wrapper file here follows. */
7
7
 
8
- import { ApiClient, withClient } from "../util/api.js";
8
+ import { ApiClient, ApiRequestError, apiUrl, withClient, withCsrfHeader } from "../util/api.js";
9
9
  import { ListParams, buildQuery } from "../util/apiQuery.js";
10
10
  import {
11
11
  type EncryptionPreference,
@@ -252,6 +252,39 @@ export function setContactFavorite(contact: Contact, favorite: boolean, client?:
252
252
  return updateContact({ uid: contact.uid, version: contact.version, favorite }, client);
253
253
  }
254
254
 
255
+ /**
256
+ * The URL of a contact's own picture, or `undefined` when they have none. The version is part of it so a replaced picture is fetched again rather than
257
+ * served from the browser's cache.
258
+ */
259
+ export function contactPhotoUrl(contact: Pick<Contact, "uid" | "version" | "photoBlobKey">): string | undefined {
260
+ return contact.photoBlobKey ? apiUrl(`/mail/contacts/${encodeURIComponent(contact.uid)}/photo?v=${contact.version}`) : undefined;
261
+ }
262
+
263
+ /** The picture types the server accepts for a contact, and the most bytes it takes. */
264
+ export const CONTACT_PHOTO_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
265
+ export const CONTACT_PHOTO_MAX_BYTES = 1024 * 1024;
266
+
267
+ /** Sets a contact's own picture from an image file (the file's own bytes go up, not JSON) and returns the updated contact. */
268
+ export async function uploadContactPhoto(uid: string, version: number, file: Blob): Promise<Contact> {
269
+ const res = await fetch(apiUrl(`/mail/contacts/${encodeURIComponent(uid)}/photo?version=${version}`), {
270
+ method: "PUT",
271
+ credentials: "include",
272
+ headers: withCsrfHeader({ "Content-Type": file.type }, "PUT"),
273
+ body: file,
274
+ });
275
+ const contentType = res.headers.get("content-type") ?? "";
276
+ const body = contentType.includes("application/json") ? await res.json().catch(() => undefined) : undefined;
277
+ if (!res.ok) {
278
+ throw new ApiRequestError((body && (body.message || body.error)) || res.statusText || "Upload failed.", res.status, body?.code);
279
+ }
280
+ return body as Contact;
281
+ }
282
+
283
+ /** Removes a contact's own picture and returns the updated contact. */
284
+ export function deleteContactPhoto(uid: string, version: number, client?: ApiClient): Promise<Contact> {
285
+ return withClient(client, `/mail/contacts/${encodeURIComponent(uid)}/photo?version=${version}`, { method: "DELETE" });
286
+ }
287
+
255
288
  export function deleteContact(uid: string, version: number, client?: ApiClient): Promise<void> {
256
289
  return withClient(client, `/mail/contacts/${encodeURIComponent(uid)}?version=${version}`, { method: "DELETE" });
257
290
  }