human hand off bug
This commit is contained in:
parent
223f846782
commit
6e7a0b9539
22 changed files with 1040 additions and 336 deletions
|
|
@ -20,6 +20,7 @@ import {
|
|||
listMyDomains,
|
||||
verifyMyDomainDns,
|
||||
} from "../api";
|
||||
import { formatRelativeTime, formatShortDate } from "../utils/datetime";
|
||||
|
||||
const ADD_STEPS = [
|
||||
{ id: 1, title: "Add domain" },
|
||||
|
|
@ -57,18 +58,6 @@ const DNS_GROUPS = [
|
|||
},
|
||||
];
|
||||
|
||||
function formatRelativeTime(date) {
|
||||
if (!date) return null;
|
||||
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||
if (seconds < 60) return "just now";
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days} day${days === 1 ? "" : "s"} ago`;
|
||||
}
|
||||
|
||||
async function copyText(value) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
|
|
@ -419,7 +408,7 @@ function DomainDetail({
|
|||
<dl className="company-domain-meta">
|
||||
<div><dt>Status</dt><dd>{domain.status === "active" ? "Active" : "Pending DNS"}</dd></div>
|
||||
<div><dt>DKIM selector</dt><dd><code>{domain.dkim_selector}</code></dd></div>
|
||||
<div><dt>Added</dt><dd>{new Date(domain.created_at).toLocaleDateString()}</dd></div>
|
||||
<div><dt>Added</dt><dd>{formatShortDate(domain.created_at)}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
listMailGroups,
|
||||
listOrgMembers,
|
||||
} from "../api";
|
||||
import { formatRelativeTime } from "../utils/datetime";
|
||||
|
||||
const TYPE_TABS = [
|
||||
{ key: "all", label: "All groups" },
|
||||
|
|
@ -49,17 +50,6 @@ const SEND_PERMISSIONS = [
|
|||
|
||||
const EMAIL_LOCAL_PATTERN = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/;
|
||||
|
||||
function formatRelativeTime(dateValue) {
|
||||
const date = new Date(dateValue);
|
||||
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||
if (seconds < 3600) return `${Math.max(1, Math.floor(seconds / 60))}m ago`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
||||
const days = Math.floor(seconds / 86400);
|
||||
if (days < 7) return `${days}d ago`;
|
||||
const weeks = Math.floor(days / 7);
|
||||
return `${weeks}w ago`;
|
||||
}
|
||||
|
||||
function groupInitial(name) {
|
||||
return (name?.trim()?.[0] || "G").toUpperCase();
|
||||
}
|
||||
|
|
@ -594,7 +584,7 @@ export default function CompanyGroups({ account }) {
|
|||
{group.member_count}
|
||||
</span>
|
||||
</td>
|
||||
<td>{formatRelativeTime(group.created_at)}</td>
|
||||
<td>{formatRelativeTime(group.created_at, { compact: true })}</td>
|
||||
<td>
|
||||
<button type="button" className="people-row-menu" aria-label="More actions">
|
||||
<MoreHorizontal size={16} />
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
verifyMyDomainDns,
|
||||
} from "../api";
|
||||
import { getOrgDetails } from "./CompanyOverviewSetup";
|
||||
import { formatRelativeTime, parseApiDate } from "../utils/datetime";
|
||||
|
||||
const HEALTH_ITEMS = [
|
||||
{ purpose: "ownership", label: "Domain verified" },
|
||||
|
|
@ -29,16 +30,6 @@ const HEALTH_ITEMS = [
|
|||
{ purpose: "dmarc", label: "DMARC" },
|
||||
];
|
||||
|
||||
function formatRelativeTime(dateValue) {
|
||||
const date = new Date(dateValue);
|
||||
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||
if (seconds < 3600) return `${Math.max(1, Math.floor(seconds / 60))}m ago`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
||||
const days = Math.floor(seconds / 86400);
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return `${Math.floor(days / 7)}w ago`;
|
||||
}
|
||||
|
||||
function companyTitle(account, details, domainName) {
|
||||
if (details?.companyName?.trim()) return details.companyName.trim();
|
||||
if (domainName) {
|
||||
|
|
@ -147,7 +138,7 @@ export default function CompanyOverview({ account, companyDomain, onNavigate })
|
|||
})),
|
||||
];
|
||||
return events
|
||||
.sort((left, right) => new Date(right.time) - new Date(left.time))
|
||||
.sort((left, right) => (parseApiDate(right.time)?.getTime() || 0) - (parseApiDate(left.time)?.getTime() || 0))
|
||||
.slice(0, 5);
|
||||
}, [groups.items, members.items]);
|
||||
|
||||
|
|
@ -243,7 +234,7 @@ export default function CompanyOverview({ account, companyDomain, onNavigate })
|
|||
{dnsError
|
||||
? dnsError
|
||||
: lastChecked
|
||||
? `Last checked ${formatRelativeTime(lastChecked)}`
|
||||
? `Last checked ${formatRelativeTime(lastChecked, { compact: true })}`
|
||||
: "Not checked yet"}
|
||||
</span>
|
||||
<button type="button" onClick={loadDnsHealth} disabled={dnsLoading || !primaryDomain}>
|
||||
|
|
@ -274,7 +265,7 @@ export default function CompanyOverview({ account, companyDomain, onNavigate })
|
|||
</span>
|
||||
<div>
|
||||
<p>{item.text}</p>
|
||||
<time>{formatRelativeTime(item.time)}</time>
|
||||
<time>{formatRelativeTime(item.time, { compact: true })}</time>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -28,13 +28,14 @@ import {
|
|||
RemoveFormatting,
|
||||
IndentIncrease,
|
||||
IndentDecrease,
|
||||
Type,
|
||||
} from "lucide-react";
|
||||
import AgentSpark from "./AgentSpark";
|
||||
import RecipientField, { recipientsToPayload } from "./compose/RecipientField";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { listMailAliases, listOrgMembers, sendEmail } from "../api";
|
||||
import { clearDraft, draftStorageKey } from "../utils/composeDraft";
|
||||
import { plainTextFromEmailHtml } from "../utils/emailReply";
|
||||
import { escapeHtml, htmlForOutboundEmail, plainTextFromEmailHtml, sanitizeEmailHtml } from "../utils/emailReply";
|
||||
|
||||
const EMOJI_SAMPLES = ["😀", "😊", "👍", "🙏", "✅", "🎉", "📎", "📅", "💡", "🔥", "❤️", "😅"];
|
||||
|
||||
|
|
@ -115,6 +116,37 @@ function MenuDropdown({
|
|||
);
|
||||
}
|
||||
|
||||
function normalizeLinkUrl(value) {
|
||||
const trimmed = (value || "").trim();
|
||||
if (!trimmed) return "";
|
||||
if (/^(https?:|mailto:)/i.test(trimmed)) return trimmed;
|
||||
if (trimmed.startsWith("/") || trimmed.startsWith("#")) return trimmed;
|
||||
return `https://${trimmed}`;
|
||||
}
|
||||
|
||||
function selectionInside(editor) {
|
||||
const selection = window.getSelection();
|
||||
if (!editor || !selection || selection.rangeCount === 0) return null;
|
||||
const range = selection.getRangeAt(0);
|
||||
if (!editor.contains(range.commonAncestorContainer)) return null;
|
||||
return { selection, range };
|
||||
}
|
||||
|
||||
function FormatButton({ title, active, onClick, children }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
className={active ? "active" : ""}
|
||||
aria-pressed={Boolean(active)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SendSplitButton({ onSend, onSchedule, disabled, iconStroke = 1.7 }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef(null);
|
||||
|
|
@ -179,6 +211,8 @@ export default function ComposeModal({
|
|||
const imageInputRef = useRef(null);
|
||||
const draftTimerRef = useRef(null);
|
||||
const draftHydratedRef = useRef(false);
|
||||
const savedRangeRef = useRef(null);
|
||||
const linkPopoverRef = useRef(null);
|
||||
|
||||
const [showCc, setShowCc] = useState(false);
|
||||
const [showBcc, setShowBcc] = useState(false);
|
||||
|
|
@ -195,6 +229,8 @@ export default function ComposeModal({
|
|||
const [notice, setNotice] = useState("");
|
||||
const [warnings, setWarnings] = useState([]);
|
||||
const [showEmoji, setShowEmoji] = useState(false);
|
||||
const [linkDialog, setLinkDialog] = useState(null);
|
||||
const [formatState, setFormatState] = useState({});
|
||||
const [showQuoted, setShowQuoted] = useState(false);
|
||||
const [status, setStatus] = useState({ sending: false, error: "" });
|
||||
const [replyToMessageId, setReplyToMessageId] = useState(null);
|
||||
|
|
@ -303,6 +339,26 @@ export default function ComposeModal({
|
|||
setWarnings(detectWarnings({ bodyHtml, to, cc, bcc, attachments, orgDomain }));
|
||||
}, [bodyHtml, to, cc, bcc, attachments, orgDomain]);
|
||||
|
||||
useEffect(() => {
|
||||
function onSelectionChange() {
|
||||
const current = selectionInside(editorRef.current);
|
||||
if (!current) return;
|
||||
savedRangeRef.current = current.range.cloneRange();
|
||||
updateFormatState();
|
||||
}
|
||||
document.addEventListener("selectionchange", onSelectionChange);
|
||||
return () => document.removeEventListener("selectionchange", onSelectionChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!linkDialog) return undefined;
|
||||
function onPointerDown(event) {
|
||||
if (!linkPopoverRef.current?.contains(event.target)) setLinkDialog(null);
|
||||
}
|
||||
document.addEventListener("mousedown", onPointerDown);
|
||||
return () => document.removeEventListener("mousedown", onPointerDown);
|
||||
}, [linkDialog]);
|
||||
|
||||
function flashNotice(message) {
|
||||
setNotice(message);
|
||||
window.setTimeout(() => setNotice(""), 2800);
|
||||
|
|
@ -310,24 +366,95 @@ export default function ComposeModal({
|
|||
|
||||
function syncBodyFromEditor() {
|
||||
const html = editorRef.current?.innerHTML || "";
|
||||
setBodyHtml(html === "<br>" ? "" : html);
|
||||
setBodyHtml(html === "<br>" || html === "<div><br></div>" ? "" : html);
|
||||
}
|
||||
|
||||
function saveSelection() {
|
||||
const current = selectionInside(editorRef.current);
|
||||
savedRangeRef.current = current ? current.range.cloneRange() : savedRangeRef.current;
|
||||
return savedRangeRef.current;
|
||||
}
|
||||
|
||||
function restoreSelection() {
|
||||
const editor = editorRef.current;
|
||||
const range = savedRangeRef.current;
|
||||
if (!editor) return false;
|
||||
editor.focus();
|
||||
if (!range) return false;
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
return true;
|
||||
}
|
||||
|
||||
function updateFormatState() {
|
||||
try {
|
||||
setFormatState({
|
||||
bold: document.queryCommandState("bold"),
|
||||
italic: document.queryCommandState("italic"),
|
||||
underline: document.queryCommandState("underline"),
|
||||
strikeThrough: document.queryCommandState("strikeThrough"),
|
||||
justifyLeft: document.queryCommandState("justifyLeft"),
|
||||
justifyCenter: document.queryCommandState("justifyCenter"),
|
||||
justifyRight: document.queryCommandState("justifyRight"),
|
||||
insertUnorderedList: document.queryCommandState("insertUnorderedList"),
|
||||
insertOrderedList: document.queryCommandState("insertOrderedList"),
|
||||
});
|
||||
} catch {
|
||||
/* queryCommandState can throw in some browsers */
|
||||
}
|
||||
}
|
||||
|
||||
function execFormat(command, value = null) {
|
||||
editorRef.current?.focus();
|
||||
restoreSelection();
|
||||
document.execCommand("defaultParagraphSeparator", false, "div");
|
||||
document.execCommand(command, false, value);
|
||||
saveSelection();
|
||||
syncBodyFromEditor();
|
||||
updateFormatState();
|
||||
}
|
||||
|
||||
function insertLink() {
|
||||
const url = window.prompt("Enter link URL", "https://");
|
||||
function openLinkDialog() {
|
||||
restoreSelection();
|
||||
saveSelection();
|
||||
const current = selectionInside(editorRef.current);
|
||||
const selectedText = current?.selection.toString() || "";
|
||||
let url = "";
|
||||
const node = current?.range.startContainer;
|
||||
const element = node?.nodeType === Node.ELEMENT_NODE ? node : node?.parentElement;
|
||||
const anchor = element?.closest?.("a");
|
||||
if (anchor) url = anchor.getAttribute("href") || "";
|
||||
setShowEmoji(false);
|
||||
setLinkDialog({ text: selectedText, url });
|
||||
}
|
||||
|
||||
function applyLink(event) {
|
||||
event?.preventDefault();
|
||||
if (!linkDialog) return;
|
||||
const url = normalizeLinkUrl(linkDialog.url);
|
||||
if (!url) return;
|
||||
execFormat("createLink", url);
|
||||
const label = (linkDialog.text || "").trim() || url;
|
||||
restoreSelection();
|
||||
const current = selectionInside(editorRef.current);
|
||||
const selectedText = current?.selection.toString() || "";
|
||||
if (selectedText && selectedText === label) {
|
||||
document.execCommand("createLink", false, url);
|
||||
} else {
|
||||
document.execCommand(
|
||||
"insertHTML",
|
||||
false,
|
||||
`<a href="${escapeHtml(url)}">${escapeHtml(label)}</a>`,
|
||||
);
|
||||
}
|
||||
saveSelection();
|
||||
syncBodyFromEditor();
|
||||
setLinkDialog(null);
|
||||
}
|
||||
|
||||
function insertEmoji(emoji) {
|
||||
editorRef.current?.focus();
|
||||
restoreSelection();
|
||||
document.execCommand("insertText", false, emoji);
|
||||
saveSelection();
|
||||
syncBodyFromEditor();
|
||||
setShowEmoji(false);
|
||||
}
|
||||
|
|
@ -336,9 +463,10 @@ export default function ComposeModal({
|
|||
if (!signature.html) return;
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
editor.focus();
|
||||
restoreSelection();
|
||||
const block = `${editor.innerHTML ? "<br>" : ""}${signature.html}`;
|
||||
document.execCommand("insertHTML", false, block);
|
||||
saveSelection();
|
||||
syncBodyFromEditor();
|
||||
flashNotice(`Inserted signature: ${signature.label}`);
|
||||
}
|
||||
|
|
@ -386,11 +514,25 @@ export default function ComposeModal({
|
|||
onDiscard?.();
|
||||
}
|
||||
|
||||
function handleEditorPaste(event) {
|
||||
const html = event.clipboardData?.getData("text/html");
|
||||
const text = event.clipboardData?.getData("text/plain");
|
||||
if (!html && !text) return;
|
||||
event.preventDefault();
|
||||
if (html) document.execCommand("insertHTML", false, sanitizeEmailHtml(html));
|
||||
else document.execCommand("insertText", false, text);
|
||||
syncBodyFromEditor();
|
||||
}
|
||||
|
||||
const submitSend = useCallback(async () => {
|
||||
if (!to.length) {
|
||||
setStatus({ sending: false, error: "Add at least one recipient." });
|
||||
return;
|
||||
}
|
||||
const rawHtml = editorRef.current?.innerHTML || bodyHtml;
|
||||
const outboundHtml = htmlForOutboundEmail(rawHtml);
|
||||
const outboundText = plainTextFromHtml(outboundHtml || rawHtml);
|
||||
setBodyHtml(rawHtml === "<br>" || rawHtml === "<div><br></div>" ? "" : rawHtml);
|
||||
setStatus({ sending: true, error: "" });
|
||||
try {
|
||||
const payload = {
|
||||
|
|
@ -398,8 +540,8 @@ export default function ComposeModal({
|
|||
cc: recipientsToPayload(cc),
|
||||
bcc: recipientsToPayload(bcc),
|
||||
subject,
|
||||
body_text: plainTextFromHtml(bodyHtml),
|
||||
body_html: bodyHtml || null,
|
||||
body_text: outboundText,
|
||||
body_html: outboundHtml || null,
|
||||
};
|
||||
if (replyToMessageId) {
|
||||
payload.reply_to_message_id = replyToMessageId;
|
||||
|
|
@ -419,12 +561,22 @@ export default function ComposeModal({
|
|||
const mod = event.metaKey || event.ctrlKey;
|
||||
if (!isFocused) return;
|
||||
if (mod && event.key === "Enter" && !status.sending) {
|
||||
if (linkDialog) {
|
||||
event.preventDefault();
|
||||
applyLink(event);
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
submitSend();
|
||||
}
|
||||
if (event.key === "Escape" && linkDialog) {
|
||||
event.preventDefault();
|
||||
setLinkDialog(null);
|
||||
return;
|
||||
}
|
||||
if (mod && event.key === "k") {
|
||||
event.preventDefault();
|
||||
insertLink();
|
||||
openLinkDialog();
|
||||
}
|
||||
if (mod && event.shiftKey && event.key.toLowerCase() === "c") {
|
||||
event.preventDefault();
|
||||
|
|
@ -437,7 +589,7 @@ export default function ComposeModal({
|
|||
}
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [status.sending, submitSend, isFocused]);
|
||||
}, [status.sending, submitSend, isFocused, linkDialog]);
|
||||
|
||||
function popOut() {
|
||||
const url = `${window.location.origin}${window.location.pathname}?popup=compose&compose=${windowId}`;
|
||||
|
|
@ -595,7 +747,14 @@ export default function ComposeModal({
|
|||
role="textbox"
|
||||
aria-multiline="true"
|
||||
data-placeholder="Write your message…"
|
||||
onInput={syncBodyFromEditor}
|
||||
onFocus={() => document.execCommand("defaultParagraphSeparator", false, "div")}
|
||||
onMouseUp={saveSelection}
|
||||
onKeyUp={saveSelection}
|
||||
onInput={() => {
|
||||
syncBodyFromEditor();
|
||||
updateFormatState();
|
||||
}}
|
||||
onPaste={handleEditorPaste}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
|
|
@ -614,6 +773,37 @@ export default function ComposeModal({
|
|||
</button>
|
||||
)}
|
||||
|
||||
{linkDialog && (
|
||||
<form
|
||||
className="compose-link-popover"
|
||||
ref={linkPopoverRef}
|
||||
role="dialog"
|
||||
aria-label="Insert link"
|
||||
onSubmit={applyLink}
|
||||
>
|
||||
<label className="compose-link-row">
|
||||
<Type size={16} strokeWidth={1.7} />
|
||||
<input
|
||||
value={linkDialog.text}
|
||||
onChange={(event) => setLinkDialog((current) => ({ ...current, text: event.target.value }))}
|
||||
placeholder="Text"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<label className="compose-link-row">
|
||||
<Link size={16} strokeWidth={1.7} />
|
||||
<input
|
||||
value={linkDialog.url}
|
||||
onChange={(event) => setLinkDialog((current) => ({ ...current, url: event.target.value }))}
|
||||
placeholder="Type or paste a link"
|
||||
/>
|
||||
<button type="submit" className="compose-link-apply" disabled={!linkDialog.url.trim()}>
|
||||
Apply
|
||||
</button>
|
||||
</label>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="compose-bottom-stack">
|
||||
<div className="compose-agent-bar compose-agent-bar-disabled" title="AG Agent drafting — coming soon">
|
||||
<AgentSpark />
|
||||
|
|
@ -629,29 +819,29 @@ export default function ComposeModal({
|
|||
|
||||
<div className="compose-format-bar" aria-label="Formatting">
|
||||
<div className="compose-format-group">
|
||||
<button type="button" title="Bold" onClick={() => execFormat("bold")}><Bold size={14} strokeWidth={iconStroke} /></button>
|
||||
<button type="button" title="Italic" onClick={() => execFormat("italic")}><Italic size={14} strokeWidth={iconStroke} /></button>
|
||||
<button type="button" title="Underline" onClick={() => execFormat("underline")}><Underline size={14} strokeWidth={iconStroke} /></button>
|
||||
<button type="button" title="Strikethrough" onClick={() => execFormat("strikeThrough")}><Strikethrough size={14} strokeWidth={iconStroke} /></button>
|
||||
<FormatButton title="Bold" active={formatState.bold} onClick={() => execFormat("bold")}><Bold size={14} strokeWidth={iconStroke} /></FormatButton>
|
||||
<FormatButton title="Italic" active={formatState.italic} onClick={() => execFormat("italic")}><Italic size={14} strokeWidth={iconStroke} /></FormatButton>
|
||||
<FormatButton title="Underline" active={formatState.underline} onClick={() => execFormat("underline")}><Underline size={14} strokeWidth={iconStroke} /></FormatButton>
|
||||
<FormatButton title="Strikethrough" active={formatState.strikeThrough} onClick={() => execFormat("strikeThrough")}><Strikethrough size={14} strokeWidth={iconStroke} /></FormatButton>
|
||||
</div>
|
||||
<span className="compose-format-divider" />
|
||||
<div className="compose-format-group compose-format-align">
|
||||
<button type="button" title="Align left" onClick={() => execFormat("justifyLeft")}><AlignLeft size={14} strokeWidth={iconStroke} /></button>
|
||||
<button type="button" title="Align center" onClick={() => execFormat("justifyCenter")}><AlignCenter size={14} strokeWidth={iconStroke} /></button>
|
||||
<button type="button" title="Align right" onClick={() => execFormat("justifyRight")}><AlignRight size={14} strokeWidth={iconStroke} /></button>
|
||||
<FormatButton title="Align left" active={formatState.justifyLeft} onClick={() => execFormat("justifyLeft")}><AlignLeft size={14} strokeWidth={iconStroke} /></FormatButton>
|
||||
<FormatButton title="Align center" active={formatState.justifyCenter} onClick={() => execFormat("justifyCenter")}><AlignCenter size={14} strokeWidth={iconStroke} /></FormatButton>
|
||||
<FormatButton title="Align right" active={formatState.justifyRight} onClick={() => execFormat("justifyRight")}><AlignRight size={14} strokeWidth={iconStroke} /></FormatButton>
|
||||
</div>
|
||||
<span className="compose-format-divider" />
|
||||
<div className="compose-format-group">
|
||||
<button type="button" title="Bulleted list" onClick={() => execFormat("insertUnorderedList")}><List size={14} strokeWidth={iconStroke} /></button>
|
||||
<button type="button" title="Numbered list" onClick={() => execFormat("insertOrderedList")}><ListOrdered size={14} strokeWidth={iconStroke} /></button>
|
||||
<FormatButton title="Bulleted list" active={formatState.insertUnorderedList} onClick={() => execFormat("insertUnorderedList")}><List size={14} strokeWidth={iconStroke} /></FormatButton>
|
||||
<FormatButton title="Numbered list" active={formatState.insertOrderedList} onClick={() => execFormat("insertOrderedList")}><ListOrdered size={14} strokeWidth={iconStroke} /></FormatButton>
|
||||
</div>
|
||||
<span className="compose-format-divider compose-format-divider-advanced" />
|
||||
<div className="compose-format-group compose-format-advanced">
|
||||
<button type="button" title="Indent" onClick={() => execFormat("indent")}><IndentIncrease size={14} strokeWidth={iconStroke} /></button>
|
||||
<button type="button" title="Outdent" onClick={() => execFormat("outdent")}><IndentDecrease size={14} strokeWidth={iconStroke} /></button>
|
||||
<button type="button" title="Quote" onClick={() => execFormat("formatBlock", "blockquote")}><Quote size={14} strokeWidth={iconStroke} /></button>
|
||||
<button type="button" title="Link" onClick={insertLink}><Link size={14} strokeWidth={iconStroke} /></button>
|
||||
<button type="button" title="Remove formatting" onClick={() => execFormat("removeFormat")}><RemoveFormatting size={14} strokeWidth={iconStroke} /></button>
|
||||
<FormatButton title="Indent" onClick={() => execFormat("indent")}><IndentIncrease size={14} strokeWidth={iconStroke} /></FormatButton>
|
||||
<FormatButton title="Outdent" onClick={() => execFormat("outdent")}><IndentDecrease size={14} strokeWidth={iconStroke} /></FormatButton>
|
||||
<FormatButton title="Quote" onClick={() => execFormat("formatBlock", "blockquote")}><Quote size={14} strokeWidth={iconStroke} /></FormatButton>
|
||||
<FormatButton title="Link" onClick={openLinkDialog}><Link size={14} strokeWidth={iconStroke} /></FormatButton>
|
||||
<FormatButton title="Remove formatting" onClick={() => execFormat("removeFormat")}><RemoveFormatting size={14} strokeWidth={iconStroke} /></FormatButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -680,11 +870,23 @@ export default function ComposeModal({
|
|||
<button type="button" className="compose-icon-btn" title="Insert image" onClick={() => imageInputRef.current?.click()}>
|
||||
<Image size={16} strokeWidth={iconStroke} />
|
||||
</button>
|
||||
<button type="button" className="compose-icon-btn" title="Insert link" onClick={insertLink}>
|
||||
<button
|
||||
type="button"
|
||||
className="compose-icon-btn"
|
||||
title="Insert link"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={openLinkDialog}
|
||||
>
|
||||
<Link size={16} strokeWidth={iconStroke} />
|
||||
</button>
|
||||
<div className="compose-emoji-wrap">
|
||||
<button type="button" className="compose-icon-btn" title="Emoji" onClick={() => setShowEmoji((v) => !v)}>
|
||||
<button
|
||||
type="button"
|
||||
className="compose-icon-btn"
|
||||
title="Emoji"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => setShowEmoji((v) => !v)}
|
||||
>
|
||||
<Smile size={16} strokeWidth={iconStroke} />
|
||||
</button>
|
||||
{showEmoji && (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { ArrowLeft } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { getHandoffConversation, retryHandoffDelivery } from "../api";
|
||||
import { formatClockTime } from "../utils/datetime";
|
||||
|
||||
function HandoffMessageBody({ message }) {
|
||||
const [showQuoted, setShowQuoted] = useState(false);
|
||||
|
|
@ -25,14 +26,6 @@ function HandoffMessageBody({ message }) {
|
|||
);
|
||||
}
|
||||
|
||||
function formatTime(dateValue) {
|
||||
if (!dateValue) return "";
|
||||
return new Date(dateValue).toLocaleString(undefined, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export default function HandoffConversation({ handoffId, onBack, onOpenExternal }) {
|
||||
const token = sessionStorage.getItem("agmail_access_token");
|
||||
const [conversation, setConversation] = useState(null);
|
||||
|
|
@ -111,7 +104,7 @@ export default function HandoffConversation({ handoffId, onBack, onOpenExternal
|
|||
? "HUMAN OWNER"
|
||||
: "GENTIC → HUMAN"}
|
||||
</strong>
|
||||
<time>{formatTime(message.event_at)}</time>
|
||||
<time>{formatClockTime(message.event_at)}</time>
|
||||
</header>
|
||||
<HandoffMessageBody message={message} />
|
||||
{message.role === "human" && message.relay_status === "SENT" && (
|
||||
|
|
|
|||
|
|
@ -1,20 +1,21 @@
|
|||
import { ArrowLeftRight } from "lucide-react";
|
||||
import { ArrowLeftRight, ChevronRight } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { listHandoffConversations } from "../api";
|
||||
import { formatRelativeTime } from "../utils/datetime";
|
||||
|
||||
function formatRelativeTime(dateValue) {
|
||||
if (!dateValue) return "";
|
||||
const date = new Date(dateValue);
|
||||
const diffMs = Date.now() - date.getTime();
|
||||
const minutes = Math.floor(diffMs / 60000);
|
||||
if (minutes < 1) return "Just now";
|
||||
if (minutes < 60) return `${minutes} min ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days === 1) return "Yesterday";
|
||||
if (days < 7) return `${days} days ago`;
|
||||
return date.toLocaleDateString();
|
||||
function ownerDisplayName(item) {
|
||||
const named = (item.human_name || "").trim();
|
||||
if (named) return named;
|
||||
const local = (item.human_email || "").split("@")[0];
|
||||
if (!local) return item.rule_name || "Human Owner";
|
||||
return local.replace(/[._-]+/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
function previewSnippet(value) {
|
||||
return (value || "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.replace(/^(sender|responder|human|agent)\s*:\s*/i, "");
|
||||
}
|
||||
|
||||
export default function HandoffInbox({ mailbox = "handoff_inbox", onSelect }) {
|
||||
|
|
@ -22,6 +23,7 @@ export default function HandoffInbox({ mailbox = "handoff_inbox", onSelect }) {
|
|||
const [items, setItems] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const isInbox = mailbox === "handoff_inbox";
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
|
|
@ -42,11 +44,14 @@ export default function HandoffInbox({ mailbox = "handoff_inbox", onSelect }) {
|
|||
|
||||
return (
|
||||
<div className="handoff-inbox">
|
||||
<p className="handoff-inbox-lead">
|
||||
{mailbox === "handoff_inbox"
|
||||
? "Replies received from Human Owners"
|
||||
: "Messages mirrored from Gentic to Human Owners"}
|
||||
</p>
|
||||
<header className="handoff-section-head">
|
||||
<h2>{isInbox ? "Handoff Inbox" : "Handoff Outbox"}</h2>
|
||||
<p>
|
||||
{isInbox
|
||||
? "Replies received from Human Owners."
|
||||
: "Messages mirrored from Gentic to Human Owners."}
|
||||
</p>
|
||||
</header>
|
||||
{error && <p className="form-error" role="alert">{error}</p>}
|
||||
{loading ? (
|
||||
<p className="people-table-empty">Loading handoffs…</p>
|
||||
|
|
@ -55,7 +60,7 @@ export default function HandoffInbox({ mailbox = "handoff_inbox", onSelect }) {
|
|||
<div className="human-handoff-empty-icon" aria-hidden="true">
|
||||
<ArrowLeftRight size={24} strokeWidth={1.75} />
|
||||
</div>
|
||||
<h2>{mailbox === "handoff_inbox" ? "No Human replies yet" : "No mirrored messages yet"}</h2>
|
||||
<h2>{isInbox ? "No Human replies yet" : "No mirrored messages yet"}</h2>
|
||||
<p>
|
||||
When the Agent hands off a customer conversation, it appears here as a private
|
||||
Agent ↔ Human thread — separate from your normal Inbox.
|
||||
|
|
@ -63,29 +68,41 @@ export default function HandoffInbox({ mailbox = "handoff_inbox", onSelect }) {
|
|||
</div>
|
||||
) : (
|
||||
<div className="handoff-inbox-list">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="handoff-inbox-row"
|
||||
onClick={() => onSelect(item.id)}
|
||||
>
|
||||
<div className="handoff-inbox-row-main">
|
||||
<strong>{item.rule_name} · {item.external_subject || "(No subject)"}</strong>
|
||||
<span className="handoff-inbox-row-party">
|
||||
{item.external_sender} ↔ {item.human_email}
|
||||
</span>
|
||||
{item.snippet && <p className="handoff-inbox-row-snippet">“{item.snippet}”</p>}
|
||||
</div>
|
||||
<div className="handoff-inbox-row-meta">
|
||||
<time>{formatRelativeTime(item.updated_at)}</time>
|
||||
<span>{item.message_count} message{item.message_count === 1 ? "" : "s"}</span>
|
||||
<span className={`handoff-inbox-status status-${item.status.toLowerCase()}`}>
|
||||
{item.status_label}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{items.map((item) => {
|
||||
const preview = previewSnippet(item.snippet);
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="handoff-inbox-row"
|
||||
onClick={() => onSelect(item.id)}
|
||||
>
|
||||
<div className="handoff-inbox-row-main">
|
||||
<p className="handoff-inbox-row-title">
|
||||
<strong>{ownerDisplayName(item)}</strong>
|
||||
<span> · </span>
|
||||
<em>{item.external_subject || "(No subject)"}</em>
|
||||
</p>
|
||||
<span className="handoff-inbox-row-party">
|
||||
{item.external_sender}
|
||||
<span aria-hidden="true"> ↔ </span>
|
||||
{item.human_email}
|
||||
</span>
|
||||
{preview && <p className="handoff-inbox-row-snippet">{preview}</p>}
|
||||
</div>
|
||||
<div className="handoff-inbox-row-meta">
|
||||
<time>{formatRelativeTime(item.updated_at)}</time>
|
||||
<span>
|
||||
{item.message_count} message{item.message_count === 1 ? "" : "s"}
|
||||
</span>
|
||||
<span className={`handoff-inbox-status status-${item.status.toLowerCase()}`}>
|
||||
{item.status_label}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronRight className="handoff-inbox-row-chevron" size={14} strokeWidth={1.75} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -16,8 +16,10 @@ export default function HumanHandoff({ onOpenExternal }) {
|
|||
<section className="human-handoff-page">
|
||||
<header className="human-handoff-head">
|
||||
<div>
|
||||
<h1>Human Handoff</h1>
|
||||
<p>Private mirrored conversations between your Agent and Human Owners.</p>
|
||||
<h1 className="handoff-page-title">Human Handoff</h1>
|
||||
<p className="handoff-page-lead">
|
||||
Manage conversations that your Agent has handed over to a Human Owner.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
<nav className="human-handoff-tabs" aria-label="Human Handoff">
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ import AgentSpark from "./AgentSpark";
|
|||
import SenderAvatar from "./SenderAvatar";
|
||||
import { addressText, senderDisplayName } from "../utils/senderAvatar";
|
||||
import { attachmentCount } from "../utils/inboxSignals";
|
||||
import { visibleMessageHtml } from "../utils/emailReply";
|
||||
import { formatMailDetailTime } from "../utils/datetime";
|
||||
|
||||
const TOOLBAR_ICON = { size: 16, strokeWidth: 1.7 };
|
||||
const ACTION_ICON = { size: 14, strokeWidth: 1.7 };
|
||||
|
|
@ -44,12 +46,16 @@ function messagePreview(message) {
|
|||
}
|
||||
|
||||
function messageBody(email) {
|
||||
const preview = messagePreview(email);
|
||||
if (preview) return { mode: "text", value: preview };
|
||||
const text = (email?.body_text || email?.text_body || email?.plain_text || "").trim();
|
||||
if (text) return { mode: "text", value: text };
|
||||
const html = (email?.body_html || "").trim();
|
||||
const html = visibleMessageHtml(email?.body_html || "");
|
||||
if (html) return { mode: "html", value: html };
|
||||
const text = (
|
||||
email?.new_reply_content
|
||||
|| email?.body_text
|
||||
|| email?.text_body
|
||||
|| email?.plain_text
|
||||
|| ""
|
||||
).trim();
|
||||
if (text) return { mode: "text", value: text };
|
||||
const snippet = (email?.snippet || "").trim();
|
||||
if (snippet) return { mode: "text", value: snippet };
|
||||
return { mode: "text", value: "This message has no readable content." };
|
||||
|
|
@ -89,20 +95,9 @@ function MessageBodyContent({ message }) {
|
|||
}
|
||||
|
||||
function detailTime(email) {
|
||||
const value = email?.received_at || email?.sent_at || email?.created_at || email?.updated_at;
|
||||
if (!value) return { absolute: "", relative: "" };
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return { absolute: "", relative: "" };
|
||||
const absolute = new Intl.DateTimeFormat(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
const diffMs = Date.now() - date.getTime();
|
||||
const diffHours = Math.max(1, Math.round(diffMs / (1000 * 60 * 60)));
|
||||
const relative = diffHours < 24 ? `${diffHours}h ago` : `${Math.round(diffHours / 24)}d ago`;
|
||||
return { absolute, relative };
|
||||
return formatMailDetailTime(
|
||||
email?.received_at || email?.sent_at || email?.created_at || email?.updated_at,
|
||||
);
|
||||
}
|
||||
|
||||
function senderIdentity(email) {
|
||||
|
|
|
|||
|
|
@ -11,28 +11,10 @@ import AgentSpark from "./AgentSpark";
|
|||
import SenderAvatar from "./SenderAvatar";
|
||||
import { senderDisplayName } from "../utils/senderAvatar";
|
||||
import { attachmentCount, emailLabels } from "../utils/inboxSignals";
|
||||
import { formatMailboxTime } from "../utils/datetime";
|
||||
|
||||
const ICON = { size: 15, strokeWidth: 1.75 };
|
||||
|
||||
function formatTimestamp(value) {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
const now = new Date();
|
||||
const sameDay = date.toDateString() === now.toDateString();
|
||||
if (sameDay) {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
}
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
...(date.getFullYear() !== now.getFullYear() ? { year: "numeric" } : {}),
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export default function MailRow({
|
||||
email,
|
||||
selected,
|
||||
|
|
@ -129,7 +111,7 @@ export default function MailRow({
|
|||
{files > 0 && <Paperclip size={14} strokeWidth={1.75} />}
|
||||
</span>
|
||||
<div className="mailbox-row-rail">
|
||||
<time className="mailbox-time" dateTime={timestamp || undefined}>{formatTimestamp(timestamp)}</time>
|
||||
<time className="mailbox-time" dateTime={timestamp || undefined}>{formatMailboxTime(timestamp)}</time>
|
||||
<div className="mailbox-hover-actions" aria-label="Message actions">
|
||||
<button type="button" title="Archive" aria-label="Archive" onClick={(event) => stop(event, () => onArchive?.(email))}>
|
||||
<Archive {...ICON} />
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const ICON = { size: 16, strokeWidth: 1.75 };
|
|||
export default function MailboxSelectionBar({
|
||||
count,
|
||||
total,
|
||||
hideSelectAll = false,
|
||||
onToggleAll,
|
||||
onArchive,
|
||||
onSpam,
|
||||
|
|
@ -30,12 +31,14 @@ export default function MailboxSelectionBar({
|
|||
return (
|
||||
<div className="mailbox-selection-bar" role="toolbar" aria-label="Bulk message actions">
|
||||
<div className="mailbox-selection-meta">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
aria-label={allSelected ? "Deselect all messages" : "Select all messages"}
|
||||
onChange={onToggleAll}
|
||||
/>
|
||||
{!hideSelectAll && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
aria-label={allSelected ? "Deselect all messages" : "Select all messages"}
|
||||
onChange={onToggleAll}
|
||||
/>
|
||||
)}
|
||||
<span>{count} selected</span>
|
||||
</div>
|
||||
<div className="mailbox-selection-actions">
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import {
|
|||
CircleHelp,
|
||||
Filter,
|
||||
LayoutGrid,
|
||||
List,
|
||||
Mail,
|
||||
MoreHorizontal,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings,
|
||||
|
|
@ -46,7 +46,14 @@ import {
|
|||
} from "../utils/inboxSignals";
|
||||
import { buildReplyBodyHtml } from "../utils/emailReply";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
const PAGE_SIZE_KEY = "agmail_page_size";
|
||||
const PAGE_SIZE_OPTIONS = [25, 50, 100];
|
||||
|
||||
function readPageSize() {
|
||||
const stored = Number(window.localStorage.getItem(PAGE_SIZE_KEY));
|
||||
return PAGE_SIZE_OPTIONS.includes(stored) ? stored : 50;
|
||||
}
|
||||
|
||||
const CATEGORY_TABS = [
|
||||
{ name: "Primary", icon: LayoutGrid },
|
||||
{ name: "Updates", icon: Bell },
|
||||
|
|
@ -57,10 +64,11 @@ const CATEGORY_TABS = [
|
|||
];
|
||||
|
||||
function resultPage(result) {
|
||||
if (Array.isArray(result)) return { items: result, nextCursor: null };
|
||||
if (Array.isArray(result)) return { items: result, nextCursor: null, total: result.length };
|
||||
return {
|
||||
items: result.items || result.emails || result.results || [],
|
||||
nextCursor: result.next_cursor || result.nextCursor || null,
|
||||
total: Number(result.total || 0),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -112,6 +120,9 @@ export default function MailboxView({
|
|||
const token = sessionStorage.getItem("agmail_access_token");
|
||||
const [emails, setEmails] = useState([]);
|
||||
const [nextCursor, setNextCursor] = useState(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(readPageSize);
|
||||
const [pageMenuOpen, setPageMenuOpen] = useState(false);
|
||||
const [cursor, setCursor] = useState(null);
|
||||
const [cursorHistory, setCursorHistory] = useState([]);
|
||||
const [selected, setSelected] = useState(null);
|
||||
|
|
@ -140,7 +151,7 @@ export default function MailboxView({
|
|||
const result = await listThreads(
|
||||
{
|
||||
mailbox,
|
||||
limit: PAGE_SIZE,
|
||||
limit: pageSize,
|
||||
cursor,
|
||||
q: filters.query.trim(),
|
||||
is_read: filters.read === "all" ? undefined : filters.read === "read",
|
||||
|
|
@ -153,11 +164,12 @@ export default function MailboxView({
|
|||
const page = resultPage(result);
|
||||
setEmails(page.items.map(threadToRow));
|
||||
setNextCursor(page.nextCursor);
|
||||
setTotal(page.total);
|
||||
setStatus({ loading: false, error: "" });
|
||||
} catch (error) {
|
||||
setStatus({ loading: false, error: error.message });
|
||||
}
|
||||
}, [cursor, filters, mailbox, token]);
|
||||
}, [cursor, filters, mailbox, pageSize, token]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = window.setTimeout(load, filters.query ? 250 : 0);
|
||||
|
|
@ -250,6 +262,22 @@ export default function MailboxView({
|
|||
});
|
||||
}, [emails, onStats]);
|
||||
|
||||
useEffect(() => {
|
||||
setCursor(null);
|
||||
setCursorHistory([]);
|
||||
setChecked({});
|
||||
setPageMenuOpen(false);
|
||||
}, [mailbox]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pageMenuOpen) return undefined;
|
||||
function closeMenu() {
|
||||
setPageMenuOpen(false);
|
||||
}
|
||||
window.addEventListener("click", closeMenu);
|
||||
return () => window.removeEventListener("click", closeMenu);
|
||||
}, [pageMenuOpen]);
|
||||
|
||||
function changeFilter(name, value) {
|
||||
setFilters((current) => ({ ...current, [name]: value }));
|
||||
setCursor(null);
|
||||
|
|
@ -580,6 +608,7 @@ export default function MailboxView({
|
|||
setCursor(nextCursor);
|
||||
setSelected(null);
|
||||
setDetail(null);
|
||||
setChecked({});
|
||||
}
|
||||
|
||||
function previousPage() {
|
||||
|
|
@ -589,11 +618,30 @@ export default function MailboxView({
|
|||
setCursor(previous);
|
||||
setSelected(null);
|
||||
setDetail(null);
|
||||
setChecked({});
|
||||
}
|
||||
|
||||
function changePageSize(size) {
|
||||
if (size === pageSize) {
|
||||
setPageMenuOpen(false);
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(PAGE_SIZE_KEY, String(size));
|
||||
setPageSize(size);
|
||||
setCursor(null);
|
||||
setCursorHistory([]);
|
||||
setChecked({});
|
||||
setPageMenuOpen(false);
|
||||
}
|
||||
|
||||
const initials = `${account?.first_name?.[0] || ""}${account?.last_name?.[0] || ""}`.toUpperCase();
|
||||
const rangeStart = listedEmails.length ? cursorHistory.length * PAGE_SIZE + 1 : 0;
|
||||
const rangeEnd = cursorHistory.length * PAGE_SIZE + listedEmails.length;
|
||||
const rangeStart = emails.length ? cursorHistory.length * pageSize + 1 : 0;
|
||||
const rangeEnd = emails.length ? rangeStart + emails.length - 1 : 0;
|
||||
const rangeLabel = emails.length === 0
|
||||
? `0–0 of ${total}`
|
||||
: `${rangeStart}–${rangeEnd} of ${total || rangeEnd}`;
|
||||
const allSelected = listedEmails.length > 0 && selectedCount === listedEmails.length;
|
||||
const someSelected = selectedCount > 0 && !allSelected;
|
||||
|
||||
return (
|
||||
<section className="mailbox-workspace">
|
||||
|
|
@ -652,10 +700,85 @@ export default function MailboxView({
|
|||
<div className="mailbox-main-column">
|
||||
<div className={`mailbox-shell${selected ? " detail-visible" : ""}`}>
|
||||
<div className="mailbox-list-pane">
|
||||
<div className="mailbox-list-bar" role="toolbar" aria-label="Mailbox controls">
|
||||
<div className="mailbox-list-bar-actions">
|
||||
<label className="mailbox-select-all">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
ref={(node) => {
|
||||
if (node) node.indeterminate = someSelected;
|
||||
}}
|
||||
aria-label={allSelected ? "Deselect all messages" : "Select all messages"}
|
||||
onChange={toggleSelectAll}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className={status.loading ? "is-busy" : ""}
|
||||
title="Refresh"
|
||||
aria-label="Refresh"
|
||||
onClick={load}
|
||||
>
|
||||
<RefreshCw size={16} strokeWidth={1.75} />
|
||||
</button>
|
||||
<div className="mailbox-page-size-wrap" onClick={(event) => event.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
title="More options"
|
||||
aria-label="More options"
|
||||
aria-expanded={pageMenuOpen}
|
||||
onClick={() => setPageMenuOpen((open) => !open)}
|
||||
>
|
||||
<MoreHorizontal size={16} strokeWidth={1.75} />
|
||||
</button>
|
||||
{pageMenuOpen && (
|
||||
<div className="mailbox-page-size-menu" role="menu">
|
||||
<p>Emails per page</p>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<button
|
||||
key={size}
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={pageSize === size}
|
||||
className={pageSize === size ? "active" : ""}
|
||||
onClick={() => changePageSize(size)}
|
||||
>
|
||||
{size}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mailbox-list-bar-page">
|
||||
<span className="mailbox-pagination-range">{rangeLabel}</span>
|
||||
<button
|
||||
type="button"
|
||||
title="Newer"
|
||||
aria-label="Previous page"
|
||||
disabled={!cursorHistory.length || status.loading}
|
||||
onClick={previousPage}
|
||||
>
|
||||
<ChevronLeft size={18} strokeWidth={1.75} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Older"
|
||||
aria-label="Next page"
|
||||
disabled={!nextCursor || status.loading}
|
||||
onClick={nextPage}
|
||||
>
|
||||
<ChevronRight size={18} strokeWidth={1.75} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedCount > 0 && (
|
||||
<MailboxSelectionBar
|
||||
count={selectedCount}
|
||||
total={listedEmails.length}
|
||||
hideSelectAll
|
||||
onToggleAll={toggleSelectAll}
|
||||
onArchive={() => runBulkAction("archive")}
|
||||
onSpam={() => runBulkAction("spam")}
|
||||
|
|
@ -755,32 +878,6 @@ export default function MailboxView({
|
|||
))}
|
||||
</div>
|
||||
|
||||
{!status.loading && (
|
||||
<footer className="mailbox-pagination">
|
||||
<span className="mailbox-pagination-range">
|
||||
{listedEmails.length === 0
|
||||
? "0 messages"
|
||||
: `${rangeStart}–${rangeEnd}${nextCursor ? "" : ` · ${rangeEnd} loaded`}`}
|
||||
</span>
|
||||
<div className="mailbox-pagination-controls">
|
||||
{listedEmails.length > 0 && (
|
||||
<>
|
||||
<button type="button" title="Previous page" aria-label="Previous page" disabled={!cursorHistory.length} onClick={previousPage}>
|
||||
<ChevronLeft size={16} strokeWidth={1.75} />
|
||||
</button>
|
||||
<button type="button" title="Next page" aria-label="Next page" disabled={!nextCursor} onClick={nextPage}>
|
||||
<ChevronRight size={16} strokeWidth={1.75} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="mailbox-pagination-density" aria-label="List density">
|
||||
<button type="button" className="active" title="Comfortable density" aria-label="Comfortable density"><List size={15} strokeWidth={1.75} /></button>
|
||||
<button type="button" title="Compact density" aria-label="Compact density"><LayoutGrid size={15} strokeWidth={1.75} /></button>
|
||||
</div>
|
||||
</footer>
|
||||
)}
|
||||
|
||||
<MailDetail
|
||||
email={detail}
|
||||
thread={threadDetail}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import greenPin from "../data/green.png";
|
|||
import pinkPin from "../data/pink.png";
|
||||
import yellowPin from "../data/yellow.png";
|
||||
import { senderDisplayName } from "../utils/senderAvatar";
|
||||
import { formatAbsoluteDateTime } from "../utils/datetime";
|
||||
|
||||
const NOTE_COLORS = ["yellow", "green", "blue", "pink"];
|
||||
const PIN_IMAGES = {
|
||||
|
|
@ -25,18 +26,6 @@ const PIN_IMAGES = {
|
|||
pink: pinkPin,
|
||||
};
|
||||
|
||||
function formatNoteTime(value) {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function noteMatchesContext(note, contextEmail) {
|
||||
const needle = senderDisplayName(contextEmail).toLowerCase();
|
||||
if (!needle) return false;
|
||||
|
|
@ -84,7 +73,7 @@ function NoteCard({ note, onDelete, menuOpen, onMenuToggle }) {
|
|||
<span key={tag}>{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
<time dateTime={note.updated_at}>{formatNoteTime(note.updated_at)}</time>
|
||||
<time dateTime={note.updated_at}>{formatAbsoluteDateTime(note.updated_at)}</time>
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue