import { memo, useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { Archive, ArrowLeft, CheckSquare, FolderInput, PanelRight, PanelBottom, PanelTop, Filter, Inbox, Mail, MailOpen, MoreVertical, Paperclip, RefreshCw, Reply, Search, Star, Tag, Trash2, AlertOctagon, Forward, Eraser, ShieldCheck } from "lucide-react"; import { useLocation } from "wouter"; import { useMail, type ListState } from "@/store/mail"; import { dateTimeKey, useSettings } from "@/store/settings"; import type { Email, Id } from "@/jmap/types"; import { formatListDate } from "@/lib/format"; import { displayName, shortName } from "@/lib/address"; import { Avatar, Empty, useIsMobile } from "@/ui/misc"; import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover"; import { confirmDialog } from "@/ui/dialog"; import { useCompose } from "@/store/compose"; import { FilterFromMessageDialog } from "./FilterFromMessage"; export interface ListActions { archive: (rows?: Id[]) => Promise; trash: (rows?: Id[]) => Promise; spam: (rows?: Id[]) => Promise; read: (read: boolean, rows?: Id[]) => Promise; star: (on: boolean, rows?: Id[]) => Promise; move: (rows?: Id[]) => void; label: (rows: Id[] | undefined, anchor: { x: number; y: number }) => void; moveTo: (ids: Id[], mailboxId: Id) => Promise; } interface Props { title: string; list: ListState | null; openThreadId: Id | null; focusId: Id | null; setFocusId: (id: Id | null) => void; onOpen: (rowId: Id) => void; actions: ListActions; mailboxId: Id | null; isSearch: boolean; } export function MessageList({ title, list, openThreadId, focusId, setFocusId, onOpen, actions, mailboxId, isSearch }: Props) { const [, navigate] = useLocation(); const emails = useMail((s) => s.emails); const threads = useMail((s) => s.threads); const selected = useMail((s) => s.selected); const select = useMail((s) => s.select); const selectAll = useMail((s) => s.selectAll); const clearSelection = useMail((s) => s.clearSelection); const loadMore = useMail((s) => s.loadMore); const refreshList = useMail((s) => s.refreshList); const mailboxes = useMail((s) => s.mailboxes); const settings = useSettings((s) => s.settings); const updateSettings = useSettings((s) => s.update); const parentRef = useRef(null); const isMobile = useIsMobile(); const [paneWidth, setPaneWidth] = useState(0); useEffect(() => { const el = parentRef.current; if (!el) return; const ro = new ResizeObserver((entries) => { const w = entries[0]?.contentRect.width ?? 0; setPaneWidth(w); }); ro.observe(el); return () => ro.disconnect(); }, []); const twoLine = isMobile || (paneWidth > 0 && paneWidth < 640); const ctxMenu = useMenu(); const [ctxRow, setCtxRow] = useState(null); const moreMenu = useMenu(); const [refreshing, setRefreshing] = useState(false); const [filterFrom, setFilterFrom] = useState(null); const lastClick = useRef(null); const ids = list?.ids ?? []; const selCount = Object.keys(selected).length; const mailbox = mailboxId ? mailboxes[mailboxId] : undefined; const isTrashOrJunk = mailbox?.role === "trash" || mailbox?.role === "junk"; const isDrafts = mailbox?.role === "drafts"; const rowHeight = twoLine ? (settings.density === "compact" ? 56 : settings.density === "comfortable" ? 78 : 66) : settings.density === "compact" ? 36 : settings.density === "comfortable" ? 52 : 44; const virtualizer = useVirtualizer({ count: ids.length + (list && !list.exhausted ? 1 : 0), getScrollElement: () => parentRef.current, estimateSize: () => rowHeight, overscan: 12, }); // Re-measure when the row height changes (one-line ↔ two-line, density). useEffect(() => { virtualizer.measure(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [rowHeight]); // Infinite scroll const items = virtualizer.getVirtualItems(); useEffect(() => { const last = items[items.length - 1]; if (!last || !list) return; if (last.index >= ids.length - 5 && !list.loadingMore && !list.exhausted && !list.loading) void loadMore(); }, [items, ids.length, list, loadMore]); // Pull-to-refresh-ish: manual refresh button const doRefresh = async () => { setRefreshing(true); await refreshList(); await useMail.getState().loadMailboxes(); setRefreshing(false); }; const onRowClick = useCallback( (e: MouseEvent, rowId: Id) => { if (e.shiftKey && lastClick.current) { const a = ids.indexOf(lastClick.current); const b = ids.indexOf(rowId); if (a >= 0 && b >= 0) { const [s, en] = a < b ? [a, b] : [b, a]; select(ids.slice(s, en + 1), true); window.getSelection()?.removeAllRanges(); return; } } if (e.ctrlKey || e.metaKey) { select([rowId], !selected[rowId]); lastClick.current = rowId; return; } lastClick.current = rowId; if (selCount > 0 && isMobile) { select([rowId], !selected[rowId]); return; } onOpen(rowId); }, [ids, select, selected, selCount, isMobile, onOpen], ); const onContext = useCallback( (e: MouseEvent, rowId: Id) => { e.preventDefault(); setCtxRow(rowId); setFocusId(rowId); ctxMenu.openAt(e.clientX, e.clientY); }, [ctxMenu, setFocusId], ); const ctxTargets = useMemo(() => (ctxRow ? (selected[ctxRow] ? Object.keys(selected) : [ctxRow]) : []), [ctxRow, selected]); const allSelected = ids.length > 0 && ids.every((id) => selected[id]); const someUnread = ctxTargets.some((id) => !emails[id]?.keywords.$seen); const someUnstarred = ctxTargets.some((id) => !emails[id]?.keywords.$flagged); return (
{isMobile && isSearch && ( )} { if (el) el.indeterminate = selCount > 0 && !allSelected; }} onChange={() => (allSelected || selCount > 0 ? clearSelection() : selectAll())} /> {selCount > 0 ? ( <> {selCount} selected ) : ( <> {title} {list && !list.loading && {list.total.toLocaleString()}} Reading pane } label="Right of the list" checked={settings.readingPane === "right"} onClick={() => updateSettings({ readingPane: "right" })} /> } label="Below the list" checked={settings.readingPane === "bottom"} onClick={() => updateSettings({ readingPane: "bottom" })} /> } label="Hidden (open full width)" checked={settings.readingPane === "off"} onClick={() => updateSettings({ readingPane: "off" })} /> } label="Select all" onClick={selectAll} /> } label="Mark all as read" onClick={() => mailboxId && void useMail.getState().markMailboxRead(mailboxId)} disabled={!mailboxId} /> {isTrashOrJunk && ( <> } label={`Empty ${mailbox?.name}`} onClick={async () => { if (await confirmDialog({ title: `Empty ${mailbox?.name}?`, message: "All messages will be permanently deleted.", confirmLabel: "Empty", danger: true })) void useMail.getState().emptyMailbox(mailboxId!); }} /> )} )}
{list?.error && (
{list.error}
)}
{list?.loading && ids.length === 0 ? (
{[...Array(12)].map((_, i) => (
))}
) : ids.length === 0 && list && !list.loading ? ( : } title={isSearch ? "No results" : mailbox?.role === "inbox" ? "You're all caught up" : "Nothing here"}> {isSearch ? "Try different keywords or filters." : mailbox?.role === "inbox" ? "No new mail in your inbox." : "This folder is empty."} ) : (
{items.map((vi) => { const id = ids[vi.index]; if (!id) { return (
{list?.loadingMore ? : ""}
); } const e = emails[id]; if (!e) return
; const thread = list?.collapseThreads ? threads[e.threadId] : undefined; return ( emails[x]).filter((x): x is Email => Boolean(x)) : undefined} top={vi.start} height={vi.size} selected={Boolean(selected[id])} focused={focusId === id} open={openThreadId === e.threadId} twoLine={twoLine} showAvatar={settings.showAvatars} showPreview={settings.showPreview} isDrafts={isDrafts} mailboxId={mailboxId} isSent={mailbox?.role === "sent"} onClick={onRowClick} onContext={onContext} onSelect={(rowId, on) => { select([rowId], on); lastClick.current = rowId; }} onStar={(rowId, on) => void actions.star(on, [rowId])} onArchive={(rowId) => void actions.archive([rowId])} onTrash={(rowId) => void actions.trash([rowId])} onRead={(rowId, read) => void actions.read(read, [rowId])} selectedIds={selected} /> ); })}
)}
} label="Reply" onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().reply(e, "reply"); }} /> } label="Forward" onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().reply(e, "forward"); }} /> } label="Archive" kbd="e" onClick={() => void actions.archive(ctxTargets)} /> } label="Delete" kbd="#" onClick={() => void actions.trash(ctxTargets)} /> } label={mailbox?.role === "junk" ? "Not spam" : "Report spam"} kbd="!" onClick={() => void actions.spam(ctxTargets)} /> : } label={someUnread ? "Mark as read" : "Mark as unread"} onClick={() => void actions.read(someUnread, ctxTargets)} /> } label={someUnstarred ? "Add star" : "Remove star"} kbd="s" onClick={() => void actions.star(someUnstarred, ctxTargets)} /> } label="Move to…" kbd="v" onClick={() => actions.move(ctxTargets)} /> } label="Label…" kbd="l" onClick={() => actions.label(ctxTargets, ctxMenu.anchor ?? { x: 0, y: 0 })} /> } label="Filter messages like this…" onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) setFilterFrom(e); }} /> {filterFrom && setFilterFrom(null)} />}
); } interface RowProps { email: Email; threadEmails?: Email[]; top: number; height: number; selected: boolean; focused: boolean; open: boolean; twoLine: boolean; showAvatar: boolean; showPreview: boolean; isDrafts: boolean; isSent: boolean; mailboxId: Id | null; selectedIds: Record; onClick: (e: MouseEvent, id: Id) => void; onContext: (e: MouseEvent, id: Id) => void; onSelect: (id: Id, on: boolean) => void; onStar: (id: Id, on: boolean) => void; onArchive: (id: Id) => void; onTrash: (id: Id) => void; onRead: (id: Id, read: boolean) => void; } const Row = memo(function Row({ email: e, threadEmails, top, height, selected, focused, open, twoLine, showAvatar, showPreview, isDrafts, isSent, mailboxId, selectedIds, onClick, onContext, onSelect, onStar, onArchive, onTrash, onRead }: RowProps) { const labels = useSettings((s) => s.settings.labels); // Subscribed purely so the row re-renders when the date format changes. useSettings((s) => dateTimeKey(s.settings)); const inScope = threadEmails ? threadEmails.filter((x) => (mailboxId ? x.mailboxIds[mailboxId] : true)) : [e]; const scope = inScope.length ? inScope : [e]; const unread = scope.some((x) => !x.keywords.$seen); const starred = scope.some((x) => x.keywords.$flagged); const hasAtt = scope.some((x) => x.hasAttachment); const answered = e.keywords.$answered; const forwarded = e.keywords.$forwarded; const latest = scope.reduce((a, b) => (a.receivedAt > b.receivedAt ? a : b), scope[0]!); const count = threadEmails ? scope.length : 0; // Participants: Gmail-style "Ann, Bob, Me (3)" const names = useMemo(() => { const out: string[] = []; const seen = new Set(); const src = isSent || isDrafts ? scope.flatMap((x) => x.to ?? []) : scope.map((x) => x.from?.[0]).filter(Boolean); for (const a of src) { if (!a) continue; const k = a.email.toLowerCase(); if (seen.has(k)) continue; seen.add(k); out.push(count > 1 ? shortName(a) : displayName(a)); } return out; }, [scope, isSent, isDrafts, count]); const who = (isSent || isDrafts ? (names.length ? `To: ${names.join(", ")}` : "(no recipients)") : names.join(", ")) || "(unknown)"; const rowLabels = labels.filter((l) => scope.some((x) => x.keywords[l.keyword])); const onDragStart = (ev: DragEvent) => { const ids = selectedIds[e.id] ? Object.keys(selectedIds) : [e.id]; // include thread emails in scope const all = new Set(); for (const id of ids) { all.add(id); } for (const x of scope) all.add(x.id); ev.dataTransfer.setData("application/x-ihasmail-emails", JSON.stringify([...all])); ev.dataTransfer.effectAllowed = "move"; const ghost = document.createElement("div"); ghost.className = "drag-ghost"; ghost.textContent = `${ids.length > 1 ? `${ids.length} conversations` : e.subject || "(no subject)"}`; document.body.appendChild(ghost); ev.dataTransfer.setDragImage(ghost, 10, 10); setTimeout(() => ghost.remove(), 0); }; return (
onClick(ev, e.id)} onContextMenu={(ev) => onContext(ev, e.id)} draggable onDragStart={onDragStart} role="row" aria-selected={selected} > ev.stopPropagation()} onChange={(ev) => onSelect(e.id, ev.target.checked)} aria-label="Select" /> {!twoLine && ( )} {showAvatar && } {twoLine ? (
{who} {count > 1 && {count}} {hasAtt && } {formatListDate(latest.receivedAt)}
{isDrafts && Draft} {e.subject || "(no subject)"} {showPreview && {latest.preview}}
{rowLabels.length > 0 &&
{rowLabels.map((l) => {l.name})}
}
) : ( <> {who} {count > 1 && {count}} {isDrafts && Draft} {rowLabels.length > 0 && {rowLabels.map((l) => {l.name})}} {e.subject || "(no subject)"} {showPreview && {latest.preview}} {(answered || forwarded) && {answered ? : }} {hasAtt && } {formatListDate(latest.receivedAt)} )}
); });