@rapidmx/web-client 0.7.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/admin/settings/MailboxPolicyForm.tsx +51 -12
- 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/admin/settings/MailboxPolicyForm.d.ts +5 -1
- package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +19 -3
- 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 { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
|
8
8
|
import { getDnsSetup, getDomain, verifyDomain } from "@rapidmx/react-shared/admin/domainsApi.js";
|
|
9
9
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
10
10
|
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
11
|
+
import CopyButton from "@rapidmx/react-shared/components/buttons/CopyButton.js";
|
|
11
12
|
const RECORD_TYPE_LABELS = {
|
|
12
13
|
ownership: "Ownership (TXT)",
|
|
13
14
|
mx: "MX",
|
|
@@ -15,6 +16,23 @@ const RECORD_TYPE_LABELS = {
|
|
|
15
16
|
dkim: "DKIM",
|
|
16
17
|
dmarc: "DMARC",
|
|
17
18
|
};
|
|
19
|
+
/** How each record is named in a Copy button's accessible name ("Copy value for the SPF record"). */
|
|
20
|
+
const RECORD_TYPE_NAMES = {
|
|
21
|
+
ownership: "ownership TXT",
|
|
22
|
+
mx: "MX",
|
|
23
|
+
spf: "SPF",
|
|
24
|
+
dkim: "DKIM",
|
|
25
|
+
dmarc: "DMARC",
|
|
26
|
+
};
|
|
27
|
+
/** An MX record's recommended value is `"<priority> <mail server>"` - two separate fields in a DNS provider's form. */
|
|
28
|
+
function splitMxValue(value) {
|
|
29
|
+
const match = /^(\d+)\s+(\S+)$/.exec(value.trim());
|
|
30
|
+
return match ? { priority: match[1], server: match[2] } : null;
|
|
31
|
+
}
|
|
32
|
+
/** A value to type into a DNS provider's form, shown in full (long DKIM keys wrap) with a Copy button beside it. */
|
|
33
|
+
function CopyableValue({ value, copyLabel }) {
|
|
34
|
+
return (_jsxs("div", { className: "flex items-start gap-2", children: [_jsx("code", { className: "flex-1 min-w-0 text-xs break-all", children: value }), _jsx(CopyButton, { value: value, label: copyLabel })] }));
|
|
35
|
+
}
|
|
18
36
|
/**
|
|
19
37
|
* A domain's status, its ownership TXT record with "Verify now", and the DNS setup checklist - shared by the domain
|
|
20
38
|
* detail page and the setup wizard.
|
|
@@ -26,7 +44,6 @@ export default function DomainDnsSetup({ uid, onLoaded }) {
|
|
|
26
44
|
const [error, setError] = useState(null);
|
|
27
45
|
const [verifying, setVerifying] = useState(false);
|
|
28
46
|
const [verifyError, setVerifyError] = useState(null);
|
|
29
|
-
const [copied, setCopied] = useState(false);
|
|
30
47
|
function reload(id) {
|
|
31
48
|
setLoading(true);
|
|
32
49
|
setError(null);
|
|
@@ -62,22 +79,23 @@ export default function DomainDnsSetup({ uid, onLoaded }) {
|
|
|
62
79
|
setVerifying(false);
|
|
63
80
|
}
|
|
64
81
|
}
|
|
65
|
-
async function handleCopy(value) {
|
|
66
|
-
try {
|
|
67
|
-
await navigator.clipboard.writeText(value);
|
|
68
|
-
setCopied(true);
|
|
69
|
-
setTimeout(() => setCopied(false), 2000);
|
|
70
|
-
}
|
|
71
|
-
catch {
|
|
72
|
-
// Clipboard access can be denied by the browser — the value is still selectable/copyable by hand.
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
82
|
if (loading) {
|
|
76
83
|
return _jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" });
|
|
77
84
|
}
|
|
78
85
|
if (error || !domain) {
|
|
79
86
|
return _jsx(Alert, { children: error ?? "Domain not found." });
|
|
80
87
|
}
|
|
81
|
-
const
|
|
82
|
-
|
|
88
|
+
const ownership = dnsSetup.find((c) => c.type === "ownership");
|
|
89
|
+
const ownershipValue = ownership?.recommendedValue ?? `rapidmx-domain-verification=${domain.verificationToken}`;
|
|
90
|
+
const ownershipName = ownership?.recordName || domain.name;
|
|
91
|
+
return (_jsxs("div", { className: "flex flex-col gap-5", children: [_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsxs("dl", { className: "grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 text-sm", children: [_jsx("dt", { className: "text-text-muted", children: "Enabled" }), _jsx("dd", { children: domain.enabled ? "Yes" : "No" }), _jsx("dt", { className: "text-text-muted", children: "Verification" }), _jsx("dd", { children: domain.verified ? (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-success text-white", children: "Verified" })) : (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted", children: "Unverified" })) }), _jsx("dt", { className: "text-text-muted", children: "Last checked" }), _jsx("dd", { children: domain.lastCheckedAt ? new Date(domain.lastCheckedAt).toLocaleString() : "Never" })] }), !domain.verified && (_jsxs("div", { className: "mt-5 pt-5 border-t border-border", children: [_jsxs("p", { className: "text-sm mb-2", children: ["Add the following TXT record to ", _jsx("strong", { children: domain.name }), " to prove ownership, then verify:"] }), _jsxs("dl", { className: "grid grid-cols-[auto_1fr] items-start gap-x-4 gap-y-2 text-sm", children: [_jsx("dt", { className: "text-text-muted py-2", children: "Type" }), _jsx("dd", { className: "py-2", children: "TXT" }), _jsx("dt", { className: "text-text-muted py-2", children: "Name / host" }), _jsx("dd", { className: "bg-surface-alt border border-border rounded-sm py-1.5 px-3", children: _jsx(CopyableValue, { value: ownershipName, copyLabel: "Copy name for the ownership TXT record" }) }), _jsx("dt", { className: "text-text-muted py-2", children: "Value" }), _jsx("dd", { className: "bg-surface-alt border border-border rounded-sm py-1.5 px-3", children: _jsx(CopyableValue, { value: ownershipValue, copyLabel: "Copy value for the ownership TXT record" }) })] }), verifyError && (_jsx("div", { className: "mt-3", children: _jsx(Alert, { children: verifyError }) })), _jsx(Button, { type: "button", className: "!w-auto mt-3", loading: verifying, disabled: verifying, onClick: handleVerify, children: verifyError ? "Try again" : "Verify now" })] }))] }), dnsSetup.length > 0 && (_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-sm font-bold uppercase tracking-wide mb-3", children: "DNS setup checklist" }), _jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsx("tr", { children: ["Record", "Status", "Name / host", "Recommended value"].map((h) => (_jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: h }, h))) }) }), _jsx("tbody", { children: dnsSetup.map((check) => (_jsxs("tr", { children: [_jsxs("td", { className: "py-2.5 px-2.5 border-b border-border", children: [RECORD_TYPE_LABELS[check.type], _jsxs("div", { className: "text-xs text-text-muted", children: ["Type: ", check.recordKind] })] }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: !check.configured ? (_jsx("span", { className: "text-xs text-text-muted", children: "Not configured" })) : check.matches ? (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-success text-white", children: "Live" })) : (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted", children: "Not found" })) }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border text-text-muted", children: check.recordName ? (_jsx(CopyableValue, { value: check.recordName, copyLabel: `Copy name for the ${RECORD_TYPE_NAMES[check.type]} record` })) : (_jsx("code", { className: "text-xs", children: "\u2014" })) }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border text-text-muted", children: check.recommendedValue ? (_jsx(DnsRecordValue, { check: check, value: check.recommendedValue })) : (_jsx("code", { className: "text-xs", children: "\u2014" })) })] }, check.type))) })] }) }), _jsxs("p", { className: "text-xs text-text-muted mt-3", children: ["Some DNS providers want the name relative to the domain (for example ", _jsx("code", { children: "@" }), " for the domain itself) rather than the full name."] })] }))] }));
|
|
92
|
+
}
|
|
93
|
+
/** The value cell of a checklist row: an MX value as its two form fields (priority and mail server), anything else whole. */
|
|
94
|
+
function DnsRecordValue({ check, value }) {
|
|
95
|
+
const recordName = RECORD_TYPE_NAMES[check.type];
|
|
96
|
+
const mx = check.type === "mx" ? splitMxValue(value) : null;
|
|
97
|
+
if (!mx) {
|
|
98
|
+
return _jsx(CopyableValue, { value: value, copyLabel: `Copy value for the ${recordName} record` });
|
|
99
|
+
}
|
|
100
|
+
return (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsxs("div", { className: "flex items-start gap-2", children: [_jsx("span", { className: "text-xs w-20 shrink-0 pt-1", children: "Priority" }), _jsx(CopyableValue, { value: mx.priority, copyLabel: `Copy priority for the ${recordName} record` })] }), _jsxs("div", { className: "flex items-start gap-2", children: [_jsx("span", { className: "text-xs w-20 shrink-0 pt-1", children: "Mail server" }), _jsx(CopyableValue, { value: mx.server, copyLabel: `Copy mail server for the ${recordName} record` })] })] }));
|
|
83
101
|
}
|
|
@@ -9,5 +9,9 @@ export interface MailboxPolicyFormProps {
|
|
|
9
9
|
* lower-level heading. */
|
|
10
10
|
embedded?: boolean;
|
|
11
11
|
}
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* The mailbox defaults editor, shared by the Mailbox Policy page and the setup wizard. Each field can be reset to what
|
|
14
|
+
* the server's config currently says (`policy.defaults`), which lets an administrator take a newly deployed value
|
|
15
|
+
* without typing it in; the reset fills the field in and is saved with the rest of the form.
|
|
16
|
+
*/
|
|
13
17
|
export default function MailboxPolicyForm({ policy, onChange, onDirtyChange, embedded }: MailboxPolicyFormProps): React.JSX.Element;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx,
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
///////////////////////////////////////////////////////////////////////////////
|
|
3
3
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
@@ -14,8 +14,24 @@ const GB = 1000000000;
|
|
|
14
14
|
function toGb(bytes) {
|
|
15
15
|
return String(bytes / GB);
|
|
16
16
|
}
|
|
17
|
-
/**
|
|
17
|
+
/** Whether a quota field's text is not the given number of bytes. */
|
|
18
|
+
function differsFromBytes(text, bytes) {
|
|
19
|
+
return Math.round(Number(text) * GB) !== bytes;
|
|
20
|
+
}
|
|
21
|
+
/** Puts a field back to the server's config value; nothing is saved until the form is. */
|
|
22
|
+
function ResetToDefault({ label, shown, onClick }) {
|
|
23
|
+
if (!shown) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
return (_jsxs(Button, { type: "button", variant: "text", className: "!w-auto self-start text-xs -mt-2", onClick: onClick, children: ["Reset to server default (", label, ")"] }));
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The mailbox defaults editor, shared by the Mailbox Policy page and the setup wizard. Each field can be reset to what
|
|
30
|
+
* the server's config currently says (`policy.defaults`), which lets an administrator take a newly deployed value
|
|
31
|
+
* without typing it in; the reset fills the field in and is saved with the rest of the form.
|
|
32
|
+
*/
|
|
18
33
|
export default function MailboxPolicyForm({ policy, onChange, onDirtyChange, embedded = false }) {
|
|
34
|
+
const defaults = policy.defaults;
|
|
19
35
|
const [defaultQuotaGb, setDefaultQuotaGb] = useState(toGb(policy.defaultQuotaBytes));
|
|
20
36
|
const [autoProvisionEnabled, setAutoProvisionEnabled] = useState(policy.autoProvisionEnabled);
|
|
21
37
|
const [autoProvisionQuotaGb, setAutoProvisionQuotaGb] = useState(toGb(policy.autoProvisionQuotaBytes));
|
|
@@ -70,5 +86,5 @@ export default function MailboxPolicyForm({ policy, onChange, onDirtyChange, emb
|
|
|
70
86
|
setSaving(false);
|
|
71
87
|
}
|
|
72
88
|
}
|
|
73
|
-
return (_jsxs("div", { className: "max-w-xl", children: [embedded ? (_jsx("h3", { className: "text-base font-bold uppercase tracking-wide mb-1", children: "Mailboxes" })) : (_jsx("h2", { className: "text-lg font-bold uppercase tracking-wide mb-1", children: "Mailboxes" })), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "Defaults for new mailboxes on this server." }), error && _jsx(Alert, { children: error }), saved && !error && _jsx("div", { className: "mb-4 text-sm text-success font-medium", children: "Saved." }), _jsxs("form", { onSubmit: handleSubmit, className: "flex flex-col gap-4", children: [_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Default quota (GB)" }), _jsx("input", { "aria-label": "Default quota (GB)", type: "number", min: 0, step: "any", className: INPUT_CLASS, value: defaultQuotaGb, onChange: (e) => setDefaultQuotaGb(e.target.value) }), _jsx("span", { className: "text-xs text-text-muted", children: "The storage a mailbox starts with when an administrator creates it." })] }), _jsxs("label", { className: "flex items-start gap-2 text-sm", children: [_jsx("input", { type: "checkbox", className: "mt-1", checked: autoProvisionEnabled, onChange: (e) => setAutoProvisionEnabled(e.target.checked) }), _jsxs("span", { children: [_jsx("span", { className: "font-semibold block", children: "Let people create their own mailbox" }), _jsx("span", { className: "text-xs text-text-muted", children: "Someone who signs in without a mailbox can create one, using their username on one of this server\u2019s verified domains." })] })] }), autoProvisionEnabled && (_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Quota for self-created mailboxes (GB)" }), _jsx("input", { "aria-label": "Quota for self-created mailboxes (GB)", type: "number", min: 0, step: "any", className: INPUT_CLASS, value: autoProvisionQuotaGb, onChange: (e) => setAutoProvisionQuotaGb(e.target.value) })] })), _jsx("div", { children: _jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save" }) })] })] }));
|
|
89
|
+
return (_jsxs("div", { className: "max-w-xl", children: [embedded ? (_jsx("h3", { className: "text-base font-bold uppercase tracking-wide mb-1", children: "Mailboxes" })) : (_jsx("h2", { className: "text-lg font-bold uppercase tracking-wide mb-1", children: "Mailboxes" })), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "Defaults for new mailboxes on this server." }), error && _jsx(Alert, { children: error }), saved && !error && _jsx("div", { className: "mb-4 text-sm text-success font-medium", children: "Saved." }), _jsxs("form", { onSubmit: handleSubmit, className: "flex flex-col gap-4", children: [_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Default quota (GB)" }), _jsx("input", { "aria-label": "Default quota (GB)", type: "number", min: 0, step: "any", className: INPUT_CLASS, value: defaultQuotaGb, onChange: (e) => setDefaultQuotaGb(e.target.value) }), _jsx("span", { className: "text-xs text-text-muted", children: "The storage a mailbox starts with when an administrator creates it." })] }), _jsx(ResetToDefault, { label: defaults ? `${toGb(defaults.defaultQuotaBytes)} GB` : "", shown: !!defaults && differsFromBytes(defaultQuotaGb, defaults.defaultQuotaBytes), onClick: () => setDefaultQuotaGb(toGb(defaults.defaultQuotaBytes)) }), _jsxs("label", { className: "flex items-start gap-2 text-sm", children: [_jsx("input", { type: "checkbox", className: "mt-1", checked: autoProvisionEnabled, onChange: (e) => setAutoProvisionEnabled(e.target.checked) }), _jsxs("span", { children: [_jsx("span", { className: "font-semibold block", children: "Let people create their own mailbox" }), _jsx("span", { className: "text-xs text-text-muted", children: "Someone who signs in without a mailbox can create one, using their username on one of this server\u2019s verified domains." })] })] }), _jsx(ResetToDefault, { label: defaults?.autoProvisionEnabled ? "on" : "off", shown: !!defaults && autoProvisionEnabled !== defaults.autoProvisionEnabled, onClick: () => setAutoProvisionEnabled(defaults.autoProvisionEnabled) }), autoProvisionEnabled && (_jsxs(_Fragment, { children: [_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Quota for self-created mailboxes (GB)" }), _jsx("input", { "aria-label": "Quota for self-created mailboxes (GB)", type: "number", min: 0, step: "any", className: INPUT_CLASS, value: autoProvisionQuotaGb, onChange: (e) => setAutoProvisionQuotaGb(e.target.value) })] }), _jsx(ResetToDefault, { label: defaults ? `${toGb(defaults.autoProvisionQuotaBytes)} GB` : "", shown: !!defaults && differsFromBytes(autoProvisionQuotaGb, defaults.autoProvisionQuotaBytes), onClick: () => setAutoProvisionQuotaGb(toGb(defaults.autoProvisionQuotaBytes)) })] })), _jsx("div", { children: _jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save" }) })] })] }));
|
|
74
90
|
}
|
|
@@ -7,8 +7,9 @@ import { Branding } from "@rapidmx/react-shared/branding/brandingApi.js";
|
|
|
7
7
|
*/
|
|
8
8
|
export declare function sanitizeBrandingHtml(html: string): string;
|
|
9
9
|
/**
|
|
10
|
-
* Renders the admin-configured `Branding.headerHtml`/`footerHtml` (see `useBranding()`), sanitized
|
|
11
|
-
*
|
|
10
|
+
* Renders the admin-configured `Branding.headerHtml`/`footerHtml` (see `useBranding()`), sanitized. The webmail
|
|
11
|
+
* chrome (`AppShell`) shows both; the admin console shows only the footer - its header is the console's own - and the
|
|
12
|
+
* escrow console deliberately renders no branding HTML at all.
|
|
12
13
|
*/
|
|
13
14
|
export declare function BrandingHeader({ branding }: {
|
|
14
15
|
branding: Branding | null;
|
|
@@ -42,8 +42,9 @@ function BrandingHtml({ html }) {
|
|
|
42
42
|
return _jsx("div", { dangerouslySetInnerHTML: { __html: clean } });
|
|
43
43
|
}
|
|
44
44
|
/**
|
|
45
|
-
* Renders the admin-configured `Branding.headerHtml`/`footerHtml` (see `useBranding()`), sanitized
|
|
46
|
-
*
|
|
45
|
+
* Renders the admin-configured `Branding.headerHtml`/`footerHtml` (see `useBranding()`), sanitized. The webmail
|
|
46
|
+
* chrome (`AppShell`) shows both; the admin console shows only the footer - its header is the console's own - and the
|
|
47
|
+
* escrow console deliberately renders no branding HTML at all.
|
|
47
48
|
*/
|
|
48
49
|
export function BrandingHeader({ branding }) {
|
|
49
50
|
return _jsx(BrandingHtml, { html: branding?.headerHtml });
|
|
@@ -7,7 +7,10 @@ import React from "react";
|
|
|
7
7
|
* auto-provisioning — see `BaseMailboxRoute.autoProvision()`'s own doc comment in `@rapidmx/restapi`
|
|
8
8
|
* for the full contract — which is itself a no-op (a 404) unless an admin has both turned it on
|
|
9
9
|
* (`mail:auto_provision:enabled`) and configured at least one domain (`mail:domains`). Safe to render
|
|
10
|
-
* unconditionally in that place:
|
|
11
|
-
*
|
|
10
|
+
* unconditionally in that place: a failure ends in one of two screens. A 502 (the identity service couldn't be
|
|
11
|
+
* reached) or 503 (the server couldn't read its provisioning policy) is transient and offers "Retry". Anything
|
|
12
|
+
* else is "No mailbox available - ask an administrator", now with the server's own reason above that advice when it
|
|
13
|
+
* refused deliberately (a 4xx, e.g. "Automatic mailbox provisioning is not enabled.") so the caller and the
|
|
14
|
+
* administrator they ask can tell why - see `unavailableReason()`.
|
|
12
15
|
*/
|
|
13
16
|
export default function MailboxProvisioning(): React.JSX.Element;
|
|
@@ -8,6 +8,25 @@ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
|
8
8
|
import { autoProvisionMailbox } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
9
9
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
10
10
|
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
11
|
+
/** What the "Retry" screen says: the server couldn't read its provisioning policy (503) ... */
|
|
12
|
+
const RETRY_POLICY_TEXT = "We couldn\u2019t check whether a mailbox can be set up for you. Please try again.";
|
|
13
|
+
/** ... or couldn't reach the identity service (auth-server) it looks the caller's username up in (502). */
|
|
14
|
+
const RETRY_IDENTITY_TEXT = "We couldn\u2019t reach the identity service to set up your mailbox. Please try again.";
|
|
15
|
+
/** The longest server message shown as the reason a mailbox isn't available - a real reason is a sentence. */
|
|
16
|
+
const MAX_REASON_LENGTH = 200;
|
|
17
|
+
/**
|
|
18
|
+
* The reason to show under "No mailbox available": the server's own message, for a refusal it made on purpose - a
|
|
19
|
+
* 4xx such as 404 "Automatic mailbox provisioning is not enabled." or 404 "No username is registered for this account."
|
|
20
|
+
* Never for a 5xx (whose message is whatever went wrong inside the server) or a failure that never reached it (a network
|
|
21
|
+
* error's message is browser jargon), and only its first line, capped in length. `null` when there's nothing safe to show.
|
|
22
|
+
*/
|
|
23
|
+
function unavailableReason(err) {
|
|
24
|
+
if (!(err instanceof ApiRequestError) || err.status < 400 || err.status >= 500) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
const line = err.message.split(/\r?\n/)[0].trim();
|
|
28
|
+
return line ? line.slice(0, MAX_REASON_LENGTH) : null;
|
|
29
|
+
}
|
|
11
30
|
/**
|
|
12
31
|
* Rendered by each app's shell (Mail/Calendar/Contacts/Tasks) *instead of* the normal `AppShell`
|
|
13
32
|
* chrome (icon rail, header, folder tree, ...) when the caller has no mailbox — a full-screen,
|
|
@@ -16,14 +35,20 @@ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
|
16
35
|
* auto-provisioning — see `BaseMailboxRoute.autoProvision()`'s own doc comment in `@rapidmx/restapi`
|
|
17
36
|
* for the full contract — which is itself a no-op (a 404) unless an admin has both turned it on
|
|
18
37
|
* (`mail:auto_provision:enabled`) and configured at least one domain (`mail:domains`). Safe to render
|
|
19
|
-
* unconditionally in that place:
|
|
20
|
-
*
|
|
38
|
+
* unconditionally in that place: a failure ends in one of two screens. A 502 (the identity service couldn't be
|
|
39
|
+
* reached) or 503 (the server couldn't read its provisioning policy) is transient and offers "Retry". Anything
|
|
40
|
+
* else is "No mailbox available - ask an administrator", now with the server's own reason above that advice when it
|
|
41
|
+
* refused deliberately (a 4xx, e.g. "Automatic mailbox provisioning is not enabled.") so the caller and the
|
|
42
|
+
* administrator they ask can tell why - see `unavailableReason()`.
|
|
21
43
|
*/
|
|
22
44
|
export default function MailboxProvisioning() {
|
|
23
45
|
const [status, setStatus] = useState("checking");
|
|
24
46
|
const [options, setOptions] = useState([]);
|
|
25
47
|
const [selected, setSelected] = useState("");
|
|
26
48
|
const [error, setError] = useState(null);
|
|
49
|
+
// Why the caller has no mailbox (see `unavailableReason()`), and what the retry screen says.
|
|
50
|
+
const [reason, setReason] = useState(null);
|
|
51
|
+
const [retryText, setRetryText] = useState(RETRY_POLICY_TEXT);
|
|
27
52
|
// Bumped by "Retry" to re-run the check below.
|
|
28
53
|
const [attempt, setAttempt] = useState(0);
|
|
29
54
|
useEffect(() => {
|
|
@@ -45,9 +70,19 @@ export default function MailboxProvisioning() {
|
|
|
45
70
|
window.location.reload();
|
|
46
71
|
}
|
|
47
72
|
})
|
|
48
|
-
// A 503 means the server couldn't read its own provisioning policy right now
|
|
49
|
-
// mailbox to be had - so
|
|
50
|
-
|
|
73
|
+
// A 503 means the server couldn't read its own provisioning policy right now, and a 502 that it couldn't
|
|
74
|
+
// reach the identity service - neither says there's no mailbox to be had - so they get a retry instead of
|
|
75
|
+
// the permanent "ask an administrator" message.
|
|
76
|
+
.catch((err) => {
|
|
77
|
+
if (err instanceof ApiRequestError && (err.status === 503 || err.status === 502)) {
|
|
78
|
+
setRetryText(err.status === 502 ? RETRY_IDENTITY_TEXT : RETRY_POLICY_TEXT);
|
|
79
|
+
setStatus("retryable");
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
setReason(unavailableReason(err));
|
|
83
|
+
setStatus("unavailable");
|
|
84
|
+
}
|
|
85
|
+
});
|
|
51
86
|
}, [attempt]);
|
|
52
87
|
async function handleConfirm() {
|
|
53
88
|
// `selected` only ever takes a value from `options` itself (the initial default, or the
|
|
@@ -74,10 +109,10 @@ export default function MailboxProvisioning() {
|
|
|
74
109
|
content = (_jsxs("div", { className: "w-full flex flex-col gap-3", children: [_jsx("p", { className: "text-sm text-text-muted text-center", children: "Choose your mailbox address to get started:" }), error && _jsx(Alert, { children: error }), _jsx("select", { "aria-label": "Mailbox address", className: "text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: selected, onChange: (e) => setSelected(e.target.value), disabled: creating, children: options.map((option) => (_jsx("option", { value: option.primarySmtpAddress, children: option.primarySmtpAddress }, option.primarySmtpAddress))) }), _jsx(Button, { type: "button", onClick: handleConfirm, loading: creating, disabled: creating, className: "!w-auto self-center", children: "Continue" })] }));
|
|
75
110
|
}
|
|
76
111
|
else if (status === "retryable") {
|
|
77
|
-
content = (_jsxs(_Fragment, { children: [_jsx("h1", { className: "text-lg font-bold uppercase tracking-wide", children: "Couldn\u2019t check right now" }), _jsx("p", { className: "text-sm text-text-muted", children:
|
|
112
|
+
content = (_jsxs(_Fragment, { children: [_jsx("h1", { className: "text-lg font-bold uppercase tracking-wide", children: "Couldn\u2019t check right now" }), _jsx("p", { className: "text-sm text-text-muted", children: retryText }), _jsx(Button, { type: "button", onClick: () => setAttempt((n) => n + 1), className: "!w-auto self-center", children: "Retry" })] }));
|
|
78
113
|
}
|
|
79
114
|
else {
|
|
80
|
-
content = (_jsxs(_Fragment, { children: [_jsx("h1", { className: "text-lg font-bold uppercase tracking-wide", children: "No mailbox available" }), _jsx("p", { className: "text-sm text-text-muted", children: "Ask an administrator to create one for you." })] }));
|
|
115
|
+
content = (_jsxs(_Fragment, { children: [_jsx("h1", { className: "text-lg font-bold uppercase tracking-wide", children: "No mailbox available" }), reason && _jsx("p", { className: "text-sm", children: reason }), _jsx("p", { className: "text-sm text-text-muted", children: "Ask an administrator to create one for you." })] }));
|
|
81
116
|
}
|
|
82
117
|
return (_jsx("div", { className: "min-h-screen flex items-center justify-center p-8 bg-surface-alt", children: _jsxs("div", { className: "w-full max-w-md bg-surface border border-border rounded-md p-8 flex flex-col items-center gap-4 text-center", children: [_jsx("img", { src: "/images/wordmark.png", height: "128", alt: "" }), content] }) }));
|
|
83
118
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
export interface UserMenuProps {
|
|
3
3
|
userUid: string;
|
|
4
|
-
/** auth-server's base URL — where the caller's own profile (name/avatar)
|
|
4
|
+
/** auth-server's base URL — where the caller's own profile (name/avatar) and username are fetched from, and what
|
|
5
|
+
* the "Account" item links into, if configured. Without it the menu shows the bare uid and has no "Account" item. */
|
|
5
6
|
authServerUrl?: string;
|
|
6
7
|
onSignOut: () => void;
|
|
7
8
|
/** Shows an "Admin" item linking to `/admin`, above "Sign Out" — pass only for a trusted-role caller. */
|
|
@@ -13,11 +14,13 @@ export interface UserMenuProps {
|
|
|
13
14
|
showSettingsLink?: boolean;
|
|
14
15
|
}
|
|
15
16
|
/**
|
|
16
|
-
* The top-right "who am I" menu shared by
|
|
17
|
-
* dropdown showing the caller's avatar/name, an
|
|
18
|
-
* "Sign Out".
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
17
|
+
* The top-right "who am I" menu shared by every shell (`AppShell`, `AdminShell`, `EscrowShell`): an avatar-button
|
|
18
|
+
* trigger that opens a dropdown showing the caller's avatar/name, an "Account" link to auth-server's account page,
|
|
19
|
+
* an optional "Settings" and "Admin" link, and "Sign Out". This service has no local user directory (see
|
|
20
|
+
* `.claude/NOTES.md`), so the name and avatar come from auth-server, and the displayed name (and the initials badge)
|
|
21
|
+
* falls back down a chain: the profile's name (`GET /api/profiles/me`), else the caller's username - their first
|
|
22
|
+
* verified `name` alias (`GET /api/aliases?type=name`, asked for only when the profile gave no name) - else the bare
|
|
23
|
+
* uid. Either lookup can fail (auth-server's CORS list not including this origin, or no profile document at all - a
|
|
24
|
+
* 404 for some accounts) and neither ever surfaces as an error. The avatar image comes from the profile alone.
|
|
22
25
|
*/
|
|
23
26
|
export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminLink, showSettingsLink }: UserMenuProps): React.JSX.Element;
|
|
@@ -4,7 +4,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
6
|
import { useEffect, useRef, useState } from "react";
|
|
7
|
-
import { formatProfileName, getMyProfile, profileInitials } from "@rapidmx/react-shared/auth/profileApi.js";
|
|
7
|
+
import { formatProfileName, getMyProfile, getMyUsername, profileInitials } from "@rapidmx/react-shared/auth/profileApi.js";
|
|
8
8
|
function Avatar({ profile, initials, large }) {
|
|
9
9
|
const sizeClass = large ? "w-10 h-10 text-base" : "w-8 h-8 text-sm";
|
|
10
10
|
if (profile?.avatar) {
|
|
@@ -13,24 +13,48 @@ function Avatar({ profile, initials, large }) {
|
|
|
13
13
|
return (_jsx("span", { className: `${sizeClass} rounded-full bg-primary text-white font-semibold flex items-center justify-center shrink-0`, children: initials }));
|
|
14
14
|
}
|
|
15
15
|
/**
|
|
16
|
-
* The top-right "who am I" menu shared by
|
|
17
|
-
* dropdown showing the caller's avatar/name, an
|
|
18
|
-
* "Sign Out".
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
16
|
+
* The top-right "who am I" menu shared by every shell (`AppShell`, `AdminShell`, `EscrowShell`): an avatar-button
|
|
17
|
+
* trigger that opens a dropdown showing the caller's avatar/name, an "Account" link to auth-server's account page,
|
|
18
|
+
* an optional "Settings" and "Admin" link, and "Sign Out". This service has no local user directory (see
|
|
19
|
+
* `.claude/NOTES.md`), so the name and avatar come from auth-server, and the displayed name (and the initials badge)
|
|
20
|
+
* falls back down a chain: the profile's name (`GET /api/profiles/me`), else the caller's username - their first
|
|
21
|
+
* verified `name` alias (`GET /api/aliases?type=name`, asked for only when the profile gave no name) - else the bare
|
|
22
|
+
* uid. Either lookup can fail (auth-server's CORS list not including this origin, or no profile document at all - a
|
|
23
|
+
* 404 for some accounts) and neither ever surfaces as an error. The avatar image comes from the profile alone.
|
|
22
24
|
*/
|
|
23
25
|
export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminLink, showSettingsLink }) {
|
|
24
26
|
const [open, setOpen] = useState(false);
|
|
25
27
|
const [profile, setProfile] = useState(undefined);
|
|
28
|
+
const [username, setUsername] = useState(undefined);
|
|
26
29
|
const containerRef = useRef(null);
|
|
27
30
|
useEffect(() => {
|
|
28
31
|
if (!authServerUrl) {
|
|
29
32
|
return;
|
|
30
33
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
let cancelled = false;
|
|
35
|
+
void (async () => {
|
|
36
|
+
let loaded;
|
|
37
|
+
try {
|
|
38
|
+
loaded = await getMyProfile(authServerUrl);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// Unreachable, blocked, or no profile document (404) - fall through to the username.
|
|
42
|
+
}
|
|
43
|
+
if (cancelled) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
setProfile(loaded);
|
|
47
|
+
// The username is only the fallback for a missing name - no second request when the profile has one.
|
|
48
|
+
if (!formatProfileName(loaded)) {
|
|
49
|
+
const alias = await getMyUsername(authServerUrl);
|
|
50
|
+
if (!cancelled) {
|
|
51
|
+
setUsername(alias);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
})();
|
|
55
|
+
return () => {
|
|
56
|
+
cancelled = true;
|
|
57
|
+
};
|
|
34
58
|
}, [authServerUrl]);
|
|
35
59
|
useEffect(() => {
|
|
36
60
|
if (!open) {
|
|
@@ -53,7 +77,9 @@ export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminL
|
|
|
53
77
|
document.removeEventListener("keydown", handleKeyDown);
|
|
54
78
|
};
|
|
55
79
|
}, [open]);
|
|
56
|
-
const name = formatProfileName(profile) ?? userUid;
|
|
57
|
-
const initials = profileInitials(profile, userUid);
|
|
58
|
-
|
|
80
|
+
const name = formatProfileName(profile) ?? username ?? userUid;
|
|
81
|
+
const initials = profileInitials(profile, userUid, username);
|
|
82
|
+
// Tolerates a configured URL with a trailing slash, which would otherwise produce `//account`.
|
|
83
|
+
const accountUrl = authServerUrl ? `${authServerUrl.replace(/\/+$/, "")}/account` : undefined;
|
|
84
|
+
return (_jsxs("div", { className: "relative", ref: containerRef, children: [_jsxs("button", { type: "button", onClick: () => setOpen((o) => !o), "aria-haspopup": "true", "aria-expanded": open, "aria-label": "Account menu", className: "flex items-center gap-1.5 rounded-pill py-1 pl-1 pr-2 hover:bg-surface-alt", children: [_jsx(Avatar, { profile: profile, initials: initials }), _jsx("svg", { width: "14", height: "14", viewBox: "0 0 20 20", fill: "none", className: `text-text-muted transition-transform ${open ? "rotate-180" : ""}`, "aria-hidden": "true", children: _jsx("path", { d: "M5 7.5L10 12.5L15 7.5", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round" }) })] }), open && (_jsxs("div", { role: "menu", className: "absolute right-0 top-full mt-2 w-60 bg-surface border border-border rounded-md shadow-modal py-1.5 z-10", children: [_jsxs("div", { className: "flex items-center gap-3 px-3.5 py-2.5 border-b border-border", children: [_jsx(Avatar, { profile: profile, initials: initials, large: true }), _jsx("span", { className: "text-sm font-semibold text-text truncate", children: name })] }), accountUrl && (_jsx("a", { role: "menuitem", href: accountUrl, className: "block px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: "Account" })), showSettingsLink && (_jsx("a", { role: "menuitem", href: "/settings/auto-reply", className: "block px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: "Settings" })), showAdminLink && (_jsx("a", { role: "menuitem", href: "/admin", className: "block px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: "Admin" })), _jsx("button", { role: "menuitem", type: "button", onClick: onSignOut, className: "block w-full text-left px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: "Sign Out" })] }))] }));
|
|
59
85
|
}
|
|
@@ -6,9 +6,12 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
6
6
|
import { useState } from "react";
|
|
7
7
|
import { HiChevronDown, HiChevronRight, HiOutlineFlag, HiOutlinePaperClip } from "react-icons/hi2";
|
|
8
8
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
9
|
+
import { formatMailAddress } from "@rapidmx/react-shared/mail/mailAddress.js";
|
|
10
|
+
import MailAddress from "./MailAddress.js";
|
|
9
11
|
import { listConversationMessages } from "@rapidmx/react-shared/mail/conversationsApi.js";
|
|
10
|
-
|
|
11
|
-
|
|
12
|
+
/** Every participant as `Name <address>`, for the tooltip and the accessible text of the "+N" a crowded row folds them into. */
|
|
13
|
+
function participantList(conversation) {
|
|
14
|
+
return conversation.participants.map((participant) => formatMailAddress(participant)).join(", ");
|
|
12
15
|
}
|
|
13
16
|
/**
|
|
14
17
|
* The nested, Outlook-style conversation list: one parent row per conversation - its participants, subject,
|
|
@@ -79,13 +82,13 @@ export default function ConversationList({ conversations, mailboxUid, selectedUi
|
|
|
79
82
|
conversation.latestMessageUid === selectedUid || ticked
|
|
80
83
|
? "bg-primary/10"
|
|
81
84
|
: "hover:bg-surface-alt",
|
|
82
|
-
].join(" "), children: [selectMode && (_jsx("span", { className: "shrink-0 flex items-center pl-3", children: _jsx("input", { type: "checkbox", checked: ticked, onChange: () => onToggleSelected?.(conversation), "aria-label": `Select conversation: ${conversation.subject || "(no subject)"}`, className: "w-4 h-4 accent-primary" }) })), _jsx("button", { type: "button", onClick: () => toggle(conversation), "aria-expanded": isExpanded, "aria-controls": panelId, "aria-label": `${isExpanded ? "Collapse" : "Expand"} conversation: ${conversation.subject || "(no subject)"}`, className: "shrink-0 px-2 text-text-muted hover:text-text", children: isExpanded ? (_jsx(HiChevronDown, { size: 16, "aria-hidden": "true" })) : (_jsx(HiChevronRight, { size: 16, "aria-hidden": "true" })) }), _jsxs("button", { type: "button", onClick: () => onOpenMessage(conversation, conversation.latestMessageUid), className: ["flex-1 min-w-0 text-left pr-4 py-3", unread ? "font-semibold" : ""].join(" "), children: [_jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx("span", { className: "
|
|
85
|
+
].join(" "), children: [selectMode && (_jsx("span", { className: "shrink-0 flex items-center pl-3", children: _jsx("input", { type: "checkbox", checked: ticked, onChange: () => onToggleSelected?.(conversation), "aria-label": `Select conversation: ${conversation.subject || "(no subject)"}`, className: "w-4 h-4 accent-primary" }) })), _jsx("button", { type: "button", onClick: () => toggle(conversation), "aria-expanded": isExpanded, "aria-controls": panelId, "aria-label": `${isExpanded ? "Collapse" : "Expand"} conversation: ${conversation.subject || "(no subject)"}`, className: "shrink-0 px-2 text-text-muted hover:text-text", children: isExpanded ? (_jsx(HiChevronDown, { size: 16, "aria-hidden": "true" })) : (_jsx(HiChevronRight, { size: 16, "aria-hidden": "true" })) }), _jsxs("button", { type: "button", onClick: () => onOpenMessage(conversation, conversation.latestMessageUid), className: ["flex-1 min-w-0 text-left pr-4 py-3", unread ? "font-semibold" : ""].join(" "), children: [_jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(MailAddress, { recipient: conversation.participants[0] ?? conversation.latestFrom, className: "flex-1" }), conversation.participants.length > 1 && (_jsxs("span", { className: "text-xs text-text-muted shrink-0 font-normal", title: participantList(conversation), children: ["+", conversation.participants.length - 1, _jsxs("span", { className: "sr-only", children: [" more: ", participantList(conversation)] })] })), _jsx("span", { className: "text-xs text-text-muted shrink-0", children: new Date(conversation.latestDate).toLocaleDateString() })] }), _jsx("div", { className: "text-sm truncate", children: conversation.subject || "(no subject)" }), _jsx("div", { className: "text-xs text-text-muted truncate font-normal", children: conversation.latestPreview }), _jsxs("div", { className: "flex items-center gap-2 mt-1 text-xs text-text-muted font-normal", children: [conversation.messageCount > 1 && _jsxs("span", { children: [conversation.messageCount, " messages"] }), unread && conversation.messageCount > 1 && (_jsxs("span", { className: "py-0.5 px-2 rounded-pill bg-primary/10 text-primary-dark font-semibold", children: [conversation.unreadCount, " unread"] })), conversation.hasAttachments && (_jsx(HiOutlinePaperClip, { size: 13, "aria-label": "Has attachments" })), conversation.flagged && (_jsx(HiOutlineFlag, { size: 13, "aria-label": "Flagged", className: "text-danger" }))] })] })] }), _jsxs("ul", { id: panelId, hidden: !isExpanded, children: [errorsById[id] && (_jsx("li", { role: "alert", className: "pl-8 pr-4 py-2 text-xs text-danger border-b border-border", children: errorsById[id] })), loadingIds.has(id) && (_jsx("li", { className: "pl-8 pr-4 py-2 text-xs text-text-muted border-b border-border", children: "Loading messages\u2026" })), children?.map((fetched) => {
|
|
83
86
|
const message = messageOverrides?.[fetched.uid] ?? fetched;
|
|
84
87
|
return (_jsx("li", { children: _jsxs("button", { type: "button", onClick: () => onOpenMessage(conversation, message.uid), className: [
|
|
85
88
|
"w-full text-left pl-8 pr-4 py-2 border-b border-border",
|
|
86
89
|
message.uid === selectedUid ? "bg-primary/10" : "hover:bg-surface-alt",
|
|
87
90
|
message.flags.read ? "" : "font-semibold",
|
|
88
|
-
].join(" "), children: [_jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(
|
|
91
|
+
].join(" "), children: [_jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(MailAddress, { recipient: message.from }), _jsx("span", { className: "text-xs text-text-muted shrink-0", children: new Date(message.receivedDate).toLocaleDateString() })] }), _jsxs("div", { className: "flex items-center gap-2 text-xs text-text-muted font-normal", children: [_jsx("span", { className: "truncate", children: message.bodyPreview }), message.hasAttachments && (_jsx(HiOutlinePaperClip, { size: 12, "aria-label": "Has attachments" })), message.flags.flagged && (_jsx(HiOutlineFlag, { size: 12, "aria-label": "Flagged", className: "text-danger" }))] })] }) }, message.uid));
|
|
89
92
|
})] })] }, id));
|
|
90
93
|
}) }));
|
|
91
94
|
}
|
|
@@ -8,6 +8,7 @@ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
|
8
8
|
import { listAttachments, setMessageRead } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
9
9
|
import { listConversationMessages } from "@rapidmx/react-shared/mail/conversationsApi.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
|
/** One request's worth of the thread. The server's own default for `listConversationMessages()`. */
|
|
13
14
|
export const THREAD_PAGE_SIZE = 100;
|
|
@@ -288,7 +289,6 @@ export default function ConversationThreadPane({ conversation, mailboxUid, selec
|
|
|
288
289
|
const uid = message.uid;
|
|
289
290
|
const expanded = expandedUids.has(uid);
|
|
290
291
|
const bodyId = `thread-message-${uid}`;
|
|
291
|
-
const sender = message.from.displayName || message.from.address;
|
|
292
292
|
return (_jsxs("li", { ref: (node) => {
|
|
293
293
|
rowRefs.current[uid] = node;
|
|
294
294
|
},
|
|
@@ -302,6 +302,6 @@ export default function ConversationThreadPane({ conversation, mailboxUid, selec
|
|
|
302
302
|
}, onClick: () => toggleExpanded(uid), "aria-expanded": expanded, "aria-controls": bodyId, className: [
|
|
303
303
|
"w-full text-left px-4 py-3 hover:bg-surface-alt",
|
|
304
304
|
message.flags.read ? "" : "font-semibold",
|
|
305
|
-
].join(" "), children: [_jsxs("span", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(
|
|
305
|
+
].join(" "), children: [_jsxs("span", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(MailAddress, { recipient: message.from }), _jsx("span", { className: "text-xs text-text-muted shrink-0", children: new Date(message.receivedDate).toLocaleString() })] }), !expanded && (_jsx("span", { className: "block text-xs text-text-muted truncate font-normal", children: message.bodyPreview }))] }) }), _jsx("div", { id: bodyId, hidden: !expanded, className: expanded ? "flex-1 min-h-0 flex" : undefined, children: expanded && (_jsx(MessageDetailPane, { inThread: true, message: message, attachments: attachmentsByUid[uid] ?? [], isSentItems: folderTypeOf(message) === "sent_items", isOutbox: folderTypeOf(message) === "outbox", draftsFolderUid: folders.find((folder) => folder.type === "drafts")?.uid, folders: folders, onMoved: removeMessage, onFolderCreated: onFolderCreated, onRecalled: patchMessage, onReceiptHandled: patchMessage, onScheduledSendCanceled: removeMessage, onArchived: removeMessage, labels: labels, onLabelsChanged: patchMessage, onLabelCreated: onLabelCreated })) })] }, uid));
|
|
306
306
|
}) })] }));
|
|
307
307
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { MailAddressLike } from "@rapidmx/react-shared/mail/mailAddress.js";
|
|
3
|
+
export interface MailAddressProps {
|
|
4
|
+
/** A message's `from`, a recipient, a conversation participant. */
|
|
5
|
+
recipient: MailAddressLike;
|
|
6
|
+
className?: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* A sender or recipient as one compact line for a dense row: the name, when there is one, with the real address in muted
|
|
10
|
+
* type after it - `Jean-Philippe <jp@example.com>` - or the bare address without a name. The address is never left out,
|
|
11
|
+
* and when the row is too narrow for both the name gives way first (it shrinks a thousand times faster), then the
|
|
12
|
+
* address's own local part - the `@domain`, which is what says who really sent it, keeps its room to the end. The full
|
|
13
|
+
* `Name <address>` is the tooltip and, once, the accessible text (the two visible halves are hidden from assistive
|
|
14
|
+
* technology), so a screen reader hears one address, not fragments.
|
|
15
|
+
*/
|
|
16
|
+
export default function MailAddress({ recipient, className }: MailAddressProps): React.JSX.Element;
|
|
17
|
+
/** How many recipients a `RecipientLine` shows before folding the rest behind "and N more". */
|
|
18
|
+
export declare const RECIPIENT_LINE_LIMIT = 3;
|
|
19
|
+
/**
|
|
20
|
+
* A `To`/`Cc` line of a message header: every recipient as `Name <address>` in the text itself (never hover-only), wrapping
|
|
21
|
+
* onto more lines as needed. A long list shows its first `RECIPIENT_LINE_LIMIT` and folds the rest behind a button -
|
|
22
|
+
* "and 12 more" / "Show fewer" - so a message to a hundred people doesn't take the whole header. Nothing when empty.
|
|
23
|
+
*/
|
|
24
|
+
export declare function RecipientLine({ label, recipients }: {
|
|
25
|
+
label: string;
|
|
26
|
+
recipients: MailAddressLike[];
|
|
27
|
+
}): React.JSX.Element | null;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import React, { useState } from "react";
|
|
7
|
+
import { formatMailAddress, splitMailAddress } from "@rapidmx/react-shared/mail/mailAddress.js";
|
|
8
|
+
/**
|
|
9
|
+
* A sender or recipient as one compact line for a dense row: the name, when there is one, with the real address in muted
|
|
10
|
+
* type after it - `Jean-Philippe <jp@example.com>` - or the bare address without a name. The address is never left out,
|
|
11
|
+
* and when the row is too narrow for both the name gives way first (it shrinks a thousand times faster), then the
|
|
12
|
+
* address's own local part - the `@domain`, which is what says who really sent it, keeps its room to the end. The full
|
|
13
|
+
* `Name <address>` is the tooltip and, once, the accessible text (the two visible halves are hidden from assistive
|
|
14
|
+
* technology), so a screen reader hears one address, not fragments.
|
|
15
|
+
*/
|
|
16
|
+
export default function MailAddress({ recipient, className }) {
|
|
17
|
+
const { name, address } = splitMailAddress(recipient);
|
|
18
|
+
const full = formatMailAddress(recipient);
|
|
19
|
+
const at = address.lastIndexOf("@");
|
|
20
|
+
const local = at > 0 ? address.slice(0, at) : address;
|
|
21
|
+
const domain = at > 0 ? address.slice(at) : "";
|
|
22
|
+
return (_jsxs("span", { className: ["flex min-w-0 items-baseline gap-1.5", className].filter(Boolean).join(" "), title: full, children: [_jsx("span", { className: "sr-only", children: full }), name && (_jsx("span", { "aria-hidden": "true", className: "truncate min-w-0", style: { flexShrink: 1000 }, children: name })), _jsxs("span", { "aria-hidden": "true", className: ["flex min-w-0 items-baseline", name ? "text-xs font-normal text-text-muted" : ""].filter(Boolean).join(" "), children: [name && _jsx("span", { children: "<" }), _jsx("span", { className: "truncate min-w-0", children: local }), domain && _jsx("span", { className: "shrink-0", children: domain }), name && _jsx("span", { children: ">" })] })] }));
|
|
23
|
+
}
|
|
24
|
+
/** How many recipients a `RecipientLine` shows before folding the rest behind "and N more". */
|
|
25
|
+
export const RECIPIENT_LINE_LIMIT = 3;
|
|
26
|
+
/**
|
|
27
|
+
* A `To`/`Cc` line of a message header: every recipient as `Name <address>` in the text itself (never hover-only), wrapping
|
|
28
|
+
* onto more lines as needed. A long list shows its first `RECIPIENT_LINE_LIMIT` and folds the rest behind a button -
|
|
29
|
+
* "and 12 more" / "Show fewer" - so a message to a hundred people doesn't take the whole header. Nothing when empty.
|
|
30
|
+
*/
|
|
31
|
+
export function RecipientLine({ label, recipients }) {
|
|
32
|
+
const [expanded, setExpanded] = useState(false);
|
|
33
|
+
if (recipients.length === 0) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const folded = recipients.length > RECIPIENT_LINE_LIMIT;
|
|
37
|
+
const shown = folded && !expanded ? recipients.slice(0, RECIPIENT_LINE_LIMIT) : recipients;
|
|
38
|
+
return (_jsxs("p", { className: "text-sm text-text-muted break-words", children: [_jsx("span", { children: label }), " ", shown.map((recipient, index) => (_jsxs(React.Fragment, { children: [index > 0 && ", ", _jsx("span", { children: formatMailAddress(recipient) })] }, index))), folded && (_jsxs(_Fragment, { children: [" ", _jsx("button", { type: "button", "aria-expanded": expanded, onClick: () => setExpanded((open) => !open), className: "text-xs font-semibold text-primary-dark underline hover:no-underline", children: expanded ? "Show fewer" : `and ${recipients.length - RECIPIENT_LINE_LIMIT} more` })] }))] }));
|
|
39
|
+
}
|