@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,174 @@
|
|
|
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 { Attachment, Folder, Message, getMessage, listAttachments, setMessageRead } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
8
|
+
import { ConversationSummary } from "@rapidmx/react-shared/mail/conversationsApi.js";
|
|
9
|
+
import MessageDetailPane from "./MessageDetailPane.js";
|
|
10
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
11
|
+
|
|
12
|
+
export interface ConversationThreadPaneProps {
|
|
13
|
+
conversation: ConversationSummary | null;
|
|
14
|
+
/** The mailbox's full folder list — a conversation can span folders (e.g. an Inbox message and the
|
|
15
|
+
* Sent Items copy of its reply), so each message's own recall eligibility is looked up individually
|
|
16
|
+
* against its own `folderUid` rather than assuming one folder for the whole thread. */
|
|
17
|
+
folders: Folder[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The merged, Gmail-style reading pane for "By conversation" mode — every message in the conversation
|
|
22
|
+
* fetched and stacked oldest-to-newest, all but the most recent collapsed to a one-line summary
|
|
23
|
+
* (sender + subject) until clicked. Reuses `MessageDetailPane` for each *expanded* message's own
|
|
24
|
+
* header/attachments/body-iframe rendering rather than re-implementing it; a collapsed row is its own
|
|
25
|
+
* lightweight summary, since mounting a full `MessageDetailPane` (and its iframe) for every message in
|
|
26
|
+
* a long thread up front would be wasteful.
|
|
27
|
+
*/
|
|
28
|
+
export default function ConversationThreadPane({ conversation, folders }: ConversationThreadPaneProps) {
|
|
29
|
+
const [messages, setMessages] = useState<Record<string, Message>>({});
|
|
30
|
+
const [attachmentsByUid, setAttachmentsByUid] = useState<Record<string, Attachment[]>>({});
|
|
31
|
+
const [expandedUids, setExpandedUids] = useState<Set<string>>(new Set());
|
|
32
|
+
const [loading, setLoading] = useState(false);
|
|
33
|
+
const [error, setError] = useState<string | null>(null);
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!conversation) {
|
|
37
|
+
setMessages({});
|
|
38
|
+
setExpandedUids(new Set());
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
setLoading(true);
|
|
42
|
+
setError(null);
|
|
43
|
+
Promise.all(conversation.messageUids.map((uid) => getMessage(uid)))
|
|
44
|
+
.then((loaded) => {
|
|
45
|
+
setMessages(Object.fromEntries(loaded.map((m) => [m.uid, m])));
|
|
46
|
+
// `messageUids` always has at least one entry — a `ConversationSummary` only ever
|
|
47
|
+
// exists because it was grouped from real messages (see
|
|
48
|
+
// `BaseMessageRoute.conversations()`).
|
|
49
|
+
const latestUid = conversation.messageUids[conversation.messageUids.length - 1];
|
|
50
|
+
setExpandedUids(new Set([latestUid]));
|
|
51
|
+
})
|
|
52
|
+
.catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load this conversation."))
|
|
53
|
+
.finally(() => setLoading(false));
|
|
54
|
+
}, [conversation?.conversationId]);
|
|
55
|
+
|
|
56
|
+
// Lazily loads attachments and marks-as-read only for messages the reader has actually expanded —
|
|
57
|
+
// mirrors `mailDetailHooks.ts`'s `useMessageAttachments`/`useMarkMessageRead`, reimplemented here
|
|
58
|
+
// (rather than called in a loop, which the rules of hooks don't allow) since a thread can expand
|
|
59
|
+
// more than one message at once, unlike the single-selected-message case those hooks were built for.
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
for (const uid of expandedUids) {
|
|
62
|
+
const message = messages[uid];
|
|
63
|
+
if (!message) continue;
|
|
64
|
+
if (message.hasAttachments && !attachmentsByUid[uid]) {
|
|
65
|
+
listAttachments(message.folderUid, uid)
|
|
66
|
+
.then((loaded) => setAttachmentsByUid((prev) => ({ ...prev, [uid]: loaded })))
|
|
67
|
+
.catch(() => {
|
|
68
|
+
// Best-effort, same as the mark-as-read catch below — `attachmentsByUid[uid] ??
|
|
69
|
+
// []` already renders no attachments while this stays unset, so there is nothing
|
|
70
|
+
// further to do on failure.
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
if (!message.flags.read) {
|
|
74
|
+
setMessageRead(message, true)
|
|
75
|
+
.then((updated) => setMessages((prev) => ({ ...prev, [uid]: updated })))
|
|
76
|
+
.catch(() => {
|
|
77
|
+
// Best-effort — matches `useMarkMessageRead`'s own doc comment.
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}, [expandedUids, messages]);
|
|
82
|
+
|
|
83
|
+
function toggleExpanded(uid: string) {
|
|
84
|
+
setExpandedUids((prev) => {
|
|
85
|
+
const next = new Set(prev);
|
|
86
|
+
if (next.has(uid)) {
|
|
87
|
+
next.delete(uid);
|
|
88
|
+
} else {
|
|
89
|
+
next.add(uid);
|
|
90
|
+
}
|
|
91
|
+
return next;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!conversation) {
|
|
96
|
+
return <p className="p-8 text-sm text-text-muted">Select a conversation to read it.</p>;
|
|
97
|
+
}
|
|
98
|
+
if (loading) {
|
|
99
|
+
return <p className="p-8 text-sm text-text-muted">Loading…</p>;
|
|
100
|
+
}
|
|
101
|
+
if (error) {
|
|
102
|
+
return (
|
|
103
|
+
<div className="p-4 flex-1">
|
|
104
|
+
<Alert>{error}</Alert>
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<div className="flex-1 min-w-0 flex flex-col overflow-y-auto">
|
|
111
|
+
<div className="border-b border-border p-4">
|
|
112
|
+
<h1 className="text-lg font-bold tracking-tight">{conversation.subject || "(no subject)"}</h1>
|
|
113
|
+
<p className="text-sm text-text-muted mt-1">
|
|
114
|
+
{conversation.messageCount} message{conversation.messageCount === 1 ? "" : "s"}
|
|
115
|
+
</p>
|
|
116
|
+
</div>
|
|
117
|
+
{conversation.messageUids.map((uid) => {
|
|
118
|
+
const message = messages[uid];
|
|
119
|
+
if (!message) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
const expanded = expandedUids.has(uid);
|
|
123
|
+
if (!expanded) {
|
|
124
|
+
return (
|
|
125
|
+
<button
|
|
126
|
+
key={uid}
|
|
127
|
+
type="button"
|
|
128
|
+
onClick={() => toggleExpanded(uid)}
|
|
129
|
+
className={[
|
|
130
|
+
"w-full text-left px-4 py-3 border-b border-border hover:bg-surface-alt",
|
|
131
|
+
message.flags.read ? "" : "font-semibold",
|
|
132
|
+
].join(" ")}
|
|
133
|
+
>
|
|
134
|
+
<div className="flex items-center justify-between gap-2 text-sm">
|
|
135
|
+
<span className="truncate">{message.from.displayName || message.from.address}</span>
|
|
136
|
+
<span className="text-xs text-text-muted shrink-0">
|
|
137
|
+
{new Date(message.receivedDate).toLocaleString()}
|
|
138
|
+
</span>
|
|
139
|
+
</div>
|
|
140
|
+
<div className="text-xs text-text-muted truncate font-normal">{message.bodyPreview}</div>
|
|
141
|
+
</button>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
return (
|
|
145
|
+
<div key={uid} className="border-b border-border">
|
|
146
|
+
<button
|
|
147
|
+
type="button"
|
|
148
|
+
onClick={() => toggleExpanded(uid)}
|
|
149
|
+
className="w-full text-left px-4 pt-2 text-xs text-text-muted hover:text-text"
|
|
150
|
+
>
|
|
151
|
+
Collapse
|
|
152
|
+
</button>
|
|
153
|
+
<MessageDetailPane
|
|
154
|
+
message={message}
|
|
155
|
+
attachments={attachmentsByUid[uid] ?? []}
|
|
156
|
+
isSentItems={folders.find((f) => f.uid === message.folderUid)?.type === "sent_items"}
|
|
157
|
+
onRecalled={(updated) => setMessages((prev) => ({ ...prev, [uid]: updated }))}
|
|
158
|
+
isOutbox={folders.find((f) => f.uid === message.folderUid)?.type === "outbox"}
|
|
159
|
+
draftsFolderUid={folders.find((f) => f.type === "drafts")?.uid}
|
|
160
|
+
// Unlike `apps/www/index.tsx`'s list-removal behavior, this patches the
|
|
161
|
+
// message in place (same as `onRecalled` above) rather than removing it from
|
|
162
|
+
// the conversation — `conversation.messageUids` is owned by the parent, not
|
|
163
|
+
// this component, so there's no membership list here to remove it from.
|
|
164
|
+
onScheduledSendCanceled={(updated) => setMessages((prev) => ({ ...prev, [uid]: updated }))}
|
|
165
|
+
isInbox={folders.find((f) => f.uid === message.folderUid)?.type === "inbox"}
|
|
166
|
+
onClassified={(updated) => setMessages((prev) => ({ ...prev, [uid]: updated }))}
|
|
167
|
+
onReceiptHandled={(updated) => setMessages((prev) => ({ ...prev, [uid]: updated }))}
|
|
168
|
+
/>
|
|
169
|
+
</div>
|
|
170
|
+
);
|
|
171
|
+
})}
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { useState } from "react";
|
|
6
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
7
|
+
import {
|
|
8
|
+
Attachment,
|
|
9
|
+
Message,
|
|
10
|
+
MessageClassification,
|
|
11
|
+
ReceiptType,
|
|
12
|
+
approveReceipt,
|
|
13
|
+
attachmentContentUrl,
|
|
14
|
+
cancelScheduledSend,
|
|
15
|
+
classifyMessage,
|
|
16
|
+
declineReceipt,
|
|
17
|
+
recallMessage,
|
|
18
|
+
} from "@rapidmx/react-shared/mail/mailApi.js";
|
|
19
|
+
import { buildForwardQuote, buildReplyQuote, forwardSubject, replySubject } from "@rapidmx/react-shared/mail/compose/composeQuoting.js";
|
|
20
|
+
import { useCompose } from "./compose/ComposeContext.js";
|
|
21
|
+
import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
|
|
22
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
23
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
24
|
+
|
|
25
|
+
export interface MessageDetailPaneProps {
|
|
26
|
+
message: Message | null;
|
|
27
|
+
attachments: Attachment[];
|
|
28
|
+
/** Present only on the mobile detail route — renders a "back to messages" link above the header. Absent
|
|
29
|
+
* on the desktop reading pane, which never navigates away (selecting a different message just swaps
|
|
30
|
+
* `message` in place). */
|
|
31
|
+
backHref?: string;
|
|
32
|
+
/** Whether `message` currently lives in Sent Items — the only folder recall is offered from, matching
|
|
33
|
+
* Outlook's own restriction (and `BaseMessageRoute.recall()`'s own server-side check). Each caller
|
|
34
|
+
* computes this from its own already-loaded folder list rather than this component fetching folders
|
|
35
|
+
* itself. */
|
|
36
|
+
isSentItems?: boolean;
|
|
37
|
+
/** Called with the server's updated copy (carrying `recallRequestedAt`) after a successful recall, so
|
|
38
|
+
* the caller can patch its own in-memory message/list state — mirrors `mailDetailHooks.ts`'s
|
|
39
|
+
* `useMarkMessageRead`'s identical `onUpdated` callback. */
|
|
40
|
+
onRecalled?: (updated: Message) => void;
|
|
41
|
+
/** Whether `message` currently lives in Outbox — the only folder a scheduled send can be canceled
|
|
42
|
+
* from. Each caller computes this the same way it computes `isSentItems`. */
|
|
43
|
+
isOutbox?: boolean;
|
|
44
|
+
/** The mailbox's Drafts folder uid — required when `isOutbox` is `true`, since canceling a
|
|
45
|
+
* scheduled send moves the message back into Drafts (see `cancelScheduledSend()`'s own doc comment
|
|
46
|
+
* on why clearing `scheduledSendTime` alone doesn't do that). */
|
|
47
|
+
draftsFolderUid?: string;
|
|
48
|
+
/** Called with the server's updated copy (now back in Drafts, `scheduledSendTime` cleared) after
|
|
49
|
+
* successfully canceling a scheduled send. */
|
|
50
|
+
onScheduledSendCanceled?: (updated: Message) => void;
|
|
51
|
+
/** Whether `message` currently lives in the Inbox — Focused/Other classification is an Inbox-only
|
|
52
|
+
* concept (`FocusedInboxUtils.classifyMessage()` short-circuits to Focused for every other folder), so
|
|
53
|
+
* the "Move to Other"/"Move to Focused" control below only renders here, the same
|
|
54
|
+
* each-caller-computes-its-own-folder-type pattern `isSentItems`/`isOutbox` already use. */
|
|
55
|
+
isInbox?: boolean;
|
|
56
|
+
/** Called with the server's updated copy (carrying the new `inferenceClassification`) after a
|
|
57
|
+
* successful classify — mirrors `onRecalled`'s identical shape. */
|
|
58
|
+
onClassified?: (updated: Message) => void;
|
|
59
|
+
/** Called with the server's updated copy after approving/declining a pending delivery/read receipt —
|
|
60
|
+
* mirrors `onRecalled`'s identical shape. No gating prop needed (unlike `isSentItems`/`isOutbox`/
|
|
61
|
+
* `isInbox`): `deliveryReceiptPending`/`readReceiptPending` already live directly on `message` and are
|
|
62
|
+
* only ever `true` on a real delivered copy, so the banner below is self-gating. */
|
|
63
|
+
onReceiptHandled?: (updated: Message) => void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function formatBytes(bytes: number): string {
|
|
67
|
+
if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`;
|
|
68
|
+
if (bytes >= 1_000) return `${(bytes / 1_000).toFixed(1)} KB`;
|
|
69
|
+
return `${bytes} B`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A message's reading pane — header (subject/from/to/attachments) plus a sandboxed iframe for the body.
|
|
74
|
+
* Shared by the desktop inline pane (`apps/www/index.tsx`, always visible alongside the message list),
|
|
75
|
+
* the mobile detail route (`apps/www/messages/[uid].tsx`, a full page on its own reached by tapping
|
|
76
|
+
* a message row), and `ConversationThreadPane` (one per expanded message in a thread) — see each call
|
|
77
|
+
* site for how `message`/`attachments`/`isSentItems` are sourced.
|
|
78
|
+
*/
|
|
79
|
+
export default function MessageDetailPane({
|
|
80
|
+
message,
|
|
81
|
+
attachments,
|
|
82
|
+
backHref,
|
|
83
|
+
isSentItems,
|
|
84
|
+
onRecalled,
|
|
85
|
+
isOutbox,
|
|
86
|
+
draftsFolderUid,
|
|
87
|
+
onScheduledSendCanceled,
|
|
88
|
+
isInbox,
|
|
89
|
+
onClassified,
|
|
90
|
+
onReceiptHandled,
|
|
91
|
+
}: MessageDetailPaneProps) {
|
|
92
|
+
const { openCompose } = useCompose();
|
|
93
|
+
const [confirming, setConfirming] = useState(false);
|
|
94
|
+
const [recalling, setRecalling] = useState(false);
|
|
95
|
+
const [canceling, setCanceling] = useState(false);
|
|
96
|
+
const [error, setError] = useState<string | null>(null);
|
|
97
|
+
// Kept separate from `error` (the Recall flow's own state) since this renders inline in the main
|
|
98
|
+
// pane rather than inside a confirmation modal — the two flows never need to share one message.
|
|
99
|
+
const [cancelError, setCancelError] = useState<string | null>(null);
|
|
100
|
+
const [classifying, setClassifying] = useState(false);
|
|
101
|
+
const [classifyError, setClassifyError] = useState<string | null>(null);
|
|
102
|
+
const [alwaysForSender, setAlwaysForSender] = useState(false);
|
|
103
|
+
// Names which pending receipt (`"delivery"`/`"read"`) is currently being approved/declined, if any —
|
|
104
|
+
// `deliveryReceiptPending`/`readReceiptPending` can both be true independently, so a single boolean
|
|
105
|
+
// wouldn't distinguish which row's buttons should show a loading state.
|
|
106
|
+
const [receiptBusy, setReceiptBusy] = useState<ReceiptType | null>(null);
|
|
107
|
+
const [receiptError, setReceiptError] = useState<string | null>(null);
|
|
108
|
+
|
|
109
|
+
if (!message) {
|
|
110
|
+
return <p className="p-8 text-sm text-text-muted">Select a message to read it.</p>;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Only ever invoked from the Reply/Reply All/Forward buttons below, which themselves only render
|
|
114
|
+
// once `message` is loaded (the early return above covers the only other state) — the non-null
|
|
115
|
+
// assertions reflect that real invariant, matching `handleRecall`'s identical pattern just below.
|
|
116
|
+
function handleReply() {
|
|
117
|
+
openCompose({
|
|
118
|
+
mailboxUid: message!.mailboxUid,
|
|
119
|
+
to: message!.from.address,
|
|
120
|
+
subject: replySubject(message!.subject),
|
|
121
|
+
quotedHtml: buildReplyQuote(message!),
|
|
122
|
+
signatureContext: "reply_forward",
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function handleReplyAll() {
|
|
127
|
+
const cc = message!.recipients.filter((r) => r.type !== "bcc").map((r) => r.address);
|
|
128
|
+
openCompose({
|
|
129
|
+
mailboxUid: message!.mailboxUid,
|
|
130
|
+
to: message!.from.address,
|
|
131
|
+
cc: cc.join(", "),
|
|
132
|
+
subject: replySubject(message!.subject),
|
|
133
|
+
quotedHtml: buildReplyQuote(message!),
|
|
134
|
+
signatureContext: "reply_forward",
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function handleForward() {
|
|
139
|
+
openCompose({
|
|
140
|
+
mailboxUid: message!.mailboxUid,
|
|
141
|
+
subject: forwardSubject(message!.subject),
|
|
142
|
+
quotedHtml: buildForwardQuote(message!),
|
|
143
|
+
signatureContext: "reply_forward",
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function handleRecall() {
|
|
148
|
+
// Only ever invoked from the confirmation modal below, which itself only renders once `message`
|
|
149
|
+
// is loaded (the early return above covers the only other state) — the non-null assertion
|
|
150
|
+
// reflects that real invariant, matching this codebase's established pattern for the same class
|
|
151
|
+
// of always-true-in-practice guard.
|
|
152
|
+
setRecalling(true);
|
|
153
|
+
setError(null);
|
|
154
|
+
try {
|
|
155
|
+
const updated = await recallMessage(message!.uid);
|
|
156
|
+
setConfirming(false);
|
|
157
|
+
onRecalled?.(updated);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not recall this message.");
|
|
160
|
+
} finally {
|
|
161
|
+
setRecalling(false);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Only ever invoked from the "Cancel" button below, which itself only renders once `message` is
|
|
166
|
+
// loaded and `isOutbox`/`message.scheduledSendTime` are both truthy — `draftsFolderUid` is required
|
|
167
|
+
// by that same rendering guard (see the prop's own doc comment), so the non-null assertion reflects
|
|
168
|
+
// a real invariant, matching `handleRecall`'s identical pattern just above.
|
|
169
|
+
async function handleCancelScheduledSend() {
|
|
170
|
+
setCanceling(true);
|
|
171
|
+
setCancelError(null);
|
|
172
|
+
try {
|
|
173
|
+
const updated = await cancelScheduledSend(message!, draftsFolderUid!);
|
|
174
|
+
onScheduledSendCanceled?.(updated);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
setCancelError(err instanceof ApiRequestError ? err.message : "Could not cancel this scheduled send.");
|
|
177
|
+
} finally {
|
|
178
|
+
setCanceling(false);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Only ever invoked from the classification button below, which itself only renders once `message`
|
|
183
|
+
// is loaded and `isInbox` is true — the non-null assertion reflects the same real invariant as
|
|
184
|
+
// `handleRecall`/`handleCancelScheduledSend` above.
|
|
185
|
+
async function handleClassify(classifyAs: MessageClassification) {
|
|
186
|
+
setClassifying(true);
|
|
187
|
+
setClassifyError(null);
|
|
188
|
+
try {
|
|
189
|
+
const updated = await classifyMessage(message!.uid, classifyAs, alwaysForSender);
|
|
190
|
+
onClassified?.(updated);
|
|
191
|
+
} catch (err) {
|
|
192
|
+
setClassifyError(err instanceof ApiRequestError ? err.message : "Could not reclassify this message.");
|
|
193
|
+
} finally {
|
|
194
|
+
setClassifying(false);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Only ever invoked from the pending-receipt banner below, which itself only renders once `message`
|
|
199
|
+
// is loaded — same real invariant as every other handler above.
|
|
200
|
+
async function handleReceipt(type: ReceiptType, action: "approve" | "decline") {
|
|
201
|
+
setReceiptBusy(type);
|
|
202
|
+
setReceiptError(null);
|
|
203
|
+
try {
|
|
204
|
+
const updated = await (action === "approve" ? approveReceipt : declineReceipt)(message!.uid, type);
|
|
205
|
+
onReceiptHandled?.(updated);
|
|
206
|
+
} catch (err) {
|
|
207
|
+
setReceiptError(err instanceof ApiRequestError ? err.message : "Could not handle this receipt request.");
|
|
208
|
+
} finally {
|
|
209
|
+
setReceiptBusy(null);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return (
|
|
214
|
+
<div className="flex-1 min-w-0 flex flex-col">
|
|
215
|
+
<div className="border-b border-border p-4">
|
|
216
|
+
{backHref && (
|
|
217
|
+
<a href={backHref} className="text-sm text-primary-dark hover:underline block mb-2">
|
|
218
|
+
← Back to messages
|
|
219
|
+
</a>
|
|
220
|
+
)}
|
|
221
|
+
<div className="flex items-start justify-between gap-3">
|
|
222
|
+
<h1 className="text-lg font-bold tracking-tight">{message.subject || "(no subject)"}</h1>
|
|
223
|
+
{isSentItems &&
|
|
224
|
+
(message.recallRequestedAt ? (
|
|
225
|
+
<span className="text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt">
|
|
226
|
+
Recall requested
|
|
227
|
+
</span>
|
|
228
|
+
) : (
|
|
229
|
+
<Button
|
|
230
|
+
type="button"
|
|
231
|
+
variant="secondary"
|
|
232
|
+
className="!w-auto shrink-0"
|
|
233
|
+
onClick={() => setConfirming(true)}
|
|
234
|
+
>
|
|
235
|
+
Recall this message
|
|
236
|
+
</Button>
|
|
237
|
+
))}
|
|
238
|
+
{isOutbox && message.scheduledSendTime && (
|
|
239
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
240
|
+
<span className="text-xs font-medium text-text-muted py-1 px-2.5 rounded-pill bg-surface-alt">
|
|
241
|
+
Scheduled for {new Date(message.scheduledSendTime).toLocaleString()}
|
|
242
|
+
</span>
|
|
243
|
+
<Button
|
|
244
|
+
type="button"
|
|
245
|
+
variant="secondary"
|
|
246
|
+
className="!w-auto"
|
|
247
|
+
loading={canceling}
|
|
248
|
+
disabled={canceling}
|
|
249
|
+
onClick={handleCancelScheduledSend}
|
|
250
|
+
>
|
|
251
|
+
Cancel
|
|
252
|
+
</Button>
|
|
253
|
+
</div>
|
|
254
|
+
)}
|
|
255
|
+
</div>
|
|
256
|
+
{cancelError && (
|
|
257
|
+
<div className="mt-2">
|
|
258
|
+
<Alert>{cancelError}</Alert>
|
|
259
|
+
</div>
|
|
260
|
+
)}
|
|
261
|
+
<p className="text-sm text-text-muted mt-1">
|
|
262
|
+
From {message.from.displayName || message.from.address} ·{" "}
|
|
263
|
+
{new Date(message.receivedDate).toLocaleString()}
|
|
264
|
+
</p>
|
|
265
|
+
<p className="text-sm text-text-muted">
|
|
266
|
+
To {message.recipients.map((r) => r.displayName || r.address).join(", ")}
|
|
267
|
+
</p>
|
|
268
|
+
<div className="flex gap-2 mt-3">
|
|
269
|
+
<Button type="button" variant="secondary" className="!w-auto" onClick={handleReply}>
|
|
270
|
+
Reply
|
|
271
|
+
</Button>
|
|
272
|
+
<Button type="button" variant="secondary" className="!w-auto" onClick={handleReplyAll}>
|
|
273
|
+
Reply All
|
|
274
|
+
</Button>
|
|
275
|
+
<Button type="button" variant="secondary" className="!w-auto" onClick={handleForward}>
|
|
276
|
+
Forward
|
|
277
|
+
</Button>
|
|
278
|
+
</div>
|
|
279
|
+
{isInbox && (
|
|
280
|
+
<div className="flex flex-wrap items-center gap-2 mt-2">
|
|
281
|
+
<Button
|
|
282
|
+
type="button"
|
|
283
|
+
variant="text"
|
|
284
|
+
loading={classifying}
|
|
285
|
+
disabled={classifying}
|
|
286
|
+
onClick={() =>
|
|
287
|
+
handleClassify(message.inferenceClassification === "other" ? "focused" : "other")
|
|
288
|
+
}
|
|
289
|
+
>
|
|
290
|
+
{message.inferenceClassification === "other" ? "Move to Focused" : "Move to Other"}
|
|
291
|
+
</Button>
|
|
292
|
+
<label className="flex items-center gap-1.5 text-xs text-text-muted">
|
|
293
|
+
<input
|
|
294
|
+
type="checkbox"
|
|
295
|
+
checked={alwaysForSender}
|
|
296
|
+
onChange={(e) => setAlwaysForSender(e.target.checked)}
|
|
297
|
+
/>
|
|
298
|
+
Always for this sender
|
|
299
|
+
</label>
|
|
300
|
+
</div>
|
|
301
|
+
)}
|
|
302
|
+
{classifyError && (
|
|
303
|
+
<div className="mt-2">
|
|
304
|
+
<Alert>{classifyError}</Alert>
|
|
305
|
+
</div>
|
|
306
|
+
)}
|
|
307
|
+
{(["delivery", "read"] as const)
|
|
308
|
+
.filter((type) => (type === "delivery" ? message.deliveryReceiptPending : message.readReceiptPending))
|
|
309
|
+
.map((type) => (
|
|
310
|
+
<div
|
|
311
|
+
key={type}
|
|
312
|
+
className="flex flex-wrap items-center gap-2 mt-2 py-2 px-3 rounded-sm bg-surface-alt text-sm"
|
|
313
|
+
>
|
|
314
|
+
<span>
|
|
315
|
+
{message.from.displayName || message.from.address} requested a {type} receipt for this
|
|
316
|
+
message.
|
|
317
|
+
</span>
|
|
318
|
+
<Button
|
|
319
|
+
type="button"
|
|
320
|
+
variant="secondary"
|
|
321
|
+
className="!w-auto"
|
|
322
|
+
loading={receiptBusy === type}
|
|
323
|
+
disabled={receiptBusy !== null}
|
|
324
|
+
onClick={() => handleReceipt(type, "approve")}
|
|
325
|
+
>
|
|
326
|
+
Send receipt
|
|
327
|
+
</Button>
|
|
328
|
+
<Button
|
|
329
|
+
type="button"
|
|
330
|
+
variant="text"
|
|
331
|
+
disabled={receiptBusy !== null}
|
|
332
|
+
onClick={() => handleReceipt(type, "decline")}
|
|
333
|
+
>
|
|
334
|
+
Decline
|
|
335
|
+
</Button>
|
|
336
|
+
</div>
|
|
337
|
+
))}
|
|
338
|
+
{receiptError && (
|
|
339
|
+
<div className="mt-2">
|
|
340
|
+
<Alert>{receiptError}</Alert>
|
|
341
|
+
</div>
|
|
342
|
+
)}
|
|
343
|
+
{attachments.length > 0 && (
|
|
344
|
+
<ul className="flex flex-wrap gap-2 mt-3">
|
|
345
|
+
{attachments.map((attachment) => (
|
|
346
|
+
<li key={attachment.uid}>
|
|
347
|
+
<a
|
|
348
|
+
href={attachmentContentUrl(attachment.uid)}
|
|
349
|
+
className="text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark"
|
|
350
|
+
>
|
|
351
|
+
{attachment.filename} ({formatBytes(attachment.sizeBytes)})
|
|
352
|
+
</a>
|
|
353
|
+
</li>
|
|
354
|
+
))}
|
|
355
|
+
</ul>
|
|
356
|
+
)}
|
|
357
|
+
</div>
|
|
358
|
+
<iframe
|
|
359
|
+
key={message.uid}
|
|
360
|
+
title={message.subject || "Message content"}
|
|
361
|
+
src={`/api/mail/messages/${encodeURIComponent(message.uid)}/content`}
|
|
362
|
+
sandbox=""
|
|
363
|
+
className="flex-1 w-full border-0"
|
|
364
|
+
/>
|
|
365
|
+
|
|
366
|
+
<Modal open={confirming} onClose={() => setConfirming(false)} title="Recall this message?">
|
|
367
|
+
<p className="text-sm text-text-muted mb-4">
|
|
368
|
+
This asks every original recipient's mail system to delete their copy, but only if it's
|
|
369
|
+
still unread there — there's no way to guarantee it, and no confirmation once it either
|
|
370
|
+
succeeds or fails. Recipients who already read the message will keep it.
|
|
371
|
+
</p>
|
|
372
|
+
{error && <Alert>{error}</Alert>}
|
|
373
|
+
<div className="flex gap-3">
|
|
374
|
+
<Button type="button" loading={recalling} disabled={recalling} onClick={handleRecall} className="!w-auto">
|
|
375
|
+
Recall message
|
|
376
|
+
</Button>
|
|
377
|
+
<Button
|
|
378
|
+
type="button"
|
|
379
|
+
variant="secondary"
|
|
380
|
+
disabled={recalling}
|
|
381
|
+
onClick={() => setConfirming(false)}
|
|
382
|
+
className="!w-auto"
|
|
383
|
+
>
|
|
384
|
+
Cancel
|
|
385
|
+
</Button>
|
|
386
|
+
</div>
|
|
387
|
+
</Modal>
|
|
388
|
+
</div>
|
|
389
|
+
);
|
|
390
|
+
}
|