@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
package/README.md CHANGED
@@ -355,10 +355,20 @@ any release.
355
355
  | `shared/components/admin/layout/AdminShell.js` | Admin console chrome, gated on administrator access - an administrator whose session isn't elevated is sent to auth-server's `/auth/elevate` page and returned. `active` is the plugin's `adminNav` item id. |
356
356
  | `shared/components/layout/BrandingChrome.js` | `BrandingHeader` and `BrandingFooter`, for pages that don't use a shell, such as public pages. |
357
357
  | `shared/plugins/pluginNav.js` | The `PluginNav`, `PluginUiNavItem` and `PluginNavProps` types. |
358
+ | `shared/plugins/pluginIcons.js` | `PLUGIN_ICONS`, the icon names a nav entry's `icon` can use, and `pluginIcon()`, which resolves one. |
359
+
360
+ ### Page building blocks
361
+
362
+ | Import | Use |
363
+ | --- | --- |
364
+ | `shared/components/layout/ResponsiveToolbar.js` | A toolbar whose buttons collapse into an overflow menu when space runs out. |
365
+ | `shared/components/admin/usePagedList.js` | Loads a list a page at a time with "Load more", for tables of the plugin's own records. |
366
+ | `shared/notifications/apiErrors.js` | `notifyApiError()`, which shows a failed API call as a notification. |
367
+ | `shared/components/admin/diagnostics/Sparkline.js` and `MetricTile.js` | A small trend line and a labelled figure, for simple dashboards. |
358
368
 
359
369
  Every www and admin page receives a `pluginNav` prop from the server. It lists the settings sections, admin sections and
360
- app rail entries of every enabled plugin whose UI built. The shells append those entries after their own, with a generic
361
- icon. An entry whose id matches a core entry is skipped, and so is one whose `href` isn't a same-origin path. Pass the
370
+ app rail entries of every enabled plugin whose UI built. The shells append those entries after their own, each with the
371
+ icon its manifest names when that name is in `PLUGIN_ICONS`, and a generic puzzle piece otherwise. An entry whose id matches a core entry is skipped, and so is one whose `href` isn't a same-origin path. Pass the
362
372
  page props straight to the shell so the navigation shows:
363
373
 
364
374
  ```tsx
@@ -397,7 +407,9 @@ section above):
397
407
  - `components/buttons/Button.js`, `components/feedback/Alert.js` and `components/feedback/Skeleton.js`;
398
408
  - `components/forms/FormField.js`;
399
409
  - `components/overlays/Modal.js` and `components/overlays/Drawer.js`;
400
- - `components/pickers/MiniDatePicker.js`.
410
+ - `components/pickers/MiniDatePicker.js`;
411
+ - `components/avatar/ContactAvatar.js`;
412
+ - `util/apiQuery.js`: `buildQuery()`, for paging a list route.
401
413
 
402
414
  ## Uninstalling a plugin with its data
403
415
 
@@ -48,6 +48,7 @@ import {
48
48
  recordElevationAttempt,
49
49
  } from "../elevation.js";
50
50
  import { signOutOfConsole } from "../signOut.js";
51
+ import { pluginIcon } from "../../../plugins/pluginIcons.js";
51
52
  import { mergePluginNavItems, PluginNav, PluginNavProps } from "../../../plugins/pluginNav.js";
52
53
 
53
54
  export type AdminSection =
@@ -165,13 +166,13 @@ const SETUP_ITEM: NavItem = { id: "setup", href: "/admin/setup", label: "Setup",
165
166
 
166
167
  const OFF_RAIL_ITEMS: NavItem[] = [...MAILBOX_SCOPED_ITEMS, SETUP_ITEM];
167
168
 
168
- /** `NAV_ITEMS` followed by the plugins' `adminNav` items (generic icon). Core ids win, including the
169
+ /** `NAV_ITEMS` followed by the plugins' `adminNav` items (their manifest `icon`, or a generic one). Core ids win, including the
169
170
  * off-rail sections' - see `mergePluginNavItems`. */
170
171
  export function adminNavItems(pluginNav?: PluginNav): NavItem[] {
171
172
  return mergePluginNavItems<NavItem>(
172
173
  NAV_ITEMS,
173
174
  pluginNav?.adminNav,
174
- ({ id, href, label }) => ({ id, href, label, icon: HiOutlinePuzzlePiece }),
175
+ ({ id, href, label, icon }) => ({ id, href, label, icon: pluginIcon(icon) }),
175
176
  OFF_RAIL_ITEMS.map((item) => item.id),
176
177
  );
177
178
  }
@@ -3,11 +3,13 @@
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import React, { ReactNode, useContext, useEffect, useRef, useState } from "react";
6
- import type { Contact, ContactAddressKind } from "../../../../lib/contacts/contactsApi.js";
6
+ import { type Contact, type ContactAddressKind, contactPhotoUrl, setContactFavorite } from "../../../../lib/contacts/contactsApi.js";
7
7
  import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
8
8
  import ContactAvatar from "../../../../lib/components/avatar/ContactAvatar.js";
9
9
  import Button from "../../../../lib/components/buttons/Button.js";
10
- import CopyButton from "../../../../lib/components/buttons/CopyButton.js";
10
+ import CopyIconButton from "../../../../lib/components/buttons/CopyIconButton.js";
11
+ import { HiOutlineEnvelope, HiOutlineUserPlus } from "react-icons/hi2";
12
+ import FavoriteStarButton from "./FavoriteStarButton.js";
11
13
  import Skeleton from "../../../../lib/components/feedback/Skeleton.js";
12
14
  import Modal from "../../../../lib/components/overlays/Modal.js";
13
15
  import { formatMailAddress } from "../../../../lib/mail/mailAddress.js";
@@ -143,6 +145,8 @@ export default function ContactCard({ participant, context = {}, onClose, userUi
143
145
  const [listed, setListed] = useState<Section<Mailbox[]>>({ status: "loading" });
144
146
  const [created, setCreated] = useState<Contact | undefined>();
145
147
  const [creating, setCreating] = useState(false);
148
+ // The contact as last changed from this card (starred or unstarred), which is newer than the one looked up.
149
+ const [changed, setChanged] = useState<Contact | undefined>();
146
150
  const mounted = useRef(true);
147
151
  useEffect(() => {
148
152
  mounted.current = true;
@@ -182,7 +186,7 @@ export default function ContactCard({ participant, context = {}, onClose, userUi
182
186
  const card = useSection(() => loadParticipantCard(participant, context, client));
183
187
 
184
188
  const name = participantName(participant);
185
- const stored = created ?? (contact.status === "ready" ? contact.data : undefined);
189
+ const stored = changed ?? created ?? (contact.status === "ready" ? contact.data : undefined);
186
190
  const external = mailboxes.status === "ready" && isExternalAddress(participant.address, ownDomains(mailboxes.data));
187
191
  // What is known when there is no contact: the name, the address and the message's vCard.
188
192
  const known = stored ?? contactInputFor(participant, card.status === "ready" ? card.data : undefined, { mailboxUid: "", folderUid: "" });
@@ -211,6 +215,17 @@ export default function ContactCard({ participant, context = {}, onClose, userUi
211
215
  }
212
216
  }
213
217
 
218
+ async function handleToggleFavorite(current: Contact) {
219
+ try {
220
+ const next = await setContactFavorite(current, !current.favorite, client);
221
+ if (mounted.current) {
222
+ setChanged(next);
223
+ }
224
+ } catch (err) {
225
+ notifyApiError(err, "Couldn't update this contact");
226
+ }
227
+ }
228
+
214
229
  function handleEmail() {
215
230
  openCompose({ to: formatMailAddress(participant) });
216
231
  onClose();
@@ -219,9 +234,12 @@ export default function ContactCard({ participant, context = {}, onClose, userUi
219
234
  return (
220
235
  <Modal open onClose={onClose} title={name}>
221
236
  <div className="flex items-center gap-4">
222
- <ContactAvatar displayName={name} size={72} />
223
- <div className="min-w-0">
224
- <p className="text-sm text-text-muted break-words">{participant.address}</p>
237
+ <ContactAvatar displayName={name} size={72} photoUrl={stored && contactPhotoUrl(stored)} email={participant.address} />
238
+ <div className="min-w-0 flex-1">
239
+ <p className="text-sm text-text-muted break-words">
240
+ {participant.address}
241
+ <CopyIconButton value={participant.address} label="Copy address" className="ml-1" />
242
+ </p>
225
243
  {external && (
226
244
  <span className="inline-block mt-1 text-xs font-semibold py-0.5 px-2 rounded-pill bg-warning/15 text-text">External</span>
227
245
  )}
@@ -229,14 +247,13 @@ export default function ContactCard({ participant, context = {}, onClose, userUi
229
247
  <p className="mt-1 text-sm break-words">{[known.jobTitle, known.company].filter(Boolean).join(", ")}</p>
230
248
  )}
231
249
  </div>
250
+ {stored && <FavoriteStarButton favorite={!!stored.favorite} onToggle={() => void handleToggleFavorite(stored)} className="shrink-0 self-start" />}
232
251
  </div>
233
252
  <div className="mt-4 flex flex-wrap items-center gap-2">
234
- <Button type="button" variant="secondary" className={ACTION_CLASS} onClick={handleEmail}>
253
+ <Button type="button" variant="secondary" className={`${ACTION_CLASS} inline-flex items-center gap-1.5`} onClick={handleEmail}>
254
+ <HiOutlineEnvelope aria-hidden="true" className="size-4" />
235
255
  Email
236
256
  </Button>
237
- <CopyButton value={participant.address} label="Copy address">
238
- Copy address
239
- </CopyButton>
240
257
  {stored ? (
241
258
  <a
242
259
  href={`${APP_HREFS.contacts}/${encodeURIComponent(stored.uid)}`}
@@ -246,8 +263,16 @@ export default function ContactCard({ participant, context = {}, onClose, userUi
246
263
  Open contact
247
264
  </a>
248
265
  ) : (
249
- <Button type="button" variant="secondary" className={ACTION_CLASS} disabled={!createWith || creating} loading={creating} onClick={() => void handleCreate(createWith as CreateWith)}>
250
- Create contact
266
+ <Button
267
+ type="button"
268
+ variant="secondary"
269
+ className={`${ACTION_CLASS} inline-flex items-center gap-1.5`}
270
+ disabled={!createWith || creating}
271
+ loading={creating}
272
+ onClick={() => void handleCreate(createWith as CreateWith)}
273
+ >
274
+ <HiOutlineUserPlus aria-hidden="true" className="size-4" />
275
+ Add to contacts
251
276
  </Button>
252
277
  )}
253
278
  </div>
@@ -265,6 +290,7 @@ export default function ContactCard({ participant, context = {}, onClose, userUi
265
290
  {known.emails.map((email) => (
266
291
  <Detail key={`e:${email.address}`} label={["Email", kindLabel(email.type)].filter(Boolean).join(" ")}>
267
292
  {email.address}
293
+ <CopyIconButton value={email.address} label="Copy email address" className="ml-1" />
268
294
  </Detail>
269
295
  ))}
270
296
  {known.phones.map((phone) => (
@@ -3,7 +3,11 @@
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import React, { useState } from "react";
6
- import { Contact } from "../../../../lib/contacts/contactsApi.js";
6
+ import { Contact, contactPhotoUrl, setContactFavorite } from "../../../../lib/contacts/contactsApi.js";
7
+ import CopyIconButton from "../../../../lib/components/buttons/CopyIconButton.js";
8
+ import { useApiClient } from "../../../../lib/util/apiClientContext.js";
9
+ import { notifyApiError } from "../../notifications/apiErrors.js";
10
+ import FavoriteStarButton from "./FavoriteStarButton.js";
7
11
  import Button from "../../../../lib/components/buttons/Button.js";
8
12
  import Alert from "../../../../lib/components/feedback/Alert.js";
9
13
  import ContactAvatar from "../../../../lib/components/avatar/ContactAvatar.js";
@@ -37,6 +41,8 @@ export interface ContactDetailPaneProps {
37
41
  /** Called after one of the contact's key changes was accepted or kept, or turned out to be stale - the caller
38
42
  * re-reads the contact. */
39
43
  onKeysChanged?: () => void;
44
+ /** Called with the contact after the star was toggled, so the caller shows the changed contact. */
45
+ onChanged?: (contact: Contact) => void;
40
46
  /** `false` when the reader is known not to be able to change this contact's keys (the actions are hidden); `undefined`
41
47
  * when unknown (they're hidden after a 403). */
42
48
  canResolveKeys?: boolean;
@@ -47,13 +53,23 @@ export interface ContactDetailPaneProps {
47
53
  * always visible alongside the contact list) and the mobile detail route
48
54
  * (`apps/www/contacts/[uid].tsx`, a full page on its own reached by tapping a contact row).
49
55
  */
50
- export default function ContactDetailPane({ contact, onEdit, onDelete, backHref, onKeysChanged, canResolveKeys }: ContactDetailPaneProps) {
56
+ export default function ContactDetailPane({ contact, onEdit, onDelete, backHref, onKeysChanged, onChanged, canResolveKeys }: ContactDetailPaneProps) {
57
+ const client = useApiClient();
51
58
  // The outcome of the last key decision, kept per contact so selecting another contact doesn't carry it over.
52
59
  const [keyNotice, setKeyNotice] = useState<{ contactUid: string; text: string } | null>(null);
53
60
  const notice = keyNotice?.contactUid === contact.uid ? keyNotice.text : null;
54
61
  const keyConflicts = contact.keyConflicts ?? [];
55
62
  const previousKeys = contact.previousKeys ?? [];
56
63
 
64
+ async function handleToggleFavorite() {
65
+ try {
66
+ const changed = await setContactFavorite(contact, !contact.favorite, client);
67
+ onChanged?.(changed);
68
+ } catch (err) {
69
+ notifyApiError(err, "Couldn't update this contact");
70
+ }
71
+ }
72
+
57
73
  // Pinned signing keys vouch for signatures, so the pinned-signer cache is dropped along with re-reading the contact.
58
74
  function refresh(text: string) {
59
75
  setKeyNotice({ contactUid: contact.uid, text });
@@ -70,9 +86,12 @@ export default function ContactDetailPane({ contact, onEdit, onDelete, backHref,
70
86
  )}
71
87
  <div className="flex items-start justify-between gap-4">
72
88
  <div className="flex items-center gap-3">
73
- <ContactAvatar displayName={contact.displayName} size={48} />
89
+ <ContactAvatar displayName={contact.displayName} size={48} photoUrl={contactPhotoUrl(contact)} email={contact.emails[0]?.address} />
74
90
  <div>
75
- <h1 className="text-xl font-bold tracking-tight">{contact.displayName}</h1>
91
+ <h1 className="text-xl font-bold tracking-tight">
92
+ {contact.displayName}
93
+ <FavoriteStarButton favorite={!!contact.favorite} onToggle={() => void handleToggleFavorite()} className="ml-1 align-middle" />
94
+ </h1>
76
95
  {contact.jobTitle && contact.company && (
77
96
  <p className="text-sm text-text-muted mt-0.5">
78
97
  {contact.jobTitle} at {contact.company}
@@ -96,7 +115,8 @@ export default function ContactDetailPane({ contact, onEdit, onDelete, backHref,
96
115
  <div className="text-text-muted text-xs font-bold uppercase tracking-wide mb-1">Email</div>
97
116
  {contact.emails.map((e, i) => (
98
117
  <div key={i}>
99
- {e.address} <span className="text-text-muted">({e.type})</span>
118
+ {e.address}
119
+ <CopyIconButton value={e.address} label="Copy email address" className="mx-0.5" /> <span className="text-text-muted">({e.type})</span>
100
120
  </div>
101
121
  ))}
102
122
  </div>
@@ -11,13 +11,18 @@ import {
11
11
  ContactPatch,
12
12
  ContactPhone,
13
13
  ContactPostalAddress,
14
+ contactPhotoUrl,
14
15
  createContact,
16
+ deleteContactPhoto,
15
17
  updateContact,
18
+ uploadContactPhoto,
16
19
  } from "../../../../lib/contacts/contactsApi.js";
17
20
  import { Mailbox } from "../../../../lib/mail/mailApi.js";
18
21
  import Alert from "../../../../lib/components/feedback/Alert.js";
19
22
  import Button from "../../../../lib/components/buttons/Button.js";
20
23
  import FormField from "../../../../lib/components/forms/FormField.js";
24
+ import ContactPhotoField from "./ContactPhotoField.js";
25
+ import FavoriteStarButton from "./FavoriteStarButton.js";
21
26
  import { useApiClient } from "../../../../lib/util/apiClientContext.js";
22
27
  import { findWellKnownFolderUid } from "../../mail/findWellKnownFolderUid.js";
23
28
  import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
@@ -62,6 +67,9 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
62
67
  // Every stored address is kept and editable - editing a contact must never silently drop addresses
63
68
  // beyond the first.
64
69
  const [addresses, setAddresses] = useState<ContactPostalAddress[]>(contact?.addresses ?? []);
70
+ // A picture chosen (or the current one dropped) is applied once the contact itself is saved.
71
+ const [pickedPhoto, setPickedPhoto] = useState<File | null>(null);
72
+ const [removePhoto, setRemovePhoto] = useState(false);
65
73
  const [error, setError] = useState<string | null>(null);
66
74
  const [saving, setSaving] = useState(false);
67
75
 
@@ -143,6 +151,11 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
143
151
  client,
144
152
  );
145
153
  }
154
+ if (pickedPhoto) {
155
+ saved = await uploadContactPhoto(saved.uid, saved.version, pickedPhoto);
156
+ } else if (removePhoto && saved.photoBlobKey) {
157
+ saved = await deleteContactPhoto(saved.uid, saved.version, client);
158
+ }
146
159
  // A message opened later in this page load must see the changed contact's signing keys.
147
160
  clearPinnedSignerCache();
148
161
  onSaved(saved);
@@ -191,6 +204,29 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
191
204
  />
192
205
  </FormField>
193
206
 
207
+ <div className="flex items-start gap-2">
208
+ <div className="min-w-0 flex-1">
209
+ <ContactPhotoField
210
+ displayName={displayName}
211
+ email={emails[0]?.address}
212
+ currentUrl={contact && contactPhotoUrl(contact)}
213
+ picked={pickedPhoto}
214
+ removed={removePhoto}
215
+ onPick={(file) => {
216
+ setPickedPhoto(file);
217
+ setRemovePhoto(false);
218
+ setError(null);
219
+ }}
220
+ onRemove={() => {
221
+ setPickedPhoto(null);
222
+ setRemovePhoto(true);
223
+ }}
224
+ onReject={setError}
225
+ />
226
+ </div>
227
+ <FavoriteStarButton favorite={favorite} onToggle={() => setFavorite((value) => !value)} />
228
+ </div>
229
+
194
230
  <div className="grid grid-cols-1 @md:grid-cols-2 gap-3">
195
231
  <FormField label="First name" htmlFor="contact-givenName">
196
232
  <input id="contact-givenName" type="text" className={INPUT_CLASS} value={givenName} onChange={(e) => setGivenName(e.target.value)} />
@@ -209,11 +245,6 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
209
245
  </FormField>
210
246
  </div>
211
247
 
212
- <label className="flex items-center gap-2 text-sm mb-4">
213
- <input type="checkbox" checked={favorite} onChange={(e) => setFavorite(e.target.checked)} />
214
- Favorite
215
- </label>
216
-
217
248
  <FormField label="Categories (comma-separated)" htmlFor="contact-categories">
218
249
  <input
219
250
  id="contact-categories"
@@ -0,0 +1,86 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useEffect, useRef, useState } from "react";
6
+ import ContactAvatar from "../../../../lib/components/avatar/ContactAvatar.js";
7
+ import Button from "../../../../lib/components/buttons/Button.js";
8
+ import { CONTACT_PHOTO_MAX_BYTES, CONTACT_PHOTO_TYPES } from "../../../../lib/contacts/contactsApi.js";
9
+
10
+ export interface ContactPhotoFieldProps {
11
+ displayName: string;
12
+ /** The contact's first address, for the Gravatar shown while they have no picture of their own. */
13
+ email?: string;
14
+ /** The picture the contact has now (`contactPhotoUrl()`), if any. */
15
+ currentUrl?: string;
16
+ /** The picture chosen to replace it, not uploaded until the contact is saved; `null` for none. */
17
+ picked: File | null;
18
+ /** Whether the current picture is to be removed when the contact is saved. */
19
+ removed: boolean;
20
+ onPick: (file: File) => void;
21
+ onRemove: () => void;
22
+ /** Called with the reason a chosen file was turned down. */
23
+ onReject: (message: string) => void;
24
+ }
25
+
26
+ /**
27
+ * 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
28
+ * 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.
29
+ */
30
+ export default function ContactPhotoField({ displayName, email, currentUrl, picked, removed, onPick, onRemove, onReject }: ContactPhotoFieldProps) {
31
+ const input = useRef<HTMLInputElement>(null);
32
+ const [preview, setPreview] = useState<string | undefined>();
33
+
34
+ useEffect(() => {
35
+ if (!picked) {
36
+ setPreview(undefined);
37
+ return;
38
+ }
39
+ const url = URL.createObjectURL(picked);
40
+ setPreview(url);
41
+ return () => URL.revokeObjectURL(url);
42
+ }, [picked]);
43
+
44
+ function handleChange(file: File | undefined) {
45
+ if (!file) {
46
+ return;
47
+ }
48
+ if (!CONTACT_PHOTO_TYPES.includes(file.type)) {
49
+ onReject("Choose a JPEG, PNG, GIF or WebP image.");
50
+ } else if (file.size > CONTACT_PHOTO_MAX_BYTES) {
51
+ onReject("Choose an image no larger than 1 MB.");
52
+ } else {
53
+ onPick(file);
54
+ }
55
+ }
56
+
57
+ const shown = picked ? preview : removed ? undefined : currentUrl;
58
+ const hasOwn = !!picked || (!!currentUrl && !removed);
59
+ return (
60
+ <div className="flex items-center gap-4 mb-3">
61
+ <ContactAvatar displayName={displayName || "?"} size={72} photoUrl={shown} email={picked || (currentUrl && !removed) ? undefined : email} />
62
+ <div className="flex flex-wrap gap-2">
63
+ <input
64
+ ref={input}
65
+ type="file"
66
+ accept={CONTACT_PHOTO_TYPES.join(",")}
67
+ aria-label="Contact photo file"
68
+ className="sr-only"
69
+ tabIndex={-1}
70
+ onChange={(e) => {
71
+ handleChange(e.target.files?.[0]);
72
+ e.target.value = "";
73
+ }}
74
+ />
75
+ <Button type="button" variant="secondary" className="!w-auto !py-1 !px-3 text-xs" onClick={() => input.current?.click()}>
76
+ {hasOwn ? "Change photo" : "Add photo"}
77
+ </Button>
78
+ {hasOwn && (
79
+ <Button type="button" variant="secondary" className="!w-auto !py-1 !px-3 text-xs" onClick={onRemove}>
80
+ Remove photo
81
+ </Button>
82
+ )}
83
+ </div>
84
+ </div>
85
+ );
86
+ }
@@ -0,0 +1,36 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import { HiOutlineStar, HiStar } from "react-icons/hi2";
7
+
8
+ export interface FavoriteStarButtonProps {
9
+ favorite: boolean;
10
+ onToggle: () => void;
11
+ disabled?: boolean;
12
+ className?: string;
13
+ }
14
+
15
+ /** The star that marks a contact as a favorite: filled when it is one. A toggle button (`aria-pressed`) named "Favorite". */
16
+ export default function FavoriteStarButton({ favorite, onToggle, disabled, className }: FavoriteStarButtonProps) {
17
+ return (
18
+ <button
19
+ type="button"
20
+ aria-label="Favorite"
21
+ title={favorite ? "Remove from favorites" : "Add to favorites"}
22
+ aria-pressed={favorite}
23
+ disabled={disabled}
24
+ onClick={onToggle}
25
+ className={[
26
+ "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",
27
+ favorite ? "text-warning" : "text-text-muted hover:text-text",
28
+ className,
29
+ ]
30
+ .filter(Boolean)
31
+ .join(" ")}
32
+ >
33
+ {favorite ? <HiStar aria-hidden="true" className="size-6" /> : <HiOutlineStar aria-hidden="true" className="size-6" />}
34
+ </button>
35
+ );
36
+ }
@@ -9,7 +9,6 @@ import {
9
9
  HiOutlineCalendarDays,
10
10
  HiOutlineClipboardDocumentList,
11
11
  HiOutlineEnvelope,
12
- HiOutlinePuzzlePiece,
13
12
  HiOutlineUsers,
14
13
  } from "react-icons/hi2";
15
14
  import { useRouter } from "@rapidrest/react/client";
@@ -34,7 +33,9 @@ import { SIGN_OUT_CHANNEL, destroyAllLocalIndexes } from "../../search/localInde
34
33
  import { authApiFetch, setApiUnauthorizedObserver } from "../../../../lib/util/api.js";
35
34
  import { destroyUnlockedKeys } from "../../../../lib/crypto/keySession.js";
36
35
  import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
36
+ import { pluginIcon } from "../../plugins/pluginIcons.js";
37
37
  import { mergePluginNavItems, PluginNav, PluginNavProps } from "../../plugins/pluginNav.js";
38
+ import { type ResolvedRailLinks, useResolvedRailItems } from "../../plugins/useResolvedRailItems.js";
38
39
  import { useInAppFrame } from "../../navigation/frameContext.js";
39
40
  import { APP_HREFS } from "../../navigation/appHrefs.js";
40
41
  import { useNavigate } from "../../navigation/index.js";
@@ -131,12 +132,16 @@ export const APPS: AppDef[] = [
131
132
  * core `active` value. */
132
133
  const RESERVED_APP_IDS = ["settings"];
133
134
 
134
- /** `APPS` followed by the plugins' `appRail` items (generic icon), core ids winning - see `mergePluginNavItems`. */
135
- export function appRailItems(pluginNav?: PluginNav): NavItem[] {
135
+ /** `APPS` followed by the plugins' `appRail` items (their manifest `icon`, or a generic one), core ids winning - see `mergePluginNavItems`. */
136
+ export function appRailItems(pluginNav?: PluginNav, resolved: ResolvedRailLinks = {}): NavItem[] {
137
+ // An entry worked out per user (`resolveFrom`) is there only for a user it was answered for, and links where the answer said; the others are as declared.
138
+ const shown = (pluginNav?.appRail ?? [])
139
+ .filter((item) => !item.resolveFrom || resolved[item.id] !== undefined)
140
+ .map((item) => (item.resolveFrom ? { ...item, href: resolved[item.id] } : item));
136
141
  return mergePluginNavItems<NavItem>(
137
142
  APPS,
138
- pluginNav?.appRail,
139
- ({ id, href, label }) => ({ id, href, label, icon: HiOutlinePuzzlePiece }),
143
+ shown,
144
+ ({ id, href, label, icon }) => ({ id, href, label, icon: pluginIcon(icon) }),
140
145
  RESERVED_APP_IDS,
141
146
  );
142
147
  }
@@ -320,11 +325,14 @@ export function AppChrome({
320
325
  // of it, so a new pathname is what puts the count back. (A folder change is shallow and keeps the title.)
321
326
  useUnreadTitle(inboxUnreadTotal(mail.mailboxFolders, mail.folderCounts.counts), { enabled: inFrame, resetKey: pathname });
322
327
 
328
+ // Plugin rail entries that depend on the user (the meet plugin's personal room) are asked for once they are signed in.
329
+ const resolvedRail = useResolvedRailItems(pluginNav?.appRail, userUid);
330
+
323
331
  if (!userUid) {
324
332
  return <div className="min-h-screen" />;
325
333
  }
326
334
 
327
- const apps = appRailItems(pluginNav);
335
+ const apps = appRailItems(pluginNav, resolvedRail);
328
336
  // The header title: "settings" has no rail item, everything else is labelled by its own rail item.
329
337
  const activeLabel = active === "settings" ? "Settings" : apps.find((app) => app.id === active)?.label;
330
338
  // A custom header (`Branding.headerHtml`) replaces the app's own title bar and the icon at the top of the rail: it is the top of the app, and the
@@ -1243,33 +1243,33 @@ function MessageDetailContent({
1243
1243
  <CardShell unread={cardUnread}>
1244
1244
  {/* Header row: who it is from, when, and what can be done with it. Wraps: on a phone the actions drop under the sender. */}
1245
1245
  <div className={["flex flex-wrap items-start gap-x-3 gap-y-1 px-4 pt-3 pb-2", cardUnread ? "bg-primary/[0.07]" : ""].join(" ")}>
1246
- {threadHeader ? (
1247
- // The sender line of a thread's message is the button that collapses it, so its contact card is opened from the avatar beside it.
1248
- <ParticipantLink participant={{ address: senderAddress, displayName: senderName }} context={cardContext} label={`Contact card for ${senderLabel}`}>
1249
- <SenderAvatar from={senderRecipient} />
1250
- </ParticipantLink>
1251
- ) : (
1252
- <SenderAvatar from={senderRecipient} />
1253
- )}
1246
+ <SenderAvatar from={senderRecipient} />
1254
1247
  <div className="flex-1 min-w-[12rem]">
1255
1248
  <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
1256
1249
  {threadHeader ? (
1257
- // The sender line of a message in a thread is the button that collapses it again.
1258
- <h2 className="min-w-0 flex-1" style={BODY_FONT_STYLE}>
1250
+ // The sender line of a message in a thread collapses it again. A button cannot hold another, so the collapse button is laid
1251
+ // under the line (it fills the heading) and the sender's name, which opens their contact card, sits over it: a click anywhere
1252
+ // else on the line still reaches the collapse button.
1253
+ <h2 className="relative min-w-0 flex-1" style={BODY_FONT_STYLE}>
1259
1254
  <button
1260
1255
  type="button"
1261
1256
  ref={threadHeader.buttonRef}
1262
1257
  onClick={threadHeader.onToggle}
1263
1258
  aria-expanded={true}
1264
1259
  aria-controls={threadHeader.bodyId}
1265
- className={[
1266
- "w-full text-left rounded-sm text-sm",
1267
- ROW_FOCUS_CLASS,
1268
- ].join(" ")}
1269
- >
1270
- <UnreadLabel unread={cardUnread} />
1271
- <MailAddress recipient={senderRecipient} className={senderClass(cardUnread)} />
1272
- </button>
1260
+ aria-label={`${cardUnread ? "Unread. " : ""}${senderLabel}`}
1261
+ className={["absolute inset-0 w-full rounded-sm", ROW_FOCUS_CLASS].join(" ")}
1262
+ />
1263
+ <span className="pointer-events-none relative flex text-sm">
1264
+ <ParticipantLink
1265
+ participant={{ address: senderAddress, displayName: senderName }}
1266
+ context={cardContext}
1267
+ label={`Contact card for ${senderLabel}`}
1268
+ className="pointer-events-auto min-w-0 max-w-full"
1269
+ >
1270
+ <MailAddress recipient={senderRecipient} className={senderClass(cardUnread)} />
1271
+ </ParticipantLink>
1272
+ </span>
1273
1273
  </h2>
1274
1274
  ) : (
1275
1275
  <p className="text-sm break-words min-w-0">
@@ -13,7 +13,6 @@ import {
13
13
  HiOutlineNoSymbol,
14
14
  HiOutlinePaintBrush,
15
15
  HiOutlinePencilSquare,
16
- HiOutlinePuzzlePiece,
17
16
  HiOutlineShieldCheck,
18
17
  HiOutlineTag,
19
18
  HiOutlineUserCircle,
@@ -29,6 +28,7 @@ import AppShell, { AppShellProps } from "../../layout/AppShell.js";
29
28
  import { useLocation, useNavigate } from "../../../navigation/index.js";
30
29
  import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
31
30
  import { orderMailboxes, primaryMailboxUid } from "../../../mail/primaryMailbox.js";
31
+ import { pluginIcon } from "../../../plugins/pluginIcons.js";
32
32
  import { mergePluginNavItems, PluginNav } from "../../../plugins/pluginNav.js";
33
33
 
34
34
  export interface SettingsSectionDef {
@@ -60,11 +60,11 @@ export type SettingsSectionId = string;
60
60
  /** `SETTINGS_SECTIONS` followed by the plugins' `settingsSections` items, core ids winning - see
61
61
  * `mergePluginNavItems`. */
62
62
  export function settingsSections(pluginNav?: PluginNav): SettingsSectionDef[] {
63
- return mergePluginNavItems<SettingsSectionDef>(SETTINGS_SECTIONS, pluginNav?.settingsSections, ({ id, href, label }) => ({
63
+ return mergePluginNavItems<SettingsSectionDef>(SETTINGS_SECTIONS, pluginNav?.settingsSections, ({ id, href, label, icon }) => ({
64
64
  id,
65
65
  href,
66
66
  label,
67
- icon: HiOutlinePuzzlePiece,
67
+ icon: pluginIcon(icon),
68
68
  }));
69
69
  }
70
70