@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
|
@@ -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
|
+
}
|
|
@@ -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
|
|
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
|
|
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:
|
|
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
|
|
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: [
|
|
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: [
|
|
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: "
|
|
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
|
|
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`. */
|