import { useVirtualizer } from "@tanstack/react-virtual"; import { useRef } from "react"; import { useT } from "../../i18n"; import type { Address, 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" }); } // Stable per-sender hue so a sender's avatar keeps a consistent colour. function hue(key: string): number { let h = 0; for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) % 360; return h; } function initials(p: Address | undefined): string { const name = (p?.name || p?.email || "?").trim(); const words = name.split(/\s+/).filter(Boolean); if (words.length >= 2) return (words[0][0] + words[words.length - 1][0]).toUpperCase(); return name.slice(0, 2).toUpperCase(); } // Monocolor paperclip (inherits currentColor) — replaces the old emoji. function Paperclip() { return ( ); } // Virtualized thread list (SPEC §2 list/MessageList.tsx). export function MessageList({ threads, selectedId, folder, onSelect, onArchive, onRestore, onTrash, onDeletePermanent, onTogglePin, checkedIds, onToggleCheck, }: { threads: ThreadSummary[]; selectedId?: string; folder: string; onSelect: (id: string) => void; onArchive: (id: string) => void; onRestore: (id: string) => void; onTrash: (id: string) => void; onDeletePermanent: (id: string) => void; onTogglePin: (id: string, pinned: boolean) => void; checkedIds: Set; onToggleCheck: (id: string) => void; }) { const t = useT(); const parentRef = useRef(null); // In Trash/Spam/Archive the middle action restores; elsewhere it archives. const isRemovalFolder = folder === "trash" || folder === "junk" || folder === "archive"; // Row selection: hovering an avatar reveals a checkbox; once anything is checked, // every row shows a checkbox (selection mode) until all are cleared. const checkMode = checkedIds.size > 0; const virtualizer = useVirtualizer({ count: threads.length, getScrollElement: () => parentRef.current, estimateSize: () => 75, // 68 row + 7px gap overscan: 8, }); if (threads.length === 0) { return
{t("mail.noConversations")}
; } return (
{virtualizer.getVirtualItems().map((item) => { const thread = threads[item.index]; const unread = thread.unread_count > 0; const selected = selectedId === thread.id; const checked = checkedIds.has(thread.id); const primary = thread.participants[0]; const names = thread.participants.map((p) => p.name || p.email).join(", ") || "(no one)"; const extra = Math.max(0, thread.participants.length - 1); const avatarBg = `hsl(${hue(primary?.email || primary?.name || "?")} 45% 45%)`; const stop = (fn: () => void) => (e: React.MouseEvent) => { e.stopPropagation(); fn(); }; return (
onSelect(thread.id)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onSelect(thread.id); }} style={{ position: "absolute", top: 0, left: 0, width: "100%", height: item.size, transform: `translateY(${item.start}px)`, }} className="group absolute inset-x-0 cursor-pointer px-2 pt-[7px]" >
{/* Avatar, replaced by a selection checkbox on avatar-hover or once selection mode is active. An unread dot sits in the left margin. */}
{unread && ( )}
{initials(primary)} {extra > 0 && ( +{extra} )}
{/* Sender column */}
{names} {thread.message_count > 1 && ( {thread.message_count} )}
{/* Subject + snippet (extra left margin -> 3x the gap to the sender). */}
{thread.subject}
{thread.snippet}
{/* Right rail: four evenly-spaced icons, vertically centred. The paperclip and pin keep fixed slots (archive/trash reserve their space while hidden), and the date is an overlay on the right that is simply hidden on hover to reveal archive + trash — so nothing ever shifts between the normal and hover states. */}
{/* Attachment: fixed slot (reserved even when empty) so the rail width — and thus the excerpt width — is the same in every row. */} {thread.has_attachments && } {/* Pin: pink and always shown when pinned; otherwise revealed on hover. */} {/* Archive (or Restore in Trash/Spam/Archive) + delete: hover-only, but reserve their slots always. In Trash, delete is permanent. */} {isRemovalFolder ? ( ) : ( )} {/* Date overlay, aligned to the right edge (over the archive/trash slots); hidden on hover so the actions show through. */} {timeLabel(thread.last_message_at)}
); })}
); }