@rapidmx/web-client 0.26.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) 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/MessageDetailPane.tsx +18 -18
  10. package/apps/shared/components/settings/layout/SettingsShell.tsx +3 -3
  11. package/apps/shared/plugins/pluginIcons.ts +124 -0
  12. package/apps/shared/plugins/pluginNav.ts +8 -0
  13. package/apps/shared/plugins/useResolvedRailItems.ts +54 -0
  14. package/apps/www/contacts/[uid].tsx +1 -0
  15. package/apps/www/contacts/index.tsx +3 -1
  16. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
  17. package/dist/apps/shared/components/admin/layout/AdminShell.js +3 -2
  18. package/dist/apps/shared/components/contacts/ContactCard.js +19 -3
  19. package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +3 -1
  20. package/dist/apps/shared/components/contacts/ContactDetailPane.js +17 -2
  21. package/dist/apps/shared/components/contacts/ContactForm.js +20 -2
  22. package/dist/apps/shared/components/contacts/ContactPhotoField.d.ts +21 -0
  23. package/dist/apps/shared/components/contacts/ContactPhotoField.js +46 -0
  24. package/dist/apps/shared/components/contacts/FavoriteStarButton.d.ts +9 -0
  25. package/dist/apps/shared/components/contacts/FavoriteStarButton.js +12 -0
  26. package/dist/apps/shared/components/layout/AppShell.d.ts +3 -2
  27. package/dist/apps/shared/components/layout/AppShell.js +13 -5
  28. package/dist/apps/shared/components/mail/MessageDetailPane.js +2 -5
  29. package/dist/apps/shared/components/settings/layout/SettingsShell.js +4 -3
  30. package/dist/apps/shared/plugins/pluginIcons.d.ts +10 -0
  31. package/dist/apps/shared/plugins/pluginIcons.js +64 -0
  32. package/dist/apps/shared/plugins/pluginNav.d.ts +8 -0
  33. package/dist/apps/shared/plugins/useResolvedRailItems.d.ts +12 -0
  34. package/dist/apps/shared/plugins/useResolvedRailItems.js +47 -0
  35. package/dist/apps/www/contacts/[uid].js +1 -1
  36. package/dist/apps/www/contacts/index.js +2 -2
  37. package/dist/lib/components/avatar/ContactAvatar.d.ts +9 -3
  38. package/dist/lib/components/avatar/ContactAvatar.js +29 -6
  39. package/dist/lib/components/buttons/CopyIconButton.d.ts +13 -0
  40. package/dist/lib/components/buttons/CopyIconButton.js +16 -0
  41. package/dist/lib/contacts/contactsApi.d.ts +12 -0
  42. package/dist/lib/contacts/contactsApi.js +30 -1
  43. package/dist/lib/contacts/gravatar.d.ts +12 -0
  44. package/dist/lib/contacts/gravatar.js +43 -0
  45. package/lib/components/avatar/ContactAvatar.tsx +43 -7
  46. package/lib/components/buttons/CopyIconButton.tsx +49 -0
  47. package/lib/contacts/contactsApi.ts +34 -1
  48. package/lib/contacts/gravatar.ts +47 -0
  49. package/package.json +1 -1
@@ -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>
@@ -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) => {
@@ -5,10 +5,12 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import { useState } from "react";
7
7
  import { ApiRequestError } from "../../../../lib/util/api.js";
8
- import { createContact, updateContact, } from "../../../../lib/contacts/contactsApi.js";
8
+ import { contactPhotoUrl, createContact, deleteContactPhoto, updateContact, uploadContactPhoto, } from "../../../../lib/contacts/contactsApi.js";
9
9
  import Alert from "../../../../lib/components/feedback/Alert.js";
10
10
  import Button from "../../../../lib/components/buttons/Button.js";
11
11
  import FormField from "../../../../lib/components/forms/FormField.js";
12
+ import ContactPhotoField from "./ContactPhotoField.js";
13
+ import FavoriteStarButton from "./FavoriteStarButton.js";
12
14
  import { useApiClient } from "../../../../lib/util/apiClientContext.js";
13
15
  import { findWellKnownFolderUid } from "../../mail/findWellKnownFolderUid.js";
14
16
  import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
@@ -36,6 +38,9 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
36
38
  // Every stored address is kept and editable - editing a contact must never silently drop addresses
37
39
  // beyond the first.
38
40
  const [addresses, setAddresses] = useState(contact?.addresses ?? []);
41
+ // A picture chosen (or the current one dropped) is applied once the contact itself is saved.
42
+ const [pickedPhoto, setPickedPhoto] = useState(null);
43
+ const [removePhoto, setRemovePhoto] = useState(false);
39
44
  const [error, setError] = useState(null);
40
45
  const [saving, setSaving] = useState(false);
41
46
  function updateEmail(index, patch) {
@@ -107,6 +112,12 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
107
112
  ...input,
108
113
  }, client);
109
114
  }
115
+ if (pickedPhoto) {
116
+ saved = await uploadContactPhoto(saved.uid, saved.version, pickedPhoto);
117
+ }
118
+ else if (removePhoto && saved.photoBlobKey) {
119
+ saved = await deleteContactPhoto(saved.uid, saved.version, client);
120
+ }
110
121
  // A message opened later in this page load must see the changed contact's signing keys.
111
122
  clearPinnedSignerCache();
112
123
  onSaved(saved);
@@ -118,5 +129,12 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
118
129
  setSaving(false);
119
130
  }
120
131
  }
121
- return (_jsxs("form", { onSubmit: handleSubmit, className: "@container flex min-h-0 flex-1 flex-col", children: [_jsx("div", { className: "min-h-0 flex-1 overflow-y-auto p-6", children: _jsxs("div", { className: "flex max-w-xl flex-col gap-1", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-3", children: contact ? "Edit contact" : "New contact" }), error && _jsx(Alert, { children: error }), !contact && mailboxes && mailboxes.length > 1 && (_jsx(FormField, { label: "Mailbox", htmlFor: "contact-mailbox", children: _jsx("select", { id: "contact-mailbox", className: `${SELECT_CLASS} w-full`, value: targetMailboxUid, onChange: (e) => setTargetMailboxUid(e.target.value), children: mailboxes.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) }) })), _jsx(FormField, { label: "Display name", htmlFor: "contact-displayName", children: _jsx("input", { id: "contact-displayName", type: "text", className: INPUT_CLASS, value: displayName, onChange: (e) => setDisplayName(e.target.value) }) }), _jsxs("div", { className: "grid grid-cols-1 @md:grid-cols-2 gap-3", children: [_jsx(FormField, { label: "First name", htmlFor: "contact-givenName", children: _jsx("input", { id: "contact-givenName", type: "text", className: INPUT_CLASS, value: givenName, onChange: (e) => setGivenName(e.target.value) }) }), _jsx(FormField, { label: "Last name", htmlFor: "contact-surname", children: _jsx("input", { id: "contact-surname", type: "text", className: INPUT_CLASS, value: surname, onChange: (e) => setSurname(e.target.value) }) })] }), _jsxs("div", { className: "grid grid-cols-1 @md:grid-cols-2 gap-3", children: [_jsx(FormField, { label: "Company", htmlFor: "contact-company", children: _jsx("input", { id: "contact-company", type: "text", className: INPUT_CLASS, value: company, onChange: (e) => setCompany(e.target.value) }) }), _jsx(FormField, { label: "Job title", htmlFor: "contact-jobTitle", children: _jsx("input", { id: "contact-jobTitle", type: "text", className: INPUT_CLASS, value: jobTitle, onChange: (e) => setJobTitle(e.target.value) }) })] }), _jsxs("label", { className: "flex items-center gap-2 text-sm mb-4", children: [_jsx("input", { type: "checkbox", checked: favorite, onChange: (e) => setFavorite(e.target.checked) }), "Favorite"] }), _jsx(FormField, { label: "Categories (comma-separated)", htmlFor: "contact-categories", children: _jsx("input", { id: "contact-categories", type: "text", className: INPUT_CLASS, value: categoriesText, onChange: (e) => setCategoriesText(e.target.value), placeholder: "VIP, Work" }) }), _jsx(FormField, { label: "Email", htmlFor: "contact-emails", children: _jsxs("div", { id: "contact-emails", className: "flex flex-col gap-2", children: [emails.map((email, i) => (_jsxs("div", { className: "flex gap-2", children: [_jsx("input", { type: "email", className: `${INPUT_CLASS} flex-1`, value: email.address, onChange: (e) => updateEmail(i, { address: e.target.value }), "aria-label": `Email address ${i + 1}` }), _jsx("select", { className: SELECT_CLASS, value: email.type, onChange: (e) => updateEmail(i, { type: e.target.value }), "aria-label": `Email type ${i + 1}`, children: ADDRESS_KINDS.map((kind) => (_jsx("option", { value: kind, children: kind }, kind))) }), _jsx("button", { type: "button", onClick: () => removeEmail(i), className: "text-sm text-danger px-2", "aria-label": `Remove email ${i + 1}`, children: "\u00D7" })] }, i))), _jsx("button", { type: "button", onClick: () => setEmails((prev) => [...prev, { address: "", type: "work" }]), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add email" })] }) }), _jsx(FormField, { label: "Phone", htmlFor: "contact-phones", children: _jsxs("div", { id: "contact-phones", className: "flex flex-col gap-2", children: [phones.map((phone, i) => (_jsxs("div", { className: "flex gap-2", children: [_jsx("input", { type: "tel", className: `${INPUT_CLASS} flex-1`, value: phone.phoneNumber, onChange: (e) => updatePhone(i, { phoneNumber: e.target.value }), "aria-label": `Phone number ${i + 1}` }), _jsx("select", { className: SELECT_CLASS, value: phone.type, onChange: (e) => updatePhone(i, { type: e.target.value }), "aria-label": `Phone type ${i + 1}`, children: ADDRESS_KINDS.map((kind) => (_jsx("option", { value: kind, children: kind }, kind))) }), _jsx("button", { type: "button", onClick: () => removePhone(i), className: "text-sm text-danger px-2", "aria-label": `Remove phone ${i + 1}`, children: "\u00D7" })] }, i))), _jsx("button", { type: "button", onClick: () => setPhones((prev) => [...prev, { phoneNumber: "", type: "work" }]), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add phone" })] }) }), _jsx(FormField, { label: "Address", htmlFor: "contact-address", children: _jsxs("div", { className: "flex flex-col gap-3", children: [addresses.map((address, i) => (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("div", { className: "grid grid-cols-1 @md:grid-cols-2 gap-2", children: [_jsx("input", { type: "text", placeholder: "Street", className: INPUT_CLASS, value: address.street ?? "", onChange: (e) => updateAddress(i, { street: e.target.value }) }), _jsx("input", { type: "text", placeholder: "City", className: INPUT_CLASS, value: address.city ?? "", onChange: (e) => updateAddress(i, { city: e.target.value }) }), _jsx("input", { type: "text", placeholder: "State/Province", className: INPUT_CLASS, value: address.state ?? "", onChange: (e) => updateAddress(i, { state: e.target.value }) }), _jsx("input", { type: "text", placeholder: "Postal code", className: INPUT_CLASS, value: address.postalCode ?? "", onChange: (e) => updateAddress(i, { postalCode: e.target.value }) }), _jsx("input", { type: "text", placeholder: "Country", className: INPUT_CLASS, value: address.country ?? "", onChange: (e) => updateAddress(i, { country: e.target.value }) }), _jsx("select", { className: SELECT_CLASS, value: address.type, onChange: (e) => updateAddress(i, { type: e.target.value }), "aria-label": i === 0 ? "Address type" : `Address type ${i + 1}`, children: ADDRESS_KINDS.map((kind) => (_jsx("option", { value: kind, children: kind }, kind))) })] }), _jsx("button", { type: "button", onClick: () => removeAddress(i), className: "self-start text-xs font-medium text-danger hover:underline", "aria-label": i === 0 ? "Remove address" : `Remove address ${i + 1}`, children: "Remove address" })] }, i))), _jsx("button", { type: "button", onClick: () => setAddresses((prev) => [...prev, { type: "home" }]), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add address" })] }) }), _jsx(FormField, { label: "Notes", htmlFor: "contact-notes", children: _jsx("textarea", { id: "contact-notes", rows: 3, className: INPUT_CLASS, value: notes, onChange: (e) => setNotes(e.target.value) }) })] }) }), _jsxs("div", { className: "rr-solid sticky bottom-14 z-10 flex shrink-0 gap-3 border-t border-border bg-surface px-6 py-3 md:bottom-0", children: [_jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onCancel, children: "Cancel" })] })] }));
132
+ return (_jsxs("form", { onSubmit: handleSubmit, className: "@container flex min-h-0 flex-1 flex-col", children: [_jsx("div", { className: "min-h-0 flex-1 overflow-y-auto p-6", children: _jsxs("div", { className: "flex max-w-xl flex-col gap-1", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-3", children: contact ? "Edit contact" : "New contact" }), error && _jsx(Alert, { children: error }), !contact && mailboxes && mailboxes.length > 1 && (_jsx(FormField, { label: "Mailbox", htmlFor: "contact-mailbox", children: _jsx("select", { id: "contact-mailbox", className: `${SELECT_CLASS} w-full`, value: targetMailboxUid, onChange: (e) => setTargetMailboxUid(e.target.value), children: mailboxes.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) }) })), _jsx(FormField, { label: "Display name", htmlFor: "contact-displayName", children: _jsx("input", { id: "contact-displayName", type: "text", className: INPUT_CLASS, value: displayName, onChange: (e) => setDisplayName(e.target.value) }) }), _jsxs("div", { className: "flex items-start gap-2", children: [_jsx("div", { className: "min-w-0 flex-1", children: _jsx(ContactPhotoField, { displayName: displayName, email: emails[0]?.address, currentUrl: contact && contactPhotoUrl(contact), picked: pickedPhoto, removed: removePhoto, onPick: (file) => {
133
+ setPickedPhoto(file);
134
+ setRemovePhoto(false);
135
+ setError(null);
136
+ }, onRemove: () => {
137
+ setPickedPhoto(null);
138
+ setRemovePhoto(true);
139
+ }, onReject: setError }) }), _jsx(FavoriteStarButton, { favorite: favorite, onToggle: () => setFavorite((value) => !value) })] }), _jsxs("div", { className: "grid grid-cols-1 @md:grid-cols-2 gap-3", children: [_jsx(FormField, { label: "First name", htmlFor: "contact-givenName", children: _jsx("input", { id: "contact-givenName", type: "text", className: INPUT_CLASS, value: givenName, onChange: (e) => setGivenName(e.target.value) }) }), _jsx(FormField, { label: "Last name", htmlFor: "contact-surname", children: _jsx("input", { id: "contact-surname", type: "text", className: INPUT_CLASS, value: surname, onChange: (e) => setSurname(e.target.value) }) })] }), _jsxs("div", { className: "grid grid-cols-1 @md:grid-cols-2 gap-3", children: [_jsx(FormField, { label: "Company", htmlFor: "contact-company", children: _jsx("input", { id: "contact-company", type: "text", className: INPUT_CLASS, value: company, onChange: (e) => setCompany(e.target.value) }) }), _jsx(FormField, { label: "Job title", htmlFor: "contact-jobTitle", children: _jsx("input", { id: "contact-jobTitle", type: "text", className: INPUT_CLASS, value: jobTitle, onChange: (e) => setJobTitle(e.target.value) }) })] }), _jsx(FormField, { label: "Categories (comma-separated)", htmlFor: "contact-categories", children: _jsx("input", { id: "contact-categories", type: "text", className: INPUT_CLASS, value: categoriesText, onChange: (e) => setCategoriesText(e.target.value), placeholder: "VIP, Work" }) }), _jsx(FormField, { label: "Email", htmlFor: "contact-emails", children: _jsxs("div", { id: "contact-emails", className: "flex flex-col gap-2", children: [emails.map((email, i) => (_jsxs("div", { className: "flex gap-2", children: [_jsx("input", { type: "email", className: `${INPUT_CLASS} flex-1`, value: email.address, onChange: (e) => updateEmail(i, { address: e.target.value }), "aria-label": `Email address ${i + 1}` }), _jsx("select", { className: SELECT_CLASS, value: email.type, onChange: (e) => updateEmail(i, { type: e.target.value }), "aria-label": `Email type ${i + 1}`, children: ADDRESS_KINDS.map((kind) => (_jsx("option", { value: kind, children: kind }, kind))) }), _jsx("button", { type: "button", onClick: () => removeEmail(i), className: "text-sm text-danger px-2", "aria-label": `Remove email ${i + 1}`, children: "\u00D7" })] }, i))), _jsx("button", { type: "button", onClick: () => setEmails((prev) => [...prev, { address: "", type: "work" }]), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add email" })] }) }), _jsx(FormField, { label: "Phone", htmlFor: "contact-phones", children: _jsxs("div", { id: "contact-phones", className: "flex flex-col gap-2", children: [phones.map((phone, i) => (_jsxs("div", { className: "flex gap-2", children: [_jsx("input", { type: "tel", className: `${INPUT_CLASS} flex-1`, value: phone.phoneNumber, onChange: (e) => updatePhone(i, { phoneNumber: e.target.value }), "aria-label": `Phone number ${i + 1}` }), _jsx("select", { className: SELECT_CLASS, value: phone.type, onChange: (e) => updatePhone(i, { type: e.target.value }), "aria-label": `Phone type ${i + 1}`, children: ADDRESS_KINDS.map((kind) => (_jsx("option", { value: kind, children: kind }, kind))) }), _jsx("button", { type: "button", onClick: () => removePhone(i), className: "text-sm text-danger px-2", "aria-label": `Remove phone ${i + 1}`, children: "\u00D7" })] }, i))), _jsx("button", { type: "button", onClick: () => setPhones((prev) => [...prev, { phoneNumber: "", type: "work" }]), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add phone" })] }) }), _jsx(FormField, { label: "Address", htmlFor: "contact-address", children: _jsxs("div", { className: "flex flex-col gap-3", children: [addresses.map((address, i) => (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("div", { className: "grid grid-cols-1 @md:grid-cols-2 gap-2", children: [_jsx("input", { type: "text", placeholder: "Street", className: INPUT_CLASS, value: address.street ?? "", onChange: (e) => updateAddress(i, { street: e.target.value }) }), _jsx("input", { type: "text", placeholder: "City", className: INPUT_CLASS, value: address.city ?? "", onChange: (e) => updateAddress(i, { city: e.target.value }) }), _jsx("input", { type: "text", placeholder: "State/Province", className: INPUT_CLASS, value: address.state ?? "", onChange: (e) => updateAddress(i, { state: e.target.value }) }), _jsx("input", { type: "text", placeholder: "Postal code", className: INPUT_CLASS, value: address.postalCode ?? "", onChange: (e) => updateAddress(i, { postalCode: e.target.value }) }), _jsx("input", { type: "text", placeholder: "Country", className: INPUT_CLASS, value: address.country ?? "", onChange: (e) => updateAddress(i, { country: e.target.value }) }), _jsx("select", { className: SELECT_CLASS, value: address.type, onChange: (e) => updateAddress(i, { type: e.target.value }), "aria-label": i === 0 ? "Address type" : `Address type ${i + 1}`, children: ADDRESS_KINDS.map((kind) => (_jsx("option", { value: kind, children: kind }, kind))) })] }), _jsx("button", { type: "button", onClick: () => removeAddress(i), className: "self-start text-xs font-medium text-danger hover:underline", "aria-label": i === 0 ? "Remove address" : `Remove address ${i + 1}`, children: "Remove address" })] }, i))), _jsx("button", { type: "button", onClick: () => setAddresses((prev) => [...prev, { type: "home" }]), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add address" })] }) }), _jsx(FormField, { label: "Notes", htmlFor: "contact-notes", children: _jsx("textarea", { id: "contact-notes", rows: 3, className: INPUT_CLASS, value: notes, onChange: (e) => setNotes(e.target.value) }) })] }) }), _jsxs("div", { className: "rr-solid sticky bottom-14 z-10 flex shrink-0 gap-3 border-t border-border bg-surface px-6 py-3 md:bottom-0", children: [_jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onCancel, children: "Cancel" })] })] }));
122
140
  }
@@ -0,0 +1,21 @@
1
+ import React from "react";
2
+ export interface ContactPhotoFieldProps {
3
+ displayName: string;
4
+ /** The contact's first address, for the Gravatar shown while they have no picture of their own. */
5
+ email?: string;
6
+ /** The picture the contact has now (`contactPhotoUrl()`), if any. */
7
+ currentUrl?: string;
8
+ /** The picture chosen to replace it, not uploaded until the contact is saved; `null` for none. */
9
+ picked: File | null;
10
+ /** Whether the current picture is to be removed when the contact is saved. */
11
+ removed: boolean;
12
+ onPick: (file: File) => void;
13
+ onRemove: () => void;
14
+ /** Called with the reason a chosen file was turned down. */
15
+ onReject: (message: string) => void;
16
+ }
17
+ /**
18
+ * The picture of a contact in the edit form: the avatar as it is (their picture, else Gravatar, else initials) with "Change photo" and, when
19
+ * there is a picture of their own, "Remove photo". A file is checked here (type and size) and only uploaded when the form is saved.
20
+ */
21
+ export default function ContactPhotoField({ displayName, email, currentUrl, picked, removed, onPick, onRemove, onReject }: ContactPhotoFieldProps): React.JSX.Element;
@@ -0,0 +1,46 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useEffect, useRef, useState } from "react";
7
+ import ContactAvatar from "../../../../lib/components/avatar/ContactAvatar.js";
8
+ import Button from "../../../../lib/components/buttons/Button.js";
9
+ import { CONTACT_PHOTO_MAX_BYTES, CONTACT_PHOTO_TYPES } from "../../../../lib/contacts/contactsApi.js";
10
+ /**
11
+ * The picture of a contact in the edit form: the avatar as it is (their picture, else Gravatar, else initials) with "Change photo" and, when
12
+ * there is a picture of their own, "Remove photo". A file is checked here (type and size) and only uploaded when the form is saved.
13
+ */
14
+ export default function ContactPhotoField({ displayName, email, currentUrl, picked, removed, onPick, onRemove, onReject }) {
15
+ const input = useRef(null);
16
+ const [preview, setPreview] = useState();
17
+ useEffect(() => {
18
+ if (!picked) {
19
+ setPreview(undefined);
20
+ return;
21
+ }
22
+ const url = URL.createObjectURL(picked);
23
+ setPreview(url);
24
+ return () => URL.revokeObjectURL(url);
25
+ }, [picked]);
26
+ function handleChange(file) {
27
+ if (!file) {
28
+ return;
29
+ }
30
+ if (!CONTACT_PHOTO_TYPES.includes(file.type)) {
31
+ onReject("Choose a JPEG, PNG, GIF or WebP image.");
32
+ }
33
+ else if (file.size > CONTACT_PHOTO_MAX_BYTES) {
34
+ onReject("Choose an image no larger than 1 MB.");
35
+ }
36
+ else {
37
+ onPick(file);
38
+ }
39
+ }
40
+ const shown = picked ? preview : removed ? undefined : currentUrl;
41
+ const hasOwn = !!picked || (!!currentUrl && !removed);
42
+ return (_jsxs("div", { className: "flex items-center gap-4 mb-3", children: [_jsx(ContactAvatar, { displayName: displayName || "?", size: 72, photoUrl: shown, email: picked || (currentUrl && !removed) ? undefined : email }), _jsxs("div", { className: "flex flex-wrap gap-2", children: [_jsx("input", { ref: input, type: "file", accept: CONTACT_PHOTO_TYPES.join(","), "aria-label": "Contact photo file", className: "sr-only", tabIndex: -1, onChange: (e) => {
43
+ handleChange(e.target.files?.[0]);
44
+ e.target.value = "";
45
+ } }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto !py-1 !px-3 text-xs", onClick: () => input.current?.click(), children: hasOwn ? "Change photo" : "Add photo" }), hasOwn && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto !py-1 !px-3 text-xs", onClick: onRemove, children: "Remove photo" }))] })] }));
46
+ }
@@ -0,0 +1,9 @@
1
+ import React from "react";
2
+ export interface FavoriteStarButtonProps {
3
+ favorite: boolean;
4
+ onToggle: () => void;
5
+ disabled?: boolean;
6
+ className?: string;
7
+ }
8
+ /** The star that marks a contact as a favorite: filled when it is one. A toggle button (`aria-pressed`) named "Favorite". */
9
+ export default function FavoriteStarButton({ favorite, onToggle, disabled, className }: FavoriteStarButtonProps): React.JSX.Element;
@@ -0,0 +1,12 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { HiOutlineStar, HiStar } from "react-icons/hi2";
3
+ /** The star that marks a contact as a favorite: filled when it is one. A toggle button (`aria-pressed`) named "Favorite". */
4
+ export default function FavoriteStarButton({ favorite, onToggle, disabled, className }) {
5
+ return (_jsx("button", { type: "button", "aria-label": "Favorite", title: favorite ? "Remove from favorites" : "Add to favorites", "aria-pressed": favorite, disabled: disabled, onClick: onToggle, className: [
6
+ "inline-flex items-center justify-center rounded-sm p-1 hover:bg-surface-alt disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-primary",
7
+ favorite ? "text-warning" : "text-text-muted hover:text-text",
8
+ className,
9
+ ]
10
+ .filter(Boolean)
11
+ .join(" "), children: favorite ? _jsx(HiStar, { "aria-hidden": "true", className: "size-6" }) : _jsx(HiOutlineStar, { "aria-hidden": "true", className: "size-6" }) }));
12
+ }
@@ -4,6 +4,7 @@ import type { IconType } from "react-icons";
4
4
  import { NavItem } from "../../../../lib/components/navigation/BottomTabBar.js";
5
5
  import type { Branding } from "../../../../lib/branding/brandingApi.js";
6
6
  import { PluginNav, PluginNavProps } from "../../plugins/pluginNav.js";
7
+ import { type ResolvedRailLinks } from "../../plugins/useResolvedRailItems.js";
7
8
  /** How long sign-out waits for auth-server's logout before navigating anyway. */
8
9
  export declare const LOGOUT_TIMEOUT_MS = 3000;
9
10
  export type AppShellApp = "mail" | "calendar" | "contacts" | "tasks";
@@ -47,8 +48,8 @@ export interface AppDef {
47
48
  icon: IconType;
48
49
  }
49
50
  export declare const APPS: AppDef[];
50
- /** `APPS` followed by the plugins' `appRail` items (generic icon), core ids winning - see `mergePluginNavItems`. */
51
- export declare function appRailItems(pluginNav?: PluginNav): NavItem[];
51
+ /** `APPS` followed by the plugins' `appRail` items (their manifest `icon`, or a generic one), core ids winning - see `mergePluginNavItems`. */
52
+ export declare function appRailItems(pluginNav?: PluginNav, resolved?: ResolvedRailLinks): NavItem[];
52
53
  /** What only the persistent frame (`apps/www/_shell.tsx`) passes to the chrome it keeps mounted. */
53
54
  export interface AppChromeProps extends AppShellProps {
54
55
  /** The router is loading the next page - the content is `aria-busy`. */