@rapidmx/web-client 0.25.1 → 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/calendar/EventDetails.tsx +5 -2
- package/apps/shared/components/contacts/ContactCard.tsx +356 -0
- package/apps/shared/components/contacts/ContactCardProvider.tsx +72 -0
- 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/contacts/ParticipantLink.tsx +42 -0
- package/apps/shared/components/contacts/contactCardData.ts +220 -0
- package/apps/shared/components/contacts/participantDetails.ts +169 -0
- package/apps/shared/components/layout/AppShell.tsx +18 -6
- package/apps/shared/components/mail/InviteCard.tsx +27 -5
- package/apps/shared/components/mail/MailAddress.tsx +8 -3
- package/apps/shared/components/mail/MessageDetailPane.tsx +72 -27
- package/apps/shared/components/mail/VCardAttachmentChip.tsx +85 -0
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +112 -1
- package/apps/shared/components/mail/compose/RecipientInput.tsx +1 -0
- package/apps/shared/components/mail/compose/RichTextEditor.tsx +15 -2
- package/apps/shared/components/mail/compose/dropFiles.ts +21 -0
- 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/calendar/EventDetails.js +2 -1
- package/dist/apps/shared/components/contacts/ContactCard.d.ts +19 -0
- package/dist/apps/shared/components/contacts/ContactCard.js +148 -0
- package/dist/apps/shared/components/contacts/ContactCardProvider.d.ts +25 -0
- package/dist/apps/shared/components/contacts/ContactCardProvider.js +41 -0
- 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/contacts/ParticipantLink.d.ts +18 -0
- package/dist/apps/shared/components/contacts/ParticipantLink.js +16 -0
- package/dist/apps/shared/components/contacts/contactCardData.d.ts +69 -0
- package/dist/apps/shared/components/contacts/contactCardData.js +158 -0
- package/dist/apps/shared/components/contacts/participantDetails.d.ts +64 -0
- package/dist/apps/shared/components/contacts/participantDetails.js +129 -0
- package/dist/apps/shared/components/layout/AppShell.d.ts +3 -2
- package/dist/apps/shared/components/layout/AppShell.js +24 -15
- package/dist/apps/shared/components/mail/InviteCard.js +5 -3
- package/dist/apps/shared/components/mail/MailAddress.d.ts +5 -2
- package/dist/apps/shared/components/mail/MailAddress.js +5 -3
- package/dist/apps/shared/components/mail/MessageDetailPane.js +19 -7
- package/dist/apps/shared/components/mail/VCardAttachmentChip.d.ts +18 -0
- package/dist/apps/shared/components/mail/VCardAttachmentChip.js +53 -0
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +81 -4
- package/dist/apps/shared/components/mail/compose/RecipientInput.js +1 -0
- package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +5 -2
- package/dist/apps/shared/components/mail/compose/RichTextEditor.js +10 -1
- package/dist/apps/shared/components/mail/compose/dropFiles.d.ts +8 -0
- package/dist/apps/shared/components/mail/compose/dropFiles.js +18 -0
- 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 +15 -0
- package/dist/lib/contacts/contactsApi.js +31 -2
- package/dist/lib/contacts/gravatar.d.ts +12 -0
- package/dist/lib/contacts/gravatar.js +43 -0
- package/dist/lib/mail/directoryApi.d.ts +16 -10
- package/dist/lib/mail/directoryApi.js +20 -12
- package/lib/components/avatar/ContactAvatar.tsx +43 -7
- package/lib/components/buttons/CopyIconButton.tsx +49 -0
- package/lib/contacts/contactsApi.ts +35 -2
- package/lib/contacts/gravatar.ts +47 -0
- package/lib/mail/directoryApi.ts +30 -12
- package/package.json +1 -1
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
-
import React, { useEffect, useRef, useState } from "react";
|
|
6
|
-
import { EditorContent, useEditor } from "@tiptap/react";
|
|
5
|
+
import React, { MutableRefObject, useEffect, useRef, useState } from "react";
|
|
6
|
+
import { type Editor, EditorContent, useEditor } from "@tiptap/react";
|
|
7
7
|
import StarterKit from "@tiptap/starter-kit";
|
|
8
8
|
import { TextStyleKit } from "@tiptap/extension-text-style";
|
|
9
9
|
import TextAlign from "@tiptap/extension-text-align";
|
|
@@ -40,6 +40,8 @@ export interface RichTextEditorProps {
|
|
|
40
40
|
appendHtml?: string;
|
|
41
41
|
/** Called with the document as the editor serializes it, right after `appendHtml` was added. */
|
|
42
42
|
onAppended?: (value: string) => void;
|
|
43
|
+
/** Set to the editor while it exists (and to `null` before and after), for a caller that has to put content into the message from outside the toolbar - a picture dropped on the window. */
|
|
44
|
+
editorRef?: MutableRefObject<Editor | null>;
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
/**
|
|
@@ -73,6 +75,7 @@ export default function RichTextEditor({
|
|
|
73
75
|
onInitialized,
|
|
74
76
|
appendHtml,
|
|
75
77
|
onAppended,
|
|
78
|
+
editorRef,
|
|
76
79
|
}: RichTextEditorProps) {
|
|
77
80
|
const onChangeRef = useRef(onChange);
|
|
78
81
|
onChangeRef.current = onChange;
|
|
@@ -119,6 +122,16 @@ export default function RichTextEditor({
|
|
|
119
122
|
},
|
|
120
123
|
});
|
|
121
124
|
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (!editorRef) {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
editorRef.current = editor;
|
|
130
|
+
return () => {
|
|
131
|
+
editorRef.current = null;
|
|
132
|
+
};
|
|
133
|
+
}, [editor, editorRef]);
|
|
134
|
+
|
|
122
135
|
// Added once, at the end, whatever the reader has typed meanwhile (the caret stays where it is).
|
|
123
136
|
useEffect(() => {
|
|
124
137
|
if (!editor || appendHtml === undefined || appendedRef.current === appendHtml) {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
|
|
6
|
+
/** Picture files by name, for a file the browser gave no type to. */
|
|
7
|
+
const IMAGE_EXTENSION = /\.(jpe?g|png|gif|webp|svg|avif|bmp|ico|apng)$/i;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Whether a file dropped on the compose window goes into the message itself, at the caret, rather than onto it as an attachment: pictures do (a
|
|
11
|
+
* photo or a screenshot is meant to be seen in the text), everything else is attached. The browser's own idea of the type decides, and the file's name
|
|
12
|
+
* does when it had none.
|
|
13
|
+
*/
|
|
14
|
+
export function isInlineImage(file: Pick<File, "name" | "type">): boolean {
|
|
15
|
+
return file.type ? file.type.startsWith("image/") : IMAGE_EXTENSION.test(file.name);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Whether a drag carries files from outside the page (as opposed to the text or an image being dragged around inside the editor). */
|
|
19
|
+
export function isFileDrag(dataTransfer: Pick<DataTransfer, "types"> | null | undefined): boolean {
|
|
20
|
+
return Array.from(dataTransfer?.types ?? []).includes("Files");
|
|
21
|
+
}
|
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
HiOutlineNoSymbol,
|
|
14
14
|
HiOutlinePaintBrush,
|
|
15
15
|
HiOutlinePencilSquare,
|
|
16
|
-
HiOutlinePuzzlePiece,
|
|
17
16
|
HiOutlineShieldCheck,
|
|
18
17
|
HiOutlineTag,
|
|
19
18
|
HiOutlineUserCircle,
|
|
@@ -29,6 +28,7 @@ import AppShell, { AppShellProps } from "../../layout/AppShell.js";
|
|
|
29
28
|
import { useLocation, useNavigate } from "../../../navigation/index.js";
|
|
30
29
|
import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
|
|
31
30
|
import { orderMailboxes, primaryMailboxUid } from "../../../mail/primaryMailbox.js";
|
|
31
|
+
import { pluginIcon } from "../../../plugins/pluginIcons.js";
|
|
32
32
|
import { mergePluginNavItems, PluginNav } from "../../../plugins/pluginNav.js";
|
|
33
33
|
|
|
34
34
|
export interface SettingsSectionDef {
|
|
@@ -60,11 +60,11 @@ export type SettingsSectionId = string;
|
|
|
60
60
|
/** `SETTINGS_SECTIONS` followed by the plugins' `settingsSections` items, core ids winning - see
|
|
61
61
|
* `mergePluginNavItems`. */
|
|
62
62
|
export function settingsSections(pluginNav?: PluginNav): SettingsSectionDef[] {
|
|
63
|
-
return mergePluginNavItems<SettingsSectionDef>(SETTINGS_SECTIONS, pluginNav?.settingsSections, ({ id, href, label }) => ({
|
|
63
|
+
return mergePluginNavItems<SettingsSectionDef>(SETTINGS_SECTIONS, pluginNav?.settingsSections, ({ id, href, label, icon }) => ({
|
|
64
64
|
id,
|
|
65
65
|
href,
|
|
66
66
|
label,
|
|
67
|
-
icon:
|
|
67
|
+
icon: pluginIcon(icon),
|
|
68
68
|
}));
|
|
69
69
|
}
|
|
70
70
|
|
|
@@ -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
|
|
@@ -16,6 +16,7 @@ import { describeTimeZone, deviceTimeZone } from "../../../../lib/util/timeZone.
|
|
|
16
16
|
import Alert from "../../../../lib/components/feedback/Alert.js";
|
|
17
17
|
import Button from "../../../../lib/components/buttons/Button.js";
|
|
18
18
|
import { joinMeetingUrl } from "../../calendar/calendarReminders.js";
|
|
19
|
+
import ParticipantLink from "../contacts/ParticipantLink.js";
|
|
19
20
|
import EventDescriptionView from "./EventDescriptionView.js";
|
|
20
21
|
import RequestChangeForm from "./RequestChangeForm.js";
|
|
21
22
|
import { BUSY_STATUS_LABEL, RESPONSE_STATUS_LABEL, VISIBILITY_LABEL, describeGuestPermissions, describeReminder, formatStoredWhen, formatStoredWhenInZone } from "./eventFormat.js";
|
|
@@ -121,7 +122,7 @@ export default function EventDetails({ occurrence, isInvited, myResponse, isOwnA
|
|
|
121
122
|
{ response: "tentative", label: "Tentative" },
|
|
122
123
|
{ response: "declined", label: "Decline" },
|
|
123
124
|
];
|
|
124
|
-
return (_jsxs("div", { className: "flex flex-col", children: [_jsxs("div", { className: "flex items-start gap-3 px-5 pt-4", children: [_jsx("span", { "aria-hidden": "true", className: "w-3.5 h-3.5 rounded-full shrink-0 mt-2", style: { backgroundColor: calendarColor } }), _jsx("h2", { className: "flex-1 min-w-0 text-xl font-semibold break-words", children: occurrence.title || "(no title)" }), _jsx("button", { type: "button", "aria-label": "Close details", onClick: onClose, className: "w-8 h-8 shrink-0 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text", children: _jsx(HiOutlineXMark, { size: 20, "aria-hidden": "true" }) })] }), _jsxs("div", { className: "flex flex-col gap-3 px-5 pt-3 pb-4 text-sm", children: [error && _jsx(Alert, { children: error }), _jsxs(DetailRow, { icon: _jsx(HiOutlineClock, { size: 18 }), children: [!occurrence.allDay && eventZone && eventZone !== deviceTimeZone() ? (_jsxs(_Fragment, { children: [_jsxs("div", { children: [formatStoredWhen(occurrence.startDate, occurrence.endDate, false), " \u2022 ", describeTimeZone(deviceTimeZone(), new Date(occurrence.startDate))] }), _jsxs("div", { className: "text-text-muted", children: [formatStoredWhenInZone(occurrence.startDate, occurrence.endDate, eventZone), " \u2022 ", describeTimeZone(eventZone, new Date(occurrence.startDate))] })] })) : (_jsx("div", { children: formatStoredWhen(occurrence.startDate, occurrence.endDate, occurrence.allDay) })), occurrence.recurrenceRule && _jsxs("div", { className: "text-text-muted", children: ["Repeats ", describeRecurrence(occurrence.recurrenceRule)] })] }), occurrence.location && (_jsx(DetailRow, { icon: _jsx(HiOutlineMapPin, { size: 18 }), children: _jsx("div", { children: occurrence.location }) })), hasDescription && (_jsx(DetailRow, { icon: _jsx(HiOutlineBars3BottomLeft, { size: 18 }), children: _jsx(EventDescriptionView, { html: occurrence.descriptionHtml, text: occurrence.description }) })), videoMeetingUid && (_jsx(DetailRow, { icon: _jsx(HiOutlineVideoCamera, { size: 18 }), children: _jsxs("div", { className: "flex items-center gap-3 flex-wrap", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: !joinUrl, onClick: () => window.open(joinUrl, "_blank", "noopener,noreferrer"), children: "Join video call" }), !joinUrl && (_jsx("span", { className: "text-xs text-text-muted", children: organizerJoinUrl === undefined ? "Loading the join link…" : "This meeting’s join link isn’t available." }))] }) })), _jsxs(DetailRow, { icon: _jsx(HiOutlineUser, { size: 18 }), children: [_jsx("div", { children: organizerName || occurrence.organizer.address }), _jsxs("div", { className: "text-text-muted", children: [organizerName ? `${occurrence.organizer.address} · ` : "", "Organizer"] })] }), (guests.length > 0 || hiddenList) && (_jsxs(DetailRow, { icon: _jsx(HiOutlineUserGroup, { size: 18 }), children: [_jsx("div", { className: "font-medium mb-1", children: hiddenList ? "The organizer has hidden the guest list" : guests.length === 1 ? "1 guest" : `${guests.length} guests` }), _jsx("ul", { className: "flex flex-col gap-1", children: guests.map((guest, i) => (_jsxs("li", { className: "flex items-baseline justify-between gap-3", children: [_jsxs("span", { className: "min-w-0 truncate", children: [guest.displayName || guest.address, ROLE_LABEL[guest.role] && _jsxs("span", { className: "text-text-muted", children: [" (", ROLE_LABEL[guest.role], ")"] })] }), _jsx("span", { className: "shrink-0 text-xs text-text-muted", children: RESPONSE_STATUS_LABEL[guest.responseStatus] })] }, i))) }), !isInvited && _jsx("div", { className: "text-xs text-text-muted mt-1", children: describeGuestPermissions(permissions) })] })), _jsxs(DetailRow, { icon: _jsx(HiOutlineBriefcase, { size: 18 }), children: [_jsx("div", { children: BUSY_STATUS_LABEL[occurrence.busyStatus] }), _jsx("div", { className: "text-text-muted", children: VISIBILITY_LABEL[visibilityOf(occurrence)] }), occurrence.autoReplyEnabled && (_jsx("div", { className: "text-text-muted", children: "Sends an automatic reply while this event is happening" }))] }), _jsx(DetailRow, { icon: _jsx(HiOutlineBell, { size: 18 }), children: _jsx("div", { children: describeReminder(occurrence.reminderMinutesBeforeStart) }) }), calendarName && (_jsxs("div", { className: "flex items-center gap-3 text-text-muted", children: [_jsx("span", { "aria-hidden": "true", className: "w-3 h-3 ml-[3px] rounded-full shrink-0", style: { backgroundColor: calendarColor } }), _jsx("span", { className: "truncate", children: calendarName })] })), isInvited && (_jsxs("p", { className: "text-xs text-text-muted", children: ["You were invited to this event. Only the organizer can change its details", canChange || canInvite ? ", but you can ask them to." : "."] }))] }), requesting && (_jsx("div", { className: "mx-5 mb-3 p-3 rounded-md bg-surface-alt", children: _jsx(RequestChangeForm, { subject: {
|
|
125
|
+
return (_jsxs("div", { className: "flex flex-col", children: [_jsxs("div", { className: "flex items-start gap-3 px-5 pt-4", children: [_jsx("span", { "aria-hidden": "true", className: "w-3.5 h-3.5 rounded-full shrink-0 mt-2", style: { backgroundColor: calendarColor } }), _jsx("h2", { className: "flex-1 min-w-0 text-xl font-semibold break-words", children: occurrence.title || "(no title)" }), _jsx("button", { type: "button", "aria-label": "Close details", onClick: onClose, className: "w-8 h-8 shrink-0 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text", children: _jsx(HiOutlineXMark, { size: 20, "aria-hidden": "true" }) })] }), _jsxs("div", { className: "flex flex-col gap-3 px-5 pt-3 pb-4 text-sm", children: [error && _jsx(Alert, { children: error }), _jsxs(DetailRow, { icon: _jsx(HiOutlineClock, { size: 18 }), children: [!occurrence.allDay && eventZone && eventZone !== deviceTimeZone() ? (_jsxs(_Fragment, { children: [_jsxs("div", { children: [formatStoredWhen(occurrence.startDate, occurrence.endDate, false), " \u2022 ", describeTimeZone(deviceTimeZone(), new Date(occurrence.startDate))] }), _jsxs("div", { className: "text-text-muted", children: [formatStoredWhenInZone(occurrence.startDate, occurrence.endDate, eventZone), " \u2022 ", describeTimeZone(eventZone, new Date(occurrence.startDate))] })] })) : (_jsx("div", { children: formatStoredWhen(occurrence.startDate, occurrence.endDate, occurrence.allDay) })), occurrence.recurrenceRule && _jsxs("div", { className: "text-text-muted", children: ["Repeats ", describeRecurrence(occurrence.recurrenceRule)] })] }), occurrence.location && (_jsx(DetailRow, { icon: _jsx(HiOutlineMapPin, { size: 18 }), children: _jsx("div", { children: occurrence.location }) })), hasDescription && (_jsx(DetailRow, { icon: _jsx(HiOutlineBars3BottomLeft, { size: 18 }), children: _jsx(EventDescriptionView, { html: occurrence.descriptionHtml, text: occurrence.description }) })), videoMeetingUid && (_jsx(DetailRow, { icon: _jsx(HiOutlineVideoCamera, { size: 18 }), children: _jsxs("div", { className: "flex items-center gap-3 flex-wrap", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: !joinUrl, onClick: () => window.open(joinUrl, "_blank", "noopener,noreferrer"), children: "Join video call" }), !joinUrl && (_jsx("span", { className: "text-xs text-text-muted", children: organizerJoinUrl === undefined ? "Loading the join link…" : "This meeting’s join link isn’t available." }))] }) })), _jsxs(DetailRow, { icon: _jsx(HiOutlineUser, { size: 18 }), children: [_jsx("div", { children: _jsx(ParticipantLink, { participant: occurrence.organizer, children: organizerName || occurrence.organizer.address }) }), _jsxs("div", { className: "text-text-muted", children: [organizerName ? `${occurrence.organizer.address} · ` : "", "Organizer"] })] }), (guests.length > 0 || hiddenList) && (_jsxs(DetailRow, { icon: _jsx(HiOutlineUserGroup, { size: 18 }), children: [_jsx("div", { className: "font-medium mb-1", children: hiddenList ? "The organizer has hidden the guest list" : guests.length === 1 ? "1 guest" : `${guests.length} guests` }), _jsx("ul", { className: "flex flex-col gap-1", children: guests.map((guest, i) => (_jsxs("li", { className: "flex items-baseline justify-between gap-3", children: [_jsxs("span", { className: "min-w-0 truncate", children: [_jsx(ParticipantLink, { participant: guest, children: guest.displayName || guest.address }), ROLE_LABEL[guest.role] && _jsxs("span", { className: "text-text-muted", children: [" (", ROLE_LABEL[guest.role], ")"] })] }), _jsx("span", { className: "shrink-0 text-xs text-text-muted", children: RESPONSE_STATUS_LABEL[guest.responseStatus] })] }, i))) }), !isInvited && _jsx("div", { className: "text-xs text-text-muted mt-1", children: describeGuestPermissions(permissions) })] })), _jsxs(DetailRow, { icon: _jsx(HiOutlineBriefcase, { size: 18 }), children: [_jsx("div", { children: BUSY_STATUS_LABEL[occurrence.busyStatus] }), _jsx("div", { className: "text-text-muted", children: VISIBILITY_LABEL[visibilityOf(occurrence)] }), occurrence.autoReplyEnabled && (_jsx("div", { className: "text-text-muted", children: "Sends an automatic reply while this event is happening" }))] }), _jsx(DetailRow, { icon: _jsx(HiOutlineBell, { size: 18 }), children: _jsx("div", { children: describeReminder(occurrence.reminderMinutesBeforeStart) }) }), calendarName && (_jsxs("div", { className: "flex items-center gap-3 text-text-muted", children: [_jsx("span", { "aria-hidden": "true", className: "w-3 h-3 ml-[3px] rounded-full shrink-0", style: { backgroundColor: calendarColor } }), _jsx("span", { className: "truncate", children: calendarName })] })), isInvited && (_jsxs("p", { className: "text-xs text-text-muted", children: ["You were invited to this event. Only the organizer can change its details", canChange || canInvite ? ", but you can ask them to." : "."] }))] }), requesting && (_jsx("div", { className: "mx-5 mb-3 p-3 rounded-md bg-surface-alt", children: _jsx(RequestChangeForm, { subject: {
|
|
125
126
|
uid: occurrence.uid,
|
|
126
127
|
title: occurrence.title,
|
|
127
128
|
location: occurrence.location,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { type ContactCardContext } from "./contactCardData.js";
|
|
3
|
+
import { type Participant } from "./participantDetails.js";
|
|
4
|
+
export interface ContactCardProps {
|
|
5
|
+
participant: Participant;
|
|
6
|
+
context?: ContactCardContext;
|
|
7
|
+
onClose: () => void;
|
|
8
|
+
userUid?: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* A person's contact card, as Outlook's people card: a big avatar, their name, an "External" badge when the address is outside the user's own
|
|
12
|
+
* domains, the actions (email them, copy the address, create the contact or open it), then their contact details, the last messages to or from
|
|
13
|
+
* them and the events coming up that they organize or were invited to. Each section loads on its own, with a skeleton, an empty text and an
|
|
14
|
+
* error that touches nothing else on the card.
|
|
15
|
+
*
|
|
16
|
+
* When the address is not in the user's address book the details are what is known: the name and address, and everything a vCard attached to the
|
|
17
|
+
* message they were met in says. "Create contact" stores that. The lookups are made once, when the card opens.
|
|
18
|
+
*/
|
|
19
|
+
export default function ContactCard({ participant, context, onClose, userUid }: ContactCardProps): React.JSX.Element;
|
|
@@ -0,0 +1,148 @@
|
|
|
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 { useContext, useEffect, useRef, useState } from "react";
|
|
7
|
+
import { contactPhotoUrl, setContactFavorite } from "../../../../lib/contacts/contactsApi.js";
|
|
8
|
+
import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
|
|
9
|
+
import ContactAvatar from "../../../../lib/components/avatar/ContactAvatar.js";
|
|
10
|
+
import Button from "../../../../lib/components/buttons/Button.js";
|
|
11
|
+
import CopyIconButton from "../../../../lib/components/buttons/CopyIconButton.js";
|
|
12
|
+
import { HiOutlineEnvelope, HiOutlineUserPlus } from "react-icons/hi2";
|
|
13
|
+
import FavoriteStarButton from "./FavoriteStarButton.js";
|
|
14
|
+
import Skeleton from "../../../../lib/components/feedback/Skeleton.js";
|
|
15
|
+
import Modal from "../../../../lib/components/overlays/Modal.js";
|
|
16
|
+
import { formatMailAddress } from "../../../../lib/mail/mailAddress.js";
|
|
17
|
+
import { listMailboxes } from "../../../../lib/mail/mailApi.js";
|
|
18
|
+
import { useApiClient } from "../../../../lib/util/apiClientContext.js";
|
|
19
|
+
import { MAILBOX_LIST_LIMIT, MailConnectionContext } from "../../mail/useMailConnection.js";
|
|
20
|
+
import { notify } from "../../notifications/store.js";
|
|
21
|
+
import { notifyApiError } from "../../notifications/apiErrors.js";
|
|
22
|
+
import { APP_HREFS } from "../../navigation/appHrefs.js";
|
|
23
|
+
import { useCompose } from "../mail/compose/ComposeContext.js";
|
|
24
|
+
import { formatInviteWhen } from "../mail/invite/inviteFormat.js";
|
|
25
|
+
import { contactScope, createContactFor, findContact, loadFolders, loadParticipantCard, loadRecentMessages, loadUpcomingEvents, scopeMailboxes, } from "./contactCardData.js";
|
|
26
|
+
import { contactInputFor, isExternalAddress, ownDomains, participantName } from "./participantDetails.js";
|
|
27
|
+
/**
|
|
28
|
+
* One piece of the card's data. `load` is `undefined` while what it needs is still coming (it stays loading), and runs once when it is there; a
|
|
29
|
+
* failure is this section's error and nothing else's. `upstream` is the section this one needs: its error is this one's too.
|
|
30
|
+
*/
|
|
31
|
+
function useSection(load, upstream) {
|
|
32
|
+
const [state, setState] = useState({ status: "loading" });
|
|
33
|
+
const ready = !!load;
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!load) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
let cancelled = false;
|
|
39
|
+
load().then((data) => !cancelled && setState({ status: "ready", data }), () => !cancelled && setState({ status: "error" }));
|
|
40
|
+
return () => {
|
|
41
|
+
cancelled = true;
|
|
42
|
+
};
|
|
43
|
+
}, [ready]);
|
|
44
|
+
return upstream?.status === "error" ? { status: "error" } : state;
|
|
45
|
+
}
|
|
46
|
+
const KIND_LABEL = { work: "Work", home: "Home", other: "" };
|
|
47
|
+
function kindLabel(kind) {
|
|
48
|
+
return KIND_LABEL[kind];
|
|
49
|
+
}
|
|
50
|
+
function Detail({ label, children }) {
|
|
51
|
+
return (_jsxs("div", { className: "flex gap-3 text-sm", children: [_jsx("dt", { className: "w-24 shrink-0 text-text-muted", children: label }), _jsx("dd", { className: "min-w-0 break-words whitespace-pre-line", children: children })] }));
|
|
52
|
+
}
|
|
53
|
+
/** A card section: its heading, and while loading a skeleton, on failure the reason, when empty the empty text, else what `render` draws. */
|
|
54
|
+
function CardSection({ title, section, empty, failure, isEmpty, render, }) {
|
|
55
|
+
return (_jsxs("section", { className: "mt-5", children: [_jsx("h3", { className: "text-sm font-bold mb-2", children: title }), section.status === "loading" ? (_jsxs("div", { role: "status", "aria-label": `Loading ${title.toLowerCase()}`, className: "flex flex-col gap-2", children: [_jsx(Skeleton, { width: "w-3/4" }), _jsx(Skeleton, { width: "w-1/2" })] })) : section.status === "error" ? (_jsx("p", { role: "alert", className: "text-sm text-danger", children: failure })) : isEmpty(section.data) ? (_jsx("p", { className: "text-sm text-text-muted", children: empty })) : (render(section.data))] }));
|
|
56
|
+
}
|
|
57
|
+
const ACTION_CLASS = "!w-auto !py-1 !px-3 text-xs";
|
|
58
|
+
const ROW_LINK_CLASS = "block rounded-sm px-2 py-1.5 -mx-2 hover:bg-surface-alt focus-visible:outline-2 focus-visible:outline-primary";
|
|
59
|
+
/**
|
|
60
|
+
* A person's contact card, as Outlook's people card: a big avatar, their name, an "External" badge when the address is outside the user's own
|
|
61
|
+
* domains, the actions (email them, copy the address, create the contact or open it), then their contact details, the last messages to or from
|
|
62
|
+
* them and the events coming up that they organize or were invited to. Each section loads on its own, with a skeleton, an empty text and an
|
|
63
|
+
* error that touches nothing else on the card.
|
|
64
|
+
*
|
|
65
|
+
* When the address is not in the user's address book the details are what is known: the name and address, and everything a vCard attached to the
|
|
66
|
+
* message they were met in says. "Create contact" stores that. The lookups are made once, when the card opens.
|
|
67
|
+
*/
|
|
68
|
+
export default function ContactCard({ participant, context = {}, onClose, userUid }) {
|
|
69
|
+
const client = useApiClient();
|
|
70
|
+
const { openCompose } = useCompose();
|
|
71
|
+
const connection = useContext(MailConnectionContext);
|
|
72
|
+
const [listed, setListed] = useState({ status: "loading" });
|
|
73
|
+
const [created, setCreated] = useState();
|
|
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();
|
|
77
|
+
const mounted = useRef(true);
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
mounted.current = true;
|
|
80
|
+
return () => void (mounted.current = false);
|
|
81
|
+
}, []);
|
|
82
|
+
// The mailboxes come from the app frame's connection when there is one; otherwise (a page rendered outside it) they are asked for.
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (connection) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
listMailboxes({ limit: MAILBOX_LIST_LIMIT }, client).then((data) => mounted.current && setListed({ status: "ready", data }), () => mounted.current && setListed({ status: "error" }));
|
|
88
|
+
}, []);
|
|
89
|
+
const mailboxes = connection
|
|
90
|
+
? connection.status === "ready"
|
|
91
|
+
? { status: "ready", data: connection.mailboxes }
|
|
92
|
+
: connection.status === "error"
|
|
93
|
+
? { status: "error" }
|
|
94
|
+
: { status: "loading" }
|
|
95
|
+
: listed;
|
|
96
|
+
const scope = mailboxes.status === "ready" ? scopeMailboxes(mailboxes.data, userUid, context.message) : undefined;
|
|
97
|
+
const now = useRef(new Date());
|
|
98
|
+
const folders = useSection(scope && (() => loadFolders(scope, client)), mailboxes);
|
|
99
|
+
const contact = useSection(folders.status === "ready" ? () => findContact(folders.data, participant.address, client) : undefined, folders);
|
|
100
|
+
const messages = useSection(scope && (() => loadRecentMessages(scope, participant.address, client)), mailboxes);
|
|
101
|
+
const events = useSection(folders.status === "ready" ? () => loadUpcomingEvents(folders.data, participant.address, now.current, client) : undefined, folders);
|
|
102
|
+
const card = useSection(() => loadParticipantCard(participant, context, client));
|
|
103
|
+
const name = participantName(participant);
|
|
104
|
+
const stored = changed ?? created ?? (contact.status === "ready" ? contact.data : undefined);
|
|
105
|
+
const external = mailboxes.status === "ready" && isExternalAddress(participant.address, ownDomains(mailboxes.data));
|
|
106
|
+
// What is known when there is no contact: the name, the address and the message's vCard.
|
|
107
|
+
const known = stored ?? contactInputFor(participant, card.status === "ready" ? card.data : undefined, { mailboxUid: "", folderUid: "" });
|
|
108
|
+
// What "Create contact" needs, once it is all there.
|
|
109
|
+
const createWith = folders.status === "ready" && mailboxes.status === "ready" && card.status === "ready"
|
|
110
|
+
? { folders: folders.data, mailboxes: mailboxes.data, card: card.data }
|
|
111
|
+
: undefined;
|
|
112
|
+
async function handleCreate({ folders: folderList, mailboxes: mailboxList, card: parsed }) {
|
|
113
|
+
setCreating(true);
|
|
114
|
+
try {
|
|
115
|
+
const target = contactScope(mailboxList, folderList, userUid, context.message);
|
|
116
|
+
const made = await createContactFor(participant, parsed, target, client);
|
|
117
|
+
clearPinnedSignerCache();
|
|
118
|
+
notify({ kind: "success", title: "Contact created", message: `${made.displayName} was added to your contacts.` });
|
|
119
|
+
if (mounted.current) {
|
|
120
|
+
setCreated(made);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
notifyApiError(err, "Couldn't create this contact");
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
if (mounted.current) {
|
|
128
|
+
setCreating(false);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
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
|
+
}
|
|
143
|
+
function handleEmail() {
|
|
144
|
+
openCompose({ to: formatMailAddress(participant) });
|
|
145
|
+
onClose();
|
|
146
|
+
}
|
|
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))) })) })] }));
|
|
148
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import React, { PropsWithChildren } from "react";
|
|
2
|
+
import { type Message } from "../../../../lib/mail/mailApi.js";
|
|
3
|
+
import { type AddCardsResult, type ContactCardContext } from "./contactCardData.js";
|
|
4
|
+
import type { Participant } from "./participantDetails.js";
|
|
5
|
+
export interface ContactCardValue {
|
|
6
|
+
/** Opens the contact card of `participant`; `context` is the message they were met in, when there is one (its vCard attachments and mailbox). */
|
|
7
|
+
show: (participant: Participant, context?: ContactCardContext) => void;
|
|
8
|
+
/**
|
|
9
|
+
* Adds every card of a vCard file's `text` to the user's address book - in the default contacts folder of `message`'s mailbox when it is one of
|
|
10
|
+
* theirs, else of their primary mailbox - skipping the ones whose email is already a contact's. Rejects if a card cannot be stored (an
|
|
11
|
+
* `AddCardsError`) or the folders cannot be read. Does nothing useful outside a provider (`available` is `false`).
|
|
12
|
+
*/
|
|
13
|
+
addVCards: (text: string, message?: Message) => Promise<AddCardsResult>;
|
|
14
|
+
/** `false` outside a `ContactCardProvider`, where `show()` does nothing: a caller then draws plain text instead of a button. */
|
|
15
|
+
available: boolean;
|
|
16
|
+
}
|
|
17
|
+
/** Shows a person's contact card from anywhere inside `AppShell` - see `ContactCard`. */
|
|
18
|
+
export declare function useContactCard(): ContactCardValue;
|
|
19
|
+
/**
|
|
20
|
+
* Owns the one contact card that can be open. Mounted once in `AppShell` (inside the compose provider, whose windows the card's "Email" opens), so
|
|
21
|
+
* a participant is clickable in every app. The card is a modal: focus goes into it, and back to the participant that was clicked when it closes.
|
|
22
|
+
*/
|
|
23
|
+
export default function ContactCardProvider({ children, userUid }: PropsWithChildren<{
|
|
24
|
+
userUid?: string;
|
|
25
|
+
}>): React.JSX.Element;
|