import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { Archive, ArrowLeft, CalendarPlus, CheckSquare, FolderInput, PanelRight, PanelBottom, PanelTop, Filter, Inbox, Mail, MailOpen, MailPlus, MoreVertical, Paperclip, RefreshCw, Reply, Search, Star, Tag, Trash2, AlertOctagon, Forward, Eraser, ShieldCheck, X } 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 { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/emptyFolder"; import { displayName, shortName } from "@/lib/address"; import { Avatar, Empty, useIsMobile, useIsTouch } from "@/ui/misc"; import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover"; import { useCompose } from "@/store/compose"; import { useCalendar } from "@/store/calendar"; import { startAppointment } from "@/lib/appointment"; import { toast } from "@/ui/toast"; import { haptic, usePullToRefresh, useTouchRow, PULL_TRIGGER } from "@/lib/touch"; import { describeSwipe, type SwipeAction, type SwipeDescriptor, type SwipeIcon } from "@/lib/swipe"; import { FilterFromMessageDialog } from "./FilterFromMessage"; import { plural, t } from "@/lib/i18n"; /** * The glyph on the strip a swipe reveals. Sized larger than the toolbar's * icons: it is read at arm's length, in motion, out of the corner of an eye. */ const SWIPE_ICON: Record = { archive: , delete: , spam: , "not-spam": , read: , unread: , star: , unstar: , move: , }; 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); // The same element as `parentRef`, held in state as well: the pull-to-refresh // listeners have to be bound in an effect that re-runs when the element // arrives, and a ref does not tell anybody it has been filled in. const [scrollEl, setScrollEl] = useState(null); const isMobile = useIsMobile(); const isTouch = useIsTouch(); const [paneWidth, setPaneWidth] = useState(0); useEffect(() => { const el = scrollEl; if (!el) return; const ro = new ResizeObserver((entries) => { const w = entries[0]?.contentRect.width ?? 0; setPaneWidth(w); }); ro.observe(el); return () => ro.disconnect(); }, [scrollEl]); const twoLine = isMobile || (paneWidth > 0 && paneWidth < 640); const ctxMenu = useMenu(); const [ctxRow, setCtxRow] = useState(null); /** Only offered where there is a calendar to put the appointment in. */ const hasCalendar = useCalendar((s) => s.available); const moreMenu = useMenu(); const [refreshing, setRefreshing] = useState(false); const [filterFrom, setFilterFrom] = useState(null); const lastClick = useRef(null); const selMenu = useMenu(); /** The one row currently under a finger, and what letting go would do. */ const [swiping, setSwiping] = useState<{ id: Id; dir: -1 | 1; armed: boolean; desc: SwipeDescriptor } | null>(null); /** How far the list has been pulled down, and whether that is far enough. */ const [pull, setPull] = useState<{ y: number; armed: boolean; live: boolean }>({ y: 0, armed: false, live: false }); 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]); const doRefresh = useCallback(async () => { setRefreshing(true); await refreshList(); await useMail.getState().loadMailboxes(); setRefreshing(false); }, [refreshList]); /* * Pull the top of the list down to refresh it. * * The toolbar button stays: it is the only way to do this with a mouse, and * on a phone it is the way that works when the list is already scrolled down * a thousand messages. This is the one a thumb reaches for first. */ usePullToRefresh(scrollEl, doRefresh, { enabled: isTouch, onPull: useCallback((y: number, armed: boolean, live: boolean) => setPull({ y, armed, live }), []), }); 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], ); /* * Hold a row to select it, the way the mail app the phone came with does. * * Selection was reachable on a touchscreen already -- the checkboxes are * always visible where there is no hover -- but a checkbox is a small target * beside an avatar, and nobody aims for it, because on a phone holding the * row *is* how you select it. Once one row is selected, plain taps toggle * the rest (see `onRowClick`), so this only has to open the mode. */ const onLongPress = useCallback( (rowId: Id) => { haptic(15); setFocusId(rowId); lastClick.current = rowId; select([rowId], !useMail.getState().selected[rowId]); }, [select, setFocusId], ); const onSwipeState = useCallback((rowId: Id, state: { dir: -1 | 1; armed: boolean; desc: SwipeDescriptor } | null) => { // A row clearing itself must not clear a gesture that has since moved on // to another row -- rows unmount as the list scrolls, at any moment. setSwiping((cur) => (state ? { id: rowId, ...state } : cur?.id === rowId ? null : cur)); }, []); const fireSwipe = useCallback( async (rowId: Id, d: SwipeDescriptor) => { switch (d.action) { case "archive": await actions.archive([rowId]); break; case "delete": await actions.trash([rowId]); break; case "spam": await actions.spam([rowId]); break; // `on` rather than a fresh look at the row: fire what the strip said. case "read": await actions.read(d.on === true, [rowId]); break; case "star": await actions.star(d.on === true, [rowId]); break; case "move": actions.move([rowId]); break; } }, [actions], ); 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 ? ( <> {plural(selCount, { one: "{n} selected", other: "{n} selected" })} {/* The three buttons above marked hide-mobile have nowhere to go on a phone, and used to simply not exist there: selecting mail on a touchscreen could archive, delete, mark read and move, and could not report spam, mark unread or label. Now that holding a row is how selection starts, that gap is the first thing a thumb finds. */} {isMobile && ( <> : } label={mailbox?.role === "junk" ? "Not spam" : "Report spam"} onClick={() => void actions.spam()} /> } label={t("Mark as unread")} onClick={() => void actions.read(false)} /> } label={t("Label…")} onClick={() => actions.label(undefined, { x: window.innerWidth / 2, y: 100 })} /> {/* A phone reaches this menu by holding a row, which is also the only way it reaches per-message actions at all -- there is no right-click. Offered for one message only: the draft is one message's subject and body, and there is no sensible event to make out of five of them. */} {hasCalendar && selCount === 1 && ( } label={t("Create event…")} onClick={() => { const e = emails[Object.keys(selected)[0] as Id]; if (e) void startAppointment(e, navigate).catch((err: unknown) => toast.error((err as Error).message)); }} /> )} } label={t("Select all")} onClick={selectAll} /> } label={t("Clear selection")} onClick={clearSelection} /> )} ) : ( <> {title} {list && !list.loading && {list.total.toLocaleString()}} {t("Reading pane")} } label={t("Right of the list")} checked={settings.readingPane === "right"} onClick={() => updateSettings({ readingPane: "right" })} /> } label={t("Below the list")} checked={settings.readingPane === "bottom"} onClick={() => updateSettings({ readingPane: "bottom" })} /> } label={t("Hidden (open full width)")} checked={settings.readingPane === "off"} onClick={() => updateSettings({ readingPane: "off" })} /> } label={t("Select all")} onClick={selectAll} /> } label={t("Mark all as read")} onClick={() => mailboxId && void useMail.getState().markMailboxRead(mailboxId)} disabled={!mailboxId} /> {mailbox && canEmpty(mailbox.role) && ( <> } label={emptyLabel(mailbox)} disabled={!mailbox.totalEmails} onClick={() => void confirmAndEmpty(mailbox)} /> )} )}
{list?.error && (
{list.error}
)} {/* Junk Mail's own banner, the way every other mail client offers it: clearing spam is the one thing people come to this folder to do, and making them find it in a menu is making them hunt for it. Only here, and only with something to delete. It says "permanently" because that is the part worth knowing before clicking — these do not pass through Deleted Items on the way out. */} {mailbox?.role === "junk" && !!mailbox.totalEmails && !selCount && (
{t("Deleting spam is permanent — it does not go to Deleted Items first.")}
)}
{ parentRef.current = el; setScrollEl(el); }} className={`mail-list ${selCount ? "has-selection" : ""} ${twoLine ? "two-line" : ""} ${settings.density === "compact" ? "compact" : ""}`} tabIndex={-1} > {/* The pull dial. Zero-height and sticky, so it rides the top of the scroller without taking a row's worth of space from the list when nobody is pulling — and so it stays put while the content below it comes down. */} {isTouch && ( )}
{list?.loading && ids.length === 0 ? (
{[...Array(12)].map((_, i) => (
))}
) : ids.length === 0 && list && !list.loading ? ( : } title={isSearch ? t("No results") : mailbox?.role === "inbox" ? t("You're all caught up") : t("Nothing here")}> {isSearch ? t("Try different keywords or filters.") : mailbox?.role === "inbox" ? t("No new mail in your inbox.") : t("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; const strip = swiping?.id === id ? swiping : null; return ( {/* What the row is sliding off to reveal. Only ever one of these exists, under the row being dragged, painted into the same slot the row occupies — the row's own background is opaque, so it covers this until the finger moves it. */} {strip && ( )} 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} touch={isTouch} role={mailbox?.role ?? null} swipeLeft={settings.swipeLeft} swipeRight={settings.swipeRight} onLongPress={onLongPress} onSwipeState={onSwipeState} onSwipeFire={fireSwipe} /> ); })}
)}
} label={t("Reply")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().reply(e, "reply"); }} /> } label={t("Forward")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().reply(e, "forward"); }} /> } label={t("Compose as new")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().composeAsNew(e); }} /> } label={t("Archive")} kbd="e" onClick={() => void actions.archive(ctxTargets)} /> } label={t("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={t("Move to…")} kbd="v" onClick={() => actions.move(ctxTargets)} /> } label={t("Label…")} kbd="l" onClick={() => actions.label(ctxTargets, ctxMenu.anchor ?? { x: 0, y: 0 })} /> } label={t("Filter messages like this…")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) setFilterFrom(e); }} /> {hasCalendar && } label={t("Create event…")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void startAppointment(e, navigate).catch((err: unknown) => toast.error((err as Error).message)); }} />} {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; /** Whether this list is being pointed at with a finger. */ touch: boolean; role: string | null; swipeLeft: SwipeAction; swipeRight: SwipeAction; onLongPress: (id: Id) => void; onSwipeState: (id: Id, state: { dir: -1 | 1; armed: boolean; desc: SwipeDescriptor } | null) => void; onSwipeFire: (id: Id, desc: SwipeDescriptor) => Promise; } 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, touch, role, swipeLeft, swipeRight, onLongPress, onSwipeState, onSwipeFire }: 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])); /* * How far this row has been dragged from home, and whether it is currently * animating back. Kept here rather than in the list so that a moving finger * re-renders one row instead of the whole virtualised list; the list is told * only the three things the strip behind the row needs -- which row, which * way, and whether letting go now would fire. */ const [dx, setDx] = useState(0); const [gliding, setGliding] = useState(false); const rowRef = useRef(null); const descFor = useCallback( (dir: -1 | 1) => describeSwipe(dir === 1 ? swipeRight : swipeLeft, { role, unread, starred }), [swipeLeft, swipeRight, role, unread, starred], ); // A row that scrolls out from under a live gesture takes its strip with it. useEffect(() => () => onSwipeState(e.id, null), [e.id, onSwipeState]); const gesture = useTouchRow({ enabled: touch, onLongPress: () => onLongPress(e.id), canSwipe: (dir) => Boolean(descFor(dir)), onSwipeMove: (offset, dir, armed) => { const desc = descFor(dir); if (!desc) return; setGliding(false); setDx(offset); onSwipeState(e.id, { dir, armed, desc }); }, onSwipeEnd: (dir) => { setGliding(true); const desc = dir ? descFor(dir) : null; if (!desc) { setDx(0); onSwipeState(e.id, null); return; } const settle = () => { setDx(0); onSwipeState(e.id, null); }; if (!desc.removes) { settle(); void onSwipeFire(e.id, desc); return; } /* * An action that empties the row sees it out first: the row leaves the * way the finger was taking it, and the list closes the gap behind it. * Snapping home and vanishing a frame later reads as a misfire. * * It still settles afterwards, because "removes" is what the action * means to do rather than what it did -- a delete the reader cancels at * the confirmation leaves the row here, and it has to come back. */ setDx(dir * (rowRef.current?.offsetWidth ?? 400)); window.setTimeout(() => { void Promise.resolve(onSwipeFire(e.id, desc)).finally(settle); }, 160); }, }); 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 ? plural(ids.length, { one: "{n} conversation", other: "{n} conversations" }) : e.subject || t("(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)} /* * Dragging a row into a folder is a mouse gesture, and on a touchscreen * it is the browser's idea of a long press — the same press that now * opens selection. Only one of them can have it. */ draggable={!touch} onDragStart={onDragStart} {...gesture} role="row" aria-selected={selected} > ev.stopPropagation()} onChange={(ev) => onSelect(e.id, ev.target.checked)} aria-label={t("Select")} /> {!twoLine && ( )} {showAvatar && } {twoLine ? (
{who} {count > 1 && {count}} {hasAtt && } {formatListDate(latest.receivedAt)}
{isDrafts && {t("Draft")}} {e.subject || t("(no subject)")} {showPreview && {latest.preview}}
{rowLabels.length > 0 &&
{rowLabels.map((l) => {l.name})}
}
) : ( <> {who} {count > 1 && {count}} {isDrafts && {t("Draft")}} {rowLabels.length > 0 && {rowLabels.map((l) => {l.name})}} {e.subject || t("(no subject)")} {showPreview && {latest.preview}} {(answered || forwarded) && {answered ? : }} {hasAtt && } {formatListDate(latest.receivedAt)} )}
); });