@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 { 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
|
}
|
|
@@ -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
|
+
}
|
|
@@ -23,6 +23,8 @@ import { KEY_CHANGE_STALE_MESSAGE, sameFingerprint } from "../contacts/contactKe
|
|
|
23
23
|
import { useCompose } from "./compose/ComposeContext.js";
|
|
24
24
|
import { loadOriginalMessage } from "./compose/quotedBody.js";
|
|
25
25
|
import { formatRecipient } from "./compose/recipients.js";
|
|
26
|
+
import { formatMailAddress } from "@rapidmx/react-shared/mail/mailAddress.js";
|
|
27
|
+
import { RecipientLine } from "./MailAddress.js";
|
|
26
28
|
import { useMailShell } from "./layout/MailShell.js";
|
|
27
29
|
import { useUnlockPrompt } from "../layout/UnlockPromptProvider.js";
|
|
28
30
|
import { moveLocalEntity } from "../../search/localIndexRpcClient.js";
|
|
@@ -627,8 +629,8 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
|
|
|
627
629
|
const senderAddress = (signatureShown ? extractAddresses(security.protectedHeaders?.from)[0] : undefined) ?? message.from.address;
|
|
628
630
|
const senderName = message.from.displayName;
|
|
629
631
|
const senderNameCheck = checkSenderName(senderName, senderAddress);
|
|
630
|
-
|
|
631
|
-
const senderLabel =
|
|
632
|
+
// Always `Name <address>` (or the bare address): a name alone hides who a message is really from.
|
|
633
|
+
const senderLabel = formatMailAddress({ displayName: senderName, address: senderAddress });
|
|
632
634
|
// Offered only for a valid signature from a certificate nobody pinned for this sender - never to replace a pin.
|
|
633
635
|
// A recorded signing-key conflict for this sender is resolved from the contact, never by trusting another key.
|
|
634
636
|
const pendingConflict = senderUnpinned && senderKeyState?.conflict !== undefined;
|
|
@@ -668,7 +670,7 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
|
|
|
668
670
|
source: recordedConflict?.source,
|
|
669
671
|
}, certificate: keyChanged.signerCertificate, canReject: recordedConflict !== undefined, canResolve: canUpdateMailbox, onResolved: (action) => refreshAfterKeyChange(action === "reject" ? KEPT_CURRENT_SIGNING_KEY_MESSAGE : null), onPinnedKeyChanged: () => refreshAfterKeyChange(KEY_CHANGE_STALE_MESSAGE) })] })), security?.state === "signature_failed" && !keyChanged && (_jsx("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: security.signatureFailureReason
|
|
670
672
|
? SIGNATURE_FAILURE_MESSAGE[security.signatureFailureReason]
|
|
671
|
-
: GENERIC_SIGNATURE_FAILURE_MESSAGE })), verifiedOrSealed && !security.protectedHeaders && (_jsx("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: "The signature covers this message\u2019s content and attachments only. Its Subject, To and Cc weren\u2019t signed, so they could have been changed after it was sent." })), security?.notAddressedToReader && (_jsx("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: "The recipients this message was signed or encrypted for don\u2019t include this mailbox - it may have been forwarded or re-sent to you unchanged, or you were Bcc\u2019d." })), _jsxs("p", { className: "text-sm text-text-muted mt-1", children: ["From ", senderLabel, " \u00B7 ", new Date(message.receivedDate).toLocaleString()] }), senderNameCheck.misleading && (_jsxs("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: ["The sender\u2019s name \u201C", senderName, "\u201D looks like an email address, but this message was sent from ", _jsx("span", { className: "font-medium", children: senderAddress }), ". Don\u2019t trust it based on the name."] })),
|
|
673
|
+
: GENERIC_SIGNATURE_FAILURE_MESSAGE })), verifiedOrSealed && !security.protectedHeaders && (_jsx("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: "The signature covers this message\u2019s content and attachments only. Its Subject, To and Cc weren\u2019t signed, so they could have been changed after it was sent." })), security?.notAddressedToReader && (_jsx("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: "The recipients this message was signed or encrypted for don\u2019t include this mailbox - it may have been forwarded or re-sent to you unchanged, or you were Bcc\u2019d." })), _jsxs("p", { className: "text-sm text-text-muted mt-1 break-words", children: ["From ", _jsx("span", { className: "font-medium text-text", children: senderLabel }), " \u00B7", " ", new Date(message.receivedDate).toLocaleString()] }), senderNameCheck.misleading && (_jsxs("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: ["The sender\u2019s name \u201C", senderName, "\u201D looks like an email address, but this message was sent from ", _jsx("span", { className: "font-medium", children: senderAddress }), ". Don\u2019t trust it based on the name."] })), _jsx(RecipientLine, { label: "To", recipients: message.recipients.filter((r) => r.type !== "cc" && r.type !== "bcc") }), _jsx(RecipientLine, { label: "Cc", recipients: message.recipients.filter((r) => r.type === "cc") }), _jsx(RecipientLine, { label: "Bcc", recipients: message.recipients.filter((r) => r.type === "bcc") }), _jsxs("div", { className: "flex flex-wrap gap-1 mt-3", children: [_jsx(IconAction, { icon: _jsx(HiOutlineArrowUturnLeft, { size: 16, "aria-hidden": "true" }), label: "Reply", disabled: preparingCompose, onClick: () => void handleReplyOrForward("reply") }), _jsx(IconAction, { icon: _jsxs("span", { className: "inline-flex items-center", "aria-hidden": "true", children: [_jsx(HiOutlineArrowUturnLeft, { size: 16 }), _jsx(HiOutlineArrowUturnLeft, { size: 16, className: "-ml-2.5" })] }), label: "Reply All", disabled: preparingCompose, onClick: () => void handleReplyOrForward("replyAll") }), _jsx(IconAction, { icon: _jsx(HiOutlineArrowUturnRight, { size: 16, "aria-hidden": "true" }), label: "Forward", disabled: preparingCompose, onClick: () => void handleReplyOrForward("forward") }), !inOutbox && message.folderUid !== draftsFolderUid && (_jsx(IconAction, { icon: _jsx(HiOutlineArchiveBox, { size: 16, "aria-hidden": "true" }), label: "Archive", busy: archiving, disabled: archiving, onClick: handleArchive })), folders && folders.length > 0 && (_jsx(IconAction, { icon: _jsx(HiOutlineFolderArrowDown, { size: 16, "aria-hidden": "true" }), label: "Move to", onClick: () => setMovePrompt(true) })), labels && labels.length > 0 && (_jsx(LabelMenuButton, { "aria-label": "Labels", label: "Labels", className: "border border-border py-1.5", labels: labels, mailboxUid: message.mailboxUid, onLabelCreated: onLabelCreated, applied: currentLabelsMessage.labelUids ?? [], onCommit: (labelUids) => void handleApplyLabels(labelUids), busy: savingLabels, note: "Ticked labels are applied to this message and unticked ones removed.", emptyNote: "This mailbox has no labels yet.", commit: { label: "Apply" }, clear: { label: "Remove all labels" } }))] }), archiveError && (_jsx("div", { className: "mt-2", children: _jsx(Alert, { children: archiveError }) })), labelsError && (_jsx("div", { className: "mt-2", children: _jsx(Alert, { children: labelsError }) })), labels && (currentLabelsMessage.labelUids?.length ?? 0) > 0 && (_jsx("div", { className: "flex flex-wrap items-center gap-1.5 mt-2", children: currentLabelsMessage
|
|
672
674
|
.labelUids.map((uid) => labels.find((l) => l.uid === uid))
|
|
673
675
|
.filter((l) => !!l)
|
|
674
676
|
.map((l) => (_jsxs("span", { className: "inline-flex items-center gap-1.5 text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt", children: [_jsx("span", { className: "w-2 h-2 rounded-full shrink-0", style: { backgroundColor: l.color ?? "#6366f1" } }), l.name] }, l.uid))) })), ["delivery", "read"]
|