@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.
- package/LICENSE +382 -0
- package/apps/admin/_404.tsx +9 -0
- package/apps/admin/_500.tsx +9 -0
- package/apps/admin/_layout.tsx +41 -0
- package/apps/admin/audit-log/index.tsx +187 -0
- package/apps/admin/branding/index.tsx +404 -0
- package/apps/admin/distribution-lists/[uid].tsx +70 -0
- package/apps/admin/distribution-lists/index.tsx +115 -0
- package/apps/admin/distribution-lists/new/index.tsx +114 -0
- package/apps/admin/domains/[uid].tsx +259 -0
- package/apps/admin/domains/index.tsx +125 -0
- package/apps/admin/domains/new/index.tsx +84 -0
- package/apps/admin/index.tsx +82 -0
- package/apps/admin/ingest-queue/index.tsx +116 -0
- package/apps/admin/mailboxes/[uid].tsx +126 -0
- package/apps/admin/mailboxes/new/index.tsx +291 -0
- package/apps/admin/quarantine/index.tsx +171 -0
- package/apps/admin/transport-rules/[uid].tsx +143 -0
- package/apps/admin/transport-rules/_transportRuleConfig.tsx +75 -0
- package/apps/admin/transport-rules/index.tsx +117 -0
- package/apps/admin/transport-rules/new/index.tsx +105 -0
- package/apps/shared/components/admin/distributionLists/MemberListCard.tsx +117 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +186 -0
- package/apps/shared/components/admin/mailboxes/MailboxTable.tsx +73 -0
- package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +154 -0
- package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +124 -0
- package/apps/shared/components/booking/AvailabilityEditor.tsx +118 -0
- package/apps/shared/components/calendar/CalendarListSidebar.tsx +119 -0
- package/apps/shared/components/calendar/EventModal.tsx +513 -0
- package/apps/shared/components/calendar/MonthView.tsx +139 -0
- package/apps/shared/components/calendar/RecurrenceEditor.tsx +180 -0
- package/apps/shared/components/calendar/ResourcePicker.tsx +80 -0
- package/apps/shared/components/calendar/SplitDayView.tsx +137 -0
- package/apps/shared/components/calendar/TimeGridView.tsx +198 -0
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +211 -0
- package/apps/shared/components/contacts/ContactDetailPane.tsx +99 -0
- package/apps/shared/components/contacts/ContactForm.tsx +314 -0
- package/apps/shared/components/contacts/ContactsSidebar.tsx +242 -0
- package/apps/shared/components/contacts/ContactsToolbar.tsx +115 -0
- package/apps/shared/components/contacts/layout/ContactsShell.tsx +197 -0
- package/apps/shared/components/layout/AppShell.tsx +165 -0
- package/apps/shared/components/layout/BrandingChrome.tsx +25 -0
- package/apps/shared/components/layout/MailboxProvisioning.tsx +110 -0
- package/apps/shared/components/layout/UserMenu.tsx +150 -0
- package/apps/shared/components/mail/ConversationList.tsx +61 -0
- package/apps/shared/components/mail/ConversationThreadPane.tsx +174 -0
- package/apps/shared/components/mail/MessageDetailPane.tsx +390 -0
- package/apps/shared/components/mail/compose/ComposeContext.tsx +114 -0
- package/apps/shared/components/mail/compose/ComposeToolbar.tsx +418 -0
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +518 -0
- package/apps/shared/components/mail/compose/EmojiPicker.tsx +64 -0
- package/apps/shared/components/mail/compose/GifPicker.tsx +82 -0
- package/apps/shared/components/mail/compose/RichTextEditor.tsx +83 -0
- package/apps/shared/components/mail/compose/ScheduleSendPicker.tsx +64 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +287 -0
- package/apps/shared/components/rules/RuleBuilder.tsx +276 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +223 -0
- package/apps/shared/components/tasks/TasksSidebar.tsx +201 -0
- package/apps/shared/components/tasks/TasksToolbar.tsx +68 -0
- package/apps/shared/components/tasks/layout/TasksShell.tsx +199 -0
- package/apps/shared/styles/app.css +265 -0
- package/apps/www/_404.tsx +9 -0
- package/apps/www/_500.tsx +9 -0
- package/apps/www/_layout.tsx +45 -0
- package/apps/www/calendar/index.tsx +407 -0
- package/apps/www/contacts/[uid].tsx +88 -0
- package/apps/www/contacts/index.tsx +436 -0
- package/apps/www/index.tsx +375 -0
- package/apps/www/messages/[uid].tsx +70 -0
- package/apps/www/settings/auto-reply/index.tsx +129 -0
- package/apps/www/settings/booking-types/[uid].tsx +300 -0
- package/apps/www/settings/booking-types/index.tsx +98 -0
- package/apps/www/settings/booking-types/new/index.tsx +212 -0
- package/apps/www/settings/filters/[uid].tsx +155 -0
- package/apps/www/settings/filters/_mailFilterRuleConfig.tsx +102 -0
- package/apps/www/settings/filters/index.tsx +99 -0
- package/apps/www/settings/filters/new/index.tsx +126 -0
- package/apps/www/settings/focused-inbox/index.tsx +148 -0
- package/apps/www/settings/read-receipts/index.tsx +125 -0
- package/apps/www/settings/signatures/[uid].tsx +167 -0
- package/apps/www/settings/signatures/index.tsx +85 -0
- package/apps/www/settings/signatures/new/index.tsx +129 -0
- package/apps/www/settings/signatures/signatureDefaults.ts +36 -0
- package/apps/www/tasks/index.tsx +530 -0
- package/dist/apps/admin/_404.js +4 -0
- package/dist/apps/admin/_500.js +4 -0
- package/dist/apps/admin/_layout.js +10 -0
- package/dist/apps/admin/audit-log/index.js +66 -0
- package/dist/apps/admin/branding/index.js +126 -0
- package/dist/apps/admin/distribution-lists/[uid].js +34 -0
- package/dist/apps/admin/distribution-lists/index.js +31 -0
- package/dist/apps/admin/distribution-lists/new/index.js +51 -0
- package/dist/apps/admin/domains/[uid].js +102 -0
- package/dist/apps/admin/domains/index.js +31 -0
- package/dist/apps/admin/domains/new/index.js +41 -0
- package/dist/apps/admin/index.js +32 -0
- package/dist/apps/admin/ingest-queue/index.js +48 -0
- package/dist/apps/admin/mailboxes/[uid].js +59 -0
- package/dist/apps/admin/mailboxes/new/index.js +93 -0
- package/dist/apps/admin/quarantine/index.js +66 -0
- package/dist/apps/admin/transport-rules/[uid].js +87 -0
- package/dist/apps/admin/transport-rules/_transportRuleConfig.js +37 -0
- package/dist/apps/admin/transport-rules/index.js +32 -0
- package/dist/apps/admin/transport-rules/new/index.js +50 -0
- package/dist/apps/shared/components/admin/distributionLists/MemberListCard.js +64 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.js +94 -0
- package/dist/apps/shared/components/admin/mailboxes/MailboxTable.js +16 -0
- package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +59 -0
- package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +67 -0
- package/dist/apps/shared/components/booking/AvailabilityEditor.js +45 -0
- package/dist/apps/shared/components/calendar/CalendarListSidebar.js +44 -0
- package/dist/apps/shared/components/calendar/EventModal.js +167 -0
- package/dist/apps/shared/components/calendar/MonthView.js +38 -0
- package/dist/apps/shared/components/calendar/RecurrenceEditor.js +55 -0
- package/dist/apps/shared/components/calendar/ResourcePicker.js +36 -0
- package/dist/apps/shared/components/calendar/SplitDayView.js +44 -0
- package/dist/apps/shared/components/calendar/TimeGridView.js +58 -0
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +96 -0
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +11 -0
- package/dist/apps/shared/components/contacts/ContactForm.js +85 -0
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +105 -0
- package/dist/apps/shared/components/contacts/ContactsToolbar.js +32 -0
- package/dist/apps/shared/components/contacts/layout/ContactsShell.js +93 -0
- package/dist/apps/shared/components/layout/AppShell.js +68 -0
- package/dist/apps/shared/components/layout/BrandingChrome.js +18 -0
- package/dist/apps/shared/components/layout/MailboxProvisioning.js +75 -0
- package/dist/apps/shared/components/layout/UserMenu.js +59 -0
- package/dist/apps/shared/components/mail/ConversationList.js +17 -0
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +112 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.js +153 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +55 -0
- package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +88 -0
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +261 -0
- package/dist/apps/shared/components/mail/compose/EmojiPicker.js +25 -0
- package/dist/apps/shared/components/mail/compose/GifPicker.js +36 -0
- package/dist/apps/shared/components/mail/compose/RichTextEditor.js +60 -0
- package/dist/apps/shared/components/mail/compose/ScheduleSendPicker.js +33 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +139 -0
- package/dist/apps/shared/components/rules/RuleBuilder.js +78 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +100 -0
- package/dist/apps/shared/components/tasks/TasksSidebar.js +81 -0
- package/dist/apps/shared/components/tasks/TasksToolbar.js +15 -0
- package/dist/apps/shared/components/tasks/layout/TasksShell.js +92 -0
- package/dist/apps/shared/styles/app.css +265 -0
- package/dist/apps/www/_404.js +4 -0
- package/dist/apps/www/_500.js +4 -0
- package/dist/apps/www/_layout.js +8 -0
- package/dist/apps/www/calendar/index.js +228 -0
- package/dist/apps/www/contacts/[uid].js +62 -0
- package/dist/apps/www/contacts/index.js +262 -0
- package/dist/apps/www/index.js +228 -0
- package/dist/apps/www/messages/[uid].js +47 -0
- package/dist/apps/www/settings/auto-reply/index.js +54 -0
- package/dist/apps/www/settings/booking-types/[uid].js +129 -0
- package/dist/apps/www/settings/booking-types/index.js +29 -0
- package/dist/apps/www/settings/booking-types/new/index.js +86 -0
- package/dist/apps/www/settings/filters/[uid].js +94 -0
- package/dist/apps/www/settings/filters/_mailFilterRuleConfig.js +65 -0
- package/dist/apps/www/settings/filters/index.js +30 -0
- package/dist/apps/www/settings/filters/new/index.js +61 -0
- package/dist/apps/www/settings/focused-inbox/index.js +69 -0
- package/dist/apps/www/settings/read-receipts/index.js +51 -0
- package/dist/apps/www/settings/signatures/[uid].js +96 -0
- package/dist/apps/www/settings/signatures/index.js +34 -0
- package/dist/apps/www/settings/signatures/new/index.js +63 -0
- package/dist/apps/www/settings/signatures/signatureDefaults.js +30 -0
- package/dist/apps/www/tasks/index.js +280 -0
- 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…</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'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…</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…</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’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
|
+
}
|