On a 250 ms link, opening a conversation took two round trips: Thread/get, then the bodies. It now takes one. A known thread sends Thread/get and the missing bodies in the same tick; an unknown one chains Email/get off Thread/get with a back-reference, and falls back to fetching in parts when the thread is longer than one Email/get may carry. Conversations also start loading before the click: when the pointer rests on a row, as soon as a press begins, and for the row below the open one. The open waits for that load and does not repeat it. Going back to one of the last twelve folders shows its previous list at once, less messages that have left it, while the query runs.
840 lines
42 KiB
TypeScript
840 lines
42 KiB
TypeScript
import { Fragment, lazy, memo, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent, type PointerEvent, type ReactNode } from "react";
|
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
import { useShallow } from "zustand/react/shallow";
|
|
import { Archive, ArrowLeft, CalendarDays, CalendarRange, 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 { mailboxDisplayName } from "@/lib/mailbox/mailboxName";
|
|
import { groupByArchivePath, archivePath, type ArchiveGranularity } from "@/lib/mailbox/archiveDate";
|
|
import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/mailbox/emptyFolder";
|
|
import { rowIsOpen } from "@/lib/openMessage";
|
|
import { displayName, shortName } from "@/lib/address";
|
|
import { Avatar, Empty, useIsMobile, useIsTouch } from "@/ui/misc";
|
|
import { rowClick } from "@/lib/input/listSelection";
|
|
import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
|
|
import { useCompose } from "@/store/compose";
|
|
import { useCalendar } from "@/store/calendar";
|
|
import { startAppointment } from "@/lib/calendar/appointment";
|
|
import { toast } from "@/ui/toast";
|
|
import { haptic, usePullToRefresh, useTouchRow, PULL_TRIGGER } from "@/lib/input/touch";
|
|
import { describeSwipe, type SwipeAction, type SwipeDescriptor, type SwipeIcon } from "@/lib/input/swipe";
|
|
import { plural, t } from "@/lib/i18n";
|
|
|
|
// Loaded when first opened: it is not needed to show mail, and it is not small.
|
|
const FilterFromMessageDialog = lazy(() => import("./FilterFromMessage").then((m) => ({ default: m.FilterFromMessageDialog })));
|
|
|
|
/**
|
|
* 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<SwipeIcon, ReactNode> = {
|
|
archive: <Archive size={22} />,
|
|
delete: <Trash2 size={22} />,
|
|
spam: <AlertOctagon size={22} />,
|
|
"not-spam": <ShieldCheck size={22} />,
|
|
read: <MailOpen size={22} />,
|
|
unread: <Mail size={22} />,
|
|
star: <Star size={22} fill="currentColor" />,
|
|
unstar: <Star size={22} />,
|
|
move: <FolderInput size={22} />,
|
|
};
|
|
|
|
/**
|
|
* A callback whose identity never changes but which always runs the latest
|
|
* version of `fn`.
|
|
*
|
|
* The rows are memoized, and every handler they are given has to keep its
|
|
* identity for that to mean anything. The handlers here read the selection,
|
|
* the list and the menu, all of which change constantly -- so as ordinary
|
|
* `useCallback`s they were new on nearly every render, and every visible row
|
|
* rendered again with them.
|
|
*/
|
|
function useStableCallback<A extends unknown[], R>(fn: (...args: A) => R): (...args: A) => R {
|
|
const ref = useRef(fn);
|
|
// Updated after render rather than during it, so a render React throws away
|
|
// never leaves its version behind. Handlers only run on events, which come later.
|
|
useLayoutEffect(() => {
|
|
ref.current = fn;
|
|
});
|
|
return useCallback((...args: A) => ref.current(...args), []);
|
|
}
|
|
|
|
export interface ListActions {
|
|
archive: (rows?: Id[]) => Promise<void>;
|
|
trash: (rows?: Id[]) => Promise<void>;
|
|
spam: (rows?: Id[]) => Promise<void>;
|
|
read: (read: boolean, rows?: Id[]) => Promise<void>;
|
|
star: (on: boolean, rows?: Id[]) => Promise<void>;
|
|
move: (rows?: Id[]) => void;
|
|
label: (rows: Id[] | undefined, anchor: { x: number; y: number }) => void;
|
|
moveTo: (ids: Id[], mailboxId: Id) => Promise<void>;
|
|
}
|
|
|
|
interface Props {
|
|
title: string;
|
|
list: ListState | null;
|
|
openThreadId: Id | null;
|
|
/**
|
|
* With conversation view off, the one message the reading pane is showing.
|
|
* The row highlight follows this instead of the thread, or every message in
|
|
* a thread lights up when one of them is opened.
|
|
*/
|
|
openMessageId: 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, openMessageId, focusId, setFocusId, onOpen, actions, mailboxId, isSearch }: Props) {
|
|
const [, navigate] = useLocation();
|
|
const emails = useMail((s) => s.emails);
|
|
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<HTMLDivElement>(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<HTMLDivElement | null>(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<Id | null>(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<Email | null>(null);
|
|
const lastClick = useRef<Id | null>(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 = useStableCallback(
|
|
(e: MouseEvent, rowId: Id) => {
|
|
const action = rowClick({
|
|
rowId, ids, anchor: lastClick.current, selected,
|
|
modifiers: { shift: e.shiftKey, ctrl: e.ctrlKey || e.metaKey },
|
|
isMobile,
|
|
});
|
|
if (action.kind === "open") {
|
|
lastClick.current = rowId;
|
|
onOpen(rowId);
|
|
return;
|
|
}
|
|
select(action.ids, action.on);
|
|
if (action.moveAnchor) lastClick.current = rowId;
|
|
// Shift-clicking a list also drags a text selection across it, which
|
|
// leaves the rows looking smeared blue over the selection they meant.
|
|
else window.getSelection()?.removeAllRanges();
|
|
},
|
|
);
|
|
|
|
const onContext = useStableCallback((e: MouseEvent, rowId: Id) => {
|
|
e.preventDefault();
|
|
setCtxRow(rowId);
|
|
setFocusId(rowId);
|
|
ctxMenu.openAt(e.clientX, e.clientY);
|
|
});
|
|
|
|
/*
|
|
* 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 = useStableCallback((rowId: Id) => {
|
|
haptic(15);
|
|
setFocusId(rowId);
|
|
lastClick.current = rowId;
|
|
select([rowId], !useMail.getState().selected[rowId]);
|
|
});
|
|
|
|
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 = useStableCallback(
|
|
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;
|
|
}
|
|
},
|
|
);
|
|
const onRowSelect = useStableCallback((rowId: Id, on: boolean) => {
|
|
select([rowId], on);
|
|
lastClick.current = rowId;
|
|
});
|
|
const onRowStar = useStableCallback((rowId: Id, on: boolean) => void actions.star(on, [rowId]));
|
|
const onRowArchive = useStableCallback((rowId: Id) => void actions.archive([rowId]));
|
|
const onRowTrash = useStableCallback((rowId: Id) => void actions.trash([rowId]));
|
|
const onRowRead = useStableCallback((rowId: Id, read: boolean) => void actions.read(read, [rowId]));
|
|
|
|
const ctxTargets = useMemo(() => (ctxRow ? (selected[ctxRow] ? Object.keys(selected) : [ctxRow]) : []), [ctxRow, selected]);
|
|
|
|
/*
|
|
* Name the destination where there is only one, so the menu says where the
|
|
* mail is actually going rather than describing the rule. A selection that
|
|
* spans months has no single answer, and claiming one would be worse than
|
|
* naming the rule -- so that case falls back to it.
|
|
*/
|
|
const archiveDateLabel = useCallback(
|
|
(granularity: ArchiveGranularity) => {
|
|
const groups = groupByArchivePath(ctxTargets.map((id) => ({ id, receivedAt: emails[id]?.receivedAt })), granularity);
|
|
const only = groups.length === 1 ? groups[0]! : null;
|
|
if (only?.segments.length) return t("Archive to {folder}", { folder: archivePath(only.segments) });
|
|
return granularity === "year" ? t("Archive by year") : t("Archive by month");
|
|
},
|
|
[ctxTargets, emails],
|
|
);
|
|
const allSelected = ids.length > 0 && ids.every((id) => selected[id]);
|
|
const selectedAll = useMail((st) => st.selectedAll);
|
|
const selectAllMatching = useMail((st) => st.selectAllMatching);
|
|
const someUnread = ctxTargets.some((id) => !emails[id]?.keywords.$seen);
|
|
const someUnstarred = ctxTargets.some((id) => !emails[id]?.keywords.$flagged);
|
|
|
|
return (
|
|
<div className="mail-list-pane">
|
|
<div className="list-toolbar">
|
|
{isMobile && isSearch && (
|
|
<button className="icon-btn" onClick={() => navigate("/mail")} aria-label={t("Back")}>
|
|
<ArrowLeft size={20} />
|
|
</button>
|
|
)}
|
|
<input
|
|
type="checkbox"
|
|
className="select-all"
|
|
aria-label={t("Select all")}
|
|
checked={allSelected}
|
|
ref={(el) => {
|
|
if (el) el.indeterminate = selCount > 0 && !allSelected;
|
|
}}
|
|
onChange={() => (allSelected || selCount > 0 ? clearSelection() : selectAll())}
|
|
/>
|
|
{selCount > 0 ? (
|
|
<>
|
|
<span className="tb-count">{plural(selectedAll ? (list?.total ?? selCount) : selCount, { one: "{n} selected", other: "{n} selected" })}</span>
|
|
<span className="tb-sep" />
|
|
<button className="icon-btn" title={t("Archive (e)")} onClick={() => void actions.archive()}><Archive size={19} /></button>
|
|
<button className="icon-btn" title={isTrashOrJunk ? t("Delete forever") : t("Delete (#)")} onClick={() => void actions.trash()}><Trash2 size={19} /></button>
|
|
<button className="icon-btn hide-mobile" title={mailbox?.role === "junk" ? t("Not spam") : t("Report spam (!)")} onClick={() => void actions.spam()}>{mailbox?.role === "junk" ? <ShieldCheck size={19} /> : <AlertOctagon size={19} />}</button>
|
|
<span className="tb-sep" />
|
|
<button className="icon-btn" title={t("Mark as read (Shift+I)")} onClick={() => void actions.read(true)}><MailOpen size={19} /></button>
|
|
<button className="icon-btn hide-mobile" title={t("Mark as unread (Shift+U)")} onClick={() => void actions.read(false)}><Mail size={19} /></button>
|
|
<button className="icon-btn" title={t("Move to (v)")} onClick={() => actions.move()}><FolderInput size={19} /></button>
|
|
<button className="icon-btn hide-mobile" title={t("Labels (l)")} onClick={(e) => actions.label(undefined, { x: e.clientX, y: e.clientY })}><Tag size={19} /></button>
|
|
{/*
|
|
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 && (
|
|
<>
|
|
<span className="spacer" />
|
|
<button className="icon-btn" onClick={selMenu.open} aria-label={t("More actions")}><MoreVertical size={19} /></button>
|
|
<Popover anchor={selMenu.anchor} onClose={selMenu.close} align="end" width={240}>
|
|
<MenuItem
|
|
icon={mailbox?.role === "junk" ? <ShieldCheck size={16} /> : <AlertOctagon size={16} />}
|
|
label={mailbox?.role === "junk" ? t("Not spam") : t("Report spam")}
|
|
onClick={() => void actions.spam()}
|
|
/>
|
|
<MenuItem icon={<Mail size={16} />} label={t("Mark as unread")} onClick={() => void actions.read(false)} />
|
|
<MenuItem icon={<Tag size={16} />} 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 && (
|
|
<MenuItem
|
|
icon={<CalendarPlus size={16} />}
|
|
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)); }}
|
|
/>
|
|
)}
|
|
<MenuSep />
|
|
<MenuItem icon={<CheckSquare size={16} />} label={t("Select all")} onClick={selectAll} />
|
|
<MenuItem icon={<X size={16} />} label={t("Clear selection")} onClick={clearSelection} />
|
|
</Popover>
|
|
</>
|
|
)}
|
|
</>
|
|
) : (
|
|
<>
|
|
<span className="tb-title">{title}</span>
|
|
{list && !list.loading && <span className="tb-count">{list.total.toLocaleString()}</span>}
|
|
<span className="spacer" />
|
|
<button className={`icon-btn ${refreshing ? "active" : ""}`} title={t("Refresh")} onClick={() => void doRefresh()} aria-label={t("Refresh")}>
|
|
<RefreshCw size={18} className={refreshing ? "spin" : ""} style={refreshing ? { animation: "spin .8s linear infinite" } : undefined} />
|
|
</button>
|
|
<button className="icon-btn" onClick={moreMenu.open} aria-label={t("More")}>
|
|
<MoreVertical size={18} />
|
|
</button>
|
|
<Popover anchor={moreMenu.anchor} onClose={moreMenu.close} align="end" width={240}>
|
|
<MenuTitle>{t("Reading pane")}</MenuTitle>
|
|
<MenuItem icon={<PanelRight size={16} />} label={t("Right of the list")} checked={settings.readingPane === "right"} onClick={() => updateSettings({ readingPane: "right" })} />
|
|
<MenuItem icon={<PanelBottom size={16} />} label={t("Below the list")} checked={settings.readingPane === "bottom"} onClick={() => updateSettings({ readingPane: "bottom" })} />
|
|
<MenuItem icon={<PanelTop size={16} />} label={t("Hidden (open full width)")} checked={settings.readingPane === "off"} onClick={() => updateSettings({ readingPane: "off" })} />
|
|
<MenuSep />
|
|
<MenuItem icon={<CheckSquare size={16} />} label={t("Select all")} onClick={selectAll} />
|
|
<MenuItem icon={<MailOpen size={16} />} label={t("Mark all as read")} onClick={() => mailboxId && void useMail.getState().markMailboxRead(mailboxId)} disabled={!mailboxId} />
|
|
{mailbox && canEmpty(mailbox.role) && (
|
|
<>
|
|
<MenuSep />
|
|
<MenuItem
|
|
danger
|
|
icon={<Eraser size={16} />}
|
|
label={emptyLabel(mailbox)}
|
|
disabled={!mailbox.totalEmails}
|
|
onClick={() => void confirmAndEmpty(mailbox)}
|
|
/>
|
|
</>
|
|
)}
|
|
</Popover>
|
|
</>
|
|
)}
|
|
</div>
|
|
{/*
|
|
The step past the checkbox. Ticking it selects the rows that are
|
|
loaded, which on a folder of ten thousand is fifty of them -- and a
|
|
checkbox that silently meant all ten thousand would be the worst of
|
|
both. So the wider selection is offered here, in a line that says what
|
|
each of the two actually covers, and taken deliberately.
|
|
*/}
|
|
{allSelected && !selectedAll && (list?.total ?? 0) > ids.length && (
|
|
<div className="list-hint select-all-hint">
|
|
<span className="grow">{t("All {n} on this page are selected.", { n: String(ids.length) })}</span>
|
|
<button onClick={() => selectAllMatching()}>
|
|
{t("Select all {n} in {folder}", { n: String(list!.total), folder: mailbox ? mailboxDisplayName(mailbox) : t("this view") })}
|
|
</button>
|
|
</div>
|
|
)}
|
|
{selectedAll && (
|
|
<div className="list-hint select-all-hint">
|
|
<span className="grow">
|
|
{t("All {n} in {folder} are selected.", { n: String(list?.total ?? 0), folder: mailbox ? mailboxDisplayName(mailbox) : t("this view") })}
|
|
</span>
|
|
<button onClick={() => clearSelection()}>{t("Clear selection")}</button>
|
|
</div>
|
|
)}
|
|
{list?.error && (
|
|
<div className="list-hint">
|
|
<span className="grow" style={{ color: "var(--danger)" }}>{list.error}</span>
|
|
<button onClick={() => void doRefresh()}>{t("Retry")}</button>
|
|
</div>
|
|
)}
|
|
{/*
|
|
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 && (
|
|
<div className="list-hint">
|
|
<span className="grow">
|
|
|
|
{t("Deleting spam is permanent — it does not go to Deleted Items first.")}
|
|
</span>
|
|
<button onClick={() => void confirmAndEmpty(mailbox)}>{t("Delete all spam now")}</button>
|
|
</div>
|
|
)}
|
|
<div
|
|
ref={(el) => {
|
|
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 && (
|
|
<div className="ptr" aria-hidden="true">
|
|
<span
|
|
className={`ptr-dial ${pull.armed || refreshing ? "armed" : ""}`}
|
|
style={{ transform: `translate(-50%, ${Math.max(0, pull.y - 36)}px)`, opacity: Math.min(1, pull.y / 20), transition: pull.live ? "none" : "transform .22s var(--ease), opacity .22s" }}
|
|
>
|
|
<RefreshCw size={18} className={refreshing ? "spin" : ""} style={refreshing ? undefined : { transform: `rotate(${Math.round((pull.y / PULL_TRIGGER) * 270)}deg)` }} />
|
|
</span>
|
|
</div>
|
|
)}
|
|
<div
|
|
className="mail-list-pull"
|
|
style={pull.y ? { transform: `translateY(${pull.y}px)`, transition: pull.live ? "none" : "transform .22s var(--ease)" } : { transition: "transform .22s var(--ease)" }}
|
|
>
|
|
{list?.loading && ids.length === 0 ? (
|
|
<div style={{ padding: 8 }}>
|
|
{[...Array(12)].map((_, i) => (
|
|
<div key={i} className="row" style={{ height: rowHeight, padding: "0 8px", gap: 12 }}>
|
|
<span className="skeleton" style={{ width: 32, height: 32, borderRadius: 16 }} />
|
|
<span className="skeleton" style={{ width: 140, height: 14 }} />
|
|
<span className="skeleton grow" style={{ height: 14 }} />
|
|
<span className="skeleton" style={{ width: 50, height: 12 }} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : ids.length === 0 && list && !list.loading ? (
|
|
<Empty icon={isSearch ? <Search size={40} /> : <Inbox size={40} />} 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.")}
|
|
</Empty>
|
|
) : (
|
|
<div className="mail-list-inner" style={{ height: virtualizer.getTotalSize() }}>
|
|
{items.map((vi) => {
|
|
const id = ids[vi.index];
|
|
if (!id) {
|
|
return (
|
|
<div key="loader" className="list-footer" style={{ position: "absolute", top: vi.start, left: 0, right: 0, height: vi.size }}>
|
|
{list?.loadingMore ? <span className="spinner" style={{ display: "inline-block" }} /> : ""}
|
|
</div>
|
|
);
|
|
}
|
|
const e = emails[id];
|
|
if (!e) return <div key={id} style={{ position: "absolute", top: vi.start, height: vi.size }} />;
|
|
const strip = swiping?.id === id ? swiping : null;
|
|
return (
|
|
<Fragment key={id}>
|
|
{/*
|
|
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 && (
|
|
<div
|
|
className={`msg-swipe ${strip.desc.tone} ${strip.armed ? "armed" : ""} ${strip.dir === 1 ? "from-left" : "from-right"}`}
|
|
style={{ top: vi.start, height: vi.size }}
|
|
aria-hidden="true"
|
|
>
|
|
<span className="msg-swipe-act">
|
|
{SWIPE_ICON[strip.desc.icon]}
|
|
<span>{t(strip.desc.label)}</span>
|
|
</span>
|
|
</div>
|
|
)}
|
|
<Row
|
|
id={id}
|
|
threadId={list?.collapseThreads ? e.threadId : null}
|
|
top={vi.start}
|
|
height={vi.size}
|
|
selected={Boolean(selected[id])}
|
|
focused={focusId === id}
|
|
open={rowIsOpen(id, e.threadId, openMessageId, openThreadId)}
|
|
twoLine={twoLine}
|
|
showAvatar={settings.showAvatars}
|
|
showPreview={settings.showPreview}
|
|
isDrafts={isDrafts}
|
|
mailboxId={mailboxId}
|
|
isSent={mailbox?.role === "sent"}
|
|
onClick={onRowClick}
|
|
onContext={onContext}
|
|
onSelect={onRowSelect}
|
|
onStar={onRowStar}
|
|
onArchive={onRowArchive}
|
|
onTrash={onRowTrash}
|
|
onRead={onRowRead}
|
|
touch={isTouch}
|
|
role={mailbox?.role ?? null}
|
|
swipeLeft={settings.swipeLeft}
|
|
swipeRight={settings.swipeRight}
|
|
onLongPress={onLongPress}
|
|
onSwipeState={onSwipeState}
|
|
onSwipeFire={fireSwipe}
|
|
/>
|
|
</Fragment>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<Popover anchor={ctxMenu.anchor} onClose={ctxMenu.close} width={250}>
|
|
<MenuItem icon={<Reply size={16} />} label={t("Reply")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().reply(e, "reply"); }} />
|
|
<MenuItem icon={<Forward size={16} />} label={t("Forward")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().reply(e, "forward"); }} />
|
|
<MenuItem icon={<Paperclip size={16} />} label={t("Forward as attachment")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) useCompose.getState().forwardAsAttachment(e); }} />
|
|
<MenuItem icon={<MailPlus size={16} />} label={t("Compose as new")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().composeAsNew(e); }} />
|
|
<MenuSep />
|
|
<MenuItem icon={<Archive size={16} />} label={t("Archive")} kbd="e" onClick={() => void actions.archive(ctxTargets)} />
|
|
<MenuItem icon={<CalendarRange size={16} />} label={archiveDateLabel("year")} onClick={() => void useMail.getState().archiveByDate(ctxTargets, "year")} />
|
|
<MenuItem icon={<CalendarDays size={16} />} label={archiveDateLabel("month")} onClick={() => void useMail.getState().archiveByDate(ctxTargets, "month")} />
|
|
<MenuItem icon={<Trash2 size={16} />} label={t("Delete")} kbd="#" onClick={() => void actions.trash(ctxTargets)} />
|
|
<MenuItem icon={<AlertOctagon size={16} />} label={mailbox?.role === "junk" ? t("Not spam") : t("Report spam")} kbd="!" onClick={() => void actions.spam(ctxTargets)} />
|
|
<MenuSep />
|
|
<MenuItem icon={someUnread ? <MailOpen size={16} /> : <Mail size={16} />} label={someUnread ? t("Mark as read") : t("Mark as unread")} onClick={() => void actions.read(someUnread, ctxTargets)} />
|
|
<MenuItem icon={<Star size={16} />} label={someUnstarred ? t("Add star") : t("Remove star")} kbd="s" onClick={() => void actions.star(someUnstarred, ctxTargets)} />
|
|
<MenuItem icon={<FolderInput size={16} />} label={t("Move to…")} kbd="v" onClick={() => actions.move(ctxTargets)} />
|
|
<MenuItem icon={<Tag size={16} />} label={t("Label…")} kbd="l" onClick={() => actions.label(ctxTargets, ctxMenu.anchor ?? { x: 0, y: 0 })} />
|
|
<MenuSep />
|
|
<MenuItem icon={<Filter size={16} />} label={t("Filter messages like this…")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) setFilterFrom(e); }} />
|
|
{hasCalendar && <MenuItem icon={<CalendarPlus size={16} />} 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)); }} />}
|
|
</Popover>
|
|
{filterFrom && <Suspense fallback={null}><FilterFromMessageDialog email={filterFrom} mailboxId={mailboxId} onClose={() => setFilterFrom(null)} /></Suspense>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface RowProps {
|
|
id: Id;
|
|
/** The conversation to summarize, in conversation view; null for a single message. */
|
|
threadId: Id | null;
|
|
top: number;
|
|
height: number;
|
|
selected: boolean;
|
|
focused: boolean;
|
|
open: boolean;
|
|
twoLine: boolean;
|
|
showAvatar: boolean;
|
|
showPreview: boolean;
|
|
isDrafts: boolean;
|
|
isSent: boolean;
|
|
mailboxId: Id | null;
|
|
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<void>;
|
|
}
|
|
|
|
const NO_EMAILS: Email[] = [];
|
|
|
|
/*
|
|
* A row reads its own message and conversation from the store, rather than
|
|
* being handed them. The list re-renders on every store write -- each star,
|
|
* each push, each thread loaded -- and objects built for a row in that render
|
|
* were new every time, so no row was ever skipped. Selected this way, a row
|
|
* renders when something it shows has changed, and not otherwise.
|
|
*/
|
|
const Row = memo(function Row(props: RowProps) {
|
|
const email = useMail((s) => s.emails[props.id]);
|
|
const threadEmails = useMail(
|
|
useShallow((s) => {
|
|
if (!props.threadId) return NO_EMAILS;
|
|
const ids = s.threads[props.threadId]?.emailIds;
|
|
if (!ids) return NO_EMAILS;
|
|
return ids.map((x) => s.emails[x]).filter((x): x is Email => Boolean(x));
|
|
}),
|
|
);
|
|
if (!email) return null;
|
|
const { id: _id, threadId, ...rest } = props;
|
|
return <RowView {...rest} email={email} threadEmails={threadId ? threadEmails : undefined} />;
|
|
});
|
|
|
|
type RowViewProps = Omit<RowProps, "id" | "threadId"> & { email: Email; threadEmails?: Email[] };
|
|
|
|
function RowView({ email: e, threadEmails, top, height, selected, focused, open, twoLine, showAvatar, showPreview, isDrafts, isSent, mailboxId, onClick, onContext, onSelect, onStar, onArchive, onTrash, onRead, touch, role, swipeLeft, swipeRight, onLongPress, onSwipeState, onSwipeFire }: RowViewProps) {
|
|
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 scope = useMemo(() => {
|
|
const inScope = threadEmails ? threadEmails.filter((x) => (mailboxId ? x.mailboxIds[mailboxId] : true)) : [e];
|
|
return inScope.length ? inScope : [e];
|
|
}, [threadEmails, mailboxId, 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<string>();
|
|
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 virtualized 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<HTMLDivElement>(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);
|
|
},
|
|
});
|
|
|
|
/*
|
|
* A conversation starts loading when the pointer settles on its row, or the
|
|
* moment a finger or button goes down, so that on a slow link the click
|
|
* finds it on its way. Drafts open in the composer instead.
|
|
*/
|
|
const hover = useRef<number | undefined>(undefined);
|
|
useEffect(() => () => window.clearTimeout(hover.current), []);
|
|
const prefetch = () => {
|
|
if (!isDrafts) useMail.getState().prefetchThread(e.threadId);
|
|
};
|
|
const onPointerEnter = (ev: PointerEvent) => {
|
|
if (ev.pointerType !== "mouse") return;
|
|
window.clearTimeout(hover.current);
|
|
hover.current = window.setTimeout(prefetch, 80);
|
|
};
|
|
const onPointerLeave = () => window.clearTimeout(hover.current);
|
|
const onPointerDown = (ev: PointerEvent<HTMLDivElement>) => {
|
|
prefetch();
|
|
gesture.onPointerDown?.(ev);
|
|
};
|
|
|
|
const onDragStart = (ev: DragEvent) => {
|
|
// Read when the drag starts, so the row need not re-render on every change of selection.
|
|
const selectedIds = useMail.getState().selected;
|
|
const ids = selectedIds[e.id] ? Object.keys(selectedIds) : [e.id];
|
|
// include thread emails in scope
|
|
const all = new Set<Id>();
|
|
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 (
|
|
<div
|
|
ref={rowRef}
|
|
className={`msg-row ${unread ? "unread" : ""} ${selected ? "selected" : ""} ${focused ? "focused" : ""} ${open ? "open" : ""} ${dx ? "swiping" : ""}`}
|
|
style={{ top, height, ...(dx ? { transform: `translateX(${dx}px)` } : {}), transition: gliding ? "transform .18s var(--ease)" : "none" }}
|
|
data-row-id={e.id}
|
|
onClick={(ev) => 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}
|
|
onPointerDown={onPointerDown}
|
|
onPointerEnter={onPointerEnter}
|
|
onPointerLeave={onPointerLeave}
|
|
role="row"
|
|
aria-selected={selected}
|
|
>
|
|
<input type="checkbox" className="msg-check" checked={selected} onClick={(ev) => ev.stopPropagation()} onChange={(ev) => onSelect(e.id, ev.target.checked)} aria-label={t("Select")} />
|
|
{!twoLine && (
|
|
<button className={`msg-star ${starred ? "on" : ""}`} onClick={(ev) => { ev.stopPropagation(); onStar(e.id, !starred); }} aria-label={starred ? "Unstar" : "Star"}>
|
|
<Star size={18} fill={starred ? "currentColor" : "none"} />
|
|
</button>
|
|
)}
|
|
{showAvatar && <Avatar who={isSent || isDrafts ? (e.to?.[0] ?? null) : (latest.from?.[0] ?? null)} />}
|
|
{twoLine ? (
|
|
<div className="msg-body">
|
|
<div className="msg-line1">
|
|
<span className="msg-from truncate notranslate" translate="no">
|
|
<span className="truncate">{who}</span>
|
|
{count > 1 && <span className="thread-count"> {count}</span>}
|
|
</span>
|
|
<span className="msg-meta">
|
|
{hasAtt && <Paperclip size={14} className="msg-attach" />}
|
|
<span className="msg-date">{formatListDate(latest.receivedAt)}</span>
|
|
</span>
|
|
</div>
|
|
<div className="msg-main">
|
|
{isDrafts && <span style={{ color: "var(--danger)" }}>{t("Draft")}</span>}
|
|
<span className="msg-subject notranslate" translate="no">{e.subject || t("(no subject)")}</span>
|
|
{showPreview && <span className="msg-preview notranslate" translate="no">{latest.preview}</span>}
|
|
<button className={`msg-star ${starred ? "on" : ""}`} style={{ marginLeft: "auto" }} onClick={(ev) => { ev.stopPropagation(); onStar(e.id, !starred); }} aria-label={t("Star")}>
|
|
<Star size={16} fill={starred ? "currentColor" : "none"} />
|
|
</button>
|
|
</div>
|
|
{rowLabels.length > 0 && <div className="msg-labels">{rowLabels.map((l) => <span key={l.keyword} className="tag" style={{ background: l.color }}>{l.name}</span>)}</div>}
|
|
</div>
|
|
) : (
|
|
<>
|
|
<span className="msg-from notranslate" translate="no" title={who}>
|
|
<span className="truncate">{who}</span>
|
|
{count > 1 && <span className="thread-count">{count}</span>}
|
|
</span>
|
|
<span className="msg-main">
|
|
{isDrafts && <span style={{ color: "var(--danger)", flex: "0 0 auto" }}>{t("Draft")}</span>}
|
|
{rowLabels.length > 0 && <span className="msg-labels">{rowLabels.map((l) => <span key={l.keyword} className="tag" style={{ background: l.color }}>{l.name}</span>)}</span>}
|
|
<span className="msg-subject notranslate" translate="no">{e.subject || t("(no subject)")}</span>
|
|
{showPreview && <span className="msg-preview notranslate" translate="no">{latest.preview}</span>}
|
|
</span>
|
|
<span className="msg-meta">
|
|
{(answered || forwarded) && <span className="msg-answered" title={answered ? t("Replied") : t("Forwarded")}>{answered ? <Reply size={14} /> : <Forward size={14} />}</span>}
|
|
{hasAtt && <Paperclip size={14} className="msg-attach" />}
|
|
<span className="msg-date">{formatListDate(latest.receivedAt)}</span>
|
|
<span className="msg-actions">
|
|
<button className="icon-btn sm" title={t("Archive")} onClick={(ev) => { ev.stopPropagation(); onArchive(e.id); }}><Archive size={16} /></button>
|
|
<button className="icon-btn sm" title={t("Delete")} onClick={(ev) => { ev.stopPropagation(); onTrash(e.id); }}><Trash2 size={16} /></button>
|
|
<button className="icon-btn sm" title={unread ? t("Mark as read") : t("Mark as unread")} onClick={(ev) => { ev.stopPropagation(); onRead(e.id, unread); }}>{unread ? <MailOpen size={16} /> : <Mail size={16} />}</button>
|
|
</span>
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|