mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-26 00:00:41 +00:00
feat: shrink the From mailbox menu into a compact searchable list: single-line h-8 rows (auth dot, address, history count, sent/limit budget, best marker) with the name and score reasons moved into the tooltip, a search box that filters as you type, tag filter chips built from the org's mailbox tags (only tags actually in use are offered), an inline Auto row, and a no-match empty state
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
// MailboxPicker — the compose "From" selector. Defaults to Auto: the backend
|
||||
// scores every active mailbox for the current recipient (conversation
|
||||
// affinity, remaining daily budget, domain auth) and this control shows the
|
||||
// resolved pick with its reason. Opening the menu reveals every candidate
|
||||
// with its budget bar, history badge, and auth health so a manual override
|
||||
// is an informed choice, not a guess.
|
||||
// resolved pick. The menu is a compact, searchable list (filterable by
|
||||
// mailbox tag) so orgs with dozens of mailboxes can still pick in a second;
|
||||
// per-row detail lives in the title tooltip instead of bloating the rows.
|
||||
|
||||
import React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
@@ -12,11 +12,12 @@ import {
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
MessagesSquareIcon,
|
||||
ShieldAlertIcon,
|
||||
SearchIcon,
|
||||
SparklesIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComposeCandidate, ComposeCandidatesResponse } from "@/lib/api/models/app/unibox/Compose";
|
||||
import useClickOutside from "@/hooks/useClickOutside";
|
||||
import { useAppStore } from "@/stores";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MailboxPickerProps {
|
||||
@@ -28,16 +29,21 @@ interface MailboxPickerProps {
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const PANEL_WIDTH = 320;
|
||||
const PANEL_WIDTH = 300;
|
||||
|
||||
export default function MailboxPicker({ value, onChange, candidates, loading }: MailboxPickerProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [tagFilter, setTagFilter] = React.useState<string | null>(null);
|
||||
// Viewport anchor for the portaled panel (the compose window clips
|
||||
// overflow, so the menu can't render inside it).
|
||||
const [anchor, setAnchor] = React.useState<{ top: number; left: number; up: boolean } | null>(null);
|
||||
const boxRef = React.useRef<HTMLDivElement>(null);
|
||||
useClickOutside(boxRef, () => setOpen(false));
|
||||
|
||||
const storeEmails = useAppStore((s) => s.emails);
|
||||
const storeTags = useAppStore((s) => s.tags);
|
||||
|
||||
const measure = React.useCallback(() => {
|
||||
const el = boxRef.current;
|
||||
if (!el) return;
|
||||
@@ -46,12 +52,16 @@ export default function MailboxPicker({ value, onChange, candidates, loading }:
|
||||
const vh = window.innerHeight;
|
||||
const left = Math.min(Math.max(r.left, 8), vw - PANEL_WIDTH - 8);
|
||||
// Flip above the trigger when the space below is tight.
|
||||
const up = vh - r.bottom < 320 && r.top > vh - r.bottom;
|
||||
const up = vh - r.bottom < 300 && r.top > vh - r.bottom;
|
||||
setAnchor({ top: up ? r.top - 4 : r.bottom + 4, left, up });
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!open) {
|
||||
setSearch("");
|
||||
setTagFilter(null);
|
||||
return;
|
||||
}
|
||||
measure();
|
||||
window.addEventListener("scroll", measure, true);
|
||||
window.addEventListener("resize", measure);
|
||||
@@ -61,10 +71,38 @@ export default function MailboxPicker({ value, onChange, candidates, loading }:
|
||||
};
|
||||
}, [open, measure]);
|
||||
|
||||
const accounts = candidates?.accounts ?? [];
|
||||
const accounts = React.useMemo(() => candidates?.accounts ?? [], [candidates]);
|
||||
const recommended = accounts.find((a) => a.recommended) ?? accounts[0];
|
||||
const selected = value === "auto" ? recommended : accounts.find((a) => a.id === value);
|
||||
|
||||
// Tag ids per account come from the store's full mailbox records; only
|
||||
// tags actually used by a listed account are offered as filters.
|
||||
const tagsByAccount = React.useMemo(() => {
|
||||
const m = new Map<string, string[]>();
|
||||
for (const e of storeEmails) m.set(e.id, e.tags ?? []);
|
||||
return m;
|
||||
}, [storeEmails]);
|
||||
|
||||
const usedTags = React.useMemo(() => {
|
||||
const used = new Set<string>();
|
||||
for (const a of accounts) for (const t of tagsByAccount.get(a.id) ?? []) used.add(t);
|
||||
return storeTags.filter((t) => used.has(t.id));
|
||||
}, [accounts, storeTags, tagsByAccount]);
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return accounts.filter((a) => {
|
||||
if (tagFilter && !(tagsByAccount.get(a.id) ?? []).includes(tagFilter)) return false;
|
||||
if (!q) return true;
|
||||
return `${a.email} ${a.name}`.toLowerCase().includes(q);
|
||||
});
|
||||
}, [accounts, search, tagFilter, tagsByAccount]);
|
||||
|
||||
const pick = (next: string) => {
|
||||
onChange(next);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={boxRef} className="relative min-w-0 flex-1">
|
||||
<button
|
||||
@@ -103,81 +141,113 @@ export default function MailboxPicker({ value, onChange, candidates, loading }:
|
||||
</button>
|
||||
|
||||
{createPortal(
|
||||
<AnimatePresence>
|
||||
{open && anchor && (
|
||||
<motion.div
|
||||
data-floating=""
|
||||
initial={{ opacity: 0, y: anchor.up ? 4 : -4, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: anchor.up ? 4 : -4, scale: 0.98 }}
|
||||
transition={{ duration: 0.14, ease: [0.16, 1, 0.3, 1] }}
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: anchor.left,
|
||||
width: PANEL_WIDTH,
|
||||
zIndex: 120,
|
||||
...(anchor.up
|
||||
? { bottom: window.innerHeight - anchor.top }
|
||||
: { top: anchor.top }),
|
||||
}}
|
||||
className="max-w-[calc(100vw-16px)] rounded-lg border border-slate-200 bg-white shadow-xl overflow-hidden"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange("auto");
|
||||
setOpen(false);
|
||||
<AnimatePresence>
|
||||
{open && anchor && (
|
||||
<motion.div
|
||||
data-floating=""
|
||||
initial={{ opacity: 0, y: anchor.up ? 4 : -4, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: anchor.up ? 4 : -4, scale: 0.98 }}
|
||||
transition={{ duration: 0.14, ease: [0.16, 1, 0.3, 1] }}
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: anchor.left,
|
||||
width: PANEL_WIDTH,
|
||||
zIndex: 120,
|
||||
...(anchor.up
|
||||
? { bottom: window.innerHeight - anchor.top }
|
||||
: { top: anchor.top }),
|
||||
}}
|
||||
className={cn(
|
||||
"w-full px-3 py-2 flex items-start gap-2 text-left transition-colors hover:bg-slate-50",
|
||||
value === "auto" && "bg-sky-50/60",
|
||||
)}
|
||||
className="max-w-[calc(100vw-16px)] rounded-lg border border-slate-200 bg-white shadow-xl overflow-hidden"
|
||||
>
|
||||
<span className="size-6 rounded-md bg-sky-100 text-sky-600 inline-flex items-center justify-center shrink-0 mt-0.5">
|
||||
<SparklesIcon className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[12.5px] font-medium text-slate-900">
|
||||
Auto
|
||||
{value === "auto" && (
|
||||
<CheckIcon className="inline w-3 h-3 ml-1.5 text-sky-600" />
|
||||
)}
|
||||
</span>
|
||||
<span className="block text-[11px] text-slate-500 leading-snug">
|
||||
{recommended
|
||||
? `Picks ${recommended.email}${candidates?.recommended_reason ? `: ${candidates.recommended_reason}` : ""}`
|
||||
: "Picks the best mailbox for each recipient"}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="border-t border-slate-100 max-h-56 overflow-y-auto">
|
||||
{accounts.length === 0 && (
|
||||
<div className="px-3 py-3 text-[11.5px] text-slate-400">
|
||||
No active mailboxes. Connect one under Emails.
|
||||
{/* Search + tag filter header */}
|
||||
<div className="px-1.5 pt-1.5 pb-1 border-b border-slate-100">
|
||||
<div className="flex items-center gap-1.5 px-1.5 h-6 rounded-md bg-slate-50 focus-within:bg-white focus-within:ring-1 focus-within:ring-sky-200 transition-colors">
|
||||
<SearchIcon className="w-3 h-3 text-slate-400 shrink-0" />
|
||||
<input
|
||||
autoFocus
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search mailboxes…"
|
||||
className="flex-1 min-w-0 bg-transparent text-[11.5px] text-slate-900 placeholder:text-slate-400 outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{accounts.map((a) => (
|
||||
<CandidateRow
|
||||
key={a.id}
|
||||
candidate={a}
|
||||
active={value === a.id}
|
||||
onPick={() => {
|
||||
onChange(a.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body,
|
||||
{usedTags.length > 0 && (
|
||||
<div className="mt-1 flex items-center gap-1 overflow-x-auto pb-0.5">
|
||||
{usedTags.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setTagFilter((f) => (f === t.id ? null : t.id))}
|
||||
className={cn(
|
||||
"h-5 px-1.5 rounded-full text-[10px] font-medium inline-flex items-center gap-1 shrink-0 border transition-colors",
|
||||
tagFilter === t.id
|
||||
? "border-sky-300 bg-sky-50 text-sky-700"
|
||||
: "border-slate-200 text-slate-500 hover:border-slate-300 hover:text-slate-700",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="size-1.5 rounded-full"
|
||||
style={{ backgroundColor: t.color }}
|
||||
/>
|
||||
{t.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Auto */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pick("auto")}
|
||||
title={
|
||||
recommended
|
||||
? `Picks ${recommended.email}${candidates?.recommended_reason ? `: ${candidates.recommended_reason}` : ""}`
|
||||
: "Picks the best mailbox for each recipient"
|
||||
}
|
||||
className={cn(
|
||||
"w-full h-8 px-2.5 flex items-center gap-2 text-left transition-colors hover:bg-slate-50",
|
||||
value === "auto" && "bg-sky-50/60",
|
||||
)}
|
||||
>
|
||||
<SparklesIcon className="w-3 h-3 text-sky-500 shrink-0" />
|
||||
<span className="text-[11.5px] font-medium text-slate-900">Auto</span>
|
||||
<span className="min-w-0 flex-1 text-[10.5px] text-slate-400 truncate">
|
||||
{recommended ? recommended.email : "best mailbox per recipient"}
|
||||
</span>
|
||||
{value === "auto" && <CheckIcon className="w-3 h-3 text-sky-600 shrink-0" />}
|
||||
</button>
|
||||
|
||||
{/* Candidates, best first */}
|
||||
<div className="border-t border-slate-100 max-h-52 overflow-y-auto">
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-3 py-3 text-[11px] text-slate-400">
|
||||
{accounts.length === 0
|
||||
? "No active mailboxes. Connect one under Emails."
|
||||
: "No mailboxes match."}
|
||||
</div>
|
||||
)}
|
||||
{filtered.map((a) => (
|
||||
<CandidateRow
|
||||
key={a.id}
|
||||
candidate={a}
|
||||
active={value === a.id}
|
||||
onPick={() => pick(a.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// One compact line per mailbox: auth dot, address, then history + budget on
|
||||
// the right. Everything else (name, score reasons) lives in the tooltip.
|
||||
function CandidateRow({
|
||||
candidate: a,
|
||||
active,
|
||||
@@ -187,72 +257,50 @@ function CandidateRow({
|
||||
active: boolean;
|
||||
onPick: () => void;
|
||||
}) {
|
||||
const pct = a.daily_limit > 0 ? Math.min(100, Math.round((a.sent_today / a.daily_limit) * 100)) : 0;
|
||||
const spent = a.daily_limit > 0 && a.sent_today >= a.daily_limit;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPick}
|
||||
title={[a.name, ...a.reasons].filter(Boolean).join(" · ")}
|
||||
className={cn(
|
||||
"w-full px-3 py-2 flex items-start gap-2 text-left transition-colors hover:bg-slate-50",
|
||||
"w-full h-8 px-2.5 flex items-center gap-2 text-left transition-colors hover:bg-slate-50",
|
||||
active && "bg-sky-50/60",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full shrink-0 mt-[7px]",
|
||||
a.auth_state === "failing" ? "bg-rose-500" : a.auth_state === "passing" ? "bg-emerald-500" : "bg-slate-300",
|
||||
)}
|
||||
title={
|
||||
"size-1.5 rounded-full shrink-0",
|
||||
a.auth_state === "failing"
|
||||
? "Domain authentication failing (SPF/DMARC)"
|
||||
? "bg-rose-500"
|
||||
: a.auth_state === "passing"
|
||||
? "Domain authenticated"
|
||||
: "Domain authentication not checked yet"
|
||||
}
|
||||
? "bg-emerald-500"
|
||||
: "bg-slate-300",
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="text-[12px] font-medium text-slate-900 truncate">
|
||||
{a.name || a.email}
|
||||
<span className="min-w-0 flex-1 text-[11.5px] text-slate-800 truncate">
|
||||
{a.email}
|
||||
{a.recommended && (
|
||||
<span className="ml-1.5 text-[9px] font-semibold uppercase tracking-wide text-sky-600">
|
||||
best
|
||||
</span>
|
||||
{a.recommended && (
|
||||
<span className="inline-flex items-center gap-0.5 h-4 px-1 rounded bg-sky-50 text-sky-700 text-[9.5px] font-semibold uppercase tracking-wide shrink-0">
|
||||
best
|
||||
</span>
|
||||
)}
|
||||
{active && <CheckIcon className="w-3 h-3 text-sky-600 shrink-0" />}
|
||||
</span>
|
||||
<span className="block font-mono text-[10.5px] text-slate-500 truncate">{a.email}</span>
|
||||
<span className="mt-1 flex items-center gap-2">
|
||||
<span className="w-16 h-1 rounded-full bg-slate-100 overflow-hidden shrink-0">
|
||||
<span
|
||||
className={cn(
|
||||
"block h-full rounded-full",
|
||||
pct >= 100 ? "bg-rose-400" : pct >= 80 ? "bg-amber-400" : "bg-sky-500",
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400 tabular-nums shrink-0">
|
||||
{a.sent_today}/{a.daily_limit} today
|
||||
</span>
|
||||
{a.history_messages > 0 && (
|
||||
<span
|
||||
className="inline-flex items-center gap-0.5 text-[10px] text-emerald-700 shrink-0"
|
||||
title="Messages already exchanged with this recipient from this mailbox"
|
||||
>
|
||||
<MessagesSquareIcon className="w-2.5 h-2.5" />
|
||||
{a.history_messages}
|
||||
</span>
|
||||
)}
|
||||
{a.auth_state === "failing" && (
|
||||
<span className="inline-flex items-center gap-0.5 text-[10px] text-rose-600 shrink-0">
|
||||
<ShieldAlertIcon className="w-2.5 h-2.5" />
|
||||
auth
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{a.history_messages > 0 && (
|
||||
<span className="inline-flex items-center gap-0.5 text-[10px] text-emerald-700 shrink-0">
|
||||
<MessagesSquareIcon className="w-2.5 h-2.5" />
|
||||
{a.history_messages}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono text-[10px] tabular-nums shrink-0",
|
||||
spent ? "text-rose-500" : "text-slate-400",
|
||||
)}
|
||||
>
|
||||
{a.sent_today}/{a.daily_limit}
|
||||
</span>
|
||||
{active && <CheckIcon className="w-3 h-3 text-sky-600 shrink-0" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user