@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.
Files changed (85) hide show
  1. package/README.md +15 -3
  2. package/apps/shared/components/admin/layout/AdminShell.tsx +3 -2
  3. package/apps/shared/components/calendar/EventDetails.tsx +5 -2
  4. package/apps/shared/components/contacts/ContactCard.tsx +356 -0
  5. package/apps/shared/components/contacts/ContactCardProvider.tsx +72 -0
  6. package/apps/shared/components/contacts/ContactDetailPane.tsx +25 -5
  7. package/apps/shared/components/contacts/ContactForm.tsx +36 -5
  8. package/apps/shared/components/contacts/ContactPhotoField.tsx +86 -0
  9. package/apps/shared/components/contacts/FavoriteStarButton.tsx +36 -0
  10. package/apps/shared/components/contacts/ParticipantLink.tsx +42 -0
  11. package/apps/shared/components/contacts/contactCardData.ts +220 -0
  12. package/apps/shared/components/contacts/participantDetails.ts +169 -0
  13. package/apps/shared/components/layout/AppShell.tsx +18 -6
  14. package/apps/shared/components/mail/InviteCard.tsx +27 -5
  15. package/apps/shared/components/mail/MailAddress.tsx +8 -3
  16. package/apps/shared/components/mail/MessageDetailPane.tsx +72 -27
  17. package/apps/shared/components/mail/VCardAttachmentChip.tsx +85 -0
  18. package/apps/shared/components/mail/compose/ComposeWindow.tsx +112 -1
  19. package/apps/shared/components/mail/compose/RecipientInput.tsx +1 -0
  20. package/apps/shared/components/mail/compose/RichTextEditor.tsx +15 -2
  21. package/apps/shared/components/mail/compose/dropFiles.ts +21 -0
  22. package/apps/shared/components/settings/layout/SettingsShell.tsx +3 -3
  23. package/apps/shared/plugins/pluginIcons.ts +124 -0
  24. package/apps/shared/plugins/pluginNav.ts +8 -0
  25. package/apps/shared/plugins/useResolvedRailItems.ts +54 -0
  26. package/apps/www/contacts/[uid].tsx +1 -0
  27. package/apps/www/contacts/index.tsx +3 -1
  28. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
  29. package/dist/apps/shared/components/admin/layout/AdminShell.js +3 -2
  30. package/dist/apps/shared/components/calendar/EventDetails.js +2 -1
  31. package/dist/apps/shared/components/contacts/ContactCard.d.ts +19 -0
  32. package/dist/apps/shared/components/contacts/ContactCard.js +148 -0
  33. package/dist/apps/shared/components/contacts/ContactCardProvider.d.ts +25 -0
  34. package/dist/apps/shared/components/contacts/ContactCardProvider.js +41 -0
  35. package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +3 -1
  36. package/dist/apps/shared/components/contacts/ContactDetailPane.js +17 -2
  37. package/dist/apps/shared/components/contacts/ContactForm.js +20 -2
  38. package/dist/apps/shared/components/contacts/ContactPhotoField.d.ts +21 -0
  39. package/dist/apps/shared/components/contacts/ContactPhotoField.js +46 -0
  40. package/dist/apps/shared/components/contacts/FavoriteStarButton.d.ts +9 -0
  41. package/dist/apps/shared/components/contacts/FavoriteStarButton.js +12 -0
  42. package/dist/apps/shared/components/contacts/ParticipantLink.d.ts +18 -0
  43. package/dist/apps/shared/components/contacts/ParticipantLink.js +16 -0
  44. package/dist/apps/shared/components/contacts/contactCardData.d.ts +69 -0
  45. package/dist/apps/shared/components/contacts/contactCardData.js +158 -0
  46. package/dist/apps/shared/components/contacts/participantDetails.d.ts +64 -0
  47. package/dist/apps/shared/components/contacts/participantDetails.js +129 -0
  48. package/dist/apps/shared/components/layout/AppShell.d.ts +3 -2
  49. package/dist/apps/shared/components/layout/AppShell.js +24 -15
  50. package/dist/apps/shared/components/mail/InviteCard.js +5 -3
  51. package/dist/apps/shared/components/mail/MailAddress.d.ts +5 -2
  52. package/dist/apps/shared/components/mail/MailAddress.js +5 -3
  53. package/dist/apps/shared/components/mail/MessageDetailPane.js +19 -7
  54. package/dist/apps/shared/components/mail/VCardAttachmentChip.d.ts +18 -0
  55. package/dist/apps/shared/components/mail/VCardAttachmentChip.js +53 -0
  56. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +81 -4
  57. package/dist/apps/shared/components/mail/compose/RecipientInput.js +1 -0
  58. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +5 -2
  59. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +10 -1
  60. package/dist/apps/shared/components/mail/compose/dropFiles.d.ts +8 -0
  61. package/dist/apps/shared/components/mail/compose/dropFiles.js +18 -0
  62. package/dist/apps/shared/components/settings/layout/SettingsShell.js +4 -3
  63. package/dist/apps/shared/plugins/pluginIcons.d.ts +10 -0
  64. package/dist/apps/shared/plugins/pluginIcons.js +64 -0
  65. package/dist/apps/shared/plugins/pluginNav.d.ts +8 -0
  66. package/dist/apps/shared/plugins/useResolvedRailItems.d.ts +12 -0
  67. package/dist/apps/shared/plugins/useResolvedRailItems.js +47 -0
  68. package/dist/apps/www/contacts/[uid].js +1 -1
  69. package/dist/apps/www/contacts/index.js +2 -2
  70. package/dist/lib/components/avatar/ContactAvatar.d.ts +9 -3
  71. package/dist/lib/components/avatar/ContactAvatar.js +29 -6
  72. package/dist/lib/components/buttons/CopyIconButton.d.ts +13 -0
  73. package/dist/lib/components/buttons/CopyIconButton.js +16 -0
  74. package/dist/lib/contacts/contactsApi.d.ts +15 -0
  75. package/dist/lib/contacts/contactsApi.js +31 -2
  76. package/dist/lib/contacts/gravatar.d.ts +12 -0
  77. package/dist/lib/contacts/gravatar.js +43 -0
  78. package/dist/lib/mail/directoryApi.d.ts +16 -10
  79. package/dist/lib/mail/directoryApi.js +20 -12
  80. package/lib/components/avatar/ContactAvatar.tsx +43 -7
  81. package/lib/components/buttons/CopyIconButton.tsx +49 -0
  82. package/lib/contacts/contactsApi.ts +35 -2
  83. package/lib/contacts/gravatar.ts +47 -0
  84. package/lib/mail/directoryApi.ts +30 -12
  85. 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: HiOutlinePuzzlePiece,
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
+ }
@@ -103,6 +103,7 @@ function ContactDetailContent({ uid }: { uid: string }) {
103
103
  onDelete={() => handleDelete(contact)}
104
104
  backHref={backHref}
105
105
  onKeysChanged={handleKeysChanged}
106
+ onChanged={setContact}
106
107
  />
107
108
  </div>
108
109
  );
@@ -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 icon). Core ids win, including the
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 icon). Core ids win, including the
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: HiOutlinePuzzlePiece }), OFF_RAIL_ITEMS.map((item) => item.id));
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;