@rapidmx/web-client 0.5.0 → 0.6.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 (164) hide show
  1. package/README.md +88 -0
  2. package/apps/shared/components/admin/layout/AdminShell.tsx +25 -8
  3. package/apps/shared/components/calendar/layout/CalendarShell.tsx +194 -192
  4. package/apps/shared/components/contacts/layout/ContactsShell.tsx +199 -197
  5. package/apps/shared/components/layout/AppShell.tsx +36 -18
  6. package/apps/shared/components/mail/layout/MailShell.tsx +420 -418
  7. package/apps/shared/components/settings/layout/SettingsShell.tsx +23 -9
  8. package/apps/shared/components/tasks/layout/TasksShell.tsx +201 -199
  9. package/apps/shared/plugins/pluginNav.ts +73 -0
  10. package/apps/shared/search/localIndexRpcClient.ts +3 -1
  11. package/dist/apps/admin/_404.d.ts +2 -0
  12. package/dist/apps/admin/_500.d.ts +2 -0
  13. package/dist/apps/admin/_layout.d.ts +8 -0
  14. package/dist/apps/admin/audit-log/index.d.ts +11 -0
  15. package/dist/apps/admin/branding/index.d.ts +3 -0
  16. package/dist/apps/admin/data-requests/index.d.ts +3 -0
  17. package/dist/apps/admin/distribution-lists/[uid].d.ts +7 -0
  18. package/dist/apps/admin/distribution-lists/index.d.ts +3 -0
  19. package/dist/apps/admin/distribution-lists/new/index.d.ts +3 -0
  20. package/dist/apps/admin/domains/[uid].d.ts +7 -0
  21. package/dist/apps/admin/domains/index.d.ts +3 -0
  22. package/dist/apps/admin/domains/new/index.d.ts +3 -0
  23. package/dist/apps/admin/encryption-policy/index.d.ts +3 -0
  24. package/dist/apps/admin/escrow-scopes/[uid].d.ts +21 -0
  25. package/dist/apps/admin/escrow-scopes/index.d.ts +3 -0
  26. package/dist/apps/admin/escrow-scopes/new/index.d.ts +3 -0
  27. package/dist/apps/admin/index.d.ts +3 -0
  28. package/dist/apps/admin/ingest-queue/index.d.ts +4 -0
  29. package/dist/apps/admin/mailbox-policy/index.d.ts +3 -0
  30. package/dist/apps/admin/mailboxes/[uid].d.ts +7 -0
  31. package/dist/apps/admin/mailboxes/new/index.d.ts +3 -0
  32. package/dist/apps/admin/plugins/index.d.ts +3 -0
  33. package/dist/apps/admin/quarantine/index.d.ts +6 -0
  34. package/dist/apps/admin/retention-policy/index.d.ts +3 -0
  35. package/dist/apps/admin/setup/index.d.ts +3 -0
  36. package/dist/apps/admin/transport-rules/[uid].d.ts +7 -0
  37. package/dist/apps/admin/transport-rules/_transportRuleConfig.d.ts +21 -0
  38. package/dist/apps/admin/transport-rules/index.d.ts +3 -0
  39. package/dist/apps/admin/transport-rules/new/index.d.ts +3 -0
  40. package/dist/apps/escrow/_layout.d.ts +10 -0
  41. package/dist/apps/escrow/audit-log/index.d.ts +9 -0
  42. package/dist/apps/escrow/index.d.ts +3 -0
  43. package/dist/apps/escrow/matters/[uid].d.ts +7 -0
  44. package/dist/apps/escrow/matters/new/index.d.ts +3 -0
  45. package/dist/apps/shared/components/admin/distributionLists/MemberListCard.d.ts +17 -0
  46. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.d.ts +32 -0
  47. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +32 -0
  48. package/dist/apps/shared/components/admin/layout/AdminShell.js +12 -5
  49. package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.d.ts +12 -0
  50. package/dist/apps/shared/components/admin/mailboxes/MailboxTable.d.ts +6 -0
  51. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.d.ts +19 -0
  52. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.d.ts +12 -0
  53. package/dist/apps/shared/components/admin/settings/BrandingForm.d.ts +9 -0
  54. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.d.ts +12 -0
  55. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.d.ts +12 -0
  56. package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.d.ts +9 -0
  57. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.d.ts +19 -0
  58. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.d.ts +10 -0
  59. package/dist/apps/shared/components/admin/settings/PluginsManager.d.ts +4 -0
  60. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.d.ts +9 -0
  61. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.d.ts +14 -0
  62. package/dist/apps/shared/components/admin/setup/SetupWizard.d.ts +36 -0
  63. package/dist/apps/shared/components/admin/signOut.d.ts +23 -0
  64. package/dist/apps/shared/components/admin/usePagedList.d.ts +39 -0
  65. package/dist/apps/shared/components/calendar/CalendarListSidebar.d.ts +26 -0
  66. package/dist/apps/shared/components/calendar/EventModal.d.ts +46 -0
  67. package/dist/apps/shared/components/calendar/MonthView.d.ts +15 -0
  68. package/dist/apps/shared/components/calendar/RecurrenceEditor.d.ts +20 -0
  69. package/dist/apps/shared/components/calendar/ResourcePicker.d.ts +19 -0
  70. package/dist/apps/shared/components/calendar/SplitDayView.d.ts +28 -0
  71. package/dist/apps/shared/components/calendar/TimeGridView.d.ts +17 -0
  72. package/dist/apps/shared/components/calendar/allDay.d.ts +51 -0
  73. package/dist/apps/shared/components/calendar/layout/CalendarShell.d.ts +40 -0
  74. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +2 -2
  75. package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +23 -0
  76. package/dist/apps/shared/components/contacts/ContactForm.d.ts +20 -0
  77. package/dist/apps/shared/components/contacts/ContactsSidebar.d.ts +40 -0
  78. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +25 -0
  79. package/dist/apps/shared/components/contacts/KeyChangeReview.d.ts +40 -0
  80. package/dist/apps/shared/components/contacts/contactKeys.d.ts +29 -0
  81. package/dist/apps/shared/components/contacts/layout/ContactsShell.d.ts +21 -0
  82. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +2 -2
  83. package/dist/apps/shared/components/escrow/layout/EscrowShell.d.ts +25 -0
  84. package/dist/apps/shared/components/forms/StringListField.d.ts +22 -0
  85. package/dist/apps/shared/components/layout/AppShell.d.ts +49 -0
  86. package/dist/apps/shared/components/layout/AppShell.js +15 -13
  87. package/dist/apps/shared/components/layout/BrandingChrome.d.ts +18 -0
  88. package/dist/apps/shared/components/layout/KeyEnrollmentGate.d.ts +51 -0
  89. package/dist/apps/shared/components/layout/MailboxProvisioning.d.ts +13 -0
  90. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.d.ts +66 -0
  91. package/dist/apps/shared/components/layout/UnlockPromptProvider.d.ts +45 -0
  92. package/dist/apps/shared/components/layout/UserMenu.d.ts +23 -0
  93. package/dist/apps/shared/components/mail/ConversationList.d.ts +11 -0
  94. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +23 -0
  95. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +93 -0
  96. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +58 -0
  97. package/dist/apps/shared/components/mail/compose/ComposeToolbar.d.ts +15 -0
  98. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +37 -0
  99. package/dist/apps/shared/components/mail/compose/EmojiPicker.d.ts +14 -0
  100. package/dist/apps/shared/components/mail/compose/GifPicker.d.ts +13 -0
  101. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +34 -0
  102. package/dist/apps/shared/components/mail/compose/ScheduleSendPicker.d.ts +16 -0
  103. package/dist/apps/shared/components/mail/compose/composeFlushRegistry.d.ts +27 -0
  104. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +57 -0
  105. package/dist/apps/shared/components/mail/layout/MailShell.js +2 -2
  106. package/dist/apps/shared/components/mail/pinnedSigners.d.ts +33 -0
  107. package/dist/apps/shared/components/mail/verificationSeals.d.ts +22 -0
  108. package/dist/apps/shared/components/mail/writableMailboxes.d.ts +15 -0
  109. package/dist/apps/shared/components/rules/RuleBuilder.d.ts +70 -0
  110. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +41 -0
  111. package/dist/apps/shared/components/settings/layout/SettingsShell.js +19 -9
  112. package/dist/apps/shared/components/tasks/TasksSidebar.d.ts +41 -0
  113. package/dist/apps/shared/components/tasks/TasksToolbar.d.ts +14 -0
  114. package/dist/apps/shared/components/tasks/layout/TasksShell.d.ts +24 -0
  115. package/dist/apps/shared/components/tasks/layout/TasksShell.js +2 -2
  116. package/dist/apps/shared/mail/findWellKnownFolderUid.d.ts +5 -0
  117. package/dist/apps/shared/mail/listAllPages.d.ts +17 -0
  118. package/dist/apps/shared/plugins/pluginNav.d.ts +39 -0
  119. package/dist/apps/shared/plugins/pluginNav.js +33 -0
  120. package/dist/apps/shared/search/LocalIndexLifecycle.d.ts +13 -0
  121. package/dist/apps/shared/search/localIndexBlockCipher.d.ts +36 -0
  122. package/dist/apps/shared/search/localIndexBuilder.d.ts +85 -0
  123. package/dist/apps/shared/search/localIndexKey.d.ts +4 -0
  124. package/dist/apps/shared/search/localIndexRpcClient.d.ts +71 -0
  125. package/dist/apps/shared/search/localIndexRpcClient.js +3 -1
  126. package/dist/apps/shared/search/localIndexSchema.d.ts +111 -0
  127. package/dist/apps/shared/search/localIndexSizePreference.d.ts +27 -0
  128. package/dist/apps/shared/search/localIndexStorage.d.ts +28 -0
  129. package/dist/apps/shared/search/localIndexVFS.d.ts +101 -0
  130. package/dist/apps/shared/search/localIndexWorker.d.ts +145 -0
  131. package/dist/apps/shared/search/searchTier2.d.ts +30 -0
  132. package/dist/apps/www/_404.d.ts +2 -0
  133. package/dist/apps/www/_500.d.ts +2 -0
  134. package/dist/apps/www/_layout.d.ts +14 -0
  135. package/dist/apps/www/calendar/index.d.ts +3 -0
  136. package/dist/apps/www/contacts/[uid].d.ts +13 -0
  137. package/dist/apps/www/contacts/index.d.ts +3 -0
  138. package/dist/apps/www/index.d.ts +3 -0
  139. package/dist/apps/www/messages/[uid].d.ts +11 -0
  140. package/dist/apps/www/settings/auto-reply/index.d.ts +4 -0
  141. package/dist/apps/www/settings/encryption/index.d.ts +7 -0
  142. package/dist/apps/www/settings/filters/[uid].d.ts +8 -0
  143. package/dist/apps/www/settings/filters/_mailFilterRuleConfig.d.ts +11 -0
  144. package/dist/apps/www/settings/filters/index.d.ts +4 -0
  145. package/dist/apps/www/settings/filters/new/index.d.ts +4 -0
  146. package/dist/apps/www/settings/focused-inbox/index.d.ts +4 -0
  147. package/dist/apps/www/settings/labels/index.d.ts +4 -0
  148. package/dist/apps/www/settings/privacy/index.d.ts +4 -0
  149. package/dist/apps/www/settings/read-receipts/index.d.ts +4 -0
  150. package/dist/apps/www/settings/sharing/index.d.ts +4 -0
  151. package/dist/apps/www/settings/signatures/[uid].d.ts +8 -0
  152. package/dist/apps/www/settings/signatures/index.d.ts +4 -0
  153. package/dist/apps/www/settings/signatures/new/index.d.ts +4 -0
  154. package/dist/apps/www/settings/signatures/signatureDefaults.d.ts +10 -0
  155. package/dist/apps/www/tasks/index.d.ts +3 -0
  156. package/package.json +3 -3
  157. package/apps/shared/components/booking/AvailabilityEditor.tsx +0 -118
  158. package/apps/www/settings/booking-types/[uid].tsx +0 -306
  159. package/apps/www/settings/booking-types/index.tsx +0 -98
  160. package/apps/www/settings/booking-types/new/index.tsx +0 -212
  161. package/dist/apps/shared/components/booking/AvailabilityEditor.js +0 -45
  162. package/dist/apps/www/settings/booking-types/[uid].js +0 -134
  163. package/dist/apps/www/settings/booking-types/index.js +0 -29
  164. package/dist/apps/www/settings/booking-types/new/index.js +0 -86
@@ -1,197 +1,199 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- import React, { createContext, PropsWithChildren, ReactNode, useContext, useEffect, useMemo, useState } from "react";
6
- import { HiOutlineBars3 } from "react-icons/hi2";
7
- import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
- import Drawer from "@rapidmx/react-shared/components/overlays/Drawer.js";
9
- import { Folder, Mailbox, listFolders, listMailboxes } from "@rapidmx/react-shared/mail/mailApi.js";
10
- import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
11
- import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
12
- import AppShell, { AppShellProps } from "../../layout/AppShell.js";
13
- import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
14
-
15
- export type ContactsShellProps = Omit<AppShellProps, "active">;
16
-
17
- export interface ContactsShellContextValue {
18
- /** The mailbox currently selected (`?mailboxUid=`, or the caller's first accessible mailbox). */
19
- mailboxUid?: string;
20
- /** The selected mailbox's single `contacts`-type folder. */
21
- folderUid?: string;
22
- mailboxes: Mailbox[];
23
- }
24
-
25
- const ContactsShellContext = createContext<ContactsShellContextValue>({ mailboxes: [] });
26
-
27
- /** Reads the mailbox/folder Contacts is currently showing, as resolved by the enclosing `ContactsShell`. */
28
- export function useContactsShell(): ContactsShellContextValue {
29
- return useContext(ContactsShellContext);
30
- }
31
-
32
- type Status = "checking" | "error" | "ready";
33
-
34
- /**
35
- * The Contacts app's shell: unlike `MailShell` (a folder tree, since a mailbox has many mail folders),
36
- * a mailbox has exactly one well-known `contacts` folder — see `BaseMailboxRoute.create()`'s eager
37
- * provisioning in `@rapidmx/restapi`, which guarantees it exists for every mailbox. This shell's own
38
- * sidebar is therefore just a mailbox switcher (when the caller has more than one); the actual contact
39
- * list/detail UI is `apps/www/contacts/index.tsx`'s own content, rendered as `children` here.
40
- */
41
- export default function ContactsShell({
42
- userUid,
43
- authServerUrl,
44
- impersonating,
45
- impersonationBaseUrl,
46
- trusted,
47
- children,
48
- }: PropsWithChildren<ContactsShellProps>) {
49
- const [status, setStatus] = useState<Status>("checking");
50
- const [error, setError] = useState<string | null>(null);
51
- const [mailboxes, setMailboxes] = useState<Mailbox[]>([]);
52
- const [folders, setFolders] = useState<Folder[]>([]);
53
- const [folderError, setFolderError] = useState<string | null>(null);
54
- const [requestedMailboxUid, setRequestedMailboxUid] = useState<string | null>(null);
55
- const [drawerOpen, setDrawerOpen] = useState(false);
56
-
57
- useEffect(() => {
58
- setRequestedMailboxUid(new URLSearchParams(window.location.search).get("mailboxUid"));
59
- }, []);
60
-
61
- useEffect(() => {
62
- if (!userUid) {
63
- return;
64
- }
65
- listMailboxes({ limit: 100 })
66
- .then((result) => {
67
- setMailboxes(result);
68
- setStatus("ready");
69
- })
70
- .catch((err) => {
71
- setError(err instanceof ApiRequestError ? err.message : "Could not load your mailboxes.");
72
- setStatus("error");
73
- });
74
- }, [userUid]);
75
-
76
- const mailboxUid: string | undefined =
77
- (requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined) ??
78
- mailboxes[0]?.uid;
79
-
80
- useEffect(() => {
81
- if (!mailboxUid) {
82
- setFolders([]);
83
- return;
84
- }
85
- setFolderError(null);
86
- listFolders(mailboxUid)
87
- .then(setFolders)
88
- .catch((err) => setFolderError(err instanceof ApiRequestError ? err.message : "Could not load this mailbox's contacts folder."));
89
- }, [mailboxUid]);
90
-
91
- const folderUid: string | undefined = folders.find((f) => f.type === "contacts")?.uid;
92
-
93
- const contextValue = useMemo<ContactsShellContextValue>(
94
- () => ({ mailboxUid, folderUid, mailboxes }),
95
- [mailboxUid, folderUid, mailboxes],
96
- );
97
-
98
- // A full-screen takeover, not nested inside the rest of the app's chrome — there's nothing else
99
- // for a mailbox-less caller to do here yet, so the icon rail/header don't render at all.
100
- if (userUid && status === "ready" && !mailboxUid) {
101
- return <MailboxProvisioning />;
102
- }
103
-
104
- let inner: ReactNode = null;
105
- if (userUid && status === "checking") {
106
- // Renders immediately (no network round trip needed) so switching into Contacts never shows a
107
- // blank pane while `listMailboxes()` is in flight — the real sidebar (favorites/lists/categories)
108
- // lives in `apps/www/contacts/index.tsx`'s own content, so this mimics its rough shape rather
109
- // than the (minimal, mailbox-switcher-only) shape of this shell's own sidebar.
110
- inner = (
111
- <div className="w-56 shrink-0 border-r border-border flex flex-col gap-4 p-3">
112
- <Skeleton height="h-9" />
113
- <SkeletonList count={5} />
114
- </div>
115
- );
116
- } else if (userUid && status === "error") {
117
- inner = (
118
- <div className="flex-1 flex items-center justify-center p-8">
119
- <div className="w-full max-w-md">
120
- <Alert>{error}</Alert>
121
- </div>
122
- </div>
123
- );
124
- } else if (userUid && status === "ready") {
125
- // A function, not a plain JSX constant — see MailShell's identical comment: it's rendered twice
126
- // (desktop `<aside>` + mobile `Drawer`), possibly simultaneously mounted, so the `<select>`'s
127
- // `id`/its `<label>`'s `htmlFor` need a distinct value per instance to avoid a duplicate-id
128
- // label-association break.
129
- const sidebarContent = (idPrefix: string) => (
130
- <>
131
- {mailboxes.length > 1 && (
132
- <div>
133
- <label
134
- className="block text-xs font-bold uppercase tracking-wide text-text-muted mb-1"
135
- htmlFor={`${idPrefix}-contacts-mailbox-switcher`}
136
- >
137
- Mailbox
138
- </label>
139
- <select
140
- id={`${idPrefix}-contacts-mailbox-switcher`}
141
- className="w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
142
- value={mailboxUid}
143
- onChange={(e) => {
144
- window.location.href = `/contacts?mailboxUid=${encodeURIComponent(e.target.value)}`;
145
- }}
146
- >
147
- {mailboxes.map((mb) => (
148
- <option key={mb.uid} value={mb.uid}>
149
- {mb.displayName}
150
- {mb.ownerUserUid ? "" : " (shared)"}
151
- </option>
152
- ))}
153
- </select>
154
- </div>
155
- )}
156
- {folderError && <Alert>{folderError}</Alert>}
157
- </>
158
- );
159
-
160
- inner = (
161
- <>
162
- <aside className="hidden md:flex w-56 shrink-0 bg-surface border-r border-border flex-col p-3 gap-3">
163
- {sidebarContent("desktop")}
164
- </aside>
165
- <Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} title="Mailbox">
166
- <div className="flex flex-col gap-3">{sidebarContent("mobile")}</div>
167
- </Drawer>
168
- <main className="flex-1 min-w-0 flex flex-col">
169
- {(mailboxes.length > 1 || folderError) && (
170
- <button
171
- type="button"
172
- className="md:hidden m-3 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text"
173
- aria-label="Open mailbox switcher"
174
- onClick={() => setDrawerOpen(true)}
175
- >
176
- <HiOutlineBars3 size={20} aria-hidden="true" />
177
- </button>
178
- )}
179
- <ContactsShellContext.Provider value={contextValue}>{children}</ContactsShellContext.Provider>
180
- </main>
181
- </>
182
- );
183
- }
184
-
185
- return (
186
- <AppShell
187
- active="contacts"
188
- userUid={userUid}
189
- authServerUrl={authServerUrl}
190
- impersonating={impersonating}
191
- impersonationBaseUrl={impersonationBaseUrl}
192
- trusted={trusted}
193
- >
194
- {inner}
195
- </AppShell>
196
- );
197
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { createContext, PropsWithChildren, ReactNode, useContext, useEffect, useMemo, useState } from "react";
6
+ import { HiOutlineBars3 } from "react-icons/hi2";
7
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
+ import Drawer from "@rapidmx/react-shared/components/overlays/Drawer.js";
9
+ import { Folder, Mailbox, listFolders, listMailboxes } from "@rapidmx/react-shared/mail/mailApi.js";
10
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
11
+ import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
12
+ import AppShell, { AppShellProps } from "../../layout/AppShell.js";
13
+ import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
14
+
15
+ export type ContactsShellProps = Omit<AppShellProps, "active">;
16
+
17
+ export interface ContactsShellContextValue {
18
+ /** The mailbox currently selected (`?mailboxUid=`, or the caller's first accessible mailbox). */
19
+ mailboxUid?: string;
20
+ /** The selected mailbox's single `contacts`-type folder. */
21
+ folderUid?: string;
22
+ mailboxes: Mailbox[];
23
+ }
24
+
25
+ const ContactsShellContext = createContext<ContactsShellContextValue>({ mailboxes: [] });
26
+
27
+ /** Reads the mailbox/folder Contacts is currently showing, as resolved by the enclosing `ContactsShell`. */
28
+ export function useContactsShell(): ContactsShellContextValue {
29
+ return useContext(ContactsShellContext);
30
+ }
31
+
32
+ type Status = "checking" | "error" | "ready";
33
+
34
+ /**
35
+ * The Contacts app's shell: unlike `MailShell` (a folder tree, since a mailbox has many mail folders),
36
+ * a mailbox has exactly one well-known `contacts` folder — see `BaseMailboxRoute.create()`'s eager
37
+ * provisioning in `@rapidmx/restapi`, which guarantees it exists for every mailbox. This shell's own
38
+ * sidebar is therefore just a mailbox switcher (when the caller has more than one); the actual contact
39
+ * list/detail UI is `apps/www/contacts/index.tsx`'s own content, rendered as `children` here.
40
+ */
41
+ export default function ContactsShell({
42
+ userUid,
43
+ authServerUrl,
44
+ impersonating,
45
+ impersonationBaseUrl,
46
+ trusted,
47
+ pluginNav,
48
+ children,
49
+ }: PropsWithChildren<ContactsShellProps>) {
50
+ const [status, setStatus] = useState<Status>("checking");
51
+ const [error, setError] = useState<string | null>(null);
52
+ const [mailboxes, setMailboxes] = useState<Mailbox[]>([]);
53
+ const [folders, setFolders] = useState<Folder[]>([]);
54
+ const [folderError, setFolderError] = useState<string | null>(null);
55
+ const [requestedMailboxUid, setRequestedMailboxUid] = useState<string | null>(null);
56
+ const [drawerOpen, setDrawerOpen] = useState(false);
57
+
58
+ useEffect(() => {
59
+ setRequestedMailboxUid(new URLSearchParams(window.location.search).get("mailboxUid"));
60
+ }, []);
61
+
62
+ useEffect(() => {
63
+ if (!userUid) {
64
+ return;
65
+ }
66
+ listMailboxes({ limit: 100 })
67
+ .then((result) => {
68
+ setMailboxes(result);
69
+ setStatus("ready");
70
+ })
71
+ .catch((err) => {
72
+ setError(err instanceof ApiRequestError ? err.message : "Could not load your mailboxes.");
73
+ setStatus("error");
74
+ });
75
+ }, [userUid]);
76
+
77
+ const mailboxUid: string | undefined =
78
+ (requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined) ??
79
+ mailboxes[0]?.uid;
80
+
81
+ useEffect(() => {
82
+ if (!mailboxUid) {
83
+ setFolders([]);
84
+ return;
85
+ }
86
+ setFolderError(null);
87
+ listFolders(mailboxUid)
88
+ .then(setFolders)
89
+ .catch((err) => setFolderError(err instanceof ApiRequestError ? err.message : "Could not load this mailbox's contacts folder."));
90
+ }, [mailboxUid]);
91
+
92
+ const folderUid: string | undefined = folders.find((f) => f.type === "contacts")?.uid;
93
+
94
+ const contextValue = useMemo<ContactsShellContextValue>(
95
+ () => ({ mailboxUid, folderUid, mailboxes }),
96
+ [mailboxUid, folderUid, mailboxes],
97
+ );
98
+
99
+ // A full-screen takeover, not nested inside the rest of the app's chrome — there's nothing else
100
+ // for a mailbox-less caller to do here yet, so the icon rail/header don't render at all.
101
+ if (userUid && status === "ready" && !mailboxUid) {
102
+ return <MailboxProvisioning />;
103
+ }
104
+
105
+ let inner: ReactNode = null;
106
+ if (userUid && status === "checking") {
107
+ // Renders immediately (no network round trip needed) so switching into Contacts never shows a
108
+ // blank pane while `listMailboxes()` is in flight — the real sidebar (favorites/lists/categories)
109
+ // lives in `apps/www/contacts/index.tsx`'s own content, so this mimics its rough shape rather
110
+ // than the (minimal, mailbox-switcher-only) shape of this shell's own sidebar.
111
+ inner = (
112
+ <div className="w-56 shrink-0 border-r border-border flex flex-col gap-4 p-3">
113
+ <Skeleton height="h-9" />
114
+ <SkeletonList count={5} />
115
+ </div>
116
+ );
117
+ } else if (userUid && status === "error") {
118
+ inner = (
119
+ <div className="flex-1 flex items-center justify-center p-8">
120
+ <div className="w-full max-w-md">
121
+ <Alert>{error}</Alert>
122
+ </div>
123
+ </div>
124
+ );
125
+ } else if (userUid && status === "ready") {
126
+ // A function, not a plain JSX constant — see MailShell's identical comment: it's rendered twice
127
+ // (desktop `<aside>` + mobile `Drawer`), possibly simultaneously mounted, so the `<select>`'s
128
+ // `id`/its `<label>`'s `htmlFor` need a distinct value per instance to avoid a duplicate-id
129
+ // label-association break.
130
+ const sidebarContent = (idPrefix: string) => (
131
+ <>
132
+ {mailboxes.length > 1 && (
133
+ <div>
134
+ <label
135
+ className="block text-xs font-bold uppercase tracking-wide text-text-muted mb-1"
136
+ htmlFor={`${idPrefix}-contacts-mailbox-switcher`}
137
+ >
138
+ Mailbox
139
+ </label>
140
+ <select
141
+ id={`${idPrefix}-contacts-mailbox-switcher`}
142
+ className="w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
143
+ value={mailboxUid}
144
+ onChange={(e) => {
145
+ window.location.href = `/contacts?mailboxUid=${encodeURIComponent(e.target.value)}`;
146
+ }}
147
+ >
148
+ {mailboxes.map((mb) => (
149
+ <option key={mb.uid} value={mb.uid}>
150
+ {mb.displayName}
151
+ {mb.ownerUserUid ? "" : " (shared)"}
152
+ </option>
153
+ ))}
154
+ </select>
155
+ </div>
156
+ )}
157
+ {folderError && <Alert>{folderError}</Alert>}
158
+ </>
159
+ );
160
+
161
+ inner = (
162
+ <>
163
+ <aside className="hidden md:flex w-56 shrink-0 bg-surface border-r border-border flex-col p-3 gap-3">
164
+ {sidebarContent("desktop")}
165
+ </aside>
166
+ <Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} title="Mailbox">
167
+ <div className="flex flex-col gap-3">{sidebarContent("mobile")}</div>
168
+ </Drawer>
169
+ <main className="flex-1 min-w-0 flex flex-col">
170
+ {(mailboxes.length > 1 || folderError) && (
171
+ <button
172
+ type="button"
173
+ className="md:hidden m-3 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text"
174
+ aria-label="Open mailbox switcher"
175
+ onClick={() => setDrawerOpen(true)}
176
+ >
177
+ <HiOutlineBars3 size={20} aria-hidden="true" />
178
+ </button>
179
+ )}
180
+ <ContactsShellContext.Provider value={contextValue}>{children}</ContactsShellContext.Provider>
181
+ </main>
182
+ </>
183
+ );
184
+ }
185
+
186
+ return (
187
+ <AppShell
188
+ active="contacts"
189
+ userUid={userUid}
190
+ authServerUrl={authServerUrl}
191
+ impersonating={impersonating}
192
+ impersonationBaseUrl={impersonationBaseUrl}
193
+ trusted={trusted}
194
+ pluginNav={pluginNav}
195
+ >
196
+ {inner}
197
+ </AppShell>
198
+ );
199
+ }
@@ -5,7 +5,13 @@
5
5
  import "../../styles/app.css";
6
6
  import React, { PropsWithChildren, useEffect, useRef, useState } from "react";
7
7
  import type { IconType } from "react-icons";
8
- import { HiOutlineCalendarDays, HiOutlineClipboardDocumentList, HiOutlineEnvelope, HiOutlineUsers } from "react-icons/hi2";
8
+ import {
9
+ HiOutlineCalendarDays,
10
+ HiOutlineClipboardDocumentList,
11
+ HiOutlineEnvelope,
12
+ HiOutlinePuzzlePiece,
13
+ HiOutlineUsers,
14
+ } from "react-icons/hi2";
9
15
  import { useRedirectIfUnauthenticated } from "@rapidmx/react-shared/auth/session.js";
10
16
  import { getSetupStatus } from "@rapidmx/react-shared/admin/setupApi.js";
11
17
  import { stopImpersonating } from "@rapidmx/react-shared/mail/mailApi.js";
@@ -13,7 +19,7 @@ import useBranding from "@rapidmx/react-shared/branding/useBranding.js";
13
19
  import { useIdleKeyTimeout } from "@rapidmx/react-shared/crypto/useIdleKeyTimeout.js";
14
20
  import ComposeProvider from "../mail/compose/ComposeContext.js";
15
21
  import { flushComposeDrafts, markSigningOut } from "../mail/compose/composeFlushRegistry.js";
16
- import BottomTabBar from "@rapidmx/react-shared/components/navigation/BottomTabBar.js";
22
+ import BottomTabBar, { NavItem } from "@rapidmx/react-shared/components/navigation/BottomTabBar.js";
17
23
  import { BrandingFooter, BrandingHeader } from "./BrandingChrome.js";
18
24
  import UserMenu from "./UserMenu.js";
19
25
  import { UnlockPromptProvider } from "./UnlockPromptProvider.js";
@@ -21,6 +27,7 @@ import { SIGN_OUT_CHANNEL, destroyAllLocalIndexes } from "../../search/localInde
21
27
  import { authApiFetch } from "@rapidmx/react-shared/util/api.js";
22
28
  import { destroyUnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
23
29
  import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
30
+ import { mergePluginNavItems, PluginNav, PluginNavProps } from "../../plugins/pluginNav.js";
24
31
 
25
32
  /** How long sign-out waits for auth-server's logout before navigating anyway. */
26
33
  export const LOGOUT_TIMEOUT_MS = 3_000;
@@ -49,11 +56,13 @@ export type AppShellApp = "mail" | "calendar" | "contacts" | "tasks";
49
56
 
50
57
  /** `"settings"` is a valid `active` value but deliberately has no entry in `APPS` below — Settings is
51
58
  * reached via a `UserMenu` item, not a 5th rail icon (see `SettingsShell.tsx`), so it highlights no
52
- * rail/tab icon at all; only the header title (`ACTIVE_LABELS` below) needs to account for it. */
53
- export type AppShellActive = AppShellApp | "settings";
59
+ * rail/tab icon at all; only the header title needs to account for it. Any other string is a plugin's
60
+ * `appRail` item id (see `PluginNav`). */
61
+ export type AppShellActive = AppShellApp | "settings" | (string & {});
54
62
 
55
- export interface AppShellProps {
56
- /** Which icon in the rail is highlighted as the current app — `"settings"` highlights none. */
63
+ export interface AppShellProps extends PluginNavProps {
64
+ /** Which icon in the rail is highlighted as the current app — `"settings"` highlights none, and a plugin
65
+ * app page passes its own `appRail` item id. */
57
66
  active: AppShellActive;
58
67
  /** Populated automatically by the framework from an authenticated request (e.g. a valid `jwt` cookie). */
59
68
  userUid?: string;
@@ -85,15 +94,19 @@ export const APPS: AppDef[] = [
85
94
  { id: "tasks", href: "/tasks", label: "Tasks", icon: HiOutlineClipboardDocumentList },
86
95
  ];
87
96
 
88
- /** The header title for every valid `active` value — a superset of `APPS`' own labels since `"settings"`
89
- * has no rail icon (and so no `AppDef`) but still needs a header title. */
90
- const ACTIVE_LABELS: Record<AppShellActive, string> = {
91
- mail: "Mail",
92
- calendar: "Calendar",
93
- contacts: "Contacts",
94
- tasks: "Tasks",
95
- settings: "Settings",
96
- };
97
+ /** Ids plugin `appRail` items can't take besides `APPS`' own - `"settings"` has no rail icon but is still a
98
+ * core `active` value. */
99
+ const RESERVED_APP_IDS = ["settings"];
100
+
101
+ /** `APPS` followed by the plugins' `appRail` items (generic icon), core ids winning - see `mergePluginNavItems`. */
102
+ export function appRailItems(pluginNav?: PluginNav): NavItem[] {
103
+ return mergePluginNavItems<NavItem>(
104
+ APPS,
105
+ pluginNav?.appRail,
106
+ ({ id, href, label }) => ({ id, href, label, icon: HiOutlinePuzzlePiece }),
107
+ RESERVED_APP_IDS,
108
+ );
109
+ }
97
110
 
98
111
  /**
99
112
  * The persistent chrome shared by every webmail app (Mail, Calendar, Contacts, Tasks): a left icon rail for
@@ -109,6 +122,7 @@ export default function AppShell({
109
122
  impersonating,
110
123
  impersonationBaseUrl,
111
124
  trusted,
125
+ pluginNav,
112
126
  children,
113
127
  }: PropsWithChildren<AppShellProps>) {
114
128
  const [stoppingImpersonation, setStoppingImpersonation] = useState(false);
@@ -204,6 +218,10 @@ export default function AppShell({
204
218
  return <div className="min-h-screen" />;
205
219
  }
206
220
 
221
+ const apps = appRailItems(pluginNav);
222
+ // The header title: "settings" has no rail item, everything else is labelled by its own rail item.
223
+ const activeLabel = active === "settings" ? "Settings" : apps.find((app) => app.id === active)?.label;
224
+
207
225
  return (
208
226
  // Mounted here, not scoped to Mail/Settings, for the same reason as useIdleKeyTimeout() above -
209
227
  // ComposeWindow's sign/encrypt toggles and MessageDetailPane's encrypted-message view (both Mail)
@@ -235,7 +253,7 @@ export default function AppShell({
235
253
  className="hidden md:flex w-16 shrink-0 bg-surface border-r border-border flex-col items-center py-3 gap-1"
236
254
  >
237
255
  <img src={iconSrc} width="96" height="96" alt="" className="mb-3" />
238
- {APPS.map(({ id, href, label, icon: Icon }) => (
256
+ {apps.map(({ id, href, label, icon: Icon }) => (
239
257
  <a
240
258
  key={id}
241
259
  href={href}
@@ -253,10 +271,10 @@ export default function AppShell({
253
271
  </a>
254
272
  ))}
255
273
  </nav>
256
- <BottomTabBar apps={APPS} active={active} />
274
+ <BottomTabBar apps={apps} active={active} />
257
275
  <div className="flex-1 flex flex-col min-w-0">
258
276
  <header className="h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-6">
259
- <span className="font-display font-bold text-lg uppercase tracking-wide">{ACTIVE_LABELS[active]}</span>
277
+ <span className="font-display font-bold text-lg uppercase tracking-wide">{activeLabel}</span>
260
278
  <UserMenu userUid={userUid} authServerUrl={authServerUrl} onSignOut={handleSignOut} showAdminLink={trusted} showSettingsLink />
261
279
  </header>
262
280
  <div className="flex-1 flex min-h-0 pb-14 md:pb-0">{children}</div>