@rapidmx/web-client 0.2.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 (168) hide show
  1. package/LICENSE +382 -0
  2. package/apps/admin/_404.tsx +9 -0
  3. package/apps/admin/_500.tsx +9 -0
  4. package/apps/admin/_layout.tsx +41 -0
  5. package/apps/admin/audit-log/index.tsx +187 -0
  6. package/apps/admin/branding/index.tsx +404 -0
  7. package/apps/admin/distribution-lists/[uid].tsx +70 -0
  8. package/apps/admin/distribution-lists/index.tsx +115 -0
  9. package/apps/admin/distribution-lists/new/index.tsx +114 -0
  10. package/apps/admin/domains/[uid].tsx +259 -0
  11. package/apps/admin/domains/index.tsx +125 -0
  12. package/apps/admin/domains/new/index.tsx +84 -0
  13. package/apps/admin/index.tsx +82 -0
  14. package/apps/admin/ingest-queue/index.tsx +116 -0
  15. package/apps/admin/mailboxes/[uid].tsx +126 -0
  16. package/apps/admin/mailboxes/new/index.tsx +291 -0
  17. package/apps/admin/quarantine/index.tsx +171 -0
  18. package/apps/admin/transport-rules/[uid].tsx +143 -0
  19. package/apps/admin/transport-rules/_transportRuleConfig.tsx +75 -0
  20. package/apps/admin/transport-rules/index.tsx +117 -0
  21. package/apps/admin/transport-rules/new/index.tsx +105 -0
  22. package/apps/shared/components/admin/distributionLists/MemberListCard.tsx +117 -0
  23. package/apps/shared/components/admin/layout/AdminShell.tsx +186 -0
  24. package/apps/shared/components/admin/mailboxes/MailboxTable.tsx +73 -0
  25. package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +154 -0
  26. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +124 -0
  27. package/apps/shared/components/booking/AvailabilityEditor.tsx +118 -0
  28. package/apps/shared/components/calendar/CalendarListSidebar.tsx +119 -0
  29. package/apps/shared/components/calendar/EventModal.tsx +513 -0
  30. package/apps/shared/components/calendar/MonthView.tsx +139 -0
  31. package/apps/shared/components/calendar/RecurrenceEditor.tsx +180 -0
  32. package/apps/shared/components/calendar/ResourcePicker.tsx +80 -0
  33. package/apps/shared/components/calendar/SplitDayView.tsx +137 -0
  34. package/apps/shared/components/calendar/TimeGridView.tsx +198 -0
  35. package/apps/shared/components/calendar/layout/CalendarShell.tsx +211 -0
  36. package/apps/shared/components/contacts/ContactDetailPane.tsx +99 -0
  37. package/apps/shared/components/contacts/ContactForm.tsx +314 -0
  38. package/apps/shared/components/contacts/ContactsSidebar.tsx +242 -0
  39. package/apps/shared/components/contacts/ContactsToolbar.tsx +115 -0
  40. package/apps/shared/components/contacts/layout/ContactsShell.tsx +197 -0
  41. package/apps/shared/components/layout/AppShell.tsx +165 -0
  42. package/apps/shared/components/layout/BrandingChrome.tsx +25 -0
  43. package/apps/shared/components/layout/MailboxProvisioning.tsx +110 -0
  44. package/apps/shared/components/layout/UserMenu.tsx +150 -0
  45. package/apps/shared/components/mail/ConversationList.tsx +61 -0
  46. package/apps/shared/components/mail/ConversationThreadPane.tsx +174 -0
  47. package/apps/shared/components/mail/MessageDetailPane.tsx +390 -0
  48. package/apps/shared/components/mail/compose/ComposeContext.tsx +114 -0
  49. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +418 -0
  50. package/apps/shared/components/mail/compose/ComposeWindow.tsx +518 -0
  51. package/apps/shared/components/mail/compose/EmojiPicker.tsx +64 -0
  52. package/apps/shared/components/mail/compose/GifPicker.tsx +82 -0
  53. package/apps/shared/components/mail/compose/RichTextEditor.tsx +83 -0
  54. package/apps/shared/components/mail/compose/ScheduleSendPicker.tsx +64 -0
  55. package/apps/shared/components/mail/layout/MailShell.tsx +287 -0
  56. package/apps/shared/components/rules/RuleBuilder.tsx +276 -0
  57. package/apps/shared/components/settings/layout/SettingsShell.tsx +223 -0
  58. package/apps/shared/components/tasks/TasksSidebar.tsx +201 -0
  59. package/apps/shared/components/tasks/TasksToolbar.tsx +68 -0
  60. package/apps/shared/components/tasks/layout/TasksShell.tsx +199 -0
  61. package/apps/shared/styles/app.css +265 -0
  62. package/apps/www/_404.tsx +9 -0
  63. package/apps/www/_500.tsx +9 -0
  64. package/apps/www/_layout.tsx +45 -0
  65. package/apps/www/calendar/index.tsx +407 -0
  66. package/apps/www/contacts/[uid].tsx +88 -0
  67. package/apps/www/contacts/index.tsx +436 -0
  68. package/apps/www/index.tsx +375 -0
  69. package/apps/www/messages/[uid].tsx +70 -0
  70. package/apps/www/settings/auto-reply/index.tsx +129 -0
  71. package/apps/www/settings/booking-types/[uid].tsx +300 -0
  72. package/apps/www/settings/booking-types/index.tsx +98 -0
  73. package/apps/www/settings/booking-types/new/index.tsx +212 -0
  74. package/apps/www/settings/filters/[uid].tsx +155 -0
  75. package/apps/www/settings/filters/_mailFilterRuleConfig.tsx +102 -0
  76. package/apps/www/settings/filters/index.tsx +99 -0
  77. package/apps/www/settings/filters/new/index.tsx +126 -0
  78. package/apps/www/settings/focused-inbox/index.tsx +148 -0
  79. package/apps/www/settings/read-receipts/index.tsx +125 -0
  80. package/apps/www/settings/signatures/[uid].tsx +167 -0
  81. package/apps/www/settings/signatures/index.tsx +85 -0
  82. package/apps/www/settings/signatures/new/index.tsx +129 -0
  83. package/apps/www/settings/signatures/signatureDefaults.ts +36 -0
  84. package/apps/www/tasks/index.tsx +530 -0
  85. package/dist/apps/admin/_404.js +4 -0
  86. package/dist/apps/admin/_500.js +4 -0
  87. package/dist/apps/admin/_layout.js +10 -0
  88. package/dist/apps/admin/audit-log/index.js +66 -0
  89. package/dist/apps/admin/branding/index.js +126 -0
  90. package/dist/apps/admin/distribution-lists/[uid].js +34 -0
  91. package/dist/apps/admin/distribution-lists/index.js +31 -0
  92. package/dist/apps/admin/distribution-lists/new/index.js +51 -0
  93. package/dist/apps/admin/domains/[uid].js +102 -0
  94. package/dist/apps/admin/domains/index.js +31 -0
  95. package/dist/apps/admin/domains/new/index.js +41 -0
  96. package/dist/apps/admin/index.js +32 -0
  97. package/dist/apps/admin/ingest-queue/index.js +48 -0
  98. package/dist/apps/admin/mailboxes/[uid].js +59 -0
  99. package/dist/apps/admin/mailboxes/new/index.js +93 -0
  100. package/dist/apps/admin/quarantine/index.js +66 -0
  101. package/dist/apps/admin/transport-rules/[uid].js +87 -0
  102. package/dist/apps/admin/transport-rules/_transportRuleConfig.js +37 -0
  103. package/dist/apps/admin/transport-rules/index.js +32 -0
  104. package/dist/apps/admin/transport-rules/new/index.js +50 -0
  105. package/dist/apps/shared/components/admin/distributionLists/MemberListCard.js +64 -0
  106. package/dist/apps/shared/components/admin/layout/AdminShell.js +94 -0
  107. package/dist/apps/shared/components/admin/mailboxes/MailboxTable.js +16 -0
  108. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +59 -0
  109. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +67 -0
  110. package/dist/apps/shared/components/booking/AvailabilityEditor.js +45 -0
  111. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +44 -0
  112. package/dist/apps/shared/components/calendar/EventModal.js +167 -0
  113. package/dist/apps/shared/components/calendar/MonthView.js +38 -0
  114. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +55 -0
  115. package/dist/apps/shared/components/calendar/ResourcePicker.js +36 -0
  116. package/dist/apps/shared/components/calendar/SplitDayView.js +44 -0
  117. package/dist/apps/shared/components/calendar/TimeGridView.js +58 -0
  118. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +96 -0
  119. package/dist/apps/shared/components/contacts/ContactDetailPane.js +11 -0
  120. package/dist/apps/shared/components/contacts/ContactForm.js +85 -0
  121. package/dist/apps/shared/components/contacts/ContactsSidebar.js +105 -0
  122. package/dist/apps/shared/components/contacts/ContactsToolbar.js +32 -0
  123. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +93 -0
  124. package/dist/apps/shared/components/layout/AppShell.js +68 -0
  125. package/dist/apps/shared/components/layout/BrandingChrome.js +18 -0
  126. package/dist/apps/shared/components/layout/MailboxProvisioning.js +75 -0
  127. package/dist/apps/shared/components/layout/UserMenu.js +59 -0
  128. package/dist/apps/shared/components/mail/ConversationList.js +17 -0
  129. package/dist/apps/shared/components/mail/ConversationThreadPane.js +112 -0
  130. package/dist/apps/shared/components/mail/MessageDetailPane.js +153 -0
  131. package/dist/apps/shared/components/mail/compose/ComposeContext.js +55 -0
  132. package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +88 -0
  133. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +261 -0
  134. package/dist/apps/shared/components/mail/compose/EmojiPicker.js +25 -0
  135. package/dist/apps/shared/components/mail/compose/GifPicker.js +36 -0
  136. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +60 -0
  137. package/dist/apps/shared/components/mail/compose/ScheduleSendPicker.js +33 -0
  138. package/dist/apps/shared/components/mail/layout/MailShell.js +139 -0
  139. package/dist/apps/shared/components/rules/RuleBuilder.js +78 -0
  140. package/dist/apps/shared/components/settings/layout/SettingsShell.js +100 -0
  141. package/dist/apps/shared/components/tasks/TasksSidebar.js +81 -0
  142. package/dist/apps/shared/components/tasks/TasksToolbar.js +15 -0
  143. package/dist/apps/shared/components/tasks/layout/TasksShell.js +92 -0
  144. package/dist/apps/shared/styles/app.css +265 -0
  145. package/dist/apps/www/_404.js +4 -0
  146. package/dist/apps/www/_500.js +4 -0
  147. package/dist/apps/www/_layout.js +8 -0
  148. package/dist/apps/www/calendar/index.js +228 -0
  149. package/dist/apps/www/contacts/[uid].js +62 -0
  150. package/dist/apps/www/contacts/index.js +262 -0
  151. package/dist/apps/www/index.js +228 -0
  152. package/dist/apps/www/messages/[uid].js +47 -0
  153. package/dist/apps/www/settings/auto-reply/index.js +54 -0
  154. package/dist/apps/www/settings/booking-types/[uid].js +129 -0
  155. package/dist/apps/www/settings/booking-types/index.js +29 -0
  156. package/dist/apps/www/settings/booking-types/new/index.js +86 -0
  157. package/dist/apps/www/settings/filters/[uid].js +94 -0
  158. package/dist/apps/www/settings/filters/_mailFilterRuleConfig.js +65 -0
  159. package/dist/apps/www/settings/filters/index.js +30 -0
  160. package/dist/apps/www/settings/filters/new/index.js +61 -0
  161. package/dist/apps/www/settings/focused-inbox/index.js +69 -0
  162. package/dist/apps/www/settings/read-receipts/index.js +51 -0
  163. package/dist/apps/www/settings/signatures/[uid].js +96 -0
  164. package/dist/apps/www/settings/signatures/index.js +34 -0
  165. package/dist/apps/www/settings/signatures/new/index.js +63 -0
  166. package/dist/apps/www/settings/signatures/signatureDefaults.js +30 -0
  167. package/dist/apps/www/tasks/index.js +280 -0
  168. package/package.json +87 -0
@@ -0,0 +1,262 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useEffect, useMemo, useState } from "react";
7
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
+ import { createContact, deleteContact, listContacts, listDeletedContacts, setContactFavorite, updateContact, } from "@rapidmx/react-shared/contacts/contactsApi.js";
9
+ import { contactsToVCardFile, contactToVCard, parseVCards } from "@rapidmx/react-shared/contacts/vcard.js";
10
+ import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
11
+ import { useCompose } from "../../shared/components/mail/compose/ComposeContext.js";
12
+ import ContactsShell, { useContactsShell } from "../../shared/components/contacts/layout/ContactsShell.js";
13
+ import ContactsSidebar from "../../shared/components/contacts/ContactsSidebar.js";
14
+ import ContactsToolbar from "../../shared/components/contacts/ContactsToolbar.js";
15
+ import ContactAvatar from "@rapidmx/react-shared/components/avatar/ContactAvatar.js";
16
+ import ContactDetailPane from "../../shared/components/contacts/ContactDetailPane.js";
17
+ import ContactForm from "../../shared/components/contacts/ContactForm.js";
18
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
19
+ const INPUT_CLASS = "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
20
+ export default function ContactsPage(props) {
21
+ return (_jsx(ContactsShell, { ...props, children: _jsx(ContactsContent, {}) }));
22
+ }
23
+ function primaryInfo(contact) {
24
+ return contact.emails[0]?.address ?? contact.phones[0]?.phoneNumber ?? "";
25
+ }
26
+ function downloadTextFile(filename, content) {
27
+ const blob = new Blob([content], { type: "text/vcard" });
28
+ const url = URL.createObjectURL(blob);
29
+ const a = document.createElement("a");
30
+ a.href = url;
31
+ a.download = filename;
32
+ a.click();
33
+ URL.revokeObjectURL(url);
34
+ }
35
+ function ContactsContent() {
36
+ const { folderUid, mailboxUid } = useContactsShell();
37
+ const { openCompose } = useCompose();
38
+ const isMobile = useIsMobile();
39
+ const [contacts, setContacts] = useState([]);
40
+ const [deletedContacts, setDeletedContacts] = useState([]);
41
+ const [deletedLoading, setDeletedLoading] = useState(false);
42
+ const [deletedError, setDeletedError] = useState(null);
43
+ const [loading, setLoading] = useState(true);
44
+ const [error, setError] = useState(null);
45
+ const [query, setQuery] = useState("");
46
+ const [view, setView] = useState({ type: "all" });
47
+ const [sortColumn, setSortColumn] = useState("name");
48
+ const [sortDesc, setSortDesc] = useState(false);
49
+ const [checkedUids, setCheckedUids] = useState(new Set());
50
+ const [selectedUid, setSelectedUid] = useState(null);
51
+ const [mode, setMode] = useState("view");
52
+ /** Returns a promise so bulk actions (below) can wait for the refreshed list before re-asserting their
53
+ * own error message — this always clears `error` first (a legitimate reset for a fresh fetch attempt),
54
+ * which would otherwise silently wipe out a bulk action's own just-set failure message before the user
55
+ * ever saw it, since every bulk handler calls this right after its own error-setting loop. */
56
+ function reload() {
57
+ if (!folderUid) {
58
+ setContacts([]);
59
+ setLoading(false);
60
+ return Promise.resolve();
61
+ }
62
+ setLoading(true);
63
+ setError(null);
64
+ return listContacts(folderUid, { limit: 500 })
65
+ .then(setContacts)
66
+ .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load contacts."))
67
+ .finally(() => setLoading(false));
68
+ }
69
+ useEffect(() => {
70
+ void reload();
71
+ }, [folderUid]);
72
+ useEffect(() => {
73
+ if (view.type !== "deleted" || !folderUid) {
74
+ return;
75
+ }
76
+ setDeletedLoading(true);
77
+ setDeletedError(null);
78
+ listDeletedContacts(folderUid, { limit: 500 })
79
+ .then(setDeletedContacts)
80
+ .catch((err) => setDeletedError(err instanceof ApiRequestError ? err.message : "Could not load deleted contacts."))
81
+ .finally(() => setDeletedLoading(false));
82
+ // Re-fetches every time the Deleted view is (re-)selected, matching this page's own `reload()`
83
+ // convention elsewhere rather than caching a possibly-stale list across visits.
84
+ }, [view, folderUid]);
85
+ const viewFiltered = useMemo(() => {
86
+ switch (view.type) {
87
+ case "all":
88
+ return contacts;
89
+ case "favorites":
90
+ return contacts.filter((c) => c.favorite);
91
+ case "list":
92
+ return contacts.filter((c) => c.contactListUid === view.uid);
93
+ case "category":
94
+ return contacts.filter((c) => (c.categories ?? []).includes(view.name));
95
+ case "deleted":
96
+ return deletedContacts;
97
+ }
98
+ }, [contacts, deletedContacts, view]);
99
+ const filtered = useMemo(() => {
100
+ const q = query.trim().toLowerCase();
101
+ if (!q) {
102
+ return viewFiltered;
103
+ }
104
+ return viewFiltered.filter((c) => c.displayName.toLowerCase().includes(q) || c.emails.some((e) => e.address.toLowerCase().includes(q)));
105
+ }, [viewFiltered, query]);
106
+ const sorted = useMemo(() => {
107
+ const arr = [...filtered];
108
+ arr.sort((a, b) => {
109
+ const cmp = (sortColumn === "name" ? a.displayName : primaryInfo(a)).localeCompare(sortColumn === "name" ? b.displayName : primaryInfo(b));
110
+ return sortDesc ? -cmp : cmp;
111
+ });
112
+ return arr;
113
+ }, [filtered, sortColumn, sortDesc]);
114
+ const selected = contacts.find((c) => c.uid === selectedUid) ?? null;
115
+ const isDeletedView = view.type === "deleted";
116
+ const checkedContacts = sorted.filter((c) => checkedUids.has(c.uid));
117
+ const allChecked = sorted.length > 0 && sorted.every((c) => checkedUids.has(c.uid));
118
+ function handleSort(column) {
119
+ if (column === sortColumn) {
120
+ setSortDesc((prev) => !prev);
121
+ }
122
+ else {
123
+ setSortColumn(column);
124
+ setSortDesc(false);
125
+ }
126
+ }
127
+ function toggleChecked(uid) {
128
+ setCheckedUids((prev) => {
129
+ const next = new Set(prev);
130
+ if (next.has(uid)) {
131
+ next.delete(uid);
132
+ }
133
+ else {
134
+ next.add(uid);
135
+ }
136
+ return next;
137
+ });
138
+ }
139
+ function toggleCheckAll() {
140
+ setCheckedUids(allChecked ? new Set() : new Set(sorted.map((c) => c.uid)));
141
+ }
142
+ function handleSelectView(next) {
143
+ setView(next);
144
+ setCheckedUids(new Set());
145
+ }
146
+ function handleSelectRow(contact) {
147
+ if (isMobile) {
148
+ window.location.href = `/contacts/${encodeURIComponent(contact.uid)}`;
149
+ return;
150
+ }
151
+ setSelectedUid(contact.uid);
152
+ setMode("view");
153
+ }
154
+ function handleNew() {
155
+ setSelectedUid(null);
156
+ setMode("new");
157
+ }
158
+ function handleSaved(contact) {
159
+ setSelectedUid(contact.uid);
160
+ setMode("view");
161
+ void reload();
162
+ }
163
+ function handleCancel() {
164
+ setMode("view");
165
+ }
166
+ async function handleDelete(contact) {
167
+ try {
168
+ await deleteContact(contact.uid, contact.version);
169
+ setSelectedUid(null);
170
+ setMode("view");
171
+ void reload();
172
+ }
173
+ catch (err) {
174
+ setError(err instanceof ApiRequestError ? err.message : "Could not delete this contact.");
175
+ }
176
+ }
177
+ async function handleBulkDelete() {
178
+ let bulkError = null;
179
+ for (const contact of checkedContacts) {
180
+ try {
181
+ await deleteContact(contact.uid, contact.version);
182
+ }
183
+ catch (err) {
184
+ bulkError = err instanceof ApiRequestError ? err.message : "Could not delete one or more contacts.";
185
+ }
186
+ }
187
+ setCheckedUids(new Set());
188
+ await reload();
189
+ setError(bulkError);
190
+ }
191
+ // `ContactsToolbar`'s Edit button — the only caller — is itself `disabled` unless exactly one contact
192
+ // is checked, so `checkedContacts[0]` is guaranteed to exist whenever this actually runs; no length
193
+ // guard needed, matching this codebase's established pattern for the same class of "always non-empty
194
+ // by the time it's called" value guaranteed by a disabled control (see `ComposeToolbar.run()`).
195
+ function handleToolbarEdit() {
196
+ setSelectedUid(checkedContacts[0].uid);
197
+ setMode("edit");
198
+ }
199
+ function handleEmail() {
200
+ const addresses = checkedContacts.map((c) => c.emails[0]?.address).filter((a) => Boolean(a));
201
+ // `ContactsShell` only ever renders this component once `mailboxUid` is resolved (see its own
202
+ // invariant comment) — same non-null pattern as `organizerAddress` in the calendar page.
203
+ openCompose({ mailboxUid: mailboxUid, to: addresses.join(", ") });
204
+ }
205
+ async function handleToggleFavorite() {
206
+ const allFavorited = checkedContacts.every((c) => c.favorite);
207
+ let bulkError = null;
208
+ for (const contact of checkedContacts) {
209
+ try {
210
+ await setContactFavorite(contact, !allFavorited);
211
+ }
212
+ catch (err) {
213
+ bulkError = err instanceof ApiRequestError ? err.message : "Could not update one or more contacts.";
214
+ }
215
+ }
216
+ await reload();
217
+ setError(bulkError);
218
+ }
219
+ async function handleAddCategory() {
220
+ const category = window.prompt("Category name");
221
+ if (!category?.trim()) {
222
+ return;
223
+ }
224
+ let bulkError = null;
225
+ for (const contact of checkedContacts) {
226
+ const categories = Array.from(new Set([...(contact.categories ?? []), category.trim()]));
227
+ try {
228
+ await updateContact({ ...contact, categories });
229
+ }
230
+ catch (err) {
231
+ bulkError = err instanceof ApiRequestError ? err.message : "Could not update one or more contacts.";
232
+ }
233
+ }
234
+ await reload();
235
+ setError(bulkError);
236
+ }
237
+ function handleExportVCard() {
238
+ downloadTextFile(checkedContacts.length === 1 ? `${checkedContacts[0].displayName}.vcf` : "contacts.vcf", checkedContacts.length === 1 ? contactToVCard(checkedContacts[0]) : contactsToVCardFile(checkedContacts));
239
+ }
240
+ async function handleImportFile(file) {
241
+ if (!mailboxUid || !folderUid) {
242
+ return;
243
+ }
244
+ const text = await file.text();
245
+ const parsed = parseVCards(text);
246
+ let bulkError = null;
247
+ for (const input of parsed) {
248
+ try {
249
+ await createContact({ mailboxUid, folderUid, ...input });
250
+ }
251
+ catch (err) {
252
+ bulkError = err instanceof ApiRequestError ? err.message : "Could not import one or more contacts.";
253
+ }
254
+ }
255
+ await reload();
256
+ setError(bulkError);
257
+ }
258
+ return (_jsxs("div", { className: "flex-1 flex min-h-0", children: [_jsx(ContactsSidebar, { mailboxUid: mailboxUid, contacts: contacts, active: view, onSelect: handleSelectView }), _jsxs("div", { className: [
259
+ "w-full md:w-96 shrink-0 md:border-r border-border md:flex flex-col min-h-0",
260
+ mode === "new" ? "hidden md:flex" : "flex",
261
+ ].join(" "), children: [_jsx(ContactsToolbar, { selectedCount: checkedContacts.length, allSelectedFavorited: checkedContacts.length > 0 && checkedContacts.every((c) => c.favorite), onNewContact: handleNew, onEdit: handleToolbarEdit, onDelete: handleBulkDelete, onEmail: handleEmail, onToggleFavorite: handleToggleFavorite, onAddCategory: handleAddCategory, onExportVCard: handleExportVCard, onImportFile: handleImportFile }), _jsx("div", { className: "p-3 border-b border-border", children: _jsx("input", { type: "search", value: query, onChange: (e) => setQuery(e.target.value), placeholder: "Search contacts", "aria-label": "Search contacts", className: INPUT_CLASS }) }), error && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: error }) })), deletedError && isDeletedView && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: deletedError }) })), (isDeletedView ? deletedLoading : loading) ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "Loading\u2026" })) : sorted.length === 0 ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "No contacts found." })) : (_jsx("div", { className: "flex-1 overflow-auto", children: _jsxs("table", { className: "w-full text-sm", children: [_jsx("thead", { children: _jsxs("tr", { className: "border-b border-border text-left", children: [!isDeletedView && (_jsx("th", { className: "w-8 px-3 py-2", children: _jsx("input", { type: "checkbox", "aria-label": "Select all contacts", checked: allChecked, onChange: toggleCheckAll }) })), _jsx("th", { className: "px-3 py-2", children: _jsxs("button", { type: "button", onClick: () => handleSort("name"), className: "text-xs font-bold uppercase tracking-wide text-text-muted", children: ["Name", sortColumn === "name" ? (sortDesc ? " ▾" : " ▴") : ""] }) }), _jsx("th", { className: "px-3 py-2", children: _jsxs("button", { type: "button", onClick: () => handleSort("info"), className: "text-xs font-bold uppercase tracking-wide text-text-muted", children: ["Contact info", sortColumn === "info" ? (sortDesc ? " ▾" : " ▴") : ""] }) })] }) }), _jsx("tbody", { children: sorted.map((contact) => (_jsxs("tr", { className: ["border-b border-border", contact.uid === selectedUid ? "bg-primary/10" : "hover:bg-surface-alt"].join(" "), children: [!isDeletedView && (_jsx("td", { className: "px-3 py-2", children: _jsx("input", { type: "checkbox", "aria-label": `Select ${contact.displayName}`, checked: checkedUids.has(contact.uid), onChange: () => toggleChecked(contact.uid) }) })), _jsx("td", { className: "px-3 py-2", children: _jsxs("button", { type: "button", onClick: () => handleSelectRow(contact), className: "flex items-center gap-2 text-left", children: [_jsx(ContactAvatar, { displayName: contact.displayName, size: 28 }), _jsxs("span", { className: "truncate font-medium", children: [contact.displayName, contact.favorite && _jsx("span", { "aria-label": "Favorite", children: " \u2605" })] })] }) }), _jsx("td", { className: "px-3 py-2 text-text-muted truncate", children: primaryInfo(contact) })] }, contact.uid))) })] }) }))] }), _jsx("div", { className: ["flex-1 min-w-0 overflow-y-auto p-6 md:block", mode === "new" ? "block" : "hidden"].join(" "), children: mode === "new" && mailboxUid && folderUid ? (_jsx(ContactForm, { mailboxUid: mailboxUid, folderUid: folderUid, onSaved: handleSaved, onCancel: handleCancel })) : mode === "edit" && selected ? (_jsx(ContactForm, { contact: selected, onSaved: handleSaved, onCancel: handleCancel })) : selected ? (_jsx(ContactDetailPane, { contact: selected, onEdit: () => setMode("edit"), onDelete: () => handleDelete(selected) })) : (_jsx("p", { className: "text-sm text-text-muted", children: "Select a contact, or create a new one." })) })] }));
262
+ }
@@ -0,0 +1,228 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useCallback, useEffect, useRef, useState } from "react";
7
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
+ import { getMessage, listMessages } from "@rapidmx/react-shared/mail/mailApi.js";
9
+ import { listConversations } from "@rapidmx/react-shared/mail/conversationsApi.js";
10
+ import { search as searchMailbox } from "@rapidmx/react-shared/search/searchApi.js";
11
+ import { useMarkMessageRead, useMessageAttachments } from "@rapidmx/react-shared/mail/mailDetailHooks.js";
12
+ import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
13
+ import MailShell, { useMailShell } from "../shared/components/mail/layout/MailShell.js";
14
+ import MessageDetailPane from "../shared/components/mail/MessageDetailPane.js";
15
+ import ConversationList from "../shared/components/mail/ConversationList.js";
16
+ import ConversationThreadPane from "../shared/components/mail/ConversationThreadPane.js";
17
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
18
+ const MESSAGE_PAGE_SIZE = 50;
19
+ const SEARCH_DEBOUNCE_MS = 300;
20
+ /** Resolves one page of search hits (of type "message") into full `Message` records for display. */
21
+ async function searchMessages(text, cursor) {
22
+ const page = await searchMailbox(text, { types: ["message"], cursor, limit: MESSAGE_PAGE_SIZE });
23
+ const resolved = await Promise.all(page.results.map((hit) => getMessage(hit.entityUid).catch(() => null)));
24
+ // A search hit can briefly outlive the message it points to (index updates are eventually consistent,
25
+ // and a message can be deleted after being indexed) - drop anything that no longer resolves rather than
26
+ // rendering a broken entry.
27
+ return { messages: resolved.filter((m) => m !== null), nextCursor: page.nextCursor };
28
+ }
29
+ export default function InboxPage(props) {
30
+ return (_jsx(MailShell, { ...props, children: _jsx(InboxContent, {}) }));
31
+ }
32
+ function InboxContent() {
33
+ const { folderUid, mailboxUid, folders } = useMailShell();
34
+ const isMobile = useIsMobile();
35
+ const [viewMode, setViewMode] = useState("date");
36
+ const [messages, setMessages] = useState([]);
37
+ const [conversations, setConversations] = useState([]);
38
+ const [loading, setLoading] = useState(true);
39
+ const [loadingMore, setLoadingMore] = useState(false);
40
+ const [hasMore, setHasMore] = useState(false);
41
+ const [error, setError] = useState(null);
42
+ const [selectedUid, setSelectedUid] = useState(null);
43
+ const [selectedConversationId, setSelectedConversationId] = useState(null);
44
+ const [classificationFilter, setClassificationFilter] = useState("all");
45
+ const [searchInput, setSearchInput] = useState("");
46
+ const [searchQuery, setSearchQuery] = useState("");
47
+ const isSearching = viewMode === "date" && searchQuery.length > 0;
48
+ const pageRef = useRef(0);
49
+ const cursorRef = useRef(undefined);
50
+ const scrollContainerRef = useRef(null);
51
+ const sentinelRef = useRef(null);
52
+ // Debounce the raw input into the query actually searched, so every keystroke doesn't fire a request.
53
+ useEffect(() => {
54
+ const handle = setTimeout(() => setSearchQuery(searchInput.trim()), SEARCH_DEBOUNCE_MS);
55
+ return () => clearTimeout(handle);
56
+ }, [searchInput]);
57
+ // Conversations are computed mailbox-wide (see `conversationsApi.ts`), not scoped to the selected
58
+ // folder — switching into "By conversation" mode replaces the per-folder list entirely, and the
59
+ // folder-tree sidebar's own selection becomes purely informational until switching back to "By date".
60
+ useEffect(() => {
61
+ setSelectedUid(null);
62
+ setSelectedConversationId(null);
63
+ setClassificationFilter("all");
64
+ pageRef.current = 0;
65
+ cursorRef.current = undefined;
66
+ setHasMore(false);
67
+ if (viewMode === "conversation") {
68
+ // `mailboxUid` is always set by this point — `MailShell` only ever resolves `folderUid`
69
+ // (this component's own guard just below, gating everything before this effect can even
70
+ // run with `viewMode === "conversation"`) after `mailboxUid` is already known.
71
+ setLoading(true);
72
+ setError(null);
73
+ listConversations(mailboxUid)
74
+ .then(setConversations)
75
+ .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load conversations."))
76
+ .finally(() => setLoading(false));
77
+ return;
78
+ }
79
+ if (!folderUid) {
80
+ setMessages([]);
81
+ setLoading(false);
82
+ return;
83
+ }
84
+ setLoading(true);
85
+ setError(null);
86
+ if (isSearching) {
87
+ searchMessages(searchQuery)
88
+ .then(({ messages: results, nextCursor }) => {
89
+ setMessages(results);
90
+ setHasMore(!!nextCursor);
91
+ cursorRef.current = nextCursor;
92
+ })
93
+ .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Search failed."))
94
+ .finally(() => setLoading(false));
95
+ return;
96
+ }
97
+ listMessages(folderUid, { limit: MESSAGE_PAGE_SIZE })
98
+ .then((results) => {
99
+ setMessages(results);
100
+ setHasMore(results.length === MESSAGE_PAGE_SIZE);
101
+ })
102
+ .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load messages."))
103
+ .finally(() => setLoading(false));
104
+ }, [viewMode, folderUid, mailboxUid, isSearching, searchQuery]);
105
+ const loadMore = useCallback(async () => {
106
+ if (loadingMore || !hasMore || loading || viewMode !== "date" || !folderUid) {
107
+ return;
108
+ }
109
+ setLoadingMore(true);
110
+ try {
111
+ if (isSearching) {
112
+ const { messages: more, nextCursor } = await searchMessages(searchQuery, cursorRef.current);
113
+ setMessages((prev) => [...prev, ...more]);
114
+ setHasMore(!!nextCursor);
115
+ cursorRef.current = nextCursor;
116
+ }
117
+ else {
118
+ const nextPage = pageRef.current + 1;
119
+ const more = await listMessages(folderUid, { page: nextPage, limit: MESSAGE_PAGE_SIZE });
120
+ setMessages((prev) => [...prev, ...more]);
121
+ setHasMore(more.length === MESSAGE_PAGE_SIZE);
122
+ pageRef.current = nextPage;
123
+ }
124
+ }
125
+ catch (err) {
126
+ setError(err instanceof ApiRequestError ? err.message : "Could not load more messages.");
127
+ }
128
+ finally {
129
+ setLoadingMore(false);
130
+ }
131
+ }, [loadingMore, hasMore, loading, viewMode, folderUid, isSearching, searchQuery]);
132
+ // Always calls the latest `loadMore` closure so the effect below doesn't need `loadMore` itself in its
133
+ // dependency array (it changes on every keystroke/page load, which would otherwise mean nothing here).
134
+ const loadMoreRef = useRef(loadMore);
135
+ loadMoreRef.current = loadMore;
136
+ // `hasMore` is deliberately a dependency: the sentinel div only renders while `hasMore` is true (see
137
+ // the JSX below), so this effect must re-run when it flips - otherwise a run that fires before the
138
+ // first page of results has loaded (sentinel not in the DOM yet, `sentinelRef.current` still null)
139
+ // would bail out once and never attach an observer to the sentinel that appears moments later.
140
+ useEffect(() => {
141
+ const sentinel = sentinelRef.current;
142
+ if (!sentinel || viewMode !== "date") {
143
+ return;
144
+ }
145
+ const observer = new IntersectionObserver((entries) => {
146
+ if (entries[0]?.isIntersecting) {
147
+ void loadMoreRef.current();
148
+ }
149
+ }, { root: scrollContainerRef.current, rootMargin: "200px" });
150
+ observer.observe(sentinel);
151
+ return () => observer.disconnect();
152
+ }, [viewMode, folderUid, isSearching, searchQuery, hasMore]);
153
+ const selected = messages.find((m) => m.uid === selectedUid) ?? null;
154
+ const selectedConversation = conversations.find((c) => c.conversationId === selectedConversationId) ?? null;
155
+ const attachments = useMessageAttachments(selected);
156
+ useMarkMessageRead(selected, (updated) => setMessages((prev) => prev.map((m) => (m.uid === updated.uid ? updated : m))));
157
+ // Search results can span every folder in the mailbox, not just the one selected in the sidebar - a
158
+ // selected message's own folderUid is the only reliable source for its actual folder type once
159
+ // searching (outside search, every message in `messages` already comes from `folderUid` itself, so
160
+ // this falls back to the sidebar selection unchanged).
161
+ const selectedFolderUid = isSearching ? (selected?.folderUid ?? folderUid) : folderUid;
162
+ const isSentItems = folders.find((f) => f.uid === selectedFolderUid)?.type === "sent_items";
163
+ const isOutbox = folders.find((f) => f.uid === selectedFolderUid)?.type === "outbox";
164
+ const isInbox = folders.find((f) => f.uid === selectedFolderUid)?.type === "inbox";
165
+ const draftsFolderUid = folders.find((f) => f.type === "drafts")?.uid;
166
+ // Focused/Other is an Inbox-only concept (see `MessageDetailPane`'s own `isInbox` doc comment) — the
167
+ // sub-tabs only ever render there, so a message with no `inferenceClassification` (the common case:
168
+ // absent means Focused) or an explicit `"focused"` counts as Focused, everything else as Other.
169
+ const visibleMessages = !isSearching && isInbox && classificationFilter !== "all"
170
+ ? messages.filter((m) => classificationFilter === "other"
171
+ ? m.inferenceClassification === "other"
172
+ : m.inferenceClassification !== "other")
173
+ : messages;
174
+ function handleSelect(message) {
175
+ if (isMobile) {
176
+ window.location.href = `/messages/${encodeURIComponent(message.uid)}`;
177
+ return;
178
+ }
179
+ setSelectedUid(message.uid);
180
+ }
181
+ function handleSelectConversation(conversation) {
182
+ if (isMobile) {
183
+ // No dedicated mobile thread route yet — the existing single-message detail route already
184
+ // handles any message uid regardless of conversation grouping, so land on the most recent
185
+ // message in the thread rather than building a second mobile detail page for this phase.
186
+ // `messageUids` always has at least one entry — a `ConversationSummary` only ever exists
187
+ // because it was grouped from real messages (see `BaseMessageRoute.conversations()`).
188
+ const latestUid = conversation.messageUids[conversation.messageUids.length - 1];
189
+ window.location.href = `/messages/${encodeURIComponent(latestUid)}`;
190
+ return;
191
+ }
192
+ setSelectedConversationId(conversation.conversationId);
193
+ }
194
+ if (!folderUid) {
195
+ // `MailShell` never renders this component at all until a mailbox is resolved (see its own
196
+ // full-screen `MailboxProvisioning` takeover otherwise) — this is purely the brief gap before
197
+ // that mailbox's own folder list has finished loading, not a "no mailbox" state. Distinct text
198
+ // from the message list's own "Loading…" below — otherwise the two transient states become
199
+ // indistinguishable to anything (a test, a user re-reading the screen) that catches this one.
200
+ return _jsx("p", { className: "p-8 text-sm text-text-muted", children: "Loading your mailbox\u2026" });
201
+ }
202
+ return (_jsxs("div", { className: "flex h-full min-h-0", children: [_jsxs("div", { ref: scrollContainerRef, className: "w-full md:w-96 shrink-0 md:border-r border-border overflow-y-auto", children: [_jsxs("div", { className: "flex border-b border-border text-sm", children: [_jsx("button", { type: "button", onClick: () => setViewMode("date"), className: [
203
+ "flex-1 py-2 font-semibold",
204
+ viewMode === "date" ? "text-primary-dark border-b-2 border-primary-dark" : "text-text-muted",
205
+ ].join(" "), children: "By date" }), _jsx("button", { type: "button", onClick: () => setViewMode("conversation"), className: [
206
+ "flex-1 py-2 font-semibold",
207
+ viewMode === "conversation" ? "text-primary-dark border-b-2 border-primary-dark" : "text-text-muted",
208
+ ].join(" "), children: "By conversation" })] }), viewMode === "conversation" && (_jsx("p", { className: "p-3 text-xs text-text-muted border-b border-border", children: "Showing every conversation in this mailbox \u2014 the selected folder doesn't filter this view." })), viewMode === "date" && (_jsx("div", { className: "p-2 border-b border-border", children: _jsx("input", { type: "search", value: searchInput, onChange: (e) => setSearchInput(e.target.value), placeholder: "Search all mail\u2026", "aria-label": "Search all mail", className: "w-full text-sm px-3 py-1.5 rounded-md border border-border bg-surface" }) })), viewMode === "date" && isInbox && !isSearching && (_jsx("div", { className: "flex border-b border-border text-xs", children: ["all", "focused", "other"].map((value) => (_jsx("button", { type: "button", onClick: () => setClassificationFilter(value), className: [
209
+ "flex-1 py-1.5 font-semibold",
210
+ classificationFilter === value
211
+ ? "text-primary-dark border-b-2 border-primary-dark"
212
+ : "text-text-muted",
213
+ ].join(" "), children: value === "all" ? "All" : value === "focused" ? "Focused" : "Other" }, value))) })), error && (_jsx("div", { className: "p-4", children: _jsx(Alert, { children: error }) })), loading ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "Loading\u2026" })) : viewMode === "conversation" ? (_jsx(ConversationList, { conversations: conversations, selectedId: selectedConversationId, onSelect: handleSelectConversation })) : visibleMessages.length === 0 ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: isSearching
214
+ ? `No messages match "${searchQuery}".`
215
+ : classificationFilter === "all"
216
+ ? "No messages in this folder."
217
+ : "No messages here." })) : (_jsxs(_Fragment, { children: [_jsx("ul", { children: visibleMessages.map((message) => (_jsx("li", { children: _jsxs("button", { type: "button", onClick: () => handleSelect(message), className: [
218
+ "w-full text-left px-4 py-3 border-b border-border",
219
+ message.uid === selectedUid ? "bg-primary/10" : "hover:bg-surface-alt",
220
+ message.flags.read ? "" : "font-semibold",
221
+ ].join(" "), children: [_jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx("span", { className: "truncate", children: message.from.displayName || message.from.address }), _jsx("span", { className: "text-xs text-text-muted shrink-0", children: new Date(message.receivedDate).toLocaleDateString() })] }), _jsx("div", { className: "text-sm truncate", children: message.subject || "(no subject)" }), _jsx("div", { className: "text-xs text-text-muted truncate font-normal", children: message.bodyPreview })] }) }, message.uid))) }), hasMore && (_jsx("div", { ref: sentinelRef, className: "p-4 text-center text-xs text-text-muted", children: loadingMore ? "Loading more…" : "" }))] }))] }), _jsx("div", { className: "hidden md:flex flex-1 min-w-0", children: viewMode === "conversation" ? (_jsx(ConversationThreadPane, { conversation: selectedConversation, folders: folders })) : (_jsx(MessageDetailPane, { message: selected, attachments: attachments, isSentItems: isSentItems, onRecalled: (updated) => setMessages((prev) => prev.map((m) => (m.uid === updated.uid ? updated : m))), isOutbox: isOutbox, isInbox: isInbox, onClassified: (updated) => setMessages((prev) => prev.map((m) => (m.uid === updated.uid ? updated : m))), onReceiptHandled: (updated) => setMessages((prev) => prev.map((m) => (m.uid === updated.uid ? updated : m))), draftsFolderUid: draftsFolderUid, onScheduledSendCanceled: (updated) => {
222
+ // The message moved out of the currently-viewed Outbox folder (into Drafts)
223
+ // — unlike a recall, which patches a message in place, this removes it from
224
+ // the list entirely, matching what a real folder switch would show.
225
+ setMessages((prev) => prev.filter((m) => m.uid !== updated.uid));
226
+ setSelectedUid(null);
227
+ } })) })] }));
228
+ }
@@ -0,0 +1,47 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useEffect, useState } from "react";
7
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
+ import { getMessage } from "@rapidmx/react-shared/mail/mailApi.js";
9
+ import { useMarkMessageRead, useMessageAttachments } from "@rapidmx/react-shared/mail/mailDetailHooks.js";
10
+ import MailShell, { useMailShell } from "../../shared/components/mail/layout/MailShell.js";
11
+ import MessageDetailPane from "../../shared/components/mail/MessageDetailPane.js";
12
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
13
+ /**
14
+ * Only reached on mobile (below the `md` breakpoint) — desktop's `apps/www/index.tsx` keeps its existing
15
+ * inline reading pane and never navigates here; see that file's `handleSelect`.
16
+ */
17
+ export default function MessageDetailPage(props) {
18
+ return (_jsx(MailShell, { ...props, children: _jsx(MessageDetailContent, { uid: props.params.uid }) }));
19
+ }
20
+ function MessageDetailContent({ uid }) {
21
+ const { folders } = useMailShell();
22
+ const [message, setMessage] = useState(null);
23
+ const [loading, setLoading] = useState(true);
24
+ const [error, setError] = useState(null);
25
+ useEffect(() => {
26
+ setLoading(true);
27
+ setError(null);
28
+ getMessage(uid)
29
+ .then(setMessage)
30
+ .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load this message."))
31
+ .finally(() => setLoading(false));
32
+ }, [uid]);
33
+ const attachments = useMessageAttachments(message);
34
+ useMarkMessageRead(message, setMessage);
35
+ if (loading) {
36
+ return _jsx("p", { className: "p-8 text-sm text-text-muted", children: "Loading\u2026" });
37
+ }
38
+ if (error || !message) {
39
+ return _jsx(Alert, { children: error ?? "Message not found." });
40
+ }
41
+ const backHref = `/?mailboxUid=${encodeURIComponent(message.mailboxUid)}&folderUid=${encodeURIComponent(message.folderUid)}`;
42
+ const isSentItems = folders.find((f) => f.uid === message.folderUid)?.type === "sent_items";
43
+ const isOutbox = folders.find((f) => f.uid === message.folderUid)?.type === "outbox";
44
+ const isInbox = folders.find((f) => f.uid === message.folderUid)?.type === "inbox";
45
+ const draftsFolderUid = folders.find((f) => f.type === "drafts")?.uid;
46
+ return (_jsx(MessageDetailPane, { message: message, attachments: attachments, backHref: backHref, isSentItems: isSentItems, onRecalled: setMessage, isOutbox: isOutbox, isInbox: isInbox, onClassified: setMessage, onReceiptHandled: setMessage, draftsFolderUid: draftsFolderUid, onScheduledSendCanceled: setMessage }));
47
+ }
@@ -0,0 +1,54 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useState } from "react";
7
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
+ import { toDatetimeLocal } from "@rapidmx/react-shared/util/dateInput.js";
9
+ import { updateMailbox } from "@rapidmx/react-shared/mail/mailApi.js";
10
+ import SettingsShell, { useSettingsShell } from "../../../shared/components/settings/layout/SettingsShell.js";
11
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
12
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
13
+ const INPUT_CLASS = "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
14
+ export default function SettingsAutoReplyPage(props) {
15
+ return (_jsx(SettingsShell, { ...props, active: "auto-reply", children: _jsx(AutoReplyContent, {}) }));
16
+ }
17
+ function AutoReplyContent() {
18
+ const { mailboxUid, mailboxes } = useSettingsShell();
19
+ // `SettingsShell` only ever renders its children once `mailboxes` has loaded and `mailboxUid` has
20
+ // resolved to one of them (a mailbox-less caller sees `MailboxProvisioning` instead) — this lookup
21
+ // can't miss, same established non-null pattern as `apps/www/contacts/index.tsx`'s `mailboxUid!`.
22
+ const mailbox = mailboxes.find((mb) => mb.uid === mailboxUid);
23
+ const [oofEnabled, setOofEnabled] = useState(mailbox.oofEnabled ?? false);
24
+ const [oofMessage, setOofMessage] = useState(mailbox.oofMessage ?? "");
25
+ const [oofStartTime, setOofStartTime] = useState(mailbox.oofStartTime ? toDatetimeLocal(mailbox.oofStartTime) : "");
26
+ const [oofEndTime, setOofEndTime] = useState(mailbox.oofEndTime ? toDatetimeLocal(mailbox.oofEndTime) : "");
27
+ const [error, setError] = useState(null);
28
+ const [saving, setSaving] = useState(false);
29
+ const [saved, setSaved] = useState(false);
30
+ async function handleSubmit(e) {
31
+ e.preventDefault();
32
+ setError(null);
33
+ setSaved(false);
34
+ setSaving(true);
35
+ try {
36
+ await updateMailbox({
37
+ uid: mailbox.uid,
38
+ version: mailbox.version,
39
+ oofEnabled,
40
+ oofMessage,
41
+ oofStartTime: oofStartTime ? new Date(oofStartTime).toISOString() : undefined,
42
+ oofEndTime: oofEndTime ? new Date(oofEndTime).toISOString() : undefined,
43
+ });
44
+ setSaved(true);
45
+ }
46
+ catch (err) {
47
+ setError(err instanceof ApiRequestError ? err.message : "Could not save automatic reply settings.");
48
+ }
49
+ finally {
50
+ setSaving(false);
51
+ }
52
+ }
53
+ return (_jsx("div", { className: "flex-1 min-w-0 overflow-y-auto p-6", children: _jsxs("div", { className: "max-w-xl", children: [_jsx("h1", { className: "text-lg font-bold tracking-tight mb-1", children: "Automatic Replies" }), _jsxs("p", { className: "text-sm text-text-muted mb-4", children: ["Send an automatic reply to anyone who emails ", mailbox.displayName, " while you\u2019re away. A calendar event with its own automatic reply enabled takes over for its own start/end window."] }), error && _jsx(Alert, { children: error }), saved && !error && _jsx("div", { className: "mb-4 text-sm text-success font-medium", children: "Saved." }), _jsxs("form", { onSubmit: handleSubmit, className: "flex flex-col gap-4", children: [_jsxs("label", { className: "flex items-center gap-2 text-sm font-medium", children: [_jsx("input", { type: "checkbox", checked: oofEnabled, onChange: (e) => setOofEnabled(e.target.checked) }), "Automatic replies are on"] }), oofEnabled && (_jsxs(_Fragment, { children: [_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Message" }), _jsx("textarea", { "aria-label": "Automatic reply message", className: INPUT_CLASS, rows: 5, value: oofMessage, onChange: (e) => setOofMessage(e.target.value) })] }), _jsxs("div", { className: "grid grid-cols-2 gap-3", children: [_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Start (optional)" }), _jsx("input", { type: "datetime-local", "aria-label": "Automatic reply start", className: INPUT_CLASS, value: oofStartTime, onChange: (e) => setOofStartTime(e.target.value) })] }), _jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "End (optional)" }), _jsx("input", { type: "datetime-local", "aria-label": "Automatic reply end", className: INPUT_CLASS, value: oofEndTime, onChange: (e) => setOofEndTime(e.target.value) })] })] }), _jsx("p", { className: "text-xs text-text-muted -mt-2", children: "Leave both blank to stay on indefinitely until turned off." })] })), _jsx("div", { children: _jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save" }) })] })] }) }));
54
+ }