@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,375 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useCallback, useEffect, useRef, useState } from "react";
6
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
+ import { Message, MessageClassification, getMessage, listMessages } from "@rapidmx/react-shared/mail/mailApi.js";
8
+ import { ConversationSummary, listConversations } from "@rapidmx/react-shared/mail/conversationsApi.js";
9
+ import { search as searchMailbox } from "@rapidmx/react-shared/search/searchApi.js";
10
+ import { useMarkMessageRead, useMessageAttachments } from "@rapidmx/react-shared/mail/mailDetailHooks.js";
11
+ import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
12
+ import MailShell, { MailShellProps, useMailShell } from "../shared/components/mail/layout/MailShell.js";
13
+ import MessageDetailPane from "../shared/components/mail/MessageDetailPane.js";
14
+ import ConversationList from "../shared/components/mail/ConversationList.js";
15
+ import ConversationThreadPane from "../shared/components/mail/ConversationThreadPane.js";
16
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
17
+
18
+ type ViewMode = "date" | "conversation";
19
+
20
+ const MESSAGE_PAGE_SIZE = 50;
21
+ const SEARCH_DEBOUNCE_MS = 300;
22
+
23
+ /** Resolves one page of search hits (of type "message") into full `Message` records for display. */
24
+ async function searchMessages(text: string, cursor?: string): Promise<{ messages: Message[]; nextCursor?: string }> {
25
+ const page = await searchMailbox(text, { types: ["message"], cursor, limit: MESSAGE_PAGE_SIZE });
26
+ const resolved = await Promise.all(
27
+ page.results.map((hit) => getMessage(hit.entityUid).catch(() => null)),
28
+ );
29
+ // A search hit can briefly outlive the message it points to (index updates are eventually consistent,
30
+ // and a message can be deleted after being indexed) - drop anything that no longer resolves rather than
31
+ // rendering a broken entry.
32
+ return { messages: resolved.filter((m): m is Message => m !== null), nextCursor: page.nextCursor };
33
+ }
34
+
35
+ export default function InboxPage(props: MailShellProps) {
36
+ return (
37
+ <MailShell {...props}>
38
+ <InboxContent />
39
+ </MailShell>
40
+ );
41
+ }
42
+
43
+ function InboxContent() {
44
+ const { folderUid, mailboxUid, folders } = useMailShell();
45
+ const isMobile = useIsMobile();
46
+ const [viewMode, setViewMode] = useState<ViewMode>("date");
47
+ const [messages, setMessages] = useState<Message[]>([]);
48
+ const [conversations, setConversations] = useState<ConversationSummary[]>([]);
49
+ const [loading, setLoading] = useState(true);
50
+ const [loadingMore, setLoadingMore] = useState(false);
51
+ const [hasMore, setHasMore] = useState(false);
52
+ const [error, setError] = useState<string | null>(null);
53
+ const [selectedUid, setSelectedUid] = useState<string | null>(null);
54
+ const [selectedConversationId, setSelectedConversationId] = useState<string | null>(null);
55
+ const [classificationFilter, setClassificationFilter] = useState<MessageClassification | "all">("all");
56
+ const [searchInput, setSearchInput] = useState("");
57
+ const [searchQuery, setSearchQuery] = useState("");
58
+ const isSearching = viewMode === "date" && searchQuery.length > 0;
59
+ const pageRef = useRef(0);
60
+ const cursorRef = useRef<string | undefined>(undefined);
61
+ const scrollContainerRef = useRef<HTMLDivElement | null>(null);
62
+ const sentinelRef = useRef<HTMLDivElement | null>(null);
63
+
64
+ // Debounce the raw input into the query actually searched, so every keystroke doesn't fire a request.
65
+ useEffect(() => {
66
+ const handle = setTimeout(() => setSearchQuery(searchInput.trim()), SEARCH_DEBOUNCE_MS);
67
+ return () => clearTimeout(handle);
68
+ }, [searchInput]);
69
+
70
+ // Conversations are computed mailbox-wide (see `conversationsApi.ts`), not scoped to the selected
71
+ // folder — switching into "By conversation" mode replaces the per-folder list entirely, and the
72
+ // folder-tree sidebar's own selection becomes purely informational until switching back to "By date".
73
+ useEffect(() => {
74
+ setSelectedUid(null);
75
+ setSelectedConversationId(null);
76
+ setClassificationFilter("all");
77
+ pageRef.current = 0;
78
+ cursorRef.current = undefined;
79
+ setHasMore(false);
80
+
81
+ if (viewMode === "conversation") {
82
+ // `mailboxUid` is always set by this point — `MailShell` only ever resolves `folderUid`
83
+ // (this component's own guard just below, gating everything before this effect can even
84
+ // run with `viewMode === "conversation"`) after `mailboxUid` is already known.
85
+ setLoading(true);
86
+ setError(null);
87
+ listConversations(mailboxUid!)
88
+ .then(setConversations)
89
+ .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load conversations."))
90
+ .finally(() => setLoading(false));
91
+ return;
92
+ }
93
+
94
+ if (!folderUid) {
95
+ setMessages([]);
96
+ setLoading(false);
97
+ return;
98
+ }
99
+ setLoading(true);
100
+ setError(null);
101
+
102
+ if (isSearching) {
103
+ searchMessages(searchQuery)
104
+ .then(({ messages: results, nextCursor }) => {
105
+ setMessages(results);
106
+ setHasMore(!!nextCursor);
107
+ cursorRef.current = nextCursor;
108
+ })
109
+ .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Search failed."))
110
+ .finally(() => setLoading(false));
111
+ return;
112
+ }
113
+
114
+ listMessages(folderUid, { limit: MESSAGE_PAGE_SIZE })
115
+ .then((results) => {
116
+ setMessages(results);
117
+ setHasMore(results.length === MESSAGE_PAGE_SIZE);
118
+ })
119
+ .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load messages."))
120
+ .finally(() => setLoading(false));
121
+ }, [viewMode, folderUid, mailboxUid, isSearching, searchQuery]);
122
+
123
+ const loadMore = useCallback(async () => {
124
+ if (loadingMore || !hasMore || loading || viewMode !== "date" || !folderUid) {
125
+ return;
126
+ }
127
+ setLoadingMore(true);
128
+ try {
129
+ if (isSearching) {
130
+ const { messages: more, nextCursor } = await searchMessages(searchQuery, cursorRef.current);
131
+ setMessages((prev) => [...prev, ...more]);
132
+ setHasMore(!!nextCursor);
133
+ cursorRef.current = nextCursor;
134
+ } else {
135
+ const nextPage = pageRef.current + 1;
136
+ const more = await listMessages(folderUid, { page: nextPage, limit: MESSAGE_PAGE_SIZE });
137
+ setMessages((prev) => [...prev, ...more]);
138
+ setHasMore(more.length === MESSAGE_PAGE_SIZE);
139
+ pageRef.current = nextPage;
140
+ }
141
+ } catch (err) {
142
+ setError(err instanceof ApiRequestError ? err.message : "Could not load more messages.");
143
+ } finally {
144
+ setLoadingMore(false);
145
+ }
146
+ }, [loadingMore, hasMore, loading, viewMode, folderUid, isSearching, searchQuery]);
147
+
148
+ // Always calls the latest `loadMore` closure so the effect below doesn't need `loadMore` itself in its
149
+ // dependency array (it changes on every keystroke/page load, which would otherwise mean nothing here).
150
+ const loadMoreRef = useRef(loadMore);
151
+ loadMoreRef.current = loadMore;
152
+
153
+ // `hasMore` is deliberately a dependency: the sentinel div only renders while `hasMore` is true (see
154
+ // the JSX below), so this effect must re-run when it flips - otherwise a run that fires before the
155
+ // first page of results has loaded (sentinel not in the DOM yet, `sentinelRef.current` still null)
156
+ // would bail out once and never attach an observer to the sentinel that appears moments later.
157
+ useEffect(() => {
158
+ const sentinel = sentinelRef.current;
159
+ if (!sentinel || viewMode !== "date") {
160
+ return;
161
+ }
162
+ const observer = new IntersectionObserver(
163
+ (entries) => {
164
+ if (entries[0]?.isIntersecting) {
165
+ void loadMoreRef.current();
166
+ }
167
+ },
168
+ { root: scrollContainerRef.current, rootMargin: "200px" },
169
+ );
170
+ observer.observe(sentinel);
171
+ return () => observer.disconnect();
172
+ }, [viewMode, folderUid, isSearching, searchQuery, hasMore]);
173
+
174
+ const selected = messages.find((m) => m.uid === selectedUid) ?? null;
175
+ const selectedConversation = conversations.find((c) => c.conversationId === selectedConversationId) ?? null;
176
+ const attachments = useMessageAttachments(selected);
177
+ useMarkMessageRead(selected, (updated) => setMessages((prev) => prev.map((m) => (m.uid === updated.uid ? updated : m))));
178
+ // Search results can span every folder in the mailbox, not just the one selected in the sidebar - a
179
+ // selected message's own folderUid is the only reliable source for its actual folder type once
180
+ // searching (outside search, every message in `messages` already comes from `folderUid` itself, so
181
+ // this falls back to the sidebar selection unchanged).
182
+ const selectedFolderUid = isSearching ? (selected?.folderUid ?? folderUid) : folderUid;
183
+ const isSentItems = folders.find((f) => f.uid === selectedFolderUid)?.type === "sent_items";
184
+ const isOutbox = folders.find((f) => f.uid === selectedFolderUid)?.type === "outbox";
185
+ const isInbox = folders.find((f) => f.uid === selectedFolderUid)?.type === "inbox";
186
+ const draftsFolderUid = folders.find((f) => f.type === "drafts")?.uid;
187
+
188
+ // Focused/Other is an Inbox-only concept (see `MessageDetailPane`'s own `isInbox` doc comment) — the
189
+ // sub-tabs only ever render there, so a message with no `inferenceClassification` (the common case:
190
+ // absent means Focused) or an explicit `"focused"` counts as Focused, everything else as Other.
191
+ const visibleMessages =
192
+ !isSearching && isInbox && classificationFilter !== "all"
193
+ ? messages.filter((m) =>
194
+ classificationFilter === "other"
195
+ ? m.inferenceClassification === "other"
196
+ : m.inferenceClassification !== "other",
197
+ )
198
+ : messages;
199
+
200
+ function handleSelect(message: Message) {
201
+ if (isMobile) {
202
+ window.location.href = `/messages/${encodeURIComponent(message.uid)}`;
203
+ return;
204
+ }
205
+ setSelectedUid(message.uid);
206
+ }
207
+
208
+ function handleSelectConversation(conversation: ConversationSummary) {
209
+ if (isMobile) {
210
+ // No dedicated mobile thread route yet — the existing single-message detail route already
211
+ // handles any message uid regardless of conversation grouping, so land on the most recent
212
+ // message in the thread rather than building a second mobile detail page for this phase.
213
+ // `messageUids` always has at least one entry — a `ConversationSummary` only ever exists
214
+ // because it was grouped from real messages (see `BaseMessageRoute.conversations()`).
215
+ const latestUid = conversation.messageUids[conversation.messageUids.length - 1];
216
+ window.location.href = `/messages/${encodeURIComponent(latestUid)}`;
217
+ return;
218
+ }
219
+ setSelectedConversationId(conversation.conversationId);
220
+ }
221
+
222
+ if (!folderUid) {
223
+ // `MailShell` never renders this component at all until a mailbox is resolved (see its own
224
+ // full-screen `MailboxProvisioning` takeover otherwise) — this is purely the brief gap before
225
+ // that mailbox's own folder list has finished loading, not a "no mailbox" state. Distinct text
226
+ // from the message list's own "Loading…" below — otherwise the two transient states become
227
+ // indistinguishable to anything (a test, a user re-reading the screen) that catches this one.
228
+ return <p className="p-8 text-sm text-text-muted">Loading your mailbox&hellip;</p>;
229
+ }
230
+
231
+ return (
232
+ <div className="flex h-full min-h-0">
233
+ <div ref={scrollContainerRef} className="w-full md:w-96 shrink-0 md:border-r border-border overflow-y-auto">
234
+ <div className="flex border-b border-border text-sm">
235
+ <button
236
+ type="button"
237
+ onClick={() => setViewMode("date")}
238
+ className={[
239
+ "flex-1 py-2 font-semibold",
240
+ viewMode === "date" ? "text-primary-dark border-b-2 border-primary-dark" : "text-text-muted",
241
+ ].join(" ")}
242
+ >
243
+ By date
244
+ </button>
245
+ <button
246
+ type="button"
247
+ onClick={() => setViewMode("conversation")}
248
+ className={[
249
+ "flex-1 py-2 font-semibold",
250
+ viewMode === "conversation" ? "text-primary-dark border-b-2 border-primary-dark" : "text-text-muted",
251
+ ].join(" ")}
252
+ >
253
+ By conversation
254
+ </button>
255
+ </div>
256
+ {viewMode === "conversation" && (
257
+ <p className="p-3 text-xs text-text-muted border-b border-border">
258
+ Showing every conversation in this mailbox — the selected folder doesn&apos;t filter this view.
259
+ </p>
260
+ )}
261
+ {viewMode === "date" && (
262
+ <div className="p-2 border-b border-border">
263
+ <input
264
+ type="search"
265
+ value={searchInput}
266
+ onChange={(e) => setSearchInput(e.target.value)}
267
+ placeholder="Search all mail…"
268
+ aria-label="Search all mail"
269
+ className="w-full text-sm px-3 py-1.5 rounded-md border border-border bg-surface"
270
+ />
271
+ </div>
272
+ )}
273
+ {viewMode === "date" && isInbox && !isSearching && (
274
+ <div className="flex border-b border-border text-xs">
275
+ {(["all", "focused", "other"] as const).map((value) => (
276
+ <button
277
+ key={value}
278
+ type="button"
279
+ onClick={() => setClassificationFilter(value)}
280
+ className={[
281
+ "flex-1 py-1.5 font-semibold",
282
+ classificationFilter === value
283
+ ? "text-primary-dark border-b-2 border-primary-dark"
284
+ : "text-text-muted",
285
+ ].join(" ")}
286
+ >
287
+ {value === "all" ? "All" : value === "focused" ? "Focused" : "Other"}
288
+ </button>
289
+ ))}
290
+ </div>
291
+ )}
292
+
293
+ {error && (
294
+ <div className="p-4">
295
+ <Alert>{error}</Alert>
296
+ </div>
297
+ )}
298
+
299
+ {loading ? (
300
+ <p className="p-4 text-sm text-text-muted">Loading&hellip;</p>
301
+ ) : viewMode === "conversation" ? (
302
+ <ConversationList
303
+ conversations={conversations}
304
+ selectedId={selectedConversationId}
305
+ onSelect={handleSelectConversation}
306
+ />
307
+ ) : visibleMessages.length === 0 ? (
308
+ <p className="p-4 text-sm text-text-muted">
309
+ {isSearching
310
+ ? `No messages match "${searchQuery}".`
311
+ : classificationFilter === "all"
312
+ ? "No messages in this folder."
313
+ : "No messages here."}
314
+ </p>
315
+ ) : (
316
+ <>
317
+ <ul>
318
+ {visibleMessages.map((message) => (
319
+ <li key={message.uid}>
320
+ <button
321
+ type="button"
322
+ onClick={() => handleSelect(message)}
323
+ className={[
324
+ "w-full text-left px-4 py-3 border-b border-border",
325
+ message.uid === selectedUid ? "bg-primary/10" : "hover:bg-surface-alt",
326
+ message.flags.read ? "" : "font-semibold",
327
+ ].join(" ")}
328
+ >
329
+ <div className="flex items-center justify-between gap-2 text-sm">
330
+ <span className="truncate">{message.from.displayName || message.from.address}</span>
331
+ <span className="text-xs text-text-muted shrink-0">
332
+ {new Date(message.receivedDate).toLocaleDateString()}
333
+ </span>
334
+ </div>
335
+ <div className="text-sm truncate">{message.subject || "(no subject)"}</div>
336
+ <div className="text-xs text-text-muted truncate font-normal">{message.bodyPreview}</div>
337
+ </button>
338
+ </li>
339
+ ))}
340
+ </ul>
341
+ {hasMore && (
342
+ <div ref={sentinelRef} className="p-4 text-center text-xs text-text-muted">
343
+ {loadingMore ? "Loading more…" : ""}
344
+ </div>
345
+ )}
346
+ </>
347
+ )}
348
+ </div>
349
+ <div className="hidden md:flex flex-1 min-w-0">
350
+ {viewMode === "conversation" ? (
351
+ <ConversationThreadPane conversation={selectedConversation} folders={folders} />
352
+ ) : (
353
+ <MessageDetailPane
354
+ message={selected}
355
+ attachments={attachments}
356
+ isSentItems={isSentItems}
357
+ onRecalled={(updated) => setMessages((prev) => prev.map((m) => (m.uid === updated.uid ? updated : m)))}
358
+ isOutbox={isOutbox}
359
+ isInbox={isInbox}
360
+ onClassified={(updated) => setMessages((prev) => prev.map((m) => (m.uid === updated.uid ? updated : m)))}
361
+ onReceiptHandled={(updated) => setMessages((prev) => prev.map((m) => (m.uid === updated.uid ? updated : m)))}
362
+ draftsFolderUid={draftsFolderUid}
363
+ onScheduledSendCanceled={(updated) => {
364
+ // The message moved out of the currently-viewed Outbox folder (into Drafts)
365
+ // — unlike a recall, which patches a message in place, this removes it from
366
+ // the list entirely, matching what a real folder switch would show.
367
+ setMessages((prev) => prev.filter((m) => m.uid !== updated.uid));
368
+ setSelectedUid(null);
369
+ }}
370
+ />
371
+ )}
372
+ </div>
373
+ </div>
374
+ );
375
+ }
@@ -0,0 +1,70 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useEffect, useState } from "react";
6
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
+ import { Message, getMessage } from "@rapidmx/react-shared/mail/mailApi.js";
8
+ import { useMarkMessageRead, useMessageAttachments } from "@rapidmx/react-shared/mail/mailDetailHooks.js";
9
+ import MailShell, { MailShellProps, useMailShell } from "../../shared/components/mail/layout/MailShell.js";
10
+ import MessageDetailPane from "../../shared/components/mail/MessageDetailPane.js";
11
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
12
+
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: MailShellProps & { params: { uid: string } }) {
18
+ return (
19
+ <MailShell {...props}>
20
+ <MessageDetailContent uid={props.params.uid} />
21
+ </MailShell>
22
+ );
23
+ }
24
+
25
+ function MessageDetailContent({ uid }: { uid: string }) {
26
+ const { folders } = useMailShell();
27
+ const [message, setMessage] = useState<Message | null>(null);
28
+ const [loading, setLoading] = useState(true);
29
+ const [error, setError] = useState<string | null>(null);
30
+
31
+ useEffect(() => {
32
+ setLoading(true);
33
+ setError(null);
34
+ getMessage(uid)
35
+ .then(setMessage)
36
+ .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load this message."))
37
+ .finally(() => setLoading(false));
38
+ }, [uid]);
39
+
40
+ const attachments = useMessageAttachments(message);
41
+ useMarkMessageRead(message, setMessage);
42
+
43
+ if (loading) {
44
+ return <p className="p-8 text-sm text-text-muted">Loading&hellip;</p>;
45
+ }
46
+ if (error || !message) {
47
+ return <Alert>{error ?? "Message not found."}</Alert>;
48
+ }
49
+
50
+ const backHref = `/?mailboxUid=${encodeURIComponent(message.mailboxUid)}&folderUid=${encodeURIComponent(message.folderUid)}`;
51
+ const isSentItems = folders.find((f) => f.uid === message.folderUid)?.type === "sent_items";
52
+ const isOutbox = folders.find((f) => f.uid === message.folderUid)?.type === "outbox";
53
+ const isInbox = folders.find((f) => f.uid === message.folderUid)?.type === "inbox";
54
+ const draftsFolderUid = folders.find((f) => f.type === "drafts")?.uid;
55
+ return (
56
+ <MessageDetailPane
57
+ message={message}
58
+ attachments={attachments}
59
+ backHref={backHref}
60
+ isSentItems={isSentItems}
61
+ onRecalled={setMessage}
62
+ isOutbox={isOutbox}
63
+ isInbox={isInbox}
64
+ onClassified={setMessage}
65
+ onReceiptHandled={setMessage}
66
+ draftsFolderUid={draftsFolderUid}
67
+ onScheduledSendCanceled={setMessage}
68
+ />
69
+ );
70
+ }
@@ -0,0 +1,129 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { FormEvent, useState } from "react";
6
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
+ import { toDatetimeLocal } from "@rapidmx/react-shared/util/dateInput.js";
8
+ import { updateMailbox } from "@rapidmx/react-shared/mail/mailApi.js";
9
+ import SettingsShell, { SettingsShellProps, useSettingsShell } from "../../../shared/components/settings/layout/SettingsShell.js";
10
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
11
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
12
+
13
+ const INPUT_CLASS =
14
+ "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
15
+
16
+ export type SettingsAutoReplyPageProps = Omit<SettingsShellProps, "active">;
17
+
18
+ export default function SettingsAutoReplyPage(props: SettingsAutoReplyPageProps) {
19
+ return (
20
+ <SettingsShell {...props} active="auto-reply">
21
+ <AutoReplyContent />
22
+ </SettingsShell>
23
+ );
24
+ }
25
+
26
+ function AutoReplyContent() {
27
+ const { mailboxUid, mailboxes } = useSettingsShell();
28
+ // `SettingsShell` only ever renders its children once `mailboxes` has loaded and `mailboxUid` has
29
+ // resolved to one of them (a mailbox-less caller sees `MailboxProvisioning` instead) — this lookup
30
+ // can't miss, same established non-null pattern as `apps/www/contacts/index.tsx`'s `mailboxUid!`.
31
+ const mailbox = mailboxes.find((mb) => mb.uid === mailboxUid)!;
32
+
33
+ const [oofEnabled, setOofEnabled] = useState(mailbox.oofEnabled ?? false);
34
+ const [oofMessage, setOofMessage] = useState(mailbox.oofMessage ?? "");
35
+ const [oofStartTime, setOofStartTime] = useState(mailbox.oofStartTime ? toDatetimeLocal(mailbox.oofStartTime) : "");
36
+ const [oofEndTime, setOofEndTime] = useState(mailbox.oofEndTime ? toDatetimeLocal(mailbox.oofEndTime) : "");
37
+ const [error, setError] = useState<string | null>(null);
38
+ const [saving, setSaving] = useState(false);
39
+ const [saved, setSaved] = useState(false);
40
+
41
+ async function handleSubmit(e: FormEvent) {
42
+ e.preventDefault();
43
+ setError(null);
44
+ setSaved(false);
45
+ setSaving(true);
46
+ try {
47
+ await updateMailbox({
48
+ uid: mailbox.uid,
49
+ version: mailbox.version,
50
+ oofEnabled,
51
+ oofMessage,
52
+ oofStartTime: oofStartTime ? new Date(oofStartTime).toISOString() : undefined,
53
+ oofEndTime: oofEndTime ? new Date(oofEndTime).toISOString() : undefined,
54
+ });
55
+ setSaved(true);
56
+ } catch (err) {
57
+ setError(err instanceof ApiRequestError ? err.message : "Could not save automatic reply settings.");
58
+ } finally {
59
+ setSaving(false);
60
+ }
61
+ }
62
+
63
+ return (
64
+ <div className="flex-1 min-w-0 overflow-y-auto p-6">
65
+ <div className="max-w-xl">
66
+ <h1 className="text-lg font-bold tracking-tight mb-1">Automatic Replies</h1>
67
+ <p className="text-sm text-text-muted mb-4">
68
+ Send an automatic reply to anyone who emails {mailbox.displayName} while you&rsquo;re away. A
69
+ calendar event with its own automatic reply enabled takes over for its own start/end window.
70
+ </p>
71
+
72
+ {error && <Alert>{error}</Alert>}
73
+ {saved && !error && <div className="mb-4 text-sm text-success font-medium">Saved.</div>}
74
+
75
+ <form onSubmit={handleSubmit} className="flex flex-col gap-4">
76
+ <label className="flex items-center gap-2 text-sm font-medium">
77
+ <input type="checkbox" checked={oofEnabled} onChange={(e) => setOofEnabled(e.target.checked)} />
78
+ Automatic replies are on
79
+ </label>
80
+
81
+ {oofEnabled && (
82
+ <>
83
+ <label className="flex flex-col gap-1.5 text-sm">
84
+ <span className="font-semibold">Message</span>
85
+ <textarea
86
+ aria-label="Automatic reply message"
87
+ className={INPUT_CLASS}
88
+ rows={5}
89
+ value={oofMessage}
90
+ onChange={(e) => setOofMessage(e.target.value)}
91
+ />
92
+ </label>
93
+
94
+ <div className="grid grid-cols-2 gap-3">
95
+ <label className="flex flex-col gap-1.5 text-sm">
96
+ <span className="font-semibold">Start (optional)</span>
97
+ <input
98
+ type="datetime-local"
99
+ aria-label="Automatic reply start"
100
+ className={INPUT_CLASS}
101
+ value={oofStartTime}
102
+ onChange={(e) => setOofStartTime(e.target.value)}
103
+ />
104
+ </label>
105
+ <label className="flex flex-col gap-1.5 text-sm">
106
+ <span className="font-semibold">End (optional)</span>
107
+ <input
108
+ type="datetime-local"
109
+ aria-label="Automatic reply end"
110
+ className={INPUT_CLASS}
111
+ value={oofEndTime}
112
+ onChange={(e) => setOofEndTime(e.target.value)}
113
+ />
114
+ </label>
115
+ </div>
116
+ <p className="text-xs text-text-muted -mt-2">Leave both blank to stay on indefinitely until turned off.</p>
117
+ </>
118
+ )}
119
+
120
+ <div>
121
+ <Button type="submit" loading={saving} disabled={saving} className="!w-auto">
122
+ Save
123
+ </Button>
124
+ </div>
125
+ </form>
126
+ </div>
127
+ </div>
128
+ );
129
+ }