@rapidmx/web-client 0.26.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/ConversationList.tsx +3 -3
- package/apps/shared/components/mail/MessageDetailPane.tsx +18 -18
- package/apps/shared/components/settings/layout/SettingsShell.tsx +3 -3
- package/apps/shared/mail/usePermanentDelete.tsx +14 -7
- 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/apps/www/index.tsx +43 -35
- 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/ConversationList.d.ts +2 -2
- package/dist/apps/shared/components/mail/ConversationList.js +1 -1
- 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/mail/usePermanentDelete.d.ts +2 -0
- package/dist/apps/shared/mail/usePermanentDelete.js +13 -8
- 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/apps/www/index.js +38 -29
- 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
|
@@ -23,6 +23,8 @@ import {
|
|
|
23
23
|
export interface EmptiableFolder {
|
|
24
24
|
uid: string;
|
|
25
25
|
name: string;
|
|
26
|
+
/** Every folder to empty, when "the folder" is the "All mailboxes" view of one type (each mailbox's own Deleted Items): `uid` is then the first of them. */
|
|
27
|
+
uids?: string[];
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export interface PermanentDelete {
|
|
@@ -113,13 +115,18 @@ export function usePermanentDelete(): PermanentDelete {
|
|
|
113
115
|
finish(outcome);
|
|
114
116
|
return;
|
|
115
117
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
118
|
+
const outcome: EmptyFolderOutcome = { emptied: true, deleted: [], failed: [] };
|
|
119
|
+
for (const folderUid of request.folder.uids ?? [request.folder.uid]) {
|
|
120
|
+
try {
|
|
121
|
+
const one = await purgeFolder(folderUid, undefined, client);
|
|
122
|
+
outcome.emptied &&= one.emptied;
|
|
123
|
+
outcome.deleted.push(...one.deleted);
|
|
124
|
+
outcome.failed.push(...one.failed);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
notifyApiError(err, `Couldn't empty ${request.folder.name}`);
|
|
127
|
+
// Part of it may have gone before the failure: the caller reloads rather than assume nothing did.
|
|
128
|
+
outcome.emptied = false;
|
|
129
|
+
}
|
|
123
130
|
}
|
|
124
131
|
afterDeleted(outcome.deleted);
|
|
125
132
|
// The whole-folder request does not say which messages went, so the counts are read back rather than adjusted.
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import type { IconType } from "react-icons";
|
|
6
|
+
import {
|
|
7
|
+
HiOutlineAcademicCap,
|
|
8
|
+
HiOutlineBanknotes,
|
|
9
|
+
HiOutlineBeaker,
|
|
10
|
+
HiOutlineBellAlert,
|
|
11
|
+
HiOutlineBolt,
|
|
12
|
+
HiOutlineBookOpen,
|
|
13
|
+
HiOutlineBriefcase,
|
|
14
|
+
HiOutlineBuildingOffice2,
|
|
15
|
+
HiOutlineCalendarDays,
|
|
16
|
+
HiOutlineChartBar,
|
|
17
|
+
HiOutlineChartPie,
|
|
18
|
+
HiOutlineChatBubbleLeftRight,
|
|
19
|
+
HiOutlineClipboardDocumentCheck,
|
|
20
|
+
HiOutlineCloud,
|
|
21
|
+
HiOutlineCog6Tooth,
|
|
22
|
+
HiOutlineCreditCard,
|
|
23
|
+
HiOutlineCube,
|
|
24
|
+
HiOutlineCurrencyDollar,
|
|
25
|
+
HiOutlineDocumentText,
|
|
26
|
+
HiOutlineEnvelopeOpen,
|
|
27
|
+
HiOutlineFolderOpen,
|
|
28
|
+
HiOutlineFunnel,
|
|
29
|
+
HiOutlineGlobeAlt,
|
|
30
|
+
HiOutlineHeart,
|
|
31
|
+
HiOutlineIdentification,
|
|
32
|
+
HiOutlineInboxStack,
|
|
33
|
+
HiOutlineKey,
|
|
34
|
+
HiOutlineLifebuoy,
|
|
35
|
+
HiOutlineLightBulb,
|
|
36
|
+
HiOutlineMap,
|
|
37
|
+
HiOutlineMegaphone,
|
|
38
|
+
HiOutlineNewspaper,
|
|
39
|
+
HiOutlinePaperAirplane,
|
|
40
|
+
HiOutlinePhone,
|
|
41
|
+
HiOutlinePhoto,
|
|
42
|
+
HiOutlinePresentationChartLine,
|
|
43
|
+
HiOutlinePuzzlePiece,
|
|
44
|
+
HiOutlineQueueList,
|
|
45
|
+
HiOutlineRectangleGroup,
|
|
46
|
+
HiOutlineRocketLaunch,
|
|
47
|
+
HiOutlineShieldCheck,
|
|
48
|
+
HiOutlineShoppingCart,
|
|
49
|
+
HiOutlineSparkles,
|
|
50
|
+
HiOutlineSquares2X2,
|
|
51
|
+
HiOutlineTag,
|
|
52
|
+
HiOutlineTicket,
|
|
53
|
+
HiOutlineTrophy,
|
|
54
|
+
HiOutlineUserGroup,
|
|
55
|
+
HiOutlineUsers,
|
|
56
|
+
HiOutlineVideoCamera,
|
|
57
|
+
HiOutlineWrenchScrewdriver,
|
|
58
|
+
} from "react-icons/hi2";
|
|
59
|
+
|
|
60
|
+
/** The icon a plugin nav item gets when its manifest names none, or one that isn't in `PLUGIN_ICONS`. */
|
|
61
|
+
export const DEFAULT_PLUGIN_ICON: IconType = HiOutlinePuzzlePiece;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The `react-icons/hi2` icons a plugin manifest's nav `icon` can name. A fixed list rather than the whole icon set: importing every
|
|
65
|
+
* icon by name would put all of them in the bundle. A plugin that needs another one gets it added here.
|
|
66
|
+
*/
|
|
67
|
+
export const PLUGIN_ICONS: Readonly<Record<string, IconType>> = {
|
|
68
|
+
HiOutlineAcademicCap,
|
|
69
|
+
HiOutlineBanknotes,
|
|
70
|
+
HiOutlineBeaker,
|
|
71
|
+
HiOutlineBellAlert,
|
|
72
|
+
HiOutlineBolt,
|
|
73
|
+
HiOutlineBookOpen,
|
|
74
|
+
HiOutlineBriefcase,
|
|
75
|
+
HiOutlineBuildingOffice2,
|
|
76
|
+
HiOutlineCalendarDays,
|
|
77
|
+
HiOutlineChartBar,
|
|
78
|
+
HiOutlineChartPie,
|
|
79
|
+
HiOutlineChatBubbleLeftRight,
|
|
80
|
+
HiOutlineClipboardDocumentCheck,
|
|
81
|
+
HiOutlineCloud,
|
|
82
|
+
HiOutlineCog6Tooth,
|
|
83
|
+
HiOutlineCreditCard,
|
|
84
|
+
HiOutlineCube,
|
|
85
|
+
HiOutlineCurrencyDollar,
|
|
86
|
+
HiOutlineDocumentText,
|
|
87
|
+
HiOutlineEnvelopeOpen,
|
|
88
|
+
HiOutlineFolderOpen,
|
|
89
|
+
HiOutlineFunnel,
|
|
90
|
+
HiOutlineGlobeAlt,
|
|
91
|
+
HiOutlineHeart,
|
|
92
|
+
HiOutlineIdentification,
|
|
93
|
+
HiOutlineInboxStack,
|
|
94
|
+
HiOutlineKey,
|
|
95
|
+
HiOutlineLifebuoy,
|
|
96
|
+
HiOutlineLightBulb,
|
|
97
|
+
HiOutlineMap,
|
|
98
|
+
HiOutlineMegaphone,
|
|
99
|
+
HiOutlineNewspaper,
|
|
100
|
+
HiOutlinePaperAirplane,
|
|
101
|
+
HiOutlinePhone,
|
|
102
|
+
HiOutlinePhoto,
|
|
103
|
+
HiOutlinePresentationChartLine,
|
|
104
|
+
HiOutlinePuzzlePiece,
|
|
105
|
+
HiOutlineQueueList,
|
|
106
|
+
HiOutlineRectangleGroup,
|
|
107
|
+
HiOutlineRocketLaunch,
|
|
108
|
+
HiOutlineShieldCheck,
|
|
109
|
+
HiOutlineShoppingCart,
|
|
110
|
+
HiOutlineSparkles,
|
|
111
|
+
HiOutlineSquares2X2,
|
|
112
|
+
HiOutlineTag,
|
|
113
|
+
HiOutlineTicket,
|
|
114
|
+
HiOutlineTrophy,
|
|
115
|
+
HiOutlineUserGroup,
|
|
116
|
+
HiOutlineUsers,
|
|
117
|
+
HiOutlineVideoCamera,
|
|
118
|
+
HiOutlineWrenchScrewdriver,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/** The icon for a plugin nav item's `icon` name - see `PLUGIN_ICONS` - or `DEFAULT_PLUGIN_ICON`. */
|
|
122
|
+
export function pluginIcon(name: string | undefined): IconType {
|
|
123
|
+
return (name && Object.prototype.hasOwnProperty.call(PLUGIN_ICONS, name) ? PLUGIN_ICONS[name] : undefined) ?? DEFAULT_PLUGIN_ICON;
|
|
124
|
+
}
|
|
@@ -13,6 +13,14 @@ export interface PluginUiNavItem {
|
|
|
13
13
|
label: string;
|
|
14
14
|
/** A same-origin path under the host's prefix (`/settings/...`, `/admin/...`, `/...`). */
|
|
15
15
|
href: string;
|
|
16
|
+
/** A `react-icons/hi2` icon name from `PLUGIN_ICONS` (`pluginIcons.ts`); anything else shows the generic puzzle piece. */
|
|
17
|
+
icon?: string;
|
|
18
|
+
/**
|
|
19
|
+
* For an app-rail entry that depends on the signed-in user: an API path (without the `/api` prefix, e.g. `/mail/video-meetings/personal-room`)
|
|
20
|
+
* the shell requests for them. The entry is shown only when that answers `200 { href }` with a same-origin path, and links there instead of to
|
|
21
|
+
* `href`; anything else hides it. See `useResolvedRailItems()`.
|
|
22
|
+
*/
|
|
23
|
+
resolveFrom?: string;
|
|
16
24
|
}
|
|
17
25
|
|
|
18
26
|
/**
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { useEffect, useState } from "react";
|
|
6
|
+
import { type ApiClient, withClient } from "../../../lib/util/api.js";
|
|
7
|
+
import { useApiClient } from "../../../lib/util/apiClientContext.js";
|
|
8
|
+
import { type PluginUiNavItem, isSafePluginHref } from "./pluginNav.js";
|
|
9
|
+
|
|
10
|
+
/** Where each app-rail entry that is worked out per user points, by entry id: only the entries the server answered for are here. */
|
|
11
|
+
export type ResolvedRailLinks = Record<string, string>;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Asks, once per signed-in user, for the entries of a plugin's app rail that are worked out per user (they name an API path in `resolveFrom` - the
|
|
15
|
+
* meet plugin's "Meet" button is one: it shows only for a user who has a personal room, and links to it). Each path is requested through
|
|
16
|
+
* `apiFetch()`; a `200` answering `{ href }` with a same-origin path puts that link in the result, and anything else (no such thing for this user, a
|
|
17
|
+
* server error, an answer that is not a path on this site) leaves the entry out, so a failing plugin never breaks the rail. Entries with no
|
|
18
|
+
* `resolveFrom` are not asked about. Nothing is requested without a signed-in user.
|
|
19
|
+
*/
|
|
20
|
+
export function useResolvedRailItems(items: PluginUiNavItem[] | undefined, userUid: string | undefined, client?: ApiClient): ResolvedRailLinks {
|
|
21
|
+
const explicitClient = useApiClient();
|
|
22
|
+
const [links, setLinks] = useState<ResolvedRailLinks>({});
|
|
23
|
+
const asked = (items ?? []).filter((item) => typeof item?.resolveFrom === "string" && item.resolveFrom !== "");
|
|
24
|
+
// A list that is the same entries again (a new array on each render) does not start over.
|
|
25
|
+
const key = asked.map((item) => `${item.id}=${item.resolveFrom}`).join("|");
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (!userUid || asked.length === 0) {
|
|
29
|
+
setLinks({});
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
let cancelled = false;
|
|
33
|
+
void Promise.all(
|
|
34
|
+
asked.map(async (item): Promise<[string, string] | null> => {
|
|
35
|
+
try {
|
|
36
|
+
const answer = await withClient<{ href?: unknown }>(client ?? explicitClient, item.resolveFrom!);
|
|
37
|
+
return typeof answer?.href === "string" && isSafePluginHref(answer.href) ? [item.id, answer.href] : null;
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}),
|
|
42
|
+
).then((results) => {
|
|
43
|
+
if (!cancelled) {
|
|
44
|
+
setLinks(Object.fromEntries(results.filter((entry): entry is [string, string] => entry !== null)));
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
return () => {
|
|
48
|
+
cancelled = true;
|
|
49
|
+
};
|
|
50
|
+
// `asked` is derived from `key`.
|
|
51
|
+
}, [key, userUid, client, explicitClient]);
|
|
52
|
+
|
|
53
|
+
return links;
|
|
54
|
+
}
|
|
@@ -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>
|
package/apps/www/index.tsx
CHANGED
|
@@ -80,7 +80,7 @@ import MailListToolbar from "../shared/components/mail/MailListToolbar.js";
|
|
|
80
80
|
import MailSelectionBar from "../shared/components/mail/MailSelectionBar.js";
|
|
81
81
|
import EmptyFolderBar from "../shared/components/mail/EmptyFolderBar.js";
|
|
82
82
|
import { EMPTIABLE_FOLDER_TYPES } from "../shared/mail/permanentDelete.js";
|
|
83
|
-
import { usePermanentDelete } from "../shared/mail/usePermanentDelete.js";
|
|
83
|
+
import { type EmptiableFolder, usePermanentDelete } from "../shared/mail/usePermanentDelete.js";
|
|
84
84
|
import { useMailboxUpdateAccess } from "../shared/mail/useMailboxUpdateAccess.js";
|
|
85
85
|
import {
|
|
86
86
|
CONVERSATION_SORT_NOTE,
|
|
@@ -1002,12 +1002,6 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1002
1002
|
setSearchAllMailboxes(false);
|
|
1003
1003
|
}
|
|
1004
1004
|
}, [searchQuery]);
|
|
1005
|
-
// Rows can be ticked in the results of a search over every mailbox, but not in the listing they came out of: clearing the search leaves select mode.
|
|
1006
|
-
useEffect(() => {
|
|
1007
|
-
if (aggregateFolderType && !isSearching) {
|
|
1008
|
-
setSelectMode(false);
|
|
1009
|
-
}
|
|
1010
|
-
}, [aggregateFolderType, isSearching]);
|
|
1011
1005
|
|
|
1012
1006
|
// Which listing this is, for the short-lived snapshot of each folder that makes going back to one instant (see
|
|
1013
1007
|
// `listSnapshots.ts`). A search, an aggregate view and a folder that hasn't resolved have no snapshot.
|
|
@@ -1842,7 +1836,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1842
1836
|
* Items copy of its reply), and the list only ever showed this folder's half of it, so a bulk action
|
|
1843
1837
|
* from here must not reach into the other folders' copies either. */
|
|
1844
1838
|
const selectedConversationMessages = [...selectedConversationIds].flatMap((id) =>
|
|
1845
|
-
(conversationMessagesById[id] ?? []).filter((m) => !folderUid || m.folderUid === folderUid),
|
|
1839
|
+
(conversationMessagesById[id] ?? []).filter((m) => (aggregateFolderType ? folderTypeOf(m.folderUid) === aggregateFolderType : !folderUid || m.folderUid === folderUid)),
|
|
1846
1840
|
);
|
|
1847
1841
|
const selectedMessages = asConversations
|
|
1848
1842
|
? selectedConversationMessages
|
|
@@ -1886,23 +1880,24 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1886
1880
|
/** Loads (once) the messages behind each of `ids`, so a ticked conversation resolves to the messages
|
|
1887
1881
|
* every bulk action below acts on. If any of them fails to load, none of that batch stays ticked and
|
|
1888
1882
|
* a pop-up says why - better than acting on the part of a selection that happened to arrive. */
|
|
1889
|
-
async function resolveConversations(
|
|
1890
|
-
const missing =
|
|
1883
|
+
async function resolveConversations(rows: ListedConversation[]) {
|
|
1884
|
+
const missing = rows.filter((row) => !conversationMessagesById[conversationKey(row)]);
|
|
1891
1885
|
if (missing.length === 0) {
|
|
1892
1886
|
return;
|
|
1893
1887
|
}
|
|
1894
1888
|
setResolvingSelection((n) => n + 1);
|
|
1895
1889
|
try {
|
|
1896
1890
|
const loaded = await Promise.all(
|
|
1897
|
-
|
|
1891
|
+
// A row of the "All mailboxes" view names its own mailbox; any other list is the open mailbox's.
|
|
1892
|
+
missing.map(async (row) => [conversationKey(row), await listConversationMessages(row.mailboxUid ?? activeMailboxUid, row.conversationId, {}, client)] as const),
|
|
1898
1893
|
);
|
|
1899
1894
|
setConversationMessagesById((prev) => ({ ...prev, ...Object.fromEntries(loaded) }));
|
|
1900
1895
|
} catch (err) {
|
|
1901
1896
|
notifyApiError(err, "Couldn't load the messages in one of those conversations");
|
|
1902
1897
|
setSelectedConversationIds((prev) => {
|
|
1903
1898
|
const next = new Set(prev);
|
|
1904
|
-
for (const
|
|
1905
|
-
next.delete(
|
|
1899
|
+
for (const row of missing) {
|
|
1900
|
+
next.delete(conversationKey(row));
|
|
1906
1901
|
}
|
|
1907
1902
|
return next;
|
|
1908
1903
|
});
|
|
@@ -1911,8 +1906,8 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1911
1906
|
}
|
|
1912
1907
|
}
|
|
1913
1908
|
|
|
1914
|
-
function toggleConversationSelected(conversation:
|
|
1915
|
-
const id = conversation
|
|
1909
|
+
function toggleConversationSelected(conversation: ListedConversation) {
|
|
1910
|
+
const id = conversationKey(conversation);
|
|
1916
1911
|
const ticking = !selectedConversationIds.has(id);
|
|
1917
1912
|
setSelectedConversationIds((prev) => {
|
|
1918
1913
|
const next = new Set(prev);
|
|
@@ -1924,14 +1919,13 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1924
1919
|
return next;
|
|
1925
1920
|
});
|
|
1926
1921
|
if (ticking) {
|
|
1927
|
-
void resolveConversations([
|
|
1922
|
+
void resolveConversations([conversation]);
|
|
1928
1923
|
}
|
|
1929
1924
|
}
|
|
1930
1925
|
|
|
1931
1926
|
function selectAllConversations() {
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
void resolveConversations(ids);
|
|
1927
|
+
setSelectedConversationIds(new Set(conversations.map(conversationKey)));
|
|
1928
|
+
void resolveConversations(conversations);
|
|
1935
1929
|
}
|
|
1936
1930
|
|
|
1937
1931
|
/**
|
|
@@ -2105,7 +2099,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2105
2099
|
}
|
|
2106
2100
|
|
|
2107
2101
|
/** Empty folder: permanently deletes everything in the folder being listed (Deleted Items or Junk Email) after the reader has confirmed. */
|
|
2108
|
-
async function emptyListedFolder(folder:
|
|
2102
|
+
async function emptyListedFolder(folder: EmptiableFolder, count: number | undefined) {
|
|
2109
2103
|
const outcome = await permanent.requestEmptyFolder({ uid: folder.uid, name: folder.name }, count);
|
|
2110
2104
|
if (!outcome) {
|
|
2111
2105
|
return;
|
|
@@ -2348,7 +2342,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2348
2342
|
const inConversations = asConversations;
|
|
2349
2343
|
/** The selection bar isn't offered in the aggregate listing (its rows belong to several mailboxes), and neither are these - but the results of a
|
|
2350
2344
|
* search over them are, each hit acted on in its own mailbox (see `inEachMailbox()`). */
|
|
2351
|
-
const keyboardActions =
|
|
2345
|
+
const keyboardActions = true;
|
|
2352
2346
|
/** What the keyboard acts on exists: the ticked rows in select mode, else the message (or conversation) open in the reading pane. */
|
|
2353
2347
|
const keyboardTargetExists = selectMode ? selectedMessages.length > 0 : threadPane ? openThread !== null : selected !== null;
|
|
2354
2348
|
const rowCount = inConversations ? listedConversations.length : messages.length;
|
|
@@ -2586,13 +2580,33 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2586
2580
|
// Delete is the permanent one for what is in Deleted Items: for the selection when there is one (search results can come from any folder),
|
|
2587
2581
|
// else for the folder being viewed, so the button says so before anything is ticked.
|
|
2588
2582
|
const deletesPermanently =
|
|
2589
|
-
selectedMessages.length > 0
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2583
|
+
selectedMessages.length > 0
|
|
2584
|
+
? selectedMessages.every(inDeletedItems)
|
|
2585
|
+
: aggregateFolderType
|
|
2586
|
+
? aggregateFolderType === "deleted_items"
|
|
2587
|
+
: currentFolders.find((f) => f.uid === folderUid)?.type === "deleted_items";
|
|
2588
|
+
// Empty folder is offered at the top of the list of a mailbox's own Deleted Items or Junk Email, and of the "All mailboxes" view of either, which
|
|
2589
|
+
// empties each mailbox's own (a share the reader may not delete in refuses, and says so) - not for search results.
|
|
2590
|
+
const emptiableFolders: Folder[] =
|
|
2591
|
+
isSearching
|
|
2592
|
+
? []
|
|
2593
|
+
: aggregateFolderType
|
|
2594
|
+
? EMPTIABLE_FOLDER_TYPES.includes(aggregateFolderType)
|
|
2595
|
+
? mailboxFolders.flatMap((entry) => entry.folders.filter((f) => f.type === aggregateFolderType))
|
|
2596
|
+
: []
|
|
2597
|
+
: currentFolders.filter((f) => f.uid === folderUid && EMPTIABLE_FOLDER_TYPES.includes(f.type));
|
|
2598
|
+
const emptiableFolder: EmptiableFolder | undefined =
|
|
2599
|
+
emptiableFolders.length === 0
|
|
2600
|
+
? undefined
|
|
2601
|
+
: {
|
|
2602
|
+
uid: emptiableFolders[0].uid,
|
|
2603
|
+
name: aggregateFolderType ? (emptiableFolders[0].type === "junk" ? "Junk Email" : "Deleted Items") : emptiableFolders[0].name,
|
|
2604
|
+
uids: aggregateFolderType ? emptiableFolders.map((f) => f.uid) : undefined,
|
|
2605
|
+
};
|
|
2593
2606
|
// How many it holds as the sidebar's counts have it (kept right as messages leave), unless that says none while the list shows some.
|
|
2594
|
-
const
|
|
2595
|
-
const
|
|
2607
|
+
const emptiableTotal = emptiableFolders.reduce((sum, f) => sum + folderCountOf(f).total, 0);
|
|
2608
|
+
const emptiableCount = emptiableFolder && emptiableTotal > 0 ? emptiableTotal : undefined;
|
|
2609
|
+
const emptyDisabledReason = !(aggregateFolderType ? emptiableFolders.length > 0 : mailboxWritable)
|
|
2596
2610
|
? "This mailbox is shared with you view-only"
|
|
2597
2611
|
: listedRowCount === 0 && emptiableCount === undefined
|
|
2598
2612
|
? "This folder is already empty"
|
|
@@ -2698,14 +2712,8 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2698
2712
|
? CONVERSATION_SORT_NOTE
|
|
2699
2713
|
: undefined
|
|
2700
2714
|
}
|
|
2701
|
-
selectDisabled={
|
|
2702
|
-
selectDisabledReason={
|
|
2703
|
-
aggregateFolderType && !isSearching
|
|
2704
|
-
? "Open a mailbox's own folder to select messages"
|
|
2705
|
-
: loading
|
|
2706
|
-
? "Wait for this folder to finish loading"
|
|
2707
|
-
: "There is nothing here to select"
|
|
2708
|
-
}
|
|
2715
|
+
selectDisabled={loading || listedRowCount === 0}
|
|
2716
|
+
selectDisabledReason={loading ? "Wait for this folder to finish loading" : "There is nothing here to select"}
|
|
2709
2717
|
/>
|
|
2710
2718
|
)}
|
|
2711
2719
|
{!isMobile && <div className="p-2 border-b border-border">{searchField}</div>}
|
|
@@ -20,7 +20,7 @@ export interface AdminShellProps extends PluginNavProps {
|
|
|
20
20
|
*/
|
|
21
21
|
impersonationBaseUrl?: string;
|
|
22
22
|
}
|
|
23
|
-
/** `NAV_ITEMS` followed by the plugins' `adminNav` items (generic
|
|
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) => {
|