import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { useVirtualizer } from "@tanstack/react-virtual"; import { useEffect, useRef } from "react"; import { useT } from "../../i18n"; import { api } from "../../lib/api"; import { useSavedThreads } from "../../lib/offline"; import { formatClock, getDateLocale } from "../../lib/timeFormat"; import type { Address, ThreadSummary } from "../../lib/types"; import { Icon } from "../Icon"; import { AvatarImage } from "../SenderAvatar"; import { LabelChips } from "../mail/LabelChips"; function timeLabel(iso: string): string { const d = new Date(iso); const now = new Date(); if (d.toDateString() === now.toDateString()) return formatClock(d); if (d.getFullYear() === now.getFullYear()) return d.toLocaleDateString(getDateLocale(), { month: "short", day: "numeric" }); return d.toLocaleDateString(getDateLocale(), { 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(); } // Past this many characters the sender column switches to first names only. const SENDERS_FULL_MAX = 25; // A lone sender's name longer than this is cut to one character less plus "...". const SINGLE_SENDER_MAX = 22; // The sender column's text. A single sender shows in full, or cut at // SINGLE_SENDER_MAX. Several show as full names joined, or — when that would run // past SENDERS_FULL_MAX — each sender's first word ("Alice Smith" -> "Alice", a // bare address -> its local part). The user's own accounts are always `me`. export function senderNames( senders: Address[], isMe: (a: Address) => boolean, me: string, ): string { if (senders.length === 1) { const [a] = senders; const name = isMe(a) ? me : a.name || a.email; return name.length > SINGLE_SENDER_MAX ? `${name.slice(0, SINGLE_SENDER_MAX - 1)}...` : name; } const full = senders.map((a) => (isMe(a) ? me : a.name || a.email)).join(", "); if (full.length <= SENDERS_FULL_MAX) return full; return senders .map((a) => { if (isMe(a)) return me; const name = a.name.trim(); return name ? name.split(/\s+/)[0] : a.email.split("@")[0]; }) .join(", "); } // Monocolor paperclip (inherits currentColor) — replaces the old emoji. function Paperclip() { return ( ); } // Virtualized thread list (SPEC §2 list/MessageList.tsx). export function MessageList({ threads, page, selectedId, folder, onSelect, onArchive, onRestore, onTrash, onDeletePermanent, onTogglePin, checkedIds, dimmedIds, onToggleCheck, compact = false, }: { threads: ThreadSummary[]; /** Which page of the list this is. Changing it scrolls back to the top. */ page: number; 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; dimmedIds?: Set; onToggleCheck: (id: string) => void; /** Phone layout: sender, subject and snippet stacked; no hover-only actions. */ compact?: boolean; }) { 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; // Aggregated labels per thread (written by the reading pane) — shown before the subject. const threadIds = threads.map((th) => th.id); const labelsQuery = useQuery({ queryKey: ["thread-labels", threadIds], queryFn: () => api.threadLabelsBatch(threadIds), enabled: threadIds.length > 0, // The key changes whenever a row joins or leaves the list; keep the chips // already known meanwhile, or every subject jumps left and back. placeholderData: keepPreviousData, }); const labelMap = labelsQuery.data ?? {}; // Offline: conversations not saved on this device are shown faded. const saved = useSavedThreads(); // The user's own addresses, shown as "me" in the sender column. const accounts = useQuery({ queryKey: ["accounts"], queryFn: api.accounts }); const myEmails = new Set((accounts.data ?? []).map((a) => a.email.toLowerCase())); const isMe = (a: Address) => myEmails.has(a.email.toLowerCase()); const virtualizer = useVirtualizer({ count: threads.length, getScrollElement: () => parentRef.current, // 68 row + the gap above it: 7px, 20px on a phone. estimateSize: () => 68 + (compact ? 20 : 7), overscan: 8, }); // A new page starts at the top. Paging while scrolled down would otherwise // drop you into the middle of the next fifty with no sense of having moved. useEffect(() => { parentRef.current?.scrollTo({ top: 0 }); }, [page]); 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 dimmed = dimmedIds?.has(thread.id) ?? false; const unsaved = saved !== null && !saved.has(thread.id); // The sender column lists everyone who wrote in the thread, oldest first, // with the user's own accounts shown as "me" — so a thread you started // and someone answered reads "me, Alice". Recipients are not listed. const senders = thread.senders.length ? thread.senders : thread.participants.slice(0, 1); const names = senders.length ? senderNames(senders, isMe, t("mail.me")) : "(no one)"; // The avatar is the other side of the conversation where there is one. const primary = senders.find((a) => !isMe(a)) ?? senders[0]; // The badge still counts everyone involved — the name is narrowed, the // "how many people are in this" signal is not. 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 (
!dimmed && onSelect(thread.id)} onKeyDown={(e) => { if (!dimmed && (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)`, }} // A row leaving the list lets the ones below glide up into its place. className={`group absolute inset-x-0 px-2 ${compact ? "pt-5" : "pt-[7px]"} motion-safe:transition-transform motion-safe:duration-200 motion-safe:ease-out ${ dimmed ? "pointer-events-none opacity-40" : "cursor-pointer" } ${unsaved ? "opacity-45" : ""}`} >
{/* 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} )}
{compact ? (
{names} {thread.message_count > 1 && ( {thread.message_count} )} {thread.has_attachments && } {thread.pinned && ( )} {timeLabel(thread.last_message_at)}
{thread.subject}
{thread.snippet}
) : ( <> {/* 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)}
)}
); })}
); }