@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.
- package/README.md +15 -3
- package/apps/shared/components/admin/layout/AdminShell.tsx +3 -2
- package/apps/shared/components/contacts/ContactCard.tsx +38 -12
- package/apps/shared/components/contacts/ContactDetailPane.tsx +25 -5
- package/apps/shared/components/contacts/ContactForm.tsx +36 -5
- package/apps/shared/components/contacts/ContactPhotoField.tsx +86 -0
- package/apps/shared/components/contacts/FavoriteStarButton.tsx +36 -0
- package/apps/shared/components/layout/AppShell.tsx +14 -6
- package/apps/shared/components/mail/MessageDetailPane.tsx +18 -18
- package/apps/shared/components/settings/layout/SettingsShell.tsx +3 -3
- package/apps/shared/plugins/pluginIcons.ts +124 -0
- package/apps/shared/plugins/pluginNav.ts +8 -0
- package/apps/shared/plugins/useResolvedRailItems.ts +54 -0
- package/apps/www/contacts/[uid].tsx +1 -0
- package/apps/www/contacts/index.tsx +3 -1
- package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
- package/dist/apps/shared/components/admin/layout/AdminShell.js +3 -2
- package/dist/apps/shared/components/contacts/ContactCard.js +19 -3
- package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +3 -1
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +17 -2
- package/dist/apps/shared/components/contacts/ContactForm.js +20 -2
- package/dist/apps/shared/components/contacts/ContactPhotoField.d.ts +21 -0
- package/dist/apps/shared/components/contacts/ContactPhotoField.js +46 -0
- package/dist/apps/shared/components/contacts/FavoriteStarButton.d.ts +9 -0
- package/dist/apps/shared/components/contacts/FavoriteStarButton.js +12 -0
- package/dist/apps/shared/components/layout/AppShell.d.ts +3 -2
- package/dist/apps/shared/components/layout/AppShell.js +13 -5
- package/dist/apps/shared/components/mail/MessageDetailPane.js +2 -5
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +4 -3
- package/dist/apps/shared/plugins/pluginIcons.d.ts +10 -0
- package/dist/apps/shared/plugins/pluginIcons.js +64 -0
- package/dist/apps/shared/plugins/pluginNav.d.ts +8 -0
- package/dist/apps/shared/plugins/useResolvedRailItems.d.ts +12 -0
- package/dist/apps/shared/plugins/useResolvedRailItems.js +47 -0
- package/dist/apps/www/contacts/[uid].js +1 -1
- package/dist/apps/www/contacts/index.js +2 -2
- package/dist/lib/components/avatar/ContactAvatar.d.ts +9 -3
- package/dist/lib/components/avatar/ContactAvatar.js +29 -6
- package/dist/lib/components/buttons/CopyIconButton.d.ts +13 -0
- package/dist/lib/components/buttons/CopyIconButton.js +16 -0
- package/dist/lib/contacts/contactsApi.d.ts +12 -0
- package/dist/lib/contacts/contactsApi.js +30 -1
- package/dist/lib/contacts/gravatar.d.ts +12 -0
- package/dist/lib/contacts/gravatar.js +43 -0
- package/lib/components/avatar/ContactAvatar.tsx +43 -7
- package/lib/components/buttons/CopyIconButton.tsx +49 -0
- package/lib/contacts/contactsApi.ts +34 -1
- package/lib/contacts/gravatar.ts +47 -0
- 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
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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">
|
|
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
|
|
250
|
-
|
|
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">
|
|
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}
|
|
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
|
|
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
|
-
|
|
139
|
-
({ id, href, label }) => ({ id, href, label, icon:
|
|
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
|
-
{
|
|
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
|
|
1258
|
-
|
|
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
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
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:
|
|
67
|
+
icon: pluginIcon(icon),
|
|
68
68
|
}));
|
|
69
69
|
}
|
|
70
70
|
|