2026-07-17 23:20:34 +02:00
|
|
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
|
|
|
import { useRef } from "react";
|
|
|
|
|
|
|
|
|
|
import { useT } from "../../i18n";
|
2026-07-22 07:40:29 +02:00
|
|
|
import type { Address, ThreadSummary } from "../../lib/types";
|
2026-07-17 23:20:34 +02:00
|
|
|
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" });
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-22 07:40:29 +02:00
|
|
|
// 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 (
|
|
|
|
|
<svg
|
|
|
|
|
width="17"
|
|
|
|
|
height="17"
|
|
|
|
|
viewBox="0 0 16 16"
|
|
|
|
|
fill="none"
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
strokeWidth="1.4"
|
|
|
|
|
strokeLinecap="round"
|
|
|
|
|
strokeLinejoin="round"
|
|
|
|
|
aria-hidden
|
|
|
|
|
className="shrink-0"
|
|
|
|
|
>
|
|
|
|
|
<path d="M12.5 6.5l-5.2 5.2a2.5 2.5 0 0 1-3.5-3.5l5.4-5.4a1.6 1.6 0 0 1 2.3 2.3l-5.3 5.3a0.8 0.8 0 0 1-1.1-1.1l4.9-4.9" />
|
|
|
|
|
</svg>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 23:20:34 +02:00
|
|
|
// Virtualized thread list (SPEC §2 list/MessageList.tsx).
|
|
|
|
|
export function MessageList({
|
|
|
|
|
threads,
|
|
|
|
|
selectedId,
|
2026-07-22 07:40:29 +02:00
|
|
|
folder,
|
2026-07-17 23:20:34 +02:00
|
|
|
onSelect,
|
2026-07-22 07:40:29 +02:00
|
|
|
onArchive,
|
|
|
|
|
onRestore,
|
|
|
|
|
onTrash,
|
|
|
|
|
onDeletePermanent,
|
|
|
|
|
onTogglePin,
|
2026-07-22 21:49:48 +02:00
|
|
|
checkedIds,
|
|
|
|
|
onToggleCheck,
|
2026-07-17 23:20:34 +02:00
|
|
|
}: {
|
|
|
|
|
threads: ThreadSummary[];
|
|
|
|
|
selectedId?: string;
|
2026-07-22 07:40:29 +02:00
|
|
|
folder: string;
|
2026-07-17 23:20:34 +02:00
|
|
|
onSelect: (id: string) => void;
|
2026-07-22 07:40:29 +02:00
|
|
|
onArchive: (id: string) => void;
|
|
|
|
|
onRestore: (id: string) => void;
|
|
|
|
|
onTrash: (id: string) => void;
|
|
|
|
|
onDeletePermanent: (id: string) => void;
|
|
|
|
|
onTogglePin: (id: string, pinned: boolean) => void;
|
2026-07-22 21:49:48 +02:00
|
|
|
checkedIds: Set<string>;
|
|
|
|
|
onToggleCheck: (id: string) => void;
|
2026-07-17 23:20:34 +02:00
|
|
|
}) {
|
|
|
|
|
const t = useT();
|
|
|
|
|
const parentRef = useRef<HTMLDivElement>(null);
|
2026-07-22 07:40:29 +02:00
|
|
|
// In Trash/Spam/Archive the middle action restores; elsewhere it archives.
|
|
|
|
|
const isRemovalFolder = folder === "trash" || folder === "junk" || folder === "archive";
|
2026-07-17 23:20:34 +02:00
|
|
|
|
2026-07-22 21:49:48 +02:00
|
|
|
// 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;
|
|
|
|
|
|
2026-07-17 23:20:34 +02:00
|
|
|
const virtualizer = useVirtualizer({
|
|
|
|
|
count: threads.length,
|
|
|
|
|
getScrollElement: () => parentRef.current,
|
2026-07-22 07:40:29 +02:00
|
|
|
estimateSize: () => 75, // 68 row + 7px gap
|
2026-07-17 23:20:34 +02:00
|
|
|
overscan: 8,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (threads.length === 0) {
|
2026-07-22 21:49:48 +02:00
|
|
|
return <div className="p-6 text-[15px] text-app-faint">{t("mail.noConversations")}</div>;
|
2026-07-17 23:20:34 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
2026-07-22 21:49:48 +02:00
|
|
|
const checked = checkedIds.has(thread.id);
|
2026-07-22 07:40:29 +02:00
|
|
|
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();
|
|
|
|
|
};
|
2026-07-17 23:20:34 +02:00
|
|
|
return (
|
2026-07-22 07:40:29 +02:00
|
|
|
<div
|
2026-07-17 23:20:34 +02:00
|
|
|
key={thread.id}
|
2026-07-22 07:40:29 +02:00
|
|
|
role="button"
|
|
|
|
|
tabIndex={0}
|
2026-07-17 23:20:34 +02:00
|
|
|
onClick={() => onSelect(thread.id)}
|
2026-07-22 07:40:29 +02:00
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
if (e.key === "Enter" || e.key === " ") onSelect(thread.id);
|
|
|
|
|
}}
|
2026-07-17 23:20:34 +02:00
|
|
|
style={{
|
|
|
|
|
position: "absolute",
|
|
|
|
|
top: 0,
|
|
|
|
|
left: 0,
|
|
|
|
|
width: "100%",
|
|
|
|
|
height: item.size,
|
|
|
|
|
transform: `translateY(${item.start}px)`,
|
|
|
|
|
}}
|
2026-07-22 07:40:29 +02:00
|
|
|
className="group absolute inset-x-0 cursor-pointer px-2 pt-[7px]"
|
2026-07-17 23:20:34 +02:00
|
|
|
>
|
2026-07-22 07:40:29 +02:00
|
|
|
<div
|
|
|
|
|
className={`flex h-full items-center gap-3 rounded-lg px-3 ${
|
2026-07-22 21:49:48 +02:00
|
|
|
selected || checked ? "bg-app-active" : "group-hover:bg-app-hover"
|
2026-07-22 07:40:29 +02:00
|
|
|
}`}
|
|
|
|
|
>
|
2026-07-22 21:49:48 +02:00
|
|
|
{/* Avatar, replaced by a selection checkbox on avatar-hover or once
|
|
|
|
|
selection mode is active. An unread dot sits in the left margin. */}
|
|
|
|
|
<div className="group/av relative ml-5 h-[33px] w-[33px] shrink-0">
|
|
|
|
|
{unread && (
|
|
|
|
|
<span
|
|
|
|
|
aria-hidden
|
|
|
|
|
className="absolute -left-[21px] top-1/2 h-2 w-2 -translate-y-1/2 rounded-full bg-app-accent"
|
|
|
|
|
/>
|
2026-07-22 07:40:29 +02:00
|
|
|
)}
|
2026-07-22 21:49:48 +02:00
|
|
|
<div className={checkMode ? "hidden" : "contents group-hover/av:hidden"}>
|
|
|
|
|
<span
|
|
|
|
|
className="flex h-[33px] w-[33px] items-center justify-center rounded-full text-[13px] font-semibold text-white"
|
|
|
|
|
style={{ backgroundColor: avatarBg }}
|
|
|
|
|
>
|
|
|
|
|
{initials(primary)}
|
|
|
|
|
</span>
|
|
|
|
|
{extra > 0 && (
|
|
|
|
|
<span className="absolute -bottom-1 -left-1.5 rounded-full bg-app-hover px-1 text-[10px] font-medium text-app-muted ring-2 ring-app-surface">
|
|
|
|
|
+{extra}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
aria-label={t("mail.select")}
|
|
|
|
|
aria-pressed={checked}
|
|
|
|
|
onClick={stop(() => onToggleCheck(thread.id))}
|
|
|
|
|
className={`absolute inset-0 items-center justify-center ${
|
|
|
|
|
checkMode ? "flex" : "hidden group-hover/av:flex"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
<span
|
|
|
|
|
className={`flex h-[17px] w-[17px] items-center justify-center rounded-md border-2 transition-colors ${
|
|
|
|
|
checked
|
|
|
|
|
? "border-app-accent bg-app-accent text-white"
|
|
|
|
|
: "border-app-faint hover:border-app-muted"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{checked && (
|
|
|
|
|
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round">
|
|
|
|
|
<path d="M3.5 8.5l3 3 6-7" />
|
|
|
|
|
</svg>
|
|
|
|
|
)}
|
|
|
|
|
</span>
|
|
|
|
|
</button>
|
2026-07-22 07:40:29 +02:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Sender column */}
|
2026-07-22 21:49:48 +02:00
|
|
|
<div className="flex w-[200px] min-w-0 shrink items-center gap-1.5">
|
2026-07-22 07:40:29 +02:00
|
|
|
<span
|
2026-07-22 21:49:48 +02:00
|
|
|
className={`truncate text-[15px] ${
|
|
|
|
|
unread ? "font-black text-app-fg" : "text-app-fg"
|
2026-07-22 07:40:29 +02:00
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{names}
|
|
|
|
|
</span>
|
|
|
|
|
{thread.message_count > 1 && (
|
2026-07-22 21:49:48 +02:00
|
|
|
<span className="shrink-0 rounded-full bg-app-hover px-1.5 text-[12px] text-app-muted">
|
2026-07-22 07:40:29 +02:00
|
|
|
{thread.message_count}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Subject + snippet (extra left margin -> 3x the gap to the sender). */}
|
|
|
|
|
<div className="ml-6 flex min-w-0 flex-1 flex-col justify-center">
|
|
|
|
|
<div
|
2026-07-22 21:49:48 +02:00
|
|
|
className={`truncate text-[14px] ${
|
|
|
|
|
unread ? "font-black text-app-fg" : "text-app-fg"
|
2026-07-22 07:40:29 +02:00
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{thread.subject}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="truncate text-xs text-app-faint">{thread.snippet}</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 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. */}
|
2026-07-22 21:49:48 +02:00
|
|
|
<div className="relative flex shrink-0 items-center justify-end gap-5">
|
2026-07-22 07:40:29 +02:00
|
|
|
{/* Attachment: fixed slot (reserved even when empty) so the rail
|
|
|
|
|
width — and thus the excerpt width — is the same in every row. */}
|
|
|
|
|
<span className="flex w-[17px] shrink-0 justify-center text-app-faint">
|
|
|
|
|
{thread.has_attachments && <Paperclip />}
|
|
|
|
|
</span>
|
|
|
|
|
{/* Pin: pink and always shown when pinned; otherwise revealed on hover. */}
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
title={thread.pinned ? t("mail.unpin") : t("mail.pin")}
|
|
|
|
|
onClick={stop(() => onTogglePin(thread.id, thread.pinned))}
|
|
|
|
|
className={
|
|
|
|
|
thread.pinned
|
|
|
|
|
? "text-[rgb(220_129_143)]"
|
|
|
|
|
: "invisible text-app-muted hover:text-app-fg group-hover:visible"
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<Icon name="pin" size={17} />
|
|
|
|
|
</button>
|
|
|
|
|
{/* Archive (or Restore in Trash/Spam/Archive) + delete: hover-only,
|
|
|
|
|
but reserve their slots always. In Trash, delete is permanent. */}
|
|
|
|
|
{isRemovalFolder ? (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
title={t("mail.restore")}
|
|
|
|
|
onClick={stop(() => onRestore(thread.id))}
|
|
|
|
|
className="invisible text-app-muted hover:text-app-fg group-hover:visible"
|
|
|
|
|
>
|
|
|
|
|
<Icon name="restore" size={17} />
|
|
|
|
|
</button>
|
|
|
|
|
) : (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
title={t("mail.archive")}
|
|
|
|
|
onClick={stop(() => onArchive(thread.id))}
|
|
|
|
|
className="invisible text-app-muted hover:text-app-fg group-hover:visible"
|
|
|
|
|
>
|
|
|
|
|
<Icon name="archive" size={15} />
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
title={t("mail.delete")}
|
|
|
|
|
onClick={stop(() =>
|
|
|
|
|
folder === "trash" ? onDeletePermanent(thread.id) : onTrash(thread.id),
|
|
|
|
|
)}
|
2026-07-22 21:49:48 +02:00
|
|
|
className="invisible mr-[7px] text-app-muted hover:text-app-fg group-hover:visible"
|
2026-07-22 07:40:29 +02:00
|
|
|
>
|
|
|
|
|
<Icon name="trash" size={17} />
|
|
|
|
|
</button>
|
|
|
|
|
{/* Date overlay, aligned to the right edge (over the archive/trash
|
|
|
|
|
slots); hidden on hover so the actions show through. */}
|
2026-07-22 21:49:48 +02:00
|
|
|
<span
|
|
|
|
|
className={`absolute inset-y-0 right-0 flex items-center whitespace-nowrap text-[13px] tabular-nums text-app-faint group-hover:hidden ${
|
|
|
|
|
unread ? "font-bold" : ""
|
|
|
|
|
}`}
|
|
|
|
|
>
|
2026-07-22 07:40:29 +02:00
|
|
|
{timeLabel(thread.last_message_at)}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
2026-07-17 23:20:34 +02:00
|
|
|
</div>
|
2026-07-22 07:40:29 +02:00
|
|
|
</div>
|
2026-07-17 23:20:34 +02:00
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|