2026-07-17 23:20:34 +02:00
|
|
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
|
|
|
import { useRef } from "react";
|
|
|
|
|
|
|
|
|
|
import { useT } from "../../i18n";
|
|
|
|
|
import type { AccountOut, ThreadSummary } from "../../lib/types";
|
|
|
|
|
import { Icon } from "../Icon";
|
|
|
|
|
|
|
|
|
|
function timeLabel(iso: string): string {
|
|
|
|
|
const d = new Date(iso);
|
|
|
|
|
const now = new Date();
|
|
|
|
|
if (d.toDateString() === now.toDateString())
|
|
|
|
|
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
|
|
|
if (d.getFullYear() === now.getFullYear())
|
|
|
|
|
return d.toLocaleDateString([], { month: "short", day: "numeric" });
|
|
|
|
|
return d.toLocaleDateString([], { year: "2-digit", month: "short", day: "numeric" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Virtualized thread list (SPEC §2 list/MessageList.tsx).
|
|
|
|
|
export function MessageList({
|
|
|
|
|
threads,
|
|
|
|
|
accounts,
|
|
|
|
|
selectedId,
|
|
|
|
|
onSelect,
|
|
|
|
|
}: {
|
|
|
|
|
threads: ThreadSummary[];
|
|
|
|
|
accounts: AccountOut[];
|
|
|
|
|
selectedId?: string;
|
|
|
|
|
onSelect: (id: string) => void;
|
|
|
|
|
}) {
|
|
|
|
|
const t = useT();
|
|
|
|
|
const parentRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
const colorFor = (id: string) => accounts.find((a) => a.id === id)?.color ?? "#888888";
|
|
|
|
|
|
|
|
|
|
const virtualizer = useVirtualizer({
|
|
|
|
|
count: threads.length,
|
|
|
|
|
getScrollElement: () => parentRef.current,
|
|
|
|
|
estimateSize: () => 82,
|
|
|
|
|
overscan: 8,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (threads.length === 0) {
|
|
|
|
|
return <div className="p-6 text-sm text-app-faint">{t("mail.noConversations")}</div>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div ref={parentRef} className="h-full overflow-y-auto" data-testid="message-list">
|
|
|
|
|
<div style={{ height: virtualizer.getTotalSize(), position: "relative", width: "100%" }}>
|
|
|
|
|
{virtualizer.getVirtualItems().map((item) => {
|
|
|
|
|
const thread = threads[item.index];
|
|
|
|
|
const unread = thread.unread_count > 0;
|
|
|
|
|
const selected = selectedId === thread.id;
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={thread.id}
|
|
|
|
|
onClick={() => onSelect(thread.id)}
|
|
|
|
|
style={{
|
|
|
|
|
position: "absolute",
|
|
|
|
|
top: 0,
|
|
|
|
|
left: 0,
|
|
|
|
|
width: "100%",
|
|
|
|
|
height: item.size,
|
|
|
|
|
transform: `translateY(${item.start}px)`,
|
|
|
|
|
}}
|
2026-07-18 15:27:09 +02:00
|
|
|
className={`flex flex-col gap-1 border-b border-app-border px-6 py-2.5 text-left ${
|
2026-07-17 23:20:34 +02:00
|
|
|
selected
|
|
|
|
|
? "bg-app-active"
|
|
|
|
|
: "hover:bg-app-hover"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className="flex shrink-0 gap-0.5">
|
|
|
|
|
{thread.account_ids.map((id) => (
|
|
|
|
|
<span
|
|
|
|
|
key={id}
|
|
|
|
|
className="h-2.5 w-2.5 rounded-full"
|
|
|
|
|
style={{ backgroundColor: colorFor(id) }}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</span>
|
|
|
|
|
<span className={`flex-1 truncate text-sm ${unread ? "font-semibold text-app-fg" : "text-app-fg"}`}>
|
|
|
|
|
{thread.participants.map((p) => p.name || p.email).join(", ") || "(no one)"}
|
|
|
|
|
</span>
|
|
|
|
|
{thread.message_count > 1 && (
|
|
|
|
|
<span className="shrink-0 rounded-full bg-app-hover px-1.5 text-[11px] text-app-muted">
|
|
|
|
|
{thread.message_count}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
{thread.pinned && (
|
|
|
|
|
<Icon name="pin" size={13} className="text-[rgb(220_129_143)]" />
|
|
|
|
|
)}
|
|
|
|
|
<span className="shrink-0 text-[11px] text-app-faint">{timeLabel(thread.last_message_at)}</span>
|
|
|
|
|
</div>
|
2026-07-18 15:27:09 +02:00
|
|
|
<div className={`truncate text-[13px] ${unread ? "font-medium text-app-fg" : "text-app-muted"}`}>
|
2026-07-17 23:20:34 +02:00
|
|
|
{thread.subject}
|
|
|
|
|
{thread.has_attachments && <span className="ml-1 text-app-faint">📎</span>}
|
|
|
|
|
</div>
|
2026-07-18 15:27:09 +02:00
|
|
|
<div className="truncate text-xs text-app-faint">{thread.snippet}</div>
|
2026-07-17 23:20:34 +02:00
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|