@rapidmx/web-client 0.8.0 → 0.9.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/README.md +1 -1
- package/apps/shared/components/admin/elevation.ts +61 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +73 -7
- package/apps/shared/components/admin/settings/BrandingForm.tsx +2 -1
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +86 -24
- package/apps/shared/components/layout/BrandingChrome.tsx +3 -2
- package/apps/shared/components/layout/MailboxProvisioning.tsx +44 -6
- package/apps/shared/components/layout/UserMenu.tsx +48 -13
- package/apps/shared/components/mail/ConversationList.tsx +15 -6
- package/apps/shared/components/mail/ConversationThreadPane.tsx +2 -2
- package/apps/shared/components/mail/MailAddress.tsx +88 -0
- package/apps/shared/components/mail/MessageDetailPane.tsx +11 -7
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +40 -4
- package/apps/shared/components/mail/compose/SendFailureAlert.tsx +48 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +37 -10
- package/apps/shared/mail/mergeFirstPage.ts +47 -0
- package/apps/shared/mail/useMailLiveUpdates.ts +224 -0
- package/apps/www/index.tsx +65 -2
- package/dist/apps/shared/components/admin/elevation.d.ts +24 -0
- package/dist/apps/shared/components/admin/elevation.js +56 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +12 -4
- package/dist/apps/shared/components/admin/layout/AdminShell.js +49 -6
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +1 -1
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +31 -13
- package/dist/apps/shared/components/layout/BrandingChrome.d.ts +3 -2
- package/dist/apps/shared/components/layout/BrandingChrome.js +3 -2
- package/dist/apps/shared/components/layout/MailboxProvisioning.d.ts +5 -2
- package/dist/apps/shared/components/layout/MailboxProvisioning.js +42 -7
- package/dist/apps/shared/components/layout/UserMenu.d.ts +10 -7
- package/dist/apps/shared/components/layout/UserMenu.js +39 -13
- package/dist/apps/shared/components/mail/ConversationList.js +7 -4
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +2 -2
- package/dist/apps/shared/components/mail/MailAddress.d.ts +27 -0
- package/dist/apps/shared/components/mail/MailAddress.js +39 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.js +5 -3
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +25 -6
- package/dist/apps/shared/components/mail/compose/SendFailureAlert.d.ts +14 -0
- package/dist/apps/shared/components/mail/compose/SendFailureAlert.js +11 -0
- package/dist/apps/shared/components/mail/layout/MailShell.d.ts +7 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +31 -11
- package/dist/apps/shared/mail/mergeFirstPage.d.ts +23 -0
- package/dist/apps/shared/mail/mergeFirstPage.js +30 -0
- package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +48 -0
- package/dist/apps/shared/mail/useMailLiveUpdates.js +180 -0
- package/dist/apps/www/index.js +63 -2
- package/package.json +2 -2
|
@@ -6,6 +6,8 @@ import React, { useState } from "react";
|
|
|
6
6
|
import { HiChevronDown, HiChevronRight, HiOutlineFlag, HiOutlinePaperClip } from "react-icons/hi2";
|
|
7
7
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
8
8
|
import { Message } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
9
|
+
import { formatMailAddress } from "@rapidmx/react-shared/mail/mailAddress.js";
|
|
10
|
+
import MailAddress from "./MailAddress.js";
|
|
9
11
|
import { ConversationSummary, listConversationMessages } from "@rapidmx/react-shared/mail/conversationsApi.js";
|
|
10
12
|
|
|
11
13
|
export interface ConversationListProps {
|
|
@@ -39,8 +41,9 @@ export interface ConversationListProps {
|
|
|
39
41
|
newestFirst?: boolean;
|
|
40
42
|
}
|
|
41
43
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
/** Every participant as `Name <address>`, for the tooltip and the accessible text of the "+N" a crowded row folds them into. */
|
|
45
|
+
function participantList(conversation: ConversationSummary): string {
|
|
46
|
+
return conversation.participants.map((participant) => formatMailAddress(participant)).join(", ");
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
/**
|
|
@@ -168,7 +171,15 @@ export default function ConversationList({
|
|
|
168
171
|
)}
|
|
169
172
|
>
|
|
170
173
|
<div className="flex items-center justify-between gap-2 text-sm">
|
|
171
|
-
|
|
174
|
+
{/* The first participant in full - name and address - and the rest as a count, with everyone's
|
|
175
|
+
address in its tooltip; the latest sender when the summary lists none. */}
|
|
176
|
+
<MailAddress recipient={conversation.participants[0] ?? conversation.latestFrom} className="flex-1" />
|
|
177
|
+
{conversation.participants.length > 1 && (
|
|
178
|
+
<span className="text-xs text-text-muted shrink-0 font-normal" title={participantList(conversation)}>
|
|
179
|
+
+{conversation.participants.length - 1}
|
|
180
|
+
<span className="sr-only"> more: {participantList(conversation)}</span>
|
|
181
|
+
</span>
|
|
182
|
+
)}
|
|
172
183
|
<span className="text-xs text-text-muted shrink-0">
|
|
173
184
|
{new Date(conversation.latestDate).toLocaleDateString()}
|
|
174
185
|
</span>
|
|
@@ -220,9 +231,7 @@ export default function ConversationList({
|
|
|
220
231
|
].join(" ")}
|
|
221
232
|
>
|
|
222
233
|
<div className="flex items-center justify-between gap-2 text-sm">
|
|
223
|
-
<
|
|
224
|
-
{message.from.displayName || message.from.address}
|
|
225
|
-
</span>
|
|
234
|
+
<MailAddress recipient={message.from} />
|
|
226
235
|
<span className="text-xs text-text-muted shrink-0">
|
|
227
236
|
{new Date(message.receivedDate).toLocaleDateString()}
|
|
228
237
|
</span>
|
|
@@ -8,6 +8,7 @@ import { Attachment, Folder, Message, listAttachments, setMessageRead } from "@r
|
|
|
8
8
|
import { ConversationSummary, listConversationMessages } from "@rapidmx/react-shared/mail/conversationsApi.js";
|
|
9
9
|
import { Label } from "@rapidmx/react-shared/mail/labelsApi.js";
|
|
10
10
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
11
|
+
import MailAddress from "./MailAddress.js";
|
|
11
12
|
import MessageDetailPane from "./MessageDetailPane.js";
|
|
12
13
|
|
|
13
14
|
/** One request's worth of the thread. The server's own default for `listConversationMessages()`. */
|
|
@@ -362,7 +363,6 @@ export default function ConversationThreadPane({
|
|
|
362
363
|
const uid = message.uid;
|
|
363
364
|
const expanded = expandedUids.has(uid);
|
|
364
365
|
const bodyId = `thread-message-${uid}`;
|
|
365
|
-
const sender = message.from.displayName || message.from.address;
|
|
366
366
|
return (
|
|
367
367
|
<li
|
|
368
368
|
key={uid}
|
|
@@ -391,7 +391,7 @@ export default function ConversationThreadPane({
|
|
|
391
391
|
].join(" ")}
|
|
392
392
|
>
|
|
393
393
|
<span className="flex items-center justify-between gap-2 text-sm">
|
|
394
|
-
<
|
|
394
|
+
<MailAddress recipient={message.from} />
|
|
395
395
|
<span className="text-xs text-text-muted shrink-0">
|
|
396
396
|
{new Date(message.receivedDate).toLocaleString()}
|
|
397
397
|
</span>
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { useState } from "react";
|
|
6
|
+
import { MailAddressLike, formatMailAddress, splitMailAddress } from "@rapidmx/react-shared/mail/mailAddress.js";
|
|
7
|
+
|
|
8
|
+
export interface MailAddressProps {
|
|
9
|
+
/** A message's `from`, a recipient, a conversation participant. */
|
|
10
|
+
recipient: MailAddressLike;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A sender or recipient as one compact line for a dense row: the name, when there is one, with the real address in muted
|
|
16
|
+
* type after it - `Jean-Philippe <jp@example.com>` - or the bare address without a name. The address is never left out,
|
|
17
|
+
* and when the row is too narrow for both the name gives way first (it shrinks a thousand times faster), then the
|
|
18
|
+
* address's own local part - the `@domain`, which is what says who really sent it, keeps its room to the end. The full
|
|
19
|
+
* `Name <address>` is the tooltip and, once, the accessible text (the two visible halves are hidden from assistive
|
|
20
|
+
* technology), so a screen reader hears one address, not fragments.
|
|
21
|
+
*/
|
|
22
|
+
export default function MailAddress({ recipient, className }: MailAddressProps) {
|
|
23
|
+
const { name, address } = splitMailAddress(recipient);
|
|
24
|
+
const full = formatMailAddress(recipient);
|
|
25
|
+
const at = address.lastIndexOf("@");
|
|
26
|
+
const local = at > 0 ? address.slice(0, at) : address;
|
|
27
|
+
const domain = at > 0 ? address.slice(at) : "";
|
|
28
|
+
return (
|
|
29
|
+
<span className={["flex min-w-0 items-baseline gap-1.5", className].filter(Boolean).join(" ")} title={full}>
|
|
30
|
+
<span className="sr-only">{full}</span>
|
|
31
|
+
{name && (
|
|
32
|
+
<span aria-hidden="true" className="truncate min-w-0" style={{ flexShrink: 1000 }}>
|
|
33
|
+
{name}
|
|
34
|
+
</span>
|
|
35
|
+
)}
|
|
36
|
+
<span
|
|
37
|
+
aria-hidden="true"
|
|
38
|
+
className={["flex min-w-0 items-baseline", name ? "text-xs font-normal text-text-muted" : ""].filter(Boolean).join(" ")}
|
|
39
|
+
>
|
|
40
|
+
{name && <span><</span>}
|
|
41
|
+
<span className="truncate min-w-0">{local}</span>
|
|
42
|
+
{domain && <span className="shrink-0">{domain}</span>}
|
|
43
|
+
{name && <span>></span>}
|
|
44
|
+
</span>
|
|
45
|
+
</span>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** How many recipients a `RecipientLine` shows before folding the rest behind "and N more". */
|
|
50
|
+
export const RECIPIENT_LINE_LIMIT = 3;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A `To`/`Cc` line of a message header: every recipient as `Name <address>` in the text itself (never hover-only), wrapping
|
|
54
|
+
* onto more lines as needed. A long list shows its first `RECIPIENT_LINE_LIMIT` and folds the rest behind a button -
|
|
55
|
+
* "and 12 more" / "Show fewer" - so a message to a hundred people doesn't take the whole header. Nothing when empty.
|
|
56
|
+
*/
|
|
57
|
+
export function RecipientLine({ label, recipients }: { label: string; recipients: MailAddressLike[] }) {
|
|
58
|
+
const [expanded, setExpanded] = useState(false);
|
|
59
|
+
if (recipients.length === 0) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
const folded = recipients.length > RECIPIENT_LINE_LIMIT;
|
|
63
|
+
const shown = folded && !expanded ? recipients.slice(0, RECIPIENT_LINE_LIMIT) : recipients;
|
|
64
|
+
return (
|
|
65
|
+
<p className="text-sm text-text-muted break-words">
|
|
66
|
+
<span>{label}</span>{" "}
|
|
67
|
+
{shown.map((recipient, index) => (
|
|
68
|
+
<React.Fragment key={index}>
|
|
69
|
+
{index > 0 && ", "}
|
|
70
|
+
<span>{formatMailAddress(recipient)}</span>
|
|
71
|
+
</React.Fragment>
|
|
72
|
+
))}
|
|
73
|
+
{folded && (
|
|
74
|
+
<>
|
|
75
|
+
{" "}
|
|
76
|
+
<button
|
|
77
|
+
type="button"
|
|
78
|
+
aria-expanded={expanded}
|
|
79
|
+
onClick={() => setExpanded((open) => !open)}
|
|
80
|
+
className="text-xs font-semibold text-primary-dark underline hover:no-underline"
|
|
81
|
+
>
|
|
82
|
+
{expanded ? "Show fewer" : `and ${recipients.length - RECIPIENT_LINE_LIMIT} more`}
|
|
83
|
+
</button>
|
|
84
|
+
</>
|
|
85
|
+
)}
|
|
86
|
+
</p>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
@@ -60,6 +60,8 @@ import { KEY_CHANGE_STALE_MESSAGE, sameFingerprint } from "../contacts/contactKe
|
|
|
60
60
|
import { useCompose } from "./compose/ComposeContext.js";
|
|
61
61
|
import { loadOriginalMessage } from "./compose/quotedBody.js";
|
|
62
62
|
import { formatRecipient } from "./compose/recipients.js";
|
|
63
|
+
import { formatMailAddress } from "@rapidmx/react-shared/mail/mailAddress.js";
|
|
64
|
+
import { RecipientLine } from "./MailAddress.js";
|
|
63
65
|
import { useMailShell } from "./layout/MailShell.js";
|
|
64
66
|
import { useUnlockPrompt } from "../layout/UnlockPromptProvider.js";
|
|
65
67
|
import { moveLocalEntity } from "../../search/localIndexRpcClient.js";
|
|
@@ -834,8 +836,8 @@ function MessageDetailContent({
|
|
|
834
836
|
const senderAddress = (signatureShown ? extractAddresses(security.protectedHeaders?.from)[0] : undefined) ?? message.from.address;
|
|
835
837
|
const senderName = message.from.displayName;
|
|
836
838
|
const senderNameCheck = checkSenderName(senderName, senderAddress);
|
|
837
|
-
|
|
838
|
-
const senderLabel =
|
|
839
|
+
// Always `Name <address>` (or the bare address): a name alone hides who a message is really from.
|
|
840
|
+
const senderLabel = formatMailAddress({ displayName: senderName, address: senderAddress });
|
|
839
841
|
// Offered only for a valid signature from a certificate nobody pinned for this sender - never to replace a pin.
|
|
840
842
|
// A recorded signing-key conflict for this sender is resolved from the contact, never by trusting another key.
|
|
841
843
|
const pendingConflict = senderUnpinned && senderKeyState?.conflict !== undefined;
|
|
@@ -1082,8 +1084,9 @@ function MessageDetailContent({
|
|
|
1082
1084
|
been forwarded or re-sent to you unchanged, or you were Bcc’d.
|
|
1083
1085
|
</p>
|
|
1084
1086
|
)}
|
|
1085
|
-
<p className="text-sm text-text-muted mt-1">
|
|
1086
|
-
From {senderLabel} ·
|
|
1087
|
+
<p className="text-sm text-text-muted mt-1 break-words">
|
|
1088
|
+
From <span className="font-medium text-text">{senderLabel}</span> ·{" "}
|
|
1089
|
+
{new Date(message.receivedDate).toLocaleString()}
|
|
1087
1090
|
</p>
|
|
1088
1091
|
{senderNameCheck.misleading && (
|
|
1089
1092
|
<p role="status" className="mt-2 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text">
|
|
@@ -1091,9 +1094,10 @@ function MessageDetailContent({
|
|
|
1091
1094
|
sent from <span className="font-medium">{senderAddress}</span>. Don’t trust it based on the name.
|
|
1092
1095
|
</p>
|
|
1093
1096
|
)}
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
+
{/* Every recipient with their address, grouped as the sender addressed them; a long list folds. */}
|
|
1098
|
+
<RecipientLine label="To" recipients={message.recipients.filter((r) => r.type !== "cc" && r.type !== "bcc")} />
|
|
1099
|
+
<RecipientLine label="Cc" recipients={message.recipients.filter((r) => r.type === "cc")} />
|
|
1100
|
+
<RecipientLine label="Bcc" recipients={message.recipients.filter((r) => r.type === "bcc")} />
|
|
1097
1101
|
{/* Wraps: in a thread the pane can be as narrow as the reading pane gets (the list takes
|
|
1098
1102
|
384px of it), and these are six controls. */}
|
|
1099
1103
|
<div className="flex flex-wrap gap-1 mt-3">
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
uploadAttachment,
|
|
34
34
|
} from "@rapidmx/react-shared/mail/mailApi.js";
|
|
35
35
|
import { listMailSignatures } from "@rapidmx/react-shared/mail/mailSignaturesApi.js";
|
|
36
|
+
import { describeSendFailure } from "@rapidmx/react-shared/mail/sendFailure.js";
|
|
36
37
|
import { buildComposeBodyHtml } from "@rapidmx/react-shared/mail/compose/composeQuoting.js";
|
|
37
38
|
import { peekMailboxWritability, useMailboxWritability } from "../writableMailboxes.js";
|
|
38
39
|
import { decideMessageEncryption, resolveRecipientEncryption, RecipientEncryptionStatus } from "@rapidmx/react-shared/crypto/composeSecurity.js";
|
|
@@ -48,6 +49,7 @@ import RecipientInput from "./RecipientInput.js";
|
|
|
48
49
|
import { parseRecipientList } from "./recipients.js";
|
|
49
50
|
import RichTextEditor from "./RichTextEditor.js";
|
|
50
51
|
import ScheduleSendPicker from "./ScheduleSendPicker.js";
|
|
52
|
+
import SendFailureAlert from "./SendFailureAlert.js";
|
|
51
53
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
52
54
|
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
53
55
|
import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
|
|
@@ -119,12 +121,19 @@ const CRYPTO_UNAVAILABLE_MESSAGE =
|
|
|
119
121
|
const NO_RECIPIENTS_CLOSE_MESSAGE =
|
|
120
122
|
"This message may be encrypted once its recipients are known, so it isn't saved as a draft until you add them. Add a recipient and close again, or discard it.";
|
|
121
123
|
|
|
124
|
+
const SEND_FAILED_TITLE = "This message wasn't sent";
|
|
125
|
+
function sendFailedCloseMessage(reason: string): string {
|
|
126
|
+
return `Sending it failed (${reason.replace(/\.$/, "")}). Closing keeps it as a draft, unsent. Keep editing to try again, or discard it.`;
|
|
127
|
+
}
|
|
128
|
+
|
|
122
129
|
/** The confirmation shown before a Close/Discard throws content away (or when a Close couldn't save it). */
|
|
123
130
|
interface ClosePrompt {
|
|
124
131
|
title: string;
|
|
125
132
|
message: string;
|
|
126
133
|
/** Also offer "Retry" for loading the encryption settings. */
|
|
127
134
|
retry?: boolean;
|
|
135
|
+
/** Also offer "Close, keep draft": a failed send's close prompt, where closing keeps the unsent draft as it is. */
|
|
136
|
+
closeAnyway?: boolean;
|
|
128
137
|
}
|
|
129
138
|
|
|
130
139
|
interface SecurityBlock {
|
|
@@ -240,6 +249,8 @@ export default function ComposeWindow({
|
|
|
240
249
|
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
|
241
250
|
const [attachError, setAttachError] = useState<string | null>(null);
|
|
242
251
|
const [sendError, setSendError] = useState<string | null>(null);
|
|
252
|
+
// The technical facts behind `sendError` (per-recipient SMTP results, a transport error), when the server gave any.
|
|
253
|
+
const [sendDetails, setSendDetails] = useState<string[]>([]);
|
|
243
254
|
const [sending, setSending] = useState(false);
|
|
244
255
|
const [requestReceipt, setRequestReceipt] = useState(false);
|
|
245
256
|
const [schedulePickerOpen, setSchedulePickerOpen] = useState(false);
|
|
@@ -909,6 +920,7 @@ export default function ComposeWindow({
|
|
|
909
920
|
setSending(true);
|
|
910
921
|
sendingRef.current = true;
|
|
911
922
|
setSendError(null);
|
|
923
|
+
setSendDetails([]);
|
|
912
924
|
setSecurityBlock(null);
|
|
913
925
|
setEncryptionBlocked(null);
|
|
914
926
|
pendingSaveRef.current = false;
|
|
@@ -928,8 +940,13 @@ export default function ComposeWindow({
|
|
|
928
940
|
finishedRef.current = true;
|
|
929
941
|
onClose();
|
|
930
942
|
} catch (err) {
|
|
931
|
-
|
|
932
|
-
|
|
943
|
+
// The window stays open with the draft exactly as it was; nothing here closes, discards or saves over it.
|
|
944
|
+
const failure = describeSendFailure(
|
|
945
|
+
err,
|
|
946
|
+
scheduledSendTimeIso ? "Could not schedule this message." : "Could not send this message.",
|
|
947
|
+
);
|
|
948
|
+
setSendError(failure.message);
|
|
949
|
+
setSendDetails(failure.lines);
|
|
933
950
|
} finally {
|
|
934
951
|
sendingRef.current = false;
|
|
935
952
|
setSending(false);
|
|
@@ -1096,11 +1113,16 @@ export default function ComposeWindow({
|
|
|
1096
1113
|
* the same confirmation as a discard. A failed save keeps the window open with Discard / Keep editing.
|
|
1097
1114
|
* Close and Discard are both disabled while a send is in progress: deleting the draft, or saving a
|
|
1098
1115
|
* plaintext copy over the message the send just assembled, mid-send would lose or leak it. */
|
|
1099
|
-
async function handleClose() {
|
|
1116
|
+
async function handleClose(sendFailureAcknowledged = false) {
|
|
1100
1117
|
if (!hasUserContent) {
|
|
1101
1118
|
void discardNow();
|
|
1102
1119
|
return;
|
|
1103
1120
|
}
|
|
1121
|
+
if (sendError && !sendFailureAcknowledged) {
|
|
1122
|
+
// Closing would keep the draft and say nothing, which reads as if it had been sent.
|
|
1123
|
+
setClosePrompt({ title: SEND_FAILED_TITLE, message: sendFailedCloseMessage(sendError), closeAnyway: true });
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1104
1126
|
if (encryptionDecided) {
|
|
1105
1127
|
setClosePrompt({ title: DISCARD_TITLE, message: ENCRYPTED_CLOSE_MESSAGE });
|
|
1106
1128
|
return;
|
|
@@ -1330,6 +1352,19 @@ export default function ComposeWindow({
|
|
|
1330
1352
|
Retry
|
|
1331
1353
|
</Button>
|
|
1332
1354
|
)}
|
|
1355
|
+
{closePrompt?.closeAnyway && (
|
|
1356
|
+
<Button
|
|
1357
|
+
type="button"
|
|
1358
|
+
onClick={() => {
|
|
1359
|
+
setClosePrompt(null);
|
|
1360
|
+
void handleClose(true);
|
|
1361
|
+
}}
|
|
1362
|
+
disabled={sending || closing}
|
|
1363
|
+
className="!w-auto"
|
|
1364
|
+
>
|
|
1365
|
+
Close, keep draft
|
|
1366
|
+
</Button>
|
|
1367
|
+
)}
|
|
1333
1368
|
<Button type="button" onClick={() => void discardNow()} disabled={sending || closing} className="!w-auto">
|
|
1334
1369
|
Discard
|
|
1335
1370
|
</Button>
|
|
@@ -1349,6 +1384,7 @@ export default function ComposeWindow({
|
|
|
1349
1384
|
onClick={onToggleMinimize}
|
|
1350
1385
|
>
|
|
1351
1386
|
<span className="text-sm font-medium truncate">{title}</span>
|
|
1387
|
+
{sendError && <span className="shrink-0 text-xs font-bold uppercase tracking-wide">Not sent</span>}
|
|
1352
1388
|
<div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}>
|
|
1353
1389
|
<HeaderButton label="Restore" onClick={onToggleMinimize} icon={HiOutlineArrowsPointingOut} />
|
|
1354
1390
|
<HeaderButton label="Discard draft" onClick={handleDiscard} icon={HiOutlineXMark} disabled={sending || closing} />
|
|
@@ -1420,7 +1456,7 @@ export default function ComposeWindow({
|
|
|
1420
1456
|
<div className="px-3 pt-2">
|
|
1421
1457
|
{folderError && <Alert>{folderError}</Alert>}
|
|
1422
1458
|
{draftError && <Alert>{draftError}</Alert>}
|
|
1423
|
-
{sendError && <
|
|
1459
|
+
{sendError && <SendFailureAlert message={sendError} lines={sendDetails} />}
|
|
1424
1460
|
{attachError && <Alert>{attachError}</Alert>}
|
|
1425
1461
|
{discardError && <Alert>{discardError}</Alert>}
|
|
1426
1462
|
</div>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React from "react";
|
|
6
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
7
|
+
import CopyButton from "@rapidmx/react-shared/components/buttons/CopyButton.js";
|
|
8
|
+
|
|
9
|
+
export interface SendFailureAlertProps {
|
|
10
|
+
/** The plain, human message - always shown. */
|
|
11
|
+
message: string;
|
|
12
|
+
/** The technical facts behind it (`describeSendFailure().lines`): per-recipient SMTP results, a transport error.
|
|
13
|
+
* With none, only the message is shown. */
|
|
14
|
+
lines: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The banner a compose window shows when a send (or scheduled send) fails: the plain message, prominent, and - when the
|
|
19
|
+
* server said more - a collapsed "Technical details" block of monospace lines, one per fact, that can be selected or
|
|
20
|
+
* copied in one go to send to whoever runs the server. Collapsed, because most people only need the message.
|
|
21
|
+
*/
|
|
22
|
+
export default function SendFailureAlert({ message, lines }: SendFailureAlertProps) {
|
|
23
|
+
return (
|
|
24
|
+
<Alert>
|
|
25
|
+
<div className="flex-1 min-w-0">
|
|
26
|
+
<p className="font-semibold break-words">{message}</p>
|
|
27
|
+
{lines.length > 0 && (
|
|
28
|
+
<details className="mt-2">
|
|
29
|
+
<summary className="cursor-pointer text-xs font-semibold">Technical details</summary>
|
|
30
|
+
<ul
|
|
31
|
+
aria-label="Technical details"
|
|
32
|
+
className="mt-2 max-h-40 overflow-auto rounded-sm border border-border bg-surface p-2 text-xs font-mono text-text select-text"
|
|
33
|
+
>
|
|
34
|
+
{lines.map((line, index) => (
|
|
35
|
+
<li key={index} className="whitespace-pre-wrap break-words">
|
|
36
|
+
{line}
|
|
37
|
+
</li>
|
|
38
|
+
))}
|
|
39
|
+
</ul>
|
|
40
|
+
<div className="mt-2">
|
|
41
|
+
<CopyButton value={lines.join("\n")} label="Copy technical details" />
|
|
42
|
+
</div>
|
|
43
|
+
</details>
|
|
44
|
+
)}
|
|
45
|
+
</div>
|
|
46
|
+
</Alert>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -14,6 +14,7 @@ import KeyEnrollmentGate from "../../layout/KeyEnrollmentGate.js";
|
|
|
14
14
|
import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
|
|
15
15
|
import { useCompose } from "../compose/ComposeContext.js";
|
|
16
16
|
import LocalIndexLifecycle from "../../../search/LocalIndexLifecycle.js";
|
|
17
|
+
import { LiveUpdates, NO_LIVE_UPDATES, useMailLiveUpdates } from "../../../mail/useMailLiveUpdates.js";
|
|
17
18
|
|
|
18
19
|
export type MailShellProps = Omit<AppShellProps, "active">;
|
|
19
20
|
|
|
@@ -62,12 +63,19 @@ export interface MailShellContextValue {
|
|
|
62
63
|
* A no-op on the default context value, which is only ever read outside a real shell.
|
|
63
64
|
*/
|
|
64
65
|
onFolderCreated: (folder: Folder) => void;
|
|
66
|
+
/**
|
|
67
|
+
* Bumped whenever new mail (or another change to a message) may have arrived - a push event, a reconnect or the safety-net
|
|
68
|
+
* poll - so the list on screen can quietly refetch its first page. See `useMailLiveUpdates()`. Never changes on the default
|
|
69
|
+
* context value, which is only ever read outside a real shell.
|
|
70
|
+
*/
|
|
71
|
+
live: LiveUpdates;
|
|
65
72
|
}
|
|
66
73
|
|
|
67
74
|
const MailShellContext = createContext<MailShellContextValue>({
|
|
68
75
|
mailboxes: [],
|
|
69
76
|
mailboxFolders: [],
|
|
70
77
|
onFolderCreated: () => undefined,
|
|
78
|
+
live: NO_LIVE_UPDATES,
|
|
71
79
|
});
|
|
72
80
|
|
|
73
81
|
/** Reads the mailbox/folder a page is currently showing, as resolved by the enclosing `MailShell`. */
|
|
@@ -105,8 +113,16 @@ function sortedFoldersOf(folders: Folder[]): Folder[] {
|
|
|
105
113
|
return [...folders].sort((a, b) => folderSortKey(a) - folderSortKey(b) || a.name.localeCompare(b.name));
|
|
106
114
|
}
|
|
107
115
|
|
|
108
|
-
|
|
109
|
-
|
|
116
|
+
/** A folder's unread count: the refreshed one when live updates have read it since load, else the one it was listed with. */
|
|
117
|
+
function unreadOf(folder: Folder, unreadCounts: Record<string, number>): number {
|
|
118
|
+
return unreadCounts[folder.uid] ?? folder.unreadCount;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function aggregateUnreadCount(mailboxFolders: MailboxFolders[], type: AggregateFolderType, unreadCounts: Record<string, number>): number {
|
|
122
|
+
return mailboxFolders.reduce((total, mf) => {
|
|
123
|
+
const folder = mf.folders.find((f) => f.type === type);
|
|
124
|
+
return total + (folder ? unreadOf(folder, unreadCounts) : 0);
|
|
125
|
+
}, 0);
|
|
110
126
|
}
|
|
111
127
|
|
|
112
128
|
type Status = "checking" | "error" | "ready";
|
|
@@ -256,14 +272,22 @@ export default function MailShell({
|
|
|
256
272
|
}
|
|
257
273
|
setMailboxFolders((prev) =>
|
|
258
274
|
prev.map((entry) =>
|
|
259
|
-
|
|
275
|
+
// Not one already there: another client's create event can arrive after (or twice, or alongside) our own.
|
|
276
|
+
entry.mailbox.uid === folder.mailboxUid && !entry.folders.some((existing) => existing.uid === folder.uid)
|
|
277
|
+
? { ...entry, folders: [...entry.folders, folder] }
|
|
278
|
+
: entry,
|
|
260
279
|
),
|
|
261
280
|
);
|
|
262
281
|
}, []);
|
|
263
282
|
|
|
283
|
+
// New mail without a reload: push events for every folder, plus a safety-net poll. Held in state beside `mailboxFolders`
|
|
284
|
+
// rather than written into it, so a refreshed unread count never looks like a change of folders to the list on screen
|
|
285
|
+
// (which reloads, and forgets its selection, whenever the folders do).
|
|
286
|
+
const { live, unreadCounts } = useMailLiveUpdates({ userUid, mailboxes, mailboxFolders, onFolderCreated });
|
|
287
|
+
|
|
264
288
|
const contextValue = useMemo<MailShellContextValue>(
|
|
265
|
-
() => ({ mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated }),
|
|
266
|
-
[mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated],
|
|
289
|
+
() => ({ mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated, live }),
|
|
290
|
+
[mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated, live],
|
|
267
291
|
);
|
|
268
292
|
|
|
269
293
|
// A full-screen takeover, not nested inside the rest of the app's chrome — there's nothing else for a
|
|
@@ -314,7 +338,7 @@ export default function MailShell({
|
|
|
314
338
|
</div>
|
|
315
339
|
<div className="flex flex-col gap-0.5">
|
|
316
340
|
{AGGREGATE_FOLDER_TYPES.map((type) => {
|
|
317
|
-
const unread = aggregateUnreadCount(mailboxFolders, type);
|
|
341
|
+
const unread = aggregateUnreadCount(mailboxFolders, type, unreadCounts);
|
|
318
342
|
return (
|
|
319
343
|
<a
|
|
320
344
|
key={type}
|
|
@@ -350,7 +374,9 @@ export default function MailShell({
|
|
|
350
374
|
</div>
|
|
351
375
|
)}
|
|
352
376
|
<div className="flex flex-col gap-0.5">
|
|
353
|
-
{sortedFoldersOf(folders).map((folder) =>
|
|
377
|
+
{sortedFoldersOf(folders).map((folder) => {
|
|
378
|
+
const unread = unreadOf(folder, unreadCounts);
|
|
379
|
+
return (
|
|
354
380
|
<a
|
|
355
381
|
key={folder.uid}
|
|
356
382
|
href={`/?mailboxUid=${encodeURIComponent(mailbox.uid)}&folderUid=${encodeURIComponent(folder.uid)}`}
|
|
@@ -362,13 +388,14 @@ export default function MailShell({
|
|
|
362
388
|
].join(" ")}
|
|
363
389
|
>
|
|
364
390
|
<span>{FOLDER_LABELS[folder.type] ?? folder.name}</span>
|
|
365
|
-
{
|
|
391
|
+
{unread > 0 && (
|
|
366
392
|
<span className="text-xs font-bold rounded-pill py-0.5 px-1.5 bg-surface-alt text-text-muted">
|
|
367
|
-
{
|
|
393
|
+
{unread}
|
|
368
394
|
</span>
|
|
369
395
|
)}
|
|
370
396
|
</a>
|
|
371
|
-
|
|
397
|
+
);
|
|
398
|
+
})}
|
|
372
399
|
</div>
|
|
373
400
|
</div>
|
|
374
401
|
))}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
|
|
6
|
+
/** What `mergeFirstPage()` made of a fresh first page and the rows already on screen. */
|
|
7
|
+
export interface MergedPage<T> {
|
|
8
|
+
/** The rows to show. */
|
|
9
|
+
rows: T[];
|
|
10
|
+
/** How many of the fresh page's rows were not on screen yet - the mail that arrived since. */
|
|
11
|
+
added: number;
|
|
12
|
+
/** `true` when `rows` is exactly the fresh page, because the whole listing fits on it. */
|
|
13
|
+
complete: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Folds a freshly fetched first page of a list into the rows already loaded (the first page plus whatever "load more"
|
|
18
|
+
* added), for the quiet refresh that follows a push event or a poll - so new mail appears without the list being reset,
|
|
19
|
+
* re-scrolled or re-selected the way a full reload would.
|
|
20
|
+
*
|
|
21
|
+
* - A page shorter than `pageSize` is the whole listing, so it simply replaces what is shown - which also drops what was
|
|
22
|
+
* deleted or moved elsewhere.
|
|
23
|
+
* - A full page is only the top of a longer listing. Its rows go first, in the server's order, followed by the loaded rows
|
|
24
|
+
* it did not repeat (older ones the reader has scrolled to). What was removed elsewhere from a long, partly loaded list
|
|
25
|
+
* stays until the next real load; nothing here can tell it from a row that merely slid off the first page.
|
|
26
|
+
* - A row in both keeps whichever copy `pick` prefers - by default the fresh one - so, for messages, a row the reader has
|
|
27
|
+
* just changed (a higher `version`) is not put back by a fetch that started before the change landed.
|
|
28
|
+
*/
|
|
29
|
+
export function mergeFirstPage<T>(
|
|
30
|
+
current: T[],
|
|
31
|
+
fresh: T[],
|
|
32
|
+
idOf: (row: T) => string,
|
|
33
|
+
pageSize: number,
|
|
34
|
+
pick: (current: T, fresh: T) => T = (_current, next) => next,
|
|
35
|
+
): MergedPage<T> {
|
|
36
|
+
const currentById = new Map(current.map((row) => [idOf(row), row]));
|
|
37
|
+
const added = fresh.filter((row) => !currentById.has(idOf(row))).length;
|
|
38
|
+
const rows = fresh.map((row) => {
|
|
39
|
+
const existing = currentById.get(idOf(row));
|
|
40
|
+
return existing ? pick(existing, row) : row;
|
|
41
|
+
});
|
|
42
|
+
if (fresh.length < pageSize) {
|
|
43
|
+
return { rows, added, complete: true };
|
|
44
|
+
}
|
|
45
|
+
const freshIds = new Set(fresh.map(idOf));
|
|
46
|
+
return { rows: [...rows, ...current.filter((row) => !freshIds.has(idOf(row)))], added, complete: false };
|
|
47
|
+
}
|