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. */}
{/* 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)}