@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
@@ -23,6 +23,8 @@ import {
23
23
  export interface EmptiableFolder {
24
24
  uid: string;
25
25
  name: string;
26
+ /** Every folder to empty, when "the folder" is the "All mailboxes" view of one type (each mailbox's own Deleted Items): `uid` is then the first of them. */
27
+ uids?: string[];
26
28
  }
27
29
 
28
30
  export interface PermanentDelete {
@@ -113,13 +115,18 @@ export function usePermanentDelete(): PermanentDelete {
113
115
  finish(outcome);
114
116
  return;
115
117
  }
116
- let outcome: EmptyFolderOutcome;
117
- try {
118
- outcome = await purgeFolder(request.folder.uid, undefined, client);
119
- } catch (err) {
120
- notifyApiError(err, `Couldn't empty ${request.folder.name}`);
121
- // Part of it may have gone before the failure: the caller reloads rather than assume nothing did.
122
- outcome = { emptied: false, deleted: [], failed: [] };
118
+ const outcome: EmptyFolderOutcome = { emptied: true, deleted: [], failed: [] };
119
+ for (const folderUid of request.folder.uids ?? [request.folder.uid]) {
120
+ try {
121
+ const one = await purgeFolder(folderUid, undefined, client);
122
+ outcome.emptied &&= one.emptied;
123
+ outcome.deleted.push(...one.deleted);
124
+ outcome.failed.push(...one.failed);
125
+ } catch (err) {
126
+ notifyApiError(err, `Couldn't empty ${request.folder.name}`);
127
+ // Part of it may have gone before the failure: the caller reloads rather than assume nothing did.
128
+ outcome.emptied = false;
129
+ }
123
130
  }
124
131
  afterDeleted(outcome.deleted);
125
132
  // The whole-folder request does not say which messages went, so the counts are read back rather than adjusted.
@@ -0,0 +1,124 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import type { IconType } from "react-icons";
6
+ import {
7
+ HiOutlineAcademicCap,
8
+ HiOutlineBanknotes,
9
+ HiOutlineBeaker,
10
+ HiOutlineBellAlert,
11
+ HiOutlineBolt,
12
+ HiOutlineBookOpen,
13
+ HiOutlineBriefcase,
14
+ HiOutlineBuildingOffice2,
15
+ HiOutlineCalendarDays,
16
+ HiOutlineChartBar,
17
+ HiOutlineChartPie,
18
+ HiOutlineChatBubbleLeftRight,
19
+ HiOutlineClipboardDocumentCheck,
20
+ HiOutlineCloud,
21
+ HiOutlineCog6Tooth,
22
+ HiOutlineCreditCard,
23
+ HiOutlineCube,
24
+ HiOutlineCurrencyDollar,
25
+ HiOutlineDocumentText,
26
+ HiOutlineEnvelopeOpen,
27
+ HiOutlineFolderOpen,
28
+ HiOutlineFunnel,
29
+ HiOutlineGlobeAlt,
30
+ HiOutlineHeart,
31
+ HiOutlineIdentification,
32
+ HiOutlineInboxStack,
33
+ HiOutlineKey,
34
+ HiOutlineLifebuoy,
35
+ HiOutlineLightBulb,
36
+ HiOutlineMap,
37
+ HiOutlineMegaphone,
38
+ HiOutlineNewspaper,
39
+ HiOutlinePaperAirplane,
40
+ HiOutlinePhone,
41
+ HiOutlinePhoto,
42
+ HiOutlinePresentationChartLine,
43
+ HiOutlinePuzzlePiece,
44
+ HiOutlineQueueList,
45
+ HiOutlineRectangleGroup,
46
+ HiOutlineRocketLaunch,
47
+ HiOutlineShieldCheck,
48
+ HiOutlineShoppingCart,
49
+ HiOutlineSparkles,
50
+ HiOutlineSquares2X2,
51
+ HiOutlineTag,
52
+ HiOutlineTicket,
53
+ HiOutlineTrophy,
54
+ HiOutlineUserGroup,
55
+ HiOutlineUsers,
56
+ HiOutlineVideoCamera,
57
+ HiOutlineWrenchScrewdriver,
58
+ } from "react-icons/hi2";
59
+
60
+ /** The icon a plugin nav item gets when its manifest names none, or one that isn't in `PLUGIN_ICONS`. */
61
+ export const DEFAULT_PLUGIN_ICON: IconType = HiOutlinePuzzlePiece;
62
+
63
+ /**
64
+ * The `react-icons/hi2` icons a plugin manifest's nav `icon` can name. A fixed list rather than the whole icon set: importing every
65
+ * icon by name would put all of them in the bundle. A plugin that needs another one gets it added here.
66
+ */
67
+ export const PLUGIN_ICONS: Readonly<Record<string, IconType>> = {
68
+ HiOutlineAcademicCap,
69
+ HiOutlineBanknotes,
70
+ HiOutlineBeaker,
71
+ HiOutlineBellAlert,
72
+ HiOutlineBolt,
73
+ HiOutlineBookOpen,
74
+ HiOutlineBriefcase,
75
+ HiOutlineBuildingOffice2,
76
+ HiOutlineCalendarDays,
77
+ HiOutlineChartBar,
78
+ HiOutlineChartPie,
79
+ HiOutlineChatBubbleLeftRight,
80
+ HiOutlineClipboardDocumentCheck,
81
+ HiOutlineCloud,
82
+ HiOutlineCog6Tooth,
83
+ HiOutlineCreditCard,
84
+ HiOutlineCube,
85
+ HiOutlineCurrencyDollar,
86
+ HiOutlineDocumentText,
87
+ HiOutlineEnvelopeOpen,
88
+ HiOutlineFolderOpen,
89
+ HiOutlineFunnel,
90
+ HiOutlineGlobeAlt,
91
+ HiOutlineHeart,
92
+ HiOutlineIdentification,
93
+ HiOutlineInboxStack,
94
+ HiOutlineKey,
95
+ HiOutlineLifebuoy,
96
+ HiOutlineLightBulb,
97
+ HiOutlineMap,
98
+ HiOutlineMegaphone,
99
+ HiOutlineNewspaper,
100
+ HiOutlinePaperAirplane,
101
+ HiOutlinePhone,
102
+ HiOutlinePhoto,
103
+ HiOutlinePresentationChartLine,
104
+ HiOutlinePuzzlePiece,
105
+ HiOutlineQueueList,
106
+ HiOutlineRectangleGroup,
107
+ HiOutlineRocketLaunch,
108
+ HiOutlineShieldCheck,
109
+ HiOutlineShoppingCart,
110
+ HiOutlineSparkles,
111
+ HiOutlineSquares2X2,
112
+ HiOutlineTag,
113
+ HiOutlineTicket,
114
+ HiOutlineTrophy,
115
+ HiOutlineUserGroup,
116
+ HiOutlineUsers,
117
+ HiOutlineVideoCamera,
118
+ HiOutlineWrenchScrewdriver,
119
+ };
120
+
121
+ /** The icon for a plugin nav item's `icon` name - see `PLUGIN_ICONS` - or `DEFAULT_PLUGIN_ICON`. */
122
+ export function pluginIcon(name: string | undefined): IconType {
123
+ return (name && Object.prototype.hasOwnProperty.call(PLUGIN_ICONS, name) ? PLUGIN_ICONS[name] : undefined) ?? DEFAULT_PLUGIN_ICON;
124
+ }
@@ -13,6 +13,14 @@ export interface PluginUiNavItem {
13
13
  label: string;
14
14
  /** A same-origin path under the host's prefix (`/settings/...`, `/admin/...`, `/...`). */
15
15
  href: string;
16
+ /** A `react-icons/hi2` icon name from `PLUGIN_ICONS` (`pluginIcons.ts`); anything else shows the generic puzzle piece. */
17
+ icon?: string;
18
+ /**
19
+ * For an app-rail entry that depends on the signed-in user: an API path (without the `/api` prefix, e.g. `/mail/video-meetings/personal-room`)
20
+ * the shell requests for them. The entry is shown only when that answers `200 { href }` with a same-origin path, and links there instead of to
21
+ * `href`; anything else hides it. See `useResolvedRailItems()`.
22
+ */
23
+ resolveFrom?: string;
16
24
  }
17
25
 
18
26
  /**
@@ -0,0 +1,54 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { useEffect, useState } from "react";
6
+ import { type ApiClient, withClient } from "../../../lib/util/api.js";
7
+ import { useApiClient } from "../../../lib/util/apiClientContext.js";
8
+ import { type PluginUiNavItem, isSafePluginHref } from "./pluginNav.js";
9
+
10
+ /** Where each app-rail entry that is worked out per user points, by entry id: only the entries the server answered for are here. */
11
+ export type ResolvedRailLinks = Record<string, string>;
12
+
13
+ /**
14
+ * Asks, once per signed-in user, for the entries of a plugin's app rail that are worked out per user (they name an API path in `resolveFrom` - the
15
+ * meet plugin's "Meet" button is one: it shows only for a user who has a personal room, and links to it). Each path is requested through
16
+ * `apiFetch()`; a `200` answering `{ href }` with a same-origin path puts that link in the result, and anything else (no such thing for this user, a
17
+ * server error, an answer that is not a path on this site) leaves the entry out, so a failing plugin never breaks the rail. Entries with no
18
+ * `resolveFrom` are not asked about. Nothing is requested without a signed-in user.
19
+ */
20
+ export function useResolvedRailItems(items: PluginUiNavItem[] | undefined, userUid: string | undefined, client?: ApiClient): ResolvedRailLinks {
21
+ const explicitClient = useApiClient();
22
+ const [links, setLinks] = useState<ResolvedRailLinks>({});
23
+ const asked = (items ?? []).filter((item) => typeof item?.resolveFrom === "string" && item.resolveFrom !== "");
24
+ // A list that is the same entries again (a new array on each render) does not start over.
25
+ const key = asked.map((item) => `${item.id}=${item.resolveFrom}`).join("|");
26
+
27
+ useEffect(() => {
28
+ if (!userUid || asked.length === 0) {
29
+ setLinks({});
30
+ return undefined;
31
+ }
32
+ let cancelled = false;
33
+ void Promise.all(
34
+ asked.map(async (item): Promise<[string, string] | null> => {
35
+ try {
36
+ const answer = await withClient<{ href?: unknown }>(client ?? explicitClient, item.resolveFrom!);
37
+ return typeof answer?.href === "string" && isSafePluginHref(answer.href) ? [item.id, answer.href] : null;
38
+ } catch {
39
+ return null;
40
+ }
41
+ }),
42
+ ).then((results) => {
43
+ if (!cancelled) {
44
+ setLinks(Object.fromEntries(results.filter((entry): entry is [string, string] => entry !== null)));
45
+ }
46
+ });
47
+ return () => {
48
+ cancelled = true;
49
+ };
50
+ // `asked` is derived from `key`.
51
+ }, [key, userUid, client, explicitClient]);
52
+
53
+ return links;
54
+ }
@@ -103,6 +103,7 @@ function ContactDetailContent({ uid }: { uid: string }) {
103
103
  onDelete={() => handleDelete(contact)}
104
104
  backHref={backHref}
105
105
  onKeysChanged={handleKeysChanged}
106
+ onChanged={setContact}
106
107
  />
107
108
  </div>
108
109
  );
@@ -13,6 +13,7 @@ import {
13
13
  listContacts,
14
14
  listDeletedContacts,
15
15
  setContactFavorite,
16
+ contactPhotoUrl,
16
17
  updateContact,
17
18
  } from "../../../lib/contacts/contactsApi.js";
18
19
  import { contactsToVCardFile, contactToVCard, parseVCards } from "../../../lib/contacts/vcard.js";
@@ -513,7 +514,7 @@ function ContactsContent({ userUid }: { userUid?: string }) {
513
514
  )}
514
515
  <td className="px-3 py-2">
515
516
  <button type="button" onClick={() => handleSelectRow(contact)} className="flex w-full min-w-0 items-center gap-2 text-left">
516
- <ContactAvatar displayName={contact.displayName} size={28} />
517
+ <ContactAvatar displayName={contact.displayName} size={28} photoUrl={contactPhotoUrl(contact)} email={contact.emails[0]?.address} />
517
518
  <span className="min-w-0 truncate font-medium">
518
519
  {contact.displayName}
519
520
  {contact.favorite && <span aria-label="Favorite"> ★</span>}
@@ -563,6 +564,7 @@ function ContactsContent({ userUid }: { userUid?: string }) {
563
564
  onEdit={() => setMode("edit")}
564
565
  onDelete={() => handleDelete(selected)}
565
566
  onKeysChanged={() => void reload()}
567
+ onChanged={() => void reload()}
566
568
  canResolveKeys={ownsMailbox || delegateCanUpdate}
567
569
  />
568
570
  </div>
@@ -80,7 +80,7 @@ import MailListToolbar from "../shared/components/mail/MailListToolbar.js";
80
80
  import MailSelectionBar from "../shared/components/mail/MailSelectionBar.js";
81
81
  import EmptyFolderBar from "../shared/components/mail/EmptyFolderBar.js";
82
82
  import { EMPTIABLE_FOLDER_TYPES } from "../shared/mail/permanentDelete.js";
83
- import { usePermanentDelete } from "../shared/mail/usePermanentDelete.js";
83
+ import { type EmptiableFolder, usePermanentDelete } from "../shared/mail/usePermanentDelete.js";
84
84
  import { useMailboxUpdateAccess } from "../shared/mail/useMailboxUpdateAccess.js";
85
85
  import {
86
86
  CONVERSATION_SORT_NOTE,
@@ -1002,12 +1002,6 @@ function InboxContent({ userUid }: { userUid?: string }) {
1002
1002
  setSearchAllMailboxes(false);
1003
1003
  }
1004
1004
  }, [searchQuery]);
1005
- // 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.
1006
- useEffect(() => {
1007
- if (aggregateFolderType && !isSearching) {
1008
- setSelectMode(false);
1009
- }
1010
- }, [aggregateFolderType, isSearching]);
1011
1005
 
1012
1006
  // Which listing this is, for the short-lived snapshot of each folder that makes going back to one instant (see
1013
1007
  // `listSnapshots.ts`). A search, an aggregate view and a folder that hasn't resolved have no snapshot.
@@ -1842,7 +1836,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
1842
1836
  * Items copy of its reply), and the list only ever showed this folder's half of it, so a bulk action
1843
1837
  * from here must not reach into the other folders' copies either. */
1844
1838
  const selectedConversationMessages = [...selectedConversationIds].flatMap((id) =>
1845
- (conversationMessagesById[id] ?? []).filter((m) => !folderUid || m.folderUid === folderUid),
1839
+ (conversationMessagesById[id] ?? []).filter((m) => (aggregateFolderType ? folderTypeOf(m.folderUid) === aggregateFolderType : !folderUid || m.folderUid === folderUid)),
1846
1840
  );
1847
1841
  const selectedMessages = asConversations
1848
1842
  ? selectedConversationMessages
@@ -1886,23 +1880,24 @@ function InboxContent({ userUid }: { userUid?: string }) {
1886
1880
  /** Loads (once) the messages behind each of `ids`, so a ticked conversation resolves to the messages
1887
1881
  * every bulk action below acts on. If any of them fails to load, none of that batch stays ticked and
1888
1882
  * a pop-up says why - better than acting on the part of a selection that happened to arrive. */
1889
- async function resolveConversations(ids: string[]) {
1890
- const missing = ids.filter((id) => !conversationMessagesById[id]);
1883
+ async function resolveConversations(rows: ListedConversation[]) {
1884
+ const missing = rows.filter((row) => !conversationMessagesById[conversationKey(row)]);
1891
1885
  if (missing.length === 0) {
1892
1886
  return;
1893
1887
  }
1894
1888
  setResolvingSelection((n) => n + 1);
1895
1889
  try {
1896
1890
  const loaded = await Promise.all(
1897
- missing.map(async (id) => [id, await listConversationMessages(activeMailboxUid, id, {}, client)] as const),
1891
+ // A row of the "All mailboxes" view names its own mailbox; any other list is the open mailbox's.
1892
+ missing.map(async (row) => [conversationKey(row), await listConversationMessages(row.mailboxUid ?? activeMailboxUid, row.conversationId, {}, client)] as const),
1898
1893
  );
1899
1894
  setConversationMessagesById((prev) => ({ ...prev, ...Object.fromEntries(loaded) }));
1900
1895
  } catch (err) {
1901
1896
  notifyApiError(err, "Couldn't load the messages in one of those conversations");
1902
1897
  setSelectedConversationIds((prev) => {
1903
1898
  const next = new Set(prev);
1904
- for (const id of missing) {
1905
- next.delete(id);
1899
+ for (const row of missing) {
1900
+ next.delete(conversationKey(row));
1906
1901
  }
1907
1902
  return next;
1908
1903
  });
@@ -1911,8 +1906,8 @@ function InboxContent({ userUid }: { userUid?: string }) {
1911
1906
  }
1912
1907
  }
1913
1908
 
1914
- function toggleConversationSelected(conversation: ConversationSummary) {
1915
- const id = conversation.conversationId;
1909
+ function toggleConversationSelected(conversation: ListedConversation) {
1910
+ const id = conversationKey(conversation);
1916
1911
  const ticking = !selectedConversationIds.has(id);
1917
1912
  setSelectedConversationIds((prev) => {
1918
1913
  const next = new Set(prev);
@@ -1924,14 +1919,13 @@ function InboxContent({ userUid }: { userUid?: string }) {
1924
1919
  return next;
1925
1920
  });
1926
1921
  if (ticking) {
1927
- void resolveConversations([id]);
1922
+ void resolveConversations([conversation]);
1928
1923
  }
1929
1924
  }
1930
1925
 
1931
1926
  function selectAllConversations() {
1932
- const ids = conversations.map(conversationKey);
1933
- setSelectedConversationIds(new Set(ids));
1934
- void resolveConversations(ids);
1927
+ setSelectedConversationIds(new Set(conversations.map(conversationKey)));
1928
+ void resolveConversations(conversations);
1935
1929
  }
1936
1930
 
1937
1931
  /**
@@ -2105,7 +2099,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
2105
2099
  }
2106
2100
 
2107
2101
  /** Empty folder: permanently deletes everything in the folder being listed (Deleted Items or Junk Email) after the reader has confirmed. */
2108
- async function emptyListedFolder(folder: Folder, count: number | undefined) {
2102
+ async function emptyListedFolder(folder: EmptiableFolder, count: number | undefined) {
2109
2103
  const outcome = await permanent.requestEmptyFolder({ uid: folder.uid, name: folder.name }, count);
2110
2104
  if (!outcome) {
2111
2105
  return;
@@ -2348,7 +2342,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
2348
2342
  const inConversations = asConversations;
2349
2343
  /** 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
2350
2344
  * search over them are, each hit acted on in its own mailbox (see `inEachMailbox()`). */
2351
- const keyboardActions = !aggregateFolderType || isSearching;
2345
+ const keyboardActions = true;
2352
2346
  /** What the keyboard acts on exists: the ticked rows in select mode, else the message (or conversation) open in the reading pane. */
2353
2347
  const keyboardTargetExists = selectMode ? selectedMessages.length > 0 : threadPane ? openThread !== null : selected !== null;
2354
2348
  const rowCount = inConversations ? listedConversations.length : messages.length;
@@ -2586,13 +2580,33 @@ function InboxContent({ userUid }: { userUid?: string }) {
2586
2580
  // 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),
2587
2581
  // else for the folder being viewed, so the button says so before anything is ticked.
2588
2582
  const deletesPermanently =
2589
- selectedMessages.length > 0 ? selectedMessages.every(inDeletedItems) : currentFolders.find((f) => f.uid === folderUid)?.type === "deleted_items";
2590
- // 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
2591
- // mailboxes" views, which have no one folder to empty.
2592
- const emptiableFolder = !isSearching && !aggregateFolderType ? currentFolders.find((f) => f.uid === folderUid && EMPTIABLE_FOLDER_TYPES.includes(f.type)) : undefined;
2583
+ selectedMessages.length > 0
2584
+ ? selectedMessages.every(inDeletedItems)
2585
+ : aggregateFolderType
2586
+ ? aggregateFolderType === "deleted_items"
2587
+ : currentFolders.find((f) => f.uid === folderUid)?.type === "deleted_items";
2588
+ // 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
2589
+ // empties each mailbox's own (a share the reader may not delete in refuses, and says so) - not for search results.
2590
+ const emptiableFolders: Folder[] =
2591
+ isSearching
2592
+ ? []
2593
+ : aggregateFolderType
2594
+ ? EMPTIABLE_FOLDER_TYPES.includes(aggregateFolderType)
2595
+ ? mailboxFolders.flatMap((entry) => entry.folders.filter((f) => f.type === aggregateFolderType))
2596
+ : []
2597
+ : currentFolders.filter((f) => f.uid === folderUid && EMPTIABLE_FOLDER_TYPES.includes(f.type));
2598
+ const emptiableFolder: EmptiableFolder | undefined =
2599
+ emptiableFolders.length === 0
2600
+ ? undefined
2601
+ : {
2602
+ uid: emptiableFolders[0].uid,
2603
+ name: aggregateFolderType ? (emptiableFolders[0].type === "junk" ? "Junk Email" : "Deleted Items") : emptiableFolders[0].name,
2604
+ uids: aggregateFolderType ? emptiableFolders.map((f) => f.uid) : undefined,
2605
+ };
2593
2606
  // 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.
2594
- const emptiableCount = emptiableFolder && folderCountOf(emptiableFolder).total > 0 ? folderCountOf(emptiableFolder).total : undefined;
2595
- const emptyDisabledReason = !mailboxWritable
2607
+ const emptiableTotal = emptiableFolders.reduce((sum, f) => sum + folderCountOf(f).total, 0);
2608
+ const emptiableCount = emptiableFolder && emptiableTotal > 0 ? emptiableTotal : undefined;
2609
+ const emptyDisabledReason = !(aggregateFolderType ? emptiableFolders.length > 0 : mailboxWritable)
2596
2610
  ? "This mailbox is shared with you view-only"
2597
2611
  : listedRowCount === 0 && emptiableCount === undefined
2598
2612
  ? "This folder is already empty"
@@ -2698,14 +2712,8 @@ function InboxContent({ userUid }: { userUid?: string }) {
2698
2712
  ? CONVERSATION_SORT_NOTE
2699
2713
  : undefined
2700
2714
  }
2701
- selectDisabled={(!!aggregateFolderType && !isSearching) || loading || listedRowCount === 0}
2702
- selectDisabledReason={
2703
- aggregateFolderType && !isSearching
2704
- ? "Open a mailbox's own folder to select messages"
2705
- : loading
2706
- ? "Wait for this folder to finish loading"
2707
- : "There is nothing here to select"
2708
- }
2715
+ selectDisabled={loading || listedRowCount === 0}
2716
+ selectDisabledReason={loading ? "Wait for this folder to finish loading" : "There is nothing here to select"}
2709
2717
  />
2710
2718
  )}
2711
2719
  {!isMobile && <div className="p-2 border-b border-border">{searchField}</div>}
@@ -20,7 +20,7 @@ export interface AdminShellProps extends PluginNavProps {
20
20
  */
21
21
  impersonationBaseUrl?: string;
22
22
  }
23
- /** `NAV_ITEMS` followed by the plugins' `adminNav` items (generic icon). Core ids win, including the
23
+ /** `NAV_ITEMS` followed by the plugins' `adminNav` items (their manifest `icon`, or a generic one). Core ids win, including the
24
24
  * off-rail sections' - see `mergePluginNavItems`. */
25
25
  export declare function adminNavItems(pluginNav?: PluginNav): NavItem[];
26
26
  /**
@@ -21,6 +21,7 @@ import AppearanceProvider from "../../../appearance/AppearanceProvider.js";
21
21
  import UserMenu from "../../layout/UserMenu.js";
22
22
  import { clearElevationAttempt, elevationAttemptedRecently, elevationUrl, isElevationRequired, recordElevationAttempt, } from "../elevation.js";
23
23
  import { signOutOfConsole } from "../signOut.js";
24
+ import { pluginIcon } from "../../../plugins/pluginIcons.js";
24
25
  import { mergePluginNavItems } from "../../../plugins/pluginNav.js";
25
26
  /** Sections shown in the persistent icon rail / mobile tab bar — every admin area reachable from
26
27
  * anywhere in the console. Deliberately excludes `quarantine`/`ingestQueue`: those are scoped to a
@@ -91,10 +92,10 @@ const MAILBOX_SCOPED_ITEMS = [
91
92
  /** The setup wizard - reached by redirect or from the Mailboxes page, not from the rail. */
92
93
  const SETUP_ITEM = { id: "setup", href: "/admin/setup", label: "Setup", icon: HiOutlineRocketLaunch };
93
94
  const OFF_RAIL_ITEMS = [...MAILBOX_SCOPED_ITEMS, SETUP_ITEM];
94
- /** `NAV_ITEMS` followed by the plugins' `adminNav` items (generic icon). Core ids win, including the
95
+ /** `NAV_ITEMS` followed by the plugins' `adminNav` items (their manifest `icon`, or a generic one). Core ids win, including the
95
96
  * off-rail sections' - see `mergePluginNavItems`. */
96
97
  export function adminNavItems(pluginNav) {
97
- return mergePluginNavItems(NAV_ITEMS, pluginNav?.adminNav, ({ id, href, label }) => ({ id, href, label, icon: HiOutlinePuzzlePiece }), OFF_RAIL_ITEMS.map((item) => item.id));
98
+ return mergePluginNavItems(NAV_ITEMS, pluginNav?.adminNav, ({ id, href, label, icon }) => ({ id, href, label, icon: pluginIcon(icon) }), OFF_RAIL_ITEMS.map((item) => item.id));
98
99
  }
99
100
  /**
100
101
  * Gates every `apps/admin` page behind the `admin` trusted role, and behind an elevated session. Uses
@@ -4,10 +4,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import { useContext, useEffect, useRef, useState } from "react";
7
+ import { contactPhotoUrl, setContactFavorite } from "../../../../lib/contacts/contactsApi.js";
7
8
  import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
8
9
  import ContactAvatar from "../../../../lib/components/avatar/ContactAvatar.js";
9
10
  import Button from "../../../../lib/components/buttons/Button.js";
10
- import CopyButton from "../../../../lib/components/buttons/CopyButton.js";
11
+ import CopyIconButton from "../../../../lib/components/buttons/CopyIconButton.js";
12
+ import { HiOutlineEnvelope, HiOutlineUserPlus } from "react-icons/hi2";
13
+ import FavoriteStarButton from "./FavoriteStarButton.js";
11
14
  import Skeleton from "../../../../lib/components/feedback/Skeleton.js";
12
15
  import Modal from "../../../../lib/components/overlays/Modal.js";
13
16
  import { formatMailAddress } from "../../../../lib/mail/mailAddress.js";
@@ -69,6 +72,8 @@ export default function ContactCard({ participant, context = {}, onClose, userUi
69
72
  const [listed, setListed] = useState({ status: "loading" });
70
73
  const [created, setCreated] = useState();
71
74
  const [creating, setCreating] = useState(false);
75
+ // The contact as last changed from this card (starred or unstarred), which is newer than the one looked up.
76
+ const [changed, setChanged] = useState();
72
77
  const mounted = useRef(true);
73
78
  useEffect(() => {
74
79
  mounted.current = true;
@@ -96,7 +101,7 @@ export default function ContactCard({ participant, context = {}, onClose, userUi
96
101
  const events = useSection(folders.status === "ready" ? () => loadUpcomingEvents(folders.data, participant.address, now.current, client) : undefined, folders);
97
102
  const card = useSection(() => loadParticipantCard(participant, context, client));
98
103
  const name = participantName(participant);
99
- const stored = created ?? (contact.status === "ready" ? contact.data : undefined);
104
+ const stored = changed ?? created ?? (contact.status === "ready" ? contact.data : undefined);
100
105
  const external = mailboxes.status === "ready" && isExternalAddress(participant.address, ownDomains(mailboxes.data));
101
106
  // What is known when there is no contact: the name, the address and the message's vCard.
102
107
  const known = stored ?? contactInputFor(participant, card.status === "ready" ? card.data : undefined, { mailboxUid: "", folderUid: "" });
@@ -124,9 +129,20 @@ export default function ContactCard({ participant, context = {}, onClose, userUi
124
129
  }
125
130
  }
126
131
  }
132
+ async function handleToggleFavorite(current) {
133
+ try {
134
+ const next = await setContactFavorite(current, !current.favorite, client);
135
+ if (mounted.current) {
136
+ setChanged(next);
137
+ }
138
+ }
139
+ catch (err) {
140
+ notifyApiError(err, "Couldn't update this contact");
141
+ }
142
+ }
127
143
  function handleEmail() {
128
144
  openCompose({ to: formatMailAddress(participant) });
129
145
  onClose();
130
146
  }
131
- return (_jsxs(Modal, { open: true, onClose: onClose, title: name, children: [_jsxs("div", { className: "flex items-center gap-4", children: [_jsx(ContactAvatar, { displayName: name, size: 72 }), _jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "text-sm text-text-muted break-words", children: participant.address }), external && (_jsx("span", { className: "inline-block mt-1 text-xs font-semibold py-0.5 px-2 rounded-pill bg-warning/15 text-text", children: "External" })), (known.jobTitle || known.company) && (_jsx("p", { className: "mt-1 text-sm break-words", children: [known.jobTitle, known.company].filter(Boolean).join(", ") }))] })] }), _jsxs("div", { className: "mt-4 flex flex-wrap items-center gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", className: ACTION_CLASS, onClick: handleEmail, children: "Email" }), _jsx(CopyButton, { value: participant.address, label: "Copy address", children: "Copy address" }), stored ? (_jsx("a", { href: `${APP_HREFS.contacts}/${encodeURIComponent(stored.uid)}`, onClick: onClose, className: "inline-flex items-center text-xs font-semibold py-1 px-3 rounded-sm border border-border hover:border-accent hover:text-accent-dark focus-visible:outline-2 focus-visible:outline-primary", children: "Open contact" })) : (_jsx(Button, { type: "button", variant: "secondary", className: ACTION_CLASS, disabled: !createWith || creating, loading: creating, onClick: () => void handleCreate(createWith), children: "Create contact" }))] }), _jsxs("section", { className: "mt-5", children: [_jsx("h3", { className: "text-sm font-bold mb-2", children: "Contact" }), !stored && contact.status === "loading" && _jsx(Skeleton, { width: "w-1/2", className: "mb-2" }), !stored && contact.status === "error" && (_jsx("p", { role: "alert", className: "text-sm text-danger mb-2", children: "Couldn\u2019t check your address book." })), !stored && contact.status === "ready" && _jsx("p", { className: "text-sm text-text-muted mb-2", children: "Not in your address book." }), _jsxs("dl", { className: "flex flex-col gap-1.5", children: [known.emails.map((email) => (_jsx(Detail, { label: ["Email", kindLabel(email.type)].filter(Boolean).join(" "), children: email.address }, `e:${email.address}`))), known.phones.map((phone) => (_jsx(Detail, { label: ["Phone", kindLabel(phone.type)].filter(Boolean).join(" "), children: phone.phoneNumber }, `p:${phone.phoneNumber}`))), known.company && _jsx(Detail, { label: "Company", children: known.company }), known.jobTitle && _jsx(Detail, { label: "Job title", children: known.jobTitle }), known.addresses.map((address, index) => (_jsx(Detail, { label: ["Address", kindLabel(address.type)].filter(Boolean).join(" "), children: [address.street, [address.postalCode, address.city].filter(Boolean).join(" "), address.state, address.country].filter(Boolean).join("\n") }, `a:${index}`))), known.notes && _jsx(Detail, { label: "Notes", children: known.notes })] })] }), _jsx(CardSection, { title: "Recent messages", section: messages, empty: "No recent messages", failure: "Couldn't load recent messages.", isEmpty: (list) => list.length === 0, render: (list) => (_jsx("ul", { className: "flex flex-col", children: list.map((message) => (_jsx("li", { children: _jsxs("a", { href: `/messages/${encodeURIComponent(message.uid)}`, onClick: onClose, className: ROW_LINK_CLASS, children: [_jsxs("span", { className: "flex items-baseline justify-between gap-3 text-sm", children: [_jsx("span", { className: "font-medium truncate", children: message.subject || "(No subject)" }), _jsx("time", { dateTime: message.receivedDate, className: "text-xs text-text-muted shrink-0", children: new Date(message.receivedDate).toLocaleDateString() })] }), _jsx("span", { className: "block text-xs text-text-muted truncate", children: message.bodyPreview })] }) }, message.uid))) })) }), _jsx(CardSection, { title: "Upcoming events", section: events, empty: "No upcoming events", failure: "Couldn't load upcoming events.", isEmpty: (list) => list.length === 0, render: (list) => (_jsx("ul", { className: "flex flex-col", children: list.map((event) => (_jsx("li", { children: _jsxs("a", { href: APP_HREFS.calendar, onClick: onClose, className: ROW_LINK_CLASS, children: [_jsx("span", { className: "block text-sm font-medium truncate", children: event.title }), _jsx("span", { className: "block text-xs text-text-muted", children: formatInviteWhen(event) })] }) }, event.occurrenceKey))) })) })] }));
147
+ return (_jsxs(Modal, { open: true, onClose: onClose, title: name, children: [_jsxs("div", { className: "flex items-center gap-4", children: [_jsx(ContactAvatar, { displayName: name, size: 72, photoUrl: stored && contactPhotoUrl(stored), email: participant.address }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsxs("p", { className: "text-sm text-text-muted break-words", children: [participant.address, _jsx(CopyIconButton, { value: participant.address, label: "Copy address", className: "ml-1" })] }), external && (_jsx("span", { className: "inline-block mt-1 text-xs font-semibold py-0.5 px-2 rounded-pill bg-warning/15 text-text", children: "External" })), (known.jobTitle || known.company) && (_jsx("p", { className: "mt-1 text-sm break-words", children: [known.jobTitle, known.company].filter(Boolean).join(", ") }))] }), stored && _jsx(FavoriteStarButton, { favorite: !!stored.favorite, onToggle: () => void handleToggleFavorite(stored), className: "shrink-0 self-start" })] }), _jsxs("div", { className: "mt-4 flex flex-wrap items-center gap-2", children: [_jsxs(Button, { type: "button", variant: "secondary", className: `${ACTION_CLASS} inline-flex items-center gap-1.5`, onClick: handleEmail, children: [_jsx(HiOutlineEnvelope, { "aria-hidden": "true", className: "size-4" }), "Email"] }), stored ? (_jsx("a", { href: `${APP_HREFS.contacts}/${encodeURIComponent(stored.uid)}`, onClick: onClose, className: "inline-flex items-center text-xs font-semibold py-1 px-3 rounded-sm border border-border hover:border-accent hover:text-accent-dark focus-visible:outline-2 focus-visible:outline-primary", children: "Open contact" })) : (_jsxs(Button, { type: "button", variant: "secondary", className: `${ACTION_CLASS} inline-flex items-center gap-1.5`, disabled: !createWith || creating, loading: creating, onClick: () => void handleCreate(createWith), children: [_jsx(HiOutlineUserPlus, { "aria-hidden": "true", className: "size-4" }), "Add to contacts"] }))] }), _jsxs("section", { className: "mt-5", children: [_jsx("h3", { className: "text-sm font-bold mb-2", children: "Contact" }), !stored && contact.status === "loading" && _jsx(Skeleton, { width: "w-1/2", className: "mb-2" }), !stored && contact.status === "error" && (_jsx("p", { role: "alert", className: "text-sm text-danger mb-2", children: "Couldn\u2019t check your address book." })), !stored && contact.status === "ready" && _jsx("p", { className: "text-sm text-text-muted mb-2", children: "Not in your address book." }), _jsxs("dl", { className: "flex flex-col gap-1.5", children: [known.emails.map((email) => (_jsxs(Detail, { label: ["Email", kindLabel(email.type)].filter(Boolean).join(" "), children: [email.address, _jsx(CopyIconButton, { value: email.address, label: "Copy email address", className: "ml-1" })] }, `e:${email.address}`))), known.phones.map((phone) => (_jsx(Detail, { label: ["Phone", kindLabel(phone.type)].filter(Boolean).join(" "), children: phone.phoneNumber }, `p:${phone.phoneNumber}`))), known.company && _jsx(Detail, { label: "Company", children: known.company }), known.jobTitle && _jsx(Detail, { label: "Job title", children: known.jobTitle }), known.addresses.map((address, index) => (_jsx(Detail, { label: ["Address", kindLabel(address.type)].filter(Boolean).join(" "), children: [address.street, [address.postalCode, address.city].filter(Boolean).join(" "), address.state, address.country].filter(Boolean).join("\n") }, `a:${index}`))), known.notes && _jsx(Detail, { label: "Notes", children: known.notes })] })] }), _jsx(CardSection, { title: "Recent messages", section: messages, empty: "No recent messages", failure: "Couldn't load recent messages.", isEmpty: (list) => list.length === 0, render: (list) => (_jsx("ul", { className: "flex flex-col", children: list.map((message) => (_jsx("li", { children: _jsxs("a", { href: `/messages/${encodeURIComponent(message.uid)}`, onClick: onClose, className: ROW_LINK_CLASS, children: [_jsxs("span", { className: "flex items-baseline justify-between gap-3 text-sm", children: [_jsx("span", { className: "font-medium truncate", children: message.subject || "(No subject)" }), _jsx("time", { dateTime: message.receivedDate, className: "text-xs text-text-muted shrink-0", children: new Date(message.receivedDate).toLocaleDateString() })] }), _jsx("span", { className: "block text-xs text-text-muted truncate", children: message.bodyPreview })] }) }, message.uid))) })) }), _jsx(CardSection, { title: "Upcoming events", section: events, empty: "No upcoming events", failure: "Couldn't load upcoming events.", isEmpty: (list) => list.length === 0, render: (list) => (_jsx("ul", { className: "flex flex-col", children: list.map((event) => (_jsx("li", { children: _jsxs("a", { href: APP_HREFS.calendar, onClick: onClose, className: ROW_LINK_CLASS, children: [_jsx("span", { className: "block text-sm font-medium truncate", children: event.title }), _jsx("span", { className: "block text-xs text-text-muted", children: formatInviteWhen(event) })] }) }, event.occurrenceKey))) })) })] }));
132
148
  }
@@ -11,6 +11,8 @@ export interface ContactDetailPaneProps {
11
11
  /** Called after one of the contact's key changes was accepted or kept, or turned out to be stale - the caller
12
12
  * re-reads the contact. */
13
13
  onKeysChanged?: () => void;
14
+ /** Called with the contact after the star was toggled, so the caller shows the changed contact. */
15
+ onChanged?: (contact: Contact) => void;
14
16
  /** `false` when the reader is known not to be able to change this contact's keys (the actions are hidden); `undefined`
15
17
  * when unknown (they're hidden after a 403). */
16
18
  canResolveKeys?: boolean;
@@ -20,4 +22,4 @@ export interface ContactDetailPaneProps {
20
22
  * always visible alongside the contact list) and the mobile detail route
21
23
  * (`apps/www/contacts/[uid].tsx`, a full page on its own reached by tapping a contact row).
22
24
  */
23
- export default function ContactDetailPane({ contact, onEdit, onDelete, backHref, onKeysChanged, canResolveKeys }: ContactDetailPaneProps): React.JSX.Element;
25
+ export default function ContactDetailPane({ contact, onEdit, onDelete, backHref, onKeysChanged, onChanged, canResolveKeys }: ContactDetailPaneProps): React.JSX.Element;
@@ -4,6 +4,11 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import { useState } from "react";
7
+ import { contactPhotoUrl, setContactFavorite } from "../../../../lib/contacts/contactsApi.js";
8
+ import CopyIconButton from "../../../../lib/components/buttons/CopyIconButton.js";
9
+ import { useApiClient } from "../../../../lib/util/apiClientContext.js";
10
+ import { notifyApiError } from "../../notifications/apiErrors.js";
11
+ import FavoriteStarButton from "./FavoriteStarButton.js";
7
12
  import Button from "../../../../lib/components/buttons/Button.js";
8
13
  import ContactAvatar from "../../../../lib/components/avatar/ContactAvatar.js";
9
14
  import KeyChangeReview from "./KeyChangeReview.js";
@@ -27,19 +32,29 @@ function RevocationBadge({ label }) {
27
32
  * always visible alongside the contact list) and the mobile detail route
28
33
  * (`apps/www/contacts/[uid].tsx`, a full page on its own reached by tapping a contact row).
29
34
  */
30
- export default function ContactDetailPane({ contact, onEdit, onDelete, backHref, onKeysChanged, canResolveKeys }) {
35
+ export default function ContactDetailPane({ contact, onEdit, onDelete, backHref, onKeysChanged, onChanged, canResolveKeys }) {
36
+ const client = useApiClient();
31
37
  // The outcome of the last key decision, kept per contact so selecting another contact doesn't carry it over.
32
38
  const [keyNotice, setKeyNotice] = useState(null);
33
39
  const notice = keyNotice?.contactUid === contact.uid ? keyNotice.text : null;
34
40
  const keyConflicts = contact.keyConflicts ?? [];
35
41
  const previousKeys = contact.previousKeys ?? [];
42
+ async function handleToggleFavorite() {
43
+ try {
44
+ const changed = await setContactFavorite(contact, !contact.favorite, client);
45
+ onChanged?.(changed);
46
+ }
47
+ catch (err) {
48
+ notifyApiError(err, "Couldn't update this contact");
49
+ }
50
+ }
36
51
  // Pinned signing keys vouch for signatures, so the pinned-signer cache is dropped along with re-reading the contact.
37
52
  function refresh(text) {
38
53
  setKeyNotice({ contactUid: contact.uid, text });
39
54
  clearPinnedSignerCache();
40
55
  onKeysChanged?.();
41
56
  }
42
- return (_jsxs("div", { role: "region", "aria-label": "Contact details", className: "max-w-xl flex flex-col gap-5", children: [backHref && (_jsx("a", { href: backHref, className: "text-sm text-primary-dark hover:underline", children: "\u2190 Back to contacts" })), _jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx(ContactAvatar, { displayName: contact.displayName, size: 48 }), _jsxs("div", { children: [_jsx("h1", { className: "text-xl font-bold tracking-tight", children: contact.displayName }), contact.jobTitle && contact.company && (_jsxs("p", { className: "text-sm text-text-muted mt-0.5", children: [contact.jobTitle, " at ", contact.company] }))] })] }), _jsxs("div", { className: "flex gap-2 shrink-0", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onEdit, children: "Edit" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", onClick: onDelete, children: "Delete" })] })] }), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6 flex flex-col gap-4 text-sm", children: [contact.emails.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Email" }), contact.emails.map((e, i) => (_jsxs("div", { children: [e.address, " ", _jsxs("span", { className: "text-text-muted", children: ["(", e.type, ")"] })] }, i)))] })), contact.phones.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Phone" }), contact.phones.map((p, i) => (_jsxs("div", { children: [p.phoneNumber, " ", _jsxs("span", { className: "text-text-muted", children: ["(", p.type, ")"] })] }, i)))] })), contact.addresses.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Address" }), contact.addresses.map((a, i) => (_jsx("div", { children: [a.street, a.city, a.state, a.postalCode, a.country].filter(Boolean).join(", ") }, i)))] })), contact.categories && contact.categories.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Categories" }), _jsx("div", { children: contact.categories.join(", ") })] })), contact.notes && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Notes" }), _jsx("div", { children: contact.notes })] })), (contact.keys && contact.keys.length > 0) ||
57
+ return (_jsxs("div", { role: "region", "aria-label": "Contact details", className: "max-w-xl flex flex-col gap-5", children: [backHref && (_jsx("a", { href: backHref, className: "text-sm text-primary-dark hover:underline", children: "\u2190 Back to contacts" })), _jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx(ContactAvatar, { displayName: contact.displayName, size: 48, photoUrl: contactPhotoUrl(contact), email: contact.emails[0]?.address }), _jsxs("div", { children: [_jsxs("h1", { className: "text-xl font-bold tracking-tight", children: [contact.displayName, _jsx(FavoriteStarButton, { favorite: !!contact.favorite, onToggle: () => void handleToggleFavorite(), className: "ml-1 align-middle" })] }), contact.jobTitle && contact.company && (_jsxs("p", { className: "text-sm text-text-muted mt-0.5", children: [contact.jobTitle, " at ", contact.company] }))] })] }), _jsxs("div", { className: "flex gap-2 shrink-0", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onEdit, children: "Edit" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", onClick: onDelete, children: "Delete" })] })] }), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6 flex flex-col gap-4 text-sm", children: [contact.emails.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Email" }), contact.emails.map((e, i) => (_jsxs("div", { children: [e.address, _jsx(CopyIconButton, { value: e.address, label: "Copy email address", className: "mx-0.5" }), " ", _jsxs("span", { className: "text-text-muted", children: ["(", e.type, ")"] })] }, i)))] })), contact.phones.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Phone" }), contact.phones.map((p, i) => (_jsxs("div", { children: [p.phoneNumber, " ", _jsxs("span", { className: "text-text-muted", children: ["(", p.type, ")"] })] }, i)))] })), contact.addresses.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Address" }), contact.addresses.map((a, i) => (_jsx("div", { children: [a.street, a.city, a.state, a.postalCode, a.country].filter(Boolean).join(", ") }, i)))] })), contact.categories && contact.categories.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Categories" }), _jsx("div", { children: contact.categories.join(", ") })] })), contact.notes && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Notes" }), _jsx("div", { children: contact.notes })] })), (contact.keys && contact.keys.length > 0) ||
43
58
  contact.encryptPreference ||
44
59
  keyConflicts.length > 0 ||
45
60
  previousKeys.length > 0 ? (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Encryption" }), notice && (_jsx("p", { role: "status", className: "mb-2 py-2 px-3 rounded-sm bg-surface-alt text-text", children: notice })), keyConflicts.map((conflict) => {