@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
|
@@ -8,6 +8,7 @@ import { HiOutlineArrowsPointingIn, HiOutlineArrowsPointingOut, HiOutlineChevron
|
|
|
8
8
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
9
9
|
import { assembleDraft, assembleDraftRaw, attachmentContentUrl, createDraft, deleteMessage, getMailbox, getMessage, listFolders, listMailboxes, sendMessage, setMessageRequestReceipt, uploadAttachment, } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
10
10
|
import { listMailSignatures } from "@rapidmx/react-shared/mail/mailSignaturesApi.js";
|
|
11
|
+
import { describeSendFailure } from "@rapidmx/react-shared/mail/sendFailure.js";
|
|
11
12
|
import { buildComposeBodyHtml } from "@rapidmx/react-shared/mail/compose/composeQuoting.js";
|
|
12
13
|
import { peekMailboxWritability, useMailboxWritability } from "../writableMailboxes.js";
|
|
13
14
|
import { decideMessageEncryption, resolveRecipientEncryption } from "@rapidmx/react-shared/crypto/composeSecurity.js";
|
|
@@ -22,6 +23,7 @@ import RecipientInput from "./RecipientInput.js";
|
|
|
22
23
|
import { parseRecipientList } from "./recipients.js";
|
|
23
24
|
import RichTextEditor from "./RichTextEditor.js";
|
|
24
25
|
import ScheduleSendPicker from "./ScheduleSendPicker.js";
|
|
26
|
+
import SendFailureAlert from "./SendFailureAlert.js";
|
|
25
27
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
26
28
|
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
27
29
|
import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
|
|
@@ -59,6 +61,10 @@ const ENCRYPTED_CLOSE_MESSAGE = "Encrypted messages aren't saved as drafts, so c
|
|
|
59
61
|
const CHECKING_CLOSE_MESSAGE = "This message may be encrypted, and that's still being checked, so it can't be saved as a draft yet. Keep editing and close again in a moment, or discard it.";
|
|
60
62
|
const CRYPTO_UNAVAILABLE_MESSAGE = "Your encryption settings couldn't be checked, so this draft isn't being saved - it might be a message that must be encrypted.";
|
|
61
63
|
const NO_RECIPIENTS_CLOSE_MESSAGE = "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.";
|
|
64
|
+
const SEND_FAILED_TITLE = "This message wasn't sent";
|
|
65
|
+
function sendFailedCloseMessage(reason) {
|
|
66
|
+
return `Sending it failed (${reason.replace(/\.$/, "")}). Closing keeps it as a draft, unsent. Keep editing to try again, or discard it.`;
|
|
67
|
+
}
|
|
62
68
|
const FIELD_ROW = "flex items-center gap-2 px-3 py-1.5 border-b border-border";
|
|
63
69
|
const FIELD_INPUT = "flex-1 min-w-0 text-sm bg-transparent outline-none";
|
|
64
70
|
const MIN_WIDTH = 320;
|
|
@@ -126,6 +132,8 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
126
132
|
const [attachments, setAttachments] = useState([]);
|
|
127
133
|
const [attachError, setAttachError] = useState(null);
|
|
128
134
|
const [sendError, setSendError] = useState(null);
|
|
135
|
+
// The technical facts behind `sendError` (per-recipient SMTP results, a transport error), when the server gave any.
|
|
136
|
+
const [sendDetails, setSendDetails] = useState([]);
|
|
129
137
|
const [sending, setSending] = useState(false);
|
|
130
138
|
const [requestReceipt, setRequestReceipt] = useState(false);
|
|
131
139
|
const [schedulePickerOpen, setSchedulePickerOpen] = useState(false);
|
|
@@ -736,6 +744,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
736
744
|
setSending(true);
|
|
737
745
|
sendingRef.current = true;
|
|
738
746
|
setSendError(null);
|
|
747
|
+
setSendDetails([]);
|
|
739
748
|
setSecurityBlock(null);
|
|
740
749
|
setEncryptionBlocked(null);
|
|
741
750
|
pendingSaveRef.current = false;
|
|
@@ -756,8 +765,10 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
756
765
|
onClose();
|
|
757
766
|
}
|
|
758
767
|
catch (err) {
|
|
759
|
-
|
|
760
|
-
|
|
768
|
+
// The window stays open with the draft exactly as it was; nothing here closes, discards or saves over it.
|
|
769
|
+
const failure = describeSendFailure(err, scheduledSendTimeIso ? "Could not schedule this message." : "Could not send this message.");
|
|
770
|
+
setSendError(failure.message);
|
|
771
|
+
setSendDetails(failure.lines);
|
|
761
772
|
}
|
|
762
773
|
finally {
|
|
763
774
|
sendingRef.current = false;
|
|
@@ -922,11 +933,16 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
922
933
|
* the same confirmation as a discard. A failed save keeps the window open with Discard / Keep editing.
|
|
923
934
|
* Close and Discard are both disabled while a send is in progress: deleting the draft, or saving a
|
|
924
935
|
* plaintext copy over the message the send just assembled, mid-send would lose or leak it. */
|
|
925
|
-
async function handleClose() {
|
|
936
|
+
async function handleClose(sendFailureAcknowledged = false) {
|
|
926
937
|
if (!hasUserContent) {
|
|
927
938
|
void discardNow();
|
|
928
939
|
return;
|
|
929
940
|
}
|
|
941
|
+
if (sendError && !sendFailureAcknowledged) {
|
|
942
|
+
// Closing would keep the draft and say nothing, which reads as if it had been sent.
|
|
943
|
+
setClosePrompt({ title: SEND_FAILED_TITLE, message: sendFailedCloseMessage(sendError), closeAnyway: true });
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
930
946
|
if (encryptionDecided) {
|
|
931
947
|
setClosePrompt({ title: DISCARD_TITLE, message: ENCRYPTED_CLOSE_MESSAGE });
|
|
932
948
|
return;
|
|
@@ -1126,16 +1142,19 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
1126
1142
|
}, [minimized, contentReady]);
|
|
1127
1143
|
const title = subject.trim() || "New Message";
|
|
1128
1144
|
const titleId = `compose-title-${id}`;
|
|
1129
|
-
const discardModal = (_jsxs(Modal, { open: closePrompt !== null, onClose: () => setClosePrompt(null), title: closePrompt?.title ?? DISCARD_TITLE, children: [_jsx("p", { className: "text-sm text-text-muted mb-4", children: closePrompt?.message }), _jsxs("div", { className: "flex gap-3", children: [closePrompt?.retry && (_jsx(Button, { type: "button", onClick: retryCryptoContext, className: "!w-auto", children: "Retry" })),
|
|
1145
|
+
const discardModal = (_jsxs(Modal, { open: closePrompt !== null, onClose: () => setClosePrompt(null), title: closePrompt?.title ?? DISCARD_TITLE, children: [_jsx("p", { className: "text-sm text-text-muted mb-4", children: closePrompt?.message }), _jsxs("div", { className: "flex gap-3", children: [closePrompt?.retry && (_jsx(Button, { type: "button", onClick: retryCryptoContext, className: "!w-auto", children: "Retry" })), closePrompt?.closeAnyway && (_jsx(Button, { type: "button", onClick: () => {
|
|
1146
|
+
setClosePrompt(null);
|
|
1147
|
+
void handleClose(true);
|
|
1148
|
+
}, disabled: sending || closing, className: "!w-auto", children: "Close, keep draft" })), _jsx(Button, { type: "button", onClick: () => void discardNow(), disabled: sending || closing, className: "!w-auto", children: "Discard" }), _jsx(Button, { type: "button", variant: "secondary", onClick: () => setClosePrompt(null), className: "!w-auto", children: "Keep editing" })] })] }));
|
|
1130
1149
|
if (minimized) {
|
|
1131
|
-
return (_jsxs(_Fragment, { children: [_jsx("div", { role: "dialog", "aria-label": title, className: "w-64 shrink-0 bg-surface border border-border border-b-0 rounded-t-md shadow-modal", children: _jsxs("div", { className: "h-10 flex items-center justify-between gap-2 px-3 rounded-t-md bg-primary-darker text-white cursor-pointer", onClick: onToggleMinimize, children: [_jsx("span", { className: "text-sm font-medium truncate", children: title }), _jsxs("div", { className: "flex items-center gap-0.5", onClick: (e) => e.stopPropagation(), children: [_jsx(HeaderButton, { label: "Restore", onClick: onToggleMinimize, icon: HiOutlineArrowsPointingOut }), _jsx(HeaderButton, { label: "Discard draft", onClick: handleDiscard, icon: HiOutlineXMark, disabled: sending || closing })] })] }) }), discardModal] }));
|
|
1150
|
+
return (_jsxs(_Fragment, { children: [_jsx("div", { role: "dialog", "aria-label": title, className: "w-64 shrink-0 bg-surface border border-border border-b-0 rounded-t-md shadow-modal", children: _jsxs("div", { className: "h-10 flex items-center justify-between gap-2 px-3 rounded-t-md bg-primary-darker text-white cursor-pointer", onClick: onToggleMinimize, children: [_jsx("span", { className: "text-sm font-medium truncate", children: title }), sendError && _jsx("span", { className: "shrink-0 text-xs font-bold uppercase tracking-wide", children: "Not sent" }), _jsxs("div", { className: "flex items-center gap-0.5", onClick: (e) => e.stopPropagation(), children: [_jsx(HeaderButton, { label: "Restore", onClick: onToggleMinimize, icon: HiOutlineArrowsPointingOut }), _jsx(HeaderButton, { label: "Discard draft", onClick: handleDiscard, icon: HiOutlineXMark, disabled: sending || closing })] })] }) }), discardModal] }));
|
|
1132
1151
|
}
|
|
1133
1152
|
return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: windowRef, role: "dialog", "aria-labelledby": titleId, style: !isMobile && manualSize ? { width: manualSize.width, height: manualSize.height } : undefined, className: [
|
|
1134
1153
|
"relative shrink-0 flex flex-col bg-surface border border-border shadow-modal overflow-hidden",
|
|
1135
1154
|
isMobile
|
|
1136
1155
|
? "fixed inset-0 w-full h-full rounded-none border-0"
|
|
1137
1156
|
: ["border-b-0 rounded-t-md", manualSize ? "" : expanded ? "w-[720px] h-[85vh]" : "w-[480px] h-[520px]"].join(" "),
|
|
1138
|
-
].join(" "), children: [!isMobile && (_jsxs(_Fragment, { children: [_jsx("div", { onPointerDown: handleResizeStart("top"), "aria-hidden": "true", className: "absolute top-0 left-0 right-0 h-1.5 cursor-ns-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("left"), "aria-hidden": "true", className: "absolute top-0 left-0 bottom-0 w-1.5 cursor-ew-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("corner"), role: "separator", "aria-label": "Resize", className: "absolute top-0 left-0 w-3 h-3 cursor-nwse-resize z-30" })] })), _jsxs("div", { className: "h-10 shrink-0 flex items-center justify-between gap-2 px-3 bg-primary-darker text-white cursor-pointer", onClick: onToggleMinimize, children: [_jsx("span", { id: titleId, className: "text-sm font-medium truncate", children: title }), _jsxs("div", { className: "flex items-center gap-0.5", onClick: (e) => e.stopPropagation(), children: [_jsx(HeaderButton, { label: "Minimize", onClick: onToggleMinimize, icon: HiOutlineMinus }), !isMobile && (_jsx(HeaderButton, { label: expanded ? "Collapse" : "Expand", onClick: toggleExpanded, icon: expanded ? HiOutlineArrowsPointingIn : HiOutlineArrowsPointingOut })), _jsx(HeaderButton, { label: "Close", onClick: () => void handleClose(), icon: HiOutlineXMark, disabled: sending || closing })] })] }), _jsxs("div", { className: "flex-1 min-h-0 flex flex-col", children: [(folderError || draftError || sendError || attachError || discardError) && (_jsxs("div", { className: "px-3 pt-2", children: [folderError && _jsx(Alert, { children: folderError }), draftError && _jsx(Alert, { children: draftError }), sendError && _jsx(
|
|
1157
|
+
].join(" "), children: [!isMobile && (_jsxs(_Fragment, { children: [_jsx("div", { onPointerDown: handleResizeStart("top"), "aria-hidden": "true", className: "absolute top-0 left-0 right-0 h-1.5 cursor-ns-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("left"), "aria-hidden": "true", className: "absolute top-0 left-0 bottom-0 w-1.5 cursor-ew-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("corner"), role: "separator", "aria-label": "Resize", className: "absolute top-0 left-0 w-3 h-3 cursor-nwse-resize z-30" })] })), _jsxs("div", { className: "h-10 shrink-0 flex items-center justify-between gap-2 px-3 bg-primary-darker text-white cursor-pointer", onClick: onToggleMinimize, children: [_jsx("span", { id: titleId, className: "text-sm font-medium truncate", children: title }), _jsxs("div", { className: "flex items-center gap-0.5", onClick: (e) => e.stopPropagation(), children: [_jsx(HeaderButton, { label: "Minimize", onClick: onToggleMinimize, icon: HiOutlineMinus }), !isMobile && (_jsx(HeaderButton, { label: expanded ? "Collapse" : "Expand", onClick: toggleExpanded, icon: expanded ? HiOutlineArrowsPointingIn : HiOutlineArrowsPointingOut })), _jsx(HeaderButton, { label: "Close", onClick: () => void handleClose(), icon: HiOutlineXMark, disabled: sending || closing })] })] }), _jsxs("div", { className: "flex-1 min-h-0 flex flex-col", children: [(folderError || draftError || sendError || attachError || discardError) && (_jsxs("div", { className: "px-3 pt-2", children: [folderError && _jsx(Alert, { children: folderError }), draftError && _jsx(Alert, { children: draftError }), sendError && _jsx(SendFailureAlert, { message: sendError, lines: sendDetails }), attachError && _jsx(Alert, { children: attachError }), discardError && _jsx(Alert, { children: discardError })] })), cryptoCheckUnavailable && (_jsx("div", { className: "px-3 pt-2", children: _jsxs(Alert, { children: [_jsx("p", { className: "mb-2", children: CRYPTO_UNAVAILABLE_MESSAGE }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: retryCryptoContext, children: "Retry" })] }) })), encryptionBlocked && (_jsx("div", { className: "px-3 pt-2", children: _jsxs(Alert, { children: [_jsxs("p", { className: "mb-2", children: ["This message can\u2019t be encrypted for everyone: ", encryptionBlocked.map((r) => r.address).join(", "), encryptionBlocked[0]?.prohibitedReason ? ` — ${encryptionBlocked[0].prohibitedReason}` : " has no encryption key on file", ". Remove ", encryptionBlocked.length > 1 ? "these recipients" : "this recipient", " from To/Cc/Bcc, or send the whole message in plaintext."] }), _jsx(Button, { type: "button", disabled: sending, onClick: () => void submit(true, blockedScheduleIso), children: "Send without encryption" })] }) })), securityBlock && (_jsx("div", { className: "px-3 pt-2", children: _jsxs(Alert, { children: [_jsx("p", { className: "mb-2", children: securityBlock.message }), _jsx(Button, { type: "button", disabled: sending, onClick: () => void submit(true, blockedScheduleIso), children: securityBlock.overrideLabel })] }) })), fromOptions.length > 1 && (_jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-from-${id}`, className: "text-xs text-text-muted shrink-0", children: "From" }), _jsx("select", { id: `compose-from-${id}`, className: `${FIELD_INPUT} disabled:opacity-55`, value: mailboxUid ?? "", disabled: hasUploads || sending, title: hasUploads ? "The sender can't be changed after adding attachments or images." : undefined, onChange: (e) => handleFromChange(e.target.value), children: fromOptions.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName ? `${mb.displayName} <${mb.primarySmtpAddress}>` : mb.primarySmtpAddress, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) })] })), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-to-${id}`, className: "text-xs text-text-muted shrink-0", children: "To" }), _jsx(RecipientInput, { id: `compose-to-${id}`, label: "To", value: to, mailboxUid: mailboxUid, onChange: setTo, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)), autoFocus: focusToOnMount }), !showCcBcc && (_jsx("button", { type: "button", onClick: () => setShowCcBcc(true), className: "text-xs text-text-muted hover:text-text shrink-0", children: "Cc Bcc" }))] }), showCcBcc && (_jsxs(_Fragment, { children: [_jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-cc-${id}`, className: "text-xs text-text-muted shrink-0", children: "Cc" }), _jsx(RecipientInput, { id: `compose-cc-${id}`, label: "Cc", value: cc, mailboxUid: mailboxUid, onChange: setCc, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)) })] }), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-bcc-${id}`, className: "text-xs text-text-muted shrink-0", children: "Bcc" }), _jsx(RecipientInput, { id: `compose-bcc-${id}`, label: "Bcc", value: bcc, mailboxUid: mailboxUid, onChange: setBcc, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)) })] })] })), (() => {
|
|
1139
1158
|
const knownRecipients = [...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]
|
|
1140
1159
|
.map((r) => recipientStatuses[r.address])
|
|
1141
1160
|
.filter((status) => !!status);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
export interface SendFailureAlertProps {
|
|
3
|
+
/** The plain, human message - always shown. */
|
|
4
|
+
message: string;
|
|
5
|
+
/** The technical facts behind it (`describeSendFailure().lines`): per-recipient SMTP results, a transport error.
|
|
6
|
+
* With none, only the message is shown. */
|
|
7
|
+
lines: string[];
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The banner a compose window shows when a send (or scheduled send) fails: the plain message, prominent, and - when the
|
|
11
|
+
* server said more - a collapsed "Technical details" block of monospace lines, one per fact, that can be selected or
|
|
12
|
+
* copied in one go to send to whoever runs the server. Collapsed, because most people only need the message.
|
|
13
|
+
*/
|
|
14
|
+
export default function SendFailureAlert({ message, lines }: SendFailureAlertProps): React.JSX.Element;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
3
|
+
import CopyButton from "@rapidmx/react-shared/components/buttons/CopyButton.js";
|
|
4
|
+
/**
|
|
5
|
+
* The banner a compose window shows when a send (or scheduled send) fails: the plain message, prominent, and - when the
|
|
6
|
+
* server said more - a collapsed "Technical details" block of monospace lines, one per fact, that can be selected or
|
|
7
|
+
* copied in one go to send to whoever runs the server. Collapsed, because most people only need the message.
|
|
8
|
+
*/
|
|
9
|
+
export default function SendFailureAlert({ message, lines }) {
|
|
10
|
+
return (_jsx(Alert, { children: _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("p", { className: "font-semibold break-words", children: message }), lines.length > 0 && (_jsxs("details", { className: "mt-2", children: [_jsx("summary", { className: "cursor-pointer text-xs font-semibold", children: "Technical details" }), _jsx("ul", { "aria-label": "Technical details", 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", children: lines.map((line, index) => (_jsx("li", { className: "whitespace-pre-wrap break-words", children: line }, index))) }), _jsx("div", { className: "mt-2", children: _jsx(CopyButton, { value: lines.join("\n"), label: "Copy technical details" }) })] }))] }) }));
|
|
11
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React, { PropsWithChildren } from "react";
|
|
2
2
|
import { Folder, Mailbox } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
3
3
|
import { AppShellProps } from "../../layout/AppShell.js";
|
|
4
|
+
import { LiveUpdates } from "../../../mail/useMailLiveUpdates.js";
|
|
4
5
|
export type MailShellProps = Omit<AppShellProps, "active">;
|
|
5
6
|
/** One mailbox's own mail folders (already filtered to `MAIL_FOLDER_TYPES` and unsorted) - one entry per
|
|
6
7
|
* mailbox in `mailboxes`, fetched in parallel so every accessible mailbox's folder tree can render
|
|
@@ -41,6 +42,12 @@ export interface MailShellContextValue {
|
|
|
41
42
|
* A no-op on the default context value, which is only ever read outside a real shell.
|
|
42
43
|
*/
|
|
43
44
|
onFolderCreated: (folder: Folder) => void;
|
|
45
|
+
/**
|
|
46
|
+
* Bumped whenever new mail (or another change to a message) may have arrived - a push event, a reconnect or the safety-net
|
|
47
|
+
* poll - so the list on screen can quietly refetch its first page. See `useMailLiveUpdates()`. Never changes on the default
|
|
48
|
+
* context value, which is only ever read outside a real shell.
|
|
49
|
+
*/
|
|
50
|
+
live: LiveUpdates;
|
|
44
51
|
}
|
|
45
52
|
/** Reads the mailbox/folder a page is currently showing, as resolved by the enclosing `MailShell`. */
|
|
46
53
|
export declare function useMailShell(): MailShellContextValue;
|
|
@@ -15,6 +15,7 @@ import KeyEnrollmentGate from "../../layout/KeyEnrollmentGate.js";
|
|
|
15
15
|
import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
|
|
16
16
|
import { useCompose } from "../compose/ComposeContext.js";
|
|
17
17
|
import LocalIndexLifecycle from "../../../search/LocalIndexLifecycle.js";
|
|
18
|
+
import { NO_LIVE_UPDATES, useMailLiveUpdates } from "../../../mail/useMailLiveUpdates.js";
|
|
18
19
|
/** The well-known mail folder types an "aggregate" pseudo-folder can merge across every accessible
|
|
19
20
|
* mailbox - a fixed, small set: `outbox` is deliberately excluded (transient per-mailbox send-queue
|
|
20
21
|
* state, not a "merge across mailboxes" concept), and there's no aggregate concept for `calendar`/
|
|
@@ -28,6 +29,7 @@ const MailShellContext = createContext({
|
|
|
28
29
|
mailboxes: [],
|
|
29
30
|
mailboxFolders: [],
|
|
30
31
|
onFolderCreated: () => undefined,
|
|
32
|
+
live: NO_LIVE_UPDATES,
|
|
31
33
|
});
|
|
32
34
|
/** Reads the mailbox/folder a page is currently showing, as resolved by the enclosing `MailShell`. */
|
|
33
35
|
export function useMailShell() {
|
|
@@ -58,8 +60,15 @@ function folderSortKey(folder) {
|
|
|
58
60
|
function sortedFoldersOf(folders) {
|
|
59
61
|
return [...folders].sort((a, b) => folderSortKey(a) - folderSortKey(b) || a.name.localeCompare(b.name));
|
|
60
62
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
+
/** A folder's unread count: the refreshed one when live updates have read it since load, else the one it was listed with. */
|
|
64
|
+
function unreadOf(folder, unreadCounts) {
|
|
65
|
+
return unreadCounts[folder.uid] ?? folder.unreadCount;
|
|
66
|
+
}
|
|
67
|
+
function aggregateUnreadCount(mailboxFolders, type, unreadCounts) {
|
|
68
|
+
return mailboxFolders.reduce((total, mf) => {
|
|
69
|
+
const folder = mf.folders.find((f) => f.type === type);
|
|
70
|
+
return total + (folder ? unreadOf(folder, unreadCounts) : 0);
|
|
71
|
+
}, 0);
|
|
63
72
|
}
|
|
64
73
|
/** Page size of the one `listMailboxes()` call this shell makes. */
|
|
65
74
|
export const MAILBOX_LIST_LIMIT = 100;
|
|
@@ -172,9 +181,17 @@ export default function MailShell({ userUid, authServerUrl, impersonating, imper
|
|
|
172
181
|
if (!MAIL_FOLDER_TYPES.has(folder.type)) {
|
|
173
182
|
return;
|
|
174
183
|
}
|
|
175
|
-
setMailboxFolders((prev) => prev.map((entry) =>
|
|
184
|
+
setMailboxFolders((prev) => prev.map((entry) =>
|
|
185
|
+
// Not one already there: another client's create event can arrive after (or twice, or alongside) our own.
|
|
186
|
+
entry.mailbox.uid === folder.mailboxUid && !entry.folders.some((existing) => existing.uid === folder.uid)
|
|
187
|
+
? { ...entry, folders: [...entry.folders, folder] }
|
|
188
|
+
: entry));
|
|
176
189
|
}, []);
|
|
177
|
-
|
|
190
|
+
// New mail without a reload: push events for every folder, plus a safety-net poll. Held in state beside `mailboxFolders`
|
|
191
|
+
// rather than written into it, so a refreshed unread count never looks like a change of folders to the list on screen
|
|
192
|
+
// (which reloads, and forgets its selection, whenever the folders do).
|
|
193
|
+
const { live, unreadCounts } = useMailLiveUpdates({ userUid, mailboxes, mailboxFolders, onFolderCreated });
|
|
194
|
+
const contextValue = useMemo(() => ({ mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated, live }), [mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated, live]);
|
|
178
195
|
// A full-screen takeover, not nested inside the rest of the app's chrome — there's nothing else for a
|
|
179
196
|
// mailbox-less caller to do here yet, so the icon rail/header/folder tree don't render at all. Checks
|
|
180
197
|
// `mailboxes.length` directly (not `!mailboxUid`) since `mailboxUid` is legitimately undefined in
|
|
@@ -196,19 +213,22 @@ export default function MailShell({ userUid, authServerUrl, impersonating, imper
|
|
|
196
213
|
// `Drawer`), possibly *simultaneously* mounted (the aside is only CSS-hidden below `md`, not
|
|
197
214
|
// unmounted).
|
|
198
215
|
const sidebarContent = () => (_jsxs(_Fragment, { children: [_jsx("div", { className: "p-3", children: _jsx(ComposeButton, {}) }), foldersLoading ? (_jsx("div", { className: "flex-1 overflow-y-auto px-3 pb-3", children: _jsx(SkeletonList, { count: 6, className: "pt-1" }) })) : (_jsxs("nav", { className: "flex-1 overflow-y-auto px-3 pb-3 flex flex-col gap-3", children: [mailboxes.length > 1 && (_jsxs("div", { children: [_jsx("div", { className: "text-xs font-bold uppercase tracking-wide text-text-muted mb-1 px-2.5", children: "All Mailboxes" }), _jsx("div", { className: "flex flex-col gap-0.5", children: AGGREGATE_FOLDER_TYPES.map((type) => {
|
|
199
|
-
const unread = aggregateUnreadCount(mailboxFolders, type);
|
|
216
|
+
const unread = aggregateUnreadCount(mailboxFolders, type, unreadCounts);
|
|
200
217
|
return (_jsxs("a", { href: `/?aggregate=${encodeURIComponent(type)}`, className: [
|
|
201
218
|
"flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5",
|
|
202
219
|
aggregateFolderType === type
|
|
203
220
|
? "bg-primary/10 text-primary-dark font-semibold"
|
|
204
221
|
: "text-text hover:bg-surface-alt",
|
|
205
222
|
].join(" "), children: [_jsx("span", { children: FOLDER_LABELS[type] }), unread > 0 && (_jsx("span", { className: "text-xs font-bold rounded-pill py-0.5 px-1.5 bg-surface-alt text-text-muted", children: unread }))] }, type));
|
|
206
|
-
}) })] })), mailboxFolders.map(({ mailbox, folders, error: mailboxError }) => (_jsxs("div", { children: [_jsxs("div", { className: "text-xs font-bold uppercase tracking-wide text-text-muted mb-1 px-2.5 truncate", children: [mailbox.displayName, mailbox.ownerUserUid ? "" : " (shared)"] }), mailboxError && (_jsx("div", { className: "px-2.5 pb-1", children: _jsx(Alert, { children: mailboxError }) })), _jsx("div", { className: "flex flex-col gap-0.5", children: sortedFoldersOf(folders).map((folder) =>
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
223
|
+
}) })] })), mailboxFolders.map(({ mailbox, folders, error: mailboxError }) => (_jsxs("div", { children: [_jsxs("div", { className: "text-xs font-bold uppercase tracking-wide text-text-muted mb-1 px-2.5 truncate", children: [mailbox.displayName, mailbox.ownerUserUid ? "" : " (shared)"] }), mailboxError && (_jsx("div", { className: "px-2.5 pb-1", children: _jsx(Alert, { children: mailboxError }) })), _jsx("div", { className: "flex flex-col gap-0.5", children: sortedFoldersOf(folders).map((folder) => {
|
|
224
|
+
const unread = unreadOf(folder, unreadCounts);
|
|
225
|
+
return (_jsxs("a", { href: `/?mailboxUid=${encodeURIComponent(mailbox.uid)}&folderUid=${encodeURIComponent(folder.uid)}`, className: [
|
|
226
|
+
"flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5",
|
|
227
|
+
folder.uid === folderUid
|
|
228
|
+
? "bg-primary/10 text-primary-dark font-semibold"
|
|
229
|
+
: "text-text hover:bg-surface-alt",
|
|
230
|
+
].join(" "), children: [_jsx("span", { children: FOLDER_LABELS[folder.type] ?? folder.name }), unread > 0 && (_jsx("span", { className: "text-xs font-bold rounded-pill py-0.5 px-1.5 bg-surface-alt text-text-muted", children: unread }))] }, folder.uid));
|
|
231
|
+
}) })] }, mailbox.uid)))] }))] }));
|
|
212
232
|
inner = (_jsxs(_Fragment, { children: [_jsx("aside", { className: "hidden md:flex w-64 shrink-0 bg-surface border-r border-border flex-col", children: sidebarContent() }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Folders", children: _jsx("div", { className: "flex flex-col", children: sidebarContent() }) }), _jsxs("main", { className: "flex-1 min-w-0 overflow-y-auto", children: [_jsx("button", { type: "button", className: "md:hidden m-3 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open folders", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx(MailShellContext.Provider, { value: contextValue, children: children })] })] }));
|
|
213
233
|
}
|
|
214
234
|
// Wraps AppShell unconditionally, at a stable tree position regardless of whether mailboxUid has
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** What `mergeFirstPage()` made of a fresh first page and the rows already on screen. */
|
|
2
|
+
export interface MergedPage<T> {
|
|
3
|
+
/** The rows to show. */
|
|
4
|
+
rows: T[];
|
|
5
|
+
/** How many of the fresh page's rows were not on screen yet - the mail that arrived since. */
|
|
6
|
+
added: number;
|
|
7
|
+
/** `true` when `rows` is exactly the fresh page, because the whole listing fits on it. */
|
|
8
|
+
complete: boolean;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Folds a freshly fetched first page of a list into the rows already loaded (the first page plus whatever "load more"
|
|
12
|
+
* added), for the quiet refresh that follows a push event or a poll - so new mail appears without the list being reset,
|
|
13
|
+
* re-scrolled or re-selected the way a full reload would.
|
|
14
|
+
*
|
|
15
|
+
* - A page shorter than `pageSize` is the whole listing, so it simply replaces what is shown - which also drops what was
|
|
16
|
+
* deleted or moved elsewhere.
|
|
17
|
+
* - 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
|
|
18
|
+
* it did not repeat (older ones the reader has scrolled to). What was removed elsewhere from a long, partly loaded list
|
|
19
|
+
* stays until the next real load; nothing here can tell it from a row that merely slid off the first page.
|
|
20
|
+
* - A row in both keeps whichever copy `pick` prefers - by default the fresh one - so, for messages, a row the reader has
|
|
21
|
+
* just changed (a higher `version`) is not put back by a fetch that started before the change landed.
|
|
22
|
+
*/
|
|
23
|
+
export declare function mergeFirstPage<T>(current: T[], fresh: T[], idOf: (row: T) => string, pageSize: number, pick?: (current: T, fresh: T) => T): MergedPage<T>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* Folds a freshly fetched first page of a list into the rows already loaded (the first page plus whatever "load more"
|
|
7
|
+
* added), for the quiet refresh that follows a push event or a poll - so new mail appears without the list being reset,
|
|
8
|
+
* re-scrolled or re-selected the way a full reload would.
|
|
9
|
+
*
|
|
10
|
+
* - A page shorter than `pageSize` is the whole listing, so it simply replaces what is shown - which also drops what was
|
|
11
|
+
* deleted or moved elsewhere.
|
|
12
|
+
* - 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
|
|
13
|
+
* it did not repeat (older ones the reader has scrolled to). What was removed elsewhere from a long, partly loaded list
|
|
14
|
+
* stays until the next real load; nothing here can tell it from a row that merely slid off the first page.
|
|
15
|
+
* - A row in both keeps whichever copy `pick` prefers - by default the fresh one - so, for messages, a row the reader has
|
|
16
|
+
* just changed (a higher `version`) is not put back by a fetch that started before the change landed.
|
|
17
|
+
*/
|
|
18
|
+
export function mergeFirstPage(current, fresh, idOf, pageSize, pick = (_current, next) => next) {
|
|
19
|
+
const currentById = new Map(current.map((row) => [idOf(row), row]));
|
|
20
|
+
const added = fresh.filter((row) => !currentById.has(idOf(row))).length;
|
|
21
|
+
const rows = fresh.map((row) => {
|
|
22
|
+
const existing = currentById.get(idOf(row));
|
|
23
|
+
return existing ? pick(existing, row) : row;
|
|
24
|
+
});
|
|
25
|
+
if (fresh.length < pageSize) {
|
|
26
|
+
return { rows, added, complete: true };
|
|
27
|
+
}
|
|
28
|
+
const freshIds = new Set(fresh.map(idOf));
|
|
29
|
+
return { rows: [...rows, ...current.filter((row) => !freshIds.has(idOf(row)))], added, complete: false };
|
|
30
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Folder, Mailbox } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
2
|
+
import type { MailboxFolders } from "../components/mail/layout/MailShell.js";
|
|
3
|
+
/** How often the safety-net poll runs while the tab is visible. The push socket does the real work; this is for a network
|
|
4
|
+
* that blocks or silently drops it, and for events published while it was reconnecting (Redis pub/sub is never replayed). */
|
|
5
|
+
export declare const LIVE_POLL_INTERVAL_MS = 45000;
|
|
6
|
+
/** Events arriving this close together are answered with one refresh - a burst of new mail is one refetch, not one each. */
|
|
7
|
+
export declare const LIVE_EVENT_DEBOUNCE_MS = 400;
|
|
8
|
+
/** What changed, as far as a list on screen is concerned. */
|
|
9
|
+
export interface LiveUpdates {
|
|
10
|
+
/** Bumped each time the list should quietly refetch its first page. `0` until the first bump. */
|
|
11
|
+
tick: number;
|
|
12
|
+
/** The folders the events behind the latest bump touched - `null` when that isn't known (a poll, a reconnect, the tab
|
|
13
|
+
* coming back to the front), meaning any folder may have changed. */
|
|
14
|
+
folderUids: ReadonlySet<string> | null;
|
|
15
|
+
}
|
|
16
|
+
export declare const NO_LIVE_UPDATES: LiveUpdates;
|
|
17
|
+
/**
|
|
18
|
+
* The channels to subscribe to, most important first (the push client keeps the first `PUSH_MAX_CHANNELS`): every
|
|
19
|
+
* mailbox's Inbox - where mail lands - then each mailbox's other folders in the sidebar's own order, then the mailbox uids
|
|
20
|
+
* themselves (which announce new folders). A folder is a channel of its own; subscribing to a mailbox does not deliver its
|
|
21
|
+
* folders' events.
|
|
22
|
+
*/
|
|
23
|
+
export declare function pushChannelsFor(mailboxFolders: MailboxFolders[], mailboxes: Mailbox[]): string[];
|
|
24
|
+
export interface UseMailLiveUpdatesOptions {
|
|
25
|
+
/** Nothing runs without a signed-in user. */
|
|
26
|
+
userUid?: string;
|
|
27
|
+
mailboxes: Mailbox[];
|
|
28
|
+
mailboxFolders: MailboxFolders[];
|
|
29
|
+
/** Called with a folder another client (or this one) created, so the sidebar shows it. Called only for one not already known. */
|
|
30
|
+
onFolderCreated: (folder: Folder) => void;
|
|
31
|
+
}
|
|
32
|
+
export interface MailLiveUpdates {
|
|
33
|
+
live: LiveUpdates;
|
|
34
|
+
/** The latest unread count of each folder that has been refreshed since load, by folder uid - what the sidebar's badges show
|
|
35
|
+
* instead of the count the folder list was loaded with. */
|
|
36
|
+
unreadCounts: Record<string, number>;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Keeps Mail current without a page reload. One shared push connection per tab (`getPushClient()`) is subscribed to every
|
|
40
|
+
* folder of every accessible mailbox; a message event for any of them, a reconnect, a poll of the safety net (every
|
|
41
|
+
* `LIVE_POLL_INTERVAL_MS` while the tab is visible - and the only mechanism, silently, where the socket can't connect), the
|
|
42
|
+
* tab coming back to the front or the browser coming back online, all end in the same debounced refresh: `live` is bumped
|
|
43
|
+
* (the list on screen refetches its first page - see `mergeFirstPage()`) and every folder's unread count is re-read.
|
|
44
|
+
*
|
|
45
|
+
* Sign-out closes the socket for good: another tab's or this one's, heard on the same channel `AppShell` listens on.
|
|
46
|
+
* Renders nothing and does nothing where there is no window (server-side rendering).
|
|
47
|
+
*/
|
|
48
|
+
export declare function useMailLiveUpdates({ userUid, mailboxes, mailboxFolders, onFolderCreated }: UseMailLiveUpdatesOptions): MailLiveUpdates;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
6
|
+
import { listFolders } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
7
|
+
import { getPushClient } from "@rapidmx/react-shared/mail/pushClient.js";
|
|
8
|
+
import { SIGN_OUT_CHANNEL } from "../search/localIndexRpcClient.js";
|
|
9
|
+
/** How often the safety-net poll runs while the tab is visible. The push socket does the real work; this is for a network
|
|
10
|
+
* that blocks or silently drops it, and for events published while it was reconnecting (Redis pub/sub is never replayed). */
|
|
11
|
+
export const LIVE_POLL_INTERVAL_MS = 45000;
|
|
12
|
+
/** Events arriving this close together are answered with one refresh - a burst of new mail is one refetch, not one each. */
|
|
13
|
+
export const LIVE_EVENT_DEBOUNCE_MS = 400;
|
|
14
|
+
export const NO_LIVE_UPDATES = { tick: 0, folderUids: null };
|
|
15
|
+
/** The class names `NotificationUtils.sendMessage()` publishes are the model's (`MessageMongo`, `MessageSQL`, `FolderMongo`). */
|
|
16
|
+
const MESSAGE_EVENT = /^Message/;
|
|
17
|
+
const FOLDER_EVENT = /^Folder/;
|
|
18
|
+
const MESSAGE_ACTIONS = new Set(["create", "update", "delete"]);
|
|
19
|
+
/**
|
|
20
|
+
* The channels to subscribe to, most important first (the push client keeps the first `PUSH_MAX_CHANNELS`): every
|
|
21
|
+
* mailbox's Inbox - where mail lands - then each mailbox's other folders in the sidebar's own order, then the mailbox uids
|
|
22
|
+
* themselves (which announce new folders). A folder is a channel of its own; subscribing to a mailbox does not deliver its
|
|
23
|
+
* folders' events.
|
|
24
|
+
*/
|
|
25
|
+
export function pushChannelsFor(mailboxFolders, mailboxes) {
|
|
26
|
+
const inboxes = mailboxFolders.flatMap((entry) => entry.folders.filter((folder) => folder.type === "inbox"));
|
|
27
|
+
const others = mailboxFolders.flatMap((entry) => entry.folders.filter((folder) => folder.type !== "inbox"));
|
|
28
|
+
return [...new Set([...inboxes, ...others].map((folder) => folder.uid).concat(mailboxes.map((mailbox) => mailbox.uid)))];
|
|
29
|
+
}
|
|
30
|
+
function isFolder(value) {
|
|
31
|
+
const folder = value;
|
|
32
|
+
return !!folder && typeof folder.uid === "string" && typeof folder.mailboxUid === "string" && typeof folder.type === "string";
|
|
33
|
+
}
|
|
34
|
+
/** The folder a message event is about: the message's own `folderUid`, else the channel it arrived on (when the server said). */
|
|
35
|
+
function folderOfMessageEvent(event) {
|
|
36
|
+
const data = event.data;
|
|
37
|
+
if (typeof data?.folderUid === "string") {
|
|
38
|
+
return data.folderUid;
|
|
39
|
+
}
|
|
40
|
+
return event.channel;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Keeps Mail current without a page reload. One shared push connection per tab (`getPushClient()`) is subscribed to every
|
|
44
|
+
* folder of every accessible mailbox; a message event for any of them, a reconnect, a poll of the safety net (every
|
|
45
|
+
* `LIVE_POLL_INTERVAL_MS` while the tab is visible - and the only mechanism, silently, where the socket can't connect), the
|
|
46
|
+
* tab coming back to the front or the browser coming back online, all end in the same debounced refresh: `live` is bumped
|
|
47
|
+
* (the list on screen refetches its first page - see `mergeFirstPage()`) and every folder's unread count is re-read.
|
|
48
|
+
*
|
|
49
|
+
* Sign-out closes the socket for good: another tab's or this one's, heard on the same channel `AppShell` listens on.
|
|
50
|
+
* Renders nothing and does nothing where there is no window (server-side rendering).
|
|
51
|
+
*/
|
|
52
|
+
export function useMailLiveUpdates({ userUid, mailboxes, mailboxFolders, onFolderCreated }) {
|
|
53
|
+
const [live, setLive] = useState(NO_LIVE_UPDATES);
|
|
54
|
+
const [unreadCounts, setUnreadCounts] = useState({});
|
|
55
|
+
// The latest inputs, for the long-lived listeners below.
|
|
56
|
+
const latestRef = useRef({ mailboxes, mailboxFolders, onFolderCreated });
|
|
57
|
+
latestRef.current = { mailboxes, mailboxFolders, onFolderCreated };
|
|
58
|
+
const channels = useMemo(() => pushChannelsFor(mailboxFolders, mailboxes), [mailboxFolders, mailboxes]);
|
|
59
|
+
const channelKey = channels.join("|");
|
|
60
|
+
const channelsRef = useRef(channels);
|
|
61
|
+
channelsRef.current = channels;
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!userUid) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const client = getPushClient();
|
|
67
|
+
let stopped = false;
|
|
68
|
+
let timer;
|
|
69
|
+
let pendingFolders = new Set();
|
|
70
|
+
let pendingUnknown = false;
|
|
71
|
+
let countsRun = 0;
|
|
72
|
+
let everOpen = false;
|
|
73
|
+
async function refreshCounts() {
|
|
74
|
+
const run = ++countsRun;
|
|
75
|
+
const results = await Promise.all(latestRef.current.mailboxes.map((mailbox) => listFolders(mailbox.uid).catch(() => undefined)));
|
|
76
|
+
if (stopped || run !== countsRun) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const fresh = {};
|
|
80
|
+
for (const folders of results) {
|
|
81
|
+
for (const folder of folders ?? []) {
|
|
82
|
+
fresh[folder.uid] = folder.unreadCount;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
setUnreadCounts((previous) => {
|
|
86
|
+
const changed = Object.entries(fresh).some(([uid, count]) => previous[uid] !== count);
|
|
87
|
+
return changed ? { ...previous, ...fresh } : previous;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
// Never runs once `stopped`: whatever sets it clears the timer first.
|
|
91
|
+
function flush() {
|
|
92
|
+
timer = undefined;
|
|
93
|
+
const folderUids = pendingUnknown ? null : new Set(pendingFolders);
|
|
94
|
+
pendingFolders = new Set();
|
|
95
|
+
pendingUnknown = false;
|
|
96
|
+
setLive((previous) => ({ tick: previous.tick + 1, folderUids }));
|
|
97
|
+
void refreshCounts();
|
|
98
|
+
}
|
|
99
|
+
/** Asks for a refresh: of the given folders, or (no argument) of whatever may have changed. */
|
|
100
|
+
function schedule(folderUid) {
|
|
101
|
+
if (stopped) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (folderUid) {
|
|
105
|
+
pendingFolders.add(folderUid);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
pendingUnknown = true;
|
|
109
|
+
}
|
|
110
|
+
timer ?? (timer = setTimeout(flush, LIVE_EVENT_DEBOUNCE_MS));
|
|
111
|
+
}
|
|
112
|
+
const offEvent = client.onEvent((event) => {
|
|
113
|
+
if (MESSAGE_EVENT.test(event.type) && MESSAGE_ACTIONS.has(event.action ?? "")) {
|
|
114
|
+
schedule(folderOfMessageEvent(event));
|
|
115
|
+
}
|
|
116
|
+
else if (FOLDER_EVENT.test(event.type) && event.action === "create" && isFolder(event.data)) {
|
|
117
|
+
const folder = event.data;
|
|
118
|
+
const known = latestRef.current.mailboxFolders.some((entry) => entry.folders.some((f) => f.uid === folder.uid));
|
|
119
|
+
if (!known) {
|
|
120
|
+
latestRef.current.onFolderCreated(folder);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
// Anything published while the socket was down is gone for good, so every reconnect is followed by a refresh.
|
|
125
|
+
const offStatus = client.onStatus((status) => {
|
|
126
|
+
if (status === "open") {
|
|
127
|
+
if (everOpen) {
|
|
128
|
+
schedule();
|
|
129
|
+
}
|
|
130
|
+
everOpen = true;
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
const poll = setInterval(() => {
|
|
134
|
+
if (document.visibilityState === "visible") {
|
|
135
|
+
schedule();
|
|
136
|
+
}
|
|
137
|
+
}, LIVE_POLL_INTERVAL_MS);
|
|
138
|
+
function refreshNow() {
|
|
139
|
+
if (document.visibilityState === "visible") {
|
|
140
|
+
client.reconnectNow();
|
|
141
|
+
schedule();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function handleOnline() {
|
|
145
|
+
client.reconnectNow();
|
|
146
|
+
schedule();
|
|
147
|
+
}
|
|
148
|
+
document.addEventListener("visibilitychange", refreshNow);
|
|
149
|
+
window.addEventListener("focus", refreshNow);
|
|
150
|
+
window.addEventListener("online", handleOnline);
|
|
151
|
+
// A sign-out, in this tab or another, ended the session behind the socket: close it for good and stop refreshing.
|
|
152
|
+
const signOut = typeof BroadcastChannel === "undefined" ? undefined : new BroadcastChannel(SIGN_OUT_CHANNEL);
|
|
153
|
+
signOut?.addEventListener("message", (event) => {
|
|
154
|
+
if (event.data?.type === "sign-out") {
|
|
155
|
+
stopped = true;
|
|
156
|
+
clearTimeout(timer);
|
|
157
|
+
client.close();
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
client.setChannels(channelsRef.current);
|
|
161
|
+
client.start();
|
|
162
|
+
return () => {
|
|
163
|
+
stopped = true;
|
|
164
|
+
clearTimeout(timer);
|
|
165
|
+
clearInterval(poll);
|
|
166
|
+
offEvent();
|
|
167
|
+
offStatus();
|
|
168
|
+
document.removeEventListener("visibilitychange", refreshNow);
|
|
169
|
+
window.removeEventListener("focus", refreshNow);
|
|
170
|
+
window.removeEventListener("online", handleOnline);
|
|
171
|
+
signOut?.close();
|
|
172
|
+
};
|
|
173
|
+
}, [userUid]);
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
if (userUid) {
|
|
176
|
+
getPushClient().setChannels(channels);
|
|
177
|
+
}
|
|
178
|
+
}, [userUid, channelKey]);
|
|
179
|
+
return { live, unreadCounts };
|
|
180
|
+
}
|