import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useLocation, useSearch } from "wouter"; import { DEFAULT_SORT, useMail, type ListQuery } from "@/store/mail"; import { appliesTo, comparatorsFor } from "@/lib/listSort"; import type { Comparator } from "@/jmap/types"; import { useSettings } from "@/store/settings"; import { withBase } from "@/lib/basePath"; import { useCompose } from "@/store/compose"; import { buildFilter, describeFilter, parseQuery } from "@/lib/search"; import { keyboard } from "@/lib/keyboard"; import { useIsNarrow } from "@/ui/misc"; import { Splitter } from "@/ui/Splitter"; import { MessageList } from "./MessageList"; import { ThreadView } from "./ThreadView"; import { MailboxPicker } from "./MailboxPicker"; import { LabelPicker } from "./LabelPicker"; import type { Id } from "@/jmap/types"; import { confirmDialog } from "@/ui/dialog"; import { toast } from "@/ui/toast"; import { isUnknownMailbox } from "@/lib/mailboxRoute"; import { scheduledMailboxIdFrom, useScheduled } from "@/store/scheduled"; import { plural, t as translate, tNode } from "@/lib/i18n"; import { mailboxDisplayName } from "@/lib/mailboxName"; export function MailView({ mailboxId, threadId, search }: { mailboxId?: string; threadId?: string; search?: boolean }) { const [, navigate] = useLocation(); const searchStr = useSearch(); const mailboxes = useMail((s) => s.mailboxes); const mailboxesLoaded = useMail((s) => s.mailboxesLoaded); const inboxId = useMail((s) => s.roleId("inbox")); const query = useMail((s) => s.query); const list = useMail((s) => s.list); const settings = useSettings((s) => s.settings); const narrow = useIsNarrow(); const [focusId, setFocusId] = useState(null); const [movePicker, setMovePicker] = useState<{ ids: Id[] } | null>(null); const [labelPicker, setLabelPicker] = useState<{ ids: Id[]; anchor: { x: number; y: number } } | null>(null); const reconcile = useScheduled((s) => s.reconcile); const scheduledId = useMail((s) => scheduledMailboxIdFrom(s.mailboxes)); const q = useMemo(() => (search ? (new URLSearchParams(searchStr).get("q") ?? "") : ""), [search, searchStr]); // Redirect /mail → inbox useEffect(() => { if (!search && !mailboxId && inboxId) navigate(`/mail/${inboxId}`, { replace: true }); }, [search, mailboxId, inboxId, navigate]); /* * A folder id this account does not have. * * It used to render the ordinary empty state -- "Nothing here. This folder is * empty." -- which is a claim about a folder that is not there, so a stale * link read as a folder that had emptied itself rather than one that was * gone (#111). Only reachable from outside the app: the sidebar links to ids * that exist. * * Inbox is the kinder landing than a dead end, but silently swapping one * folder for another would be its own small lie, so it says what happened. * `mailboxesLoaded` gates it: without that, every cold load redirects in the * moment before the folder list arrives. */ useEffect(() => { if (!isUnknownMailbox({ mailboxId, mailboxes, loaded: mailboxesLoaded, search }) || !inboxId) return; toast.show(translate("That folder no longer exists. Showing your inbox instead.")); navigate(`/mail/${inboxId}`, { replace: true }); }, [search, mailboxId, mailboxesLoaded, mailboxes, inboxId, navigate]); /* * Search keeps newest-first whatever the setting says. A result list is * already ordered by the question that was asked, and putting unread at the * top of it answers a different one. */ const sortForFolder = useCallback( (mailboxId: Id | null): Comparator[] => { const role = mailboxId ? useMail.getState().mailboxes[mailboxId]?.role : null; if (!appliesTo(settings.listSortScope, role)) return DEFAULT_SORT; return comparatorsFor(settings.listSortPreset, settings.listSortLevels); }, [settings.listSortScope, settings.listSortPreset, settings.listSortLevels], ); // Build & run the list query const listQuery = useMemo(() => { if (search) { if (!q) return null; const parsed = parseQuery(q); const filter = buildFilter(parsed, mailboxes, null); const inMb = parsed.in ? (Object.values(mailboxes).find((m) => m.name.toLowerCase() === parsed.in!.toLowerCase())?.id ?? null) : null; return { key: "", filter, sort: DEFAULT_SORT, collapseThreads: settings.conversationMode, mailboxId: inMb, label: describeFilter(parsed) }; } if (!mailboxId) return null; const mb = mailboxes[mailboxId]; // Scheduled joins Drafts and Sent as a folder of individual messages: they // are outgoing, and collapsing them into their threads hides them. const isDraftsOrSent = mb?.role === "drafts" || mb?.role === "sent" || mailboxId === scheduledId; return { key: "", filter: { inMailbox: mailboxId }, sort: sortForFolder(mailboxId), collapseThreads: settings.conversationMode && !isDraftsOrSent, mailboxId }; }, [search, q, mailboxId, mailboxes, settings.conversationMode, scheduledId]); useEffect(() => { if (listQuery && mailboxesLoaded) void query(listQuery); }, [listQuery, query, mailboxesLoaded]); // Nothing moves a message out of Scheduled when its hold expires, so settle // the folder up on the way in: sent messages to Sent, cancelled ones back to // Drafts, and refresh what is still waiting. useEffect(() => { if (mailboxesLoaded && mailboxId && mailboxId === scheduledId) void reconcile(); }, [mailboxId, scheduledId, mailboxesLoaded, reconcile]); /* * With conversation view off, a row is a message rather than a thread, and * opening one must show that message and highlight that row -- not its whole * thread and every sibling row in the list. * * The thread id stays in the path, so loading is unchanged; the message rides * in `m`. Putting it in the URL rather than in memory is what makes a reload * or a shared link land back on the same message, and dropping the parameter * degrades to the conversation, which is the right thing for a link sent to * somebody whose setting differs. */ const openThread = useCallback( (tid: Id | null, messageId?: Id | null) => { const base = search ? `/search` : `/mail/${mailboxId}`; const params = new URLSearchParams(); if (search) params.set("q", q); if (tid && messageId) params.set("m", messageId); const qs = params.size ? `?${params}` : ""; navigate(tid ? `${base}/${tid}${qs}` : `${base}${qs}`); }, [navigate, search, mailboxId, q], ); /** * The message the URL singles out, if any. Only meaningful with conversation * view off; ThreadView decides what to do when the id names nothing in the * thread, since it is the part that knows what the thread holds. */ const openMessageId = useMemo(() => { if (settings.conversationMode) return null; const m = new URLSearchParams(searchStr).get("m"); return m || null; }, [settings.conversationMode, searchStr]); // Row ids in list + helpers for keyboard nav const ids = list?.ids ?? []; const emails = useMail((s) => s.emails); const threads = useMail((s) => s.threads); const selected = useMail((s) => s.selected); const selectedAll = useMail((s) => s.selectedAll); const rowThreadId = useCallback((rowId: Id) => emails[rowId]?.threadId, [emails]); const currentRowIndex = useMemo(() => { if (focusId) { const i = ids.indexOf(focusId); if (i >= 0) return i; } // A cold load has no focus yet. With conversation view off the URL names the // row exactly; matching on the thread instead would land on whichever of its // messages sorts first, so j/k and the scroll-into-view would start from the // wrong row on any thread with more than one message in the folder. if (openMessageId) { const i = ids.indexOf(openMessageId); if (i >= 0) return i; } if (threadId) return ids.findIndex((id) => rowThreadId(id) === threadId); return -1; }, [ids, focusId, openMessageId, threadId, rowThreadId]); /** Email ids affected by an action on rows (selection or focused/open row). */ const targetIds = useCallback( async (rowIds?: Id[]): Promise => { /* * Everything the query matches, rather than the rows that happen to be * loaded. Resolved from the server here and not expanded below: the * uncollapsed query already returns every message, and the expansion * below walks loaded Email objects, which is exactly what these are not. * * Only when the action came from the selection. An action aimed at one * row -- a right-click, a swipe -- means that row, whatever is ticked. */ if (!rowIds && selectedAll) return await useMail.getState().queryAllIds(); const rows = rowIds ?? (Object.keys(selected).length ? Object.keys(selected) : focusId ? [focusId] : threadId ? ids.filter((id) => rowThreadId(id) === threadId) : []); const out = new Set(); for (const r of rows) { const e = emails[r]; if (!e) continue; if (list?.collapseThreads) { const t = threads[e.threadId]; const inScope = t ? t.emailIds.filter((id) => (list.mailboxId ? emails[id]?.mailboxIds[list.mailboxId] : true)) : [r]; for (const id of inScope.length ? inScope : [r]) out.add(id); } else out.add(r); } return [...out]; }, [selected, selectedAll, focusId, threadId, ids, rowThreadId, emails, threads, list], ); const afterAction = useCallback( (removed: boolean) => { useMail.getState().clearSelection(); if (!removed) return; /* * Move the focused row off the message that just went away. * * Nothing did this before, so `focusId` kept pointing at a row that was * no longer in the list, and two separate complaints in #71 fell out of * it. `targetIds()` falls back to the focused id, so the next `#` * re-targeted the deleted message -- which the optimistic update had * already marked as being in Deleted Items, making it look like a * permanent delete and raising a confirmation the setting had turned * off. And `moveFocus` reads `ids.indexOf(focusId)`, which was -1, which * it treats as "before the start" -- so `k` clamped to the top of the * list. * * Clicking a row was unaffected, because that sets focus to a row that * exists, which is why it only ever happened from the keyboard. * * `currentRowIndex` here is the value from the render that started this * action, so it is the index the message had *before* it was removed. * The row that slid into that slot is the one to focus. */ const wasAt = currentRowIndex; const freshIds = useMail.getState().list?.ids ?? []; if (!freshIds.length) { setFocusId(null); } else if (wasAt >= 0) { const want = settings.autoAdvance === "newer" ? wasAt - 1 : wasAt; const next = freshIds[Math.max(0, Math.min(want, freshIds.length - 1))]; if (next) setFocusId(next); } // auto-advance if (threadId) { const idx = currentRowIndex; const adv = settings.autoAdvance; if (adv === "list" || idx < 0) openThread(null); else { const next = adv === "older" ? ids[idx + 1] : ids[idx - 1]; const nt = next ? rowThreadId(next) : undefined; if (nt) openThread(nt); else openThread(null); } } }, [threadId, currentRowIndex, settings.autoAdvance, ids, rowThreadId, openThread, setFocusId], ); const actions = useMemo( () => ({ archive: async (rows?: Id[]) => { const t = await targetIds(rows); if (!t.length) return; await useMail.getState().archive(t); afterAction(true); }, trash: async (rows?: Id[]) => { const t = await targetIds(rows); if (!t.length) return; const mail = useMail.getState(); const trashId = mail.roleId("trash"); const permanent = t.every((id) => trashId && mail.emails[id]?.mailboxIds[trashId]); if (permanent || settings.confirmDelete) { // "message(s)" was doing the work a plural form should: every // language that inflects got a parenthesis instead of agreement. const ok = await confirmDialog({ title: permanent ? translate("Delete forever?") : translate("Delete?"), message: permanent ? plural(t.length, { one: "{n} message will be permanently deleted.", other: "{n} messages will be permanently deleted." }) : plural(t.length, { one: "Move {n} message to Trash?", other: "Move {n} messages to Trash?" }), confirmLabel: translate("Delete"), danger: permanent, }); if (!ok) return; } await mail.trash(t); afterAction(true); }, spam: async (rows?: Id[]) => { const t = await targetIds(rows); if (!t.length) return; const mail = useMail.getState(); const junk = mail.roleId("junk"); const inJunk = t.every((id) => junk && mail.emails[id]?.mailboxIds[junk]); await mail.spam(t, !inJunk); afterAction(true); }, read: async (read: boolean, rows?: Id[]) => { const t = await targetIds(rows); if (t.length) await useMail.getState().markRead(t, read); useMail.getState().clearSelection(); }, star: async (on: boolean, rows?: Id[]) => { const t = await targetIds(rows); if (t.length) await useMail.getState().star(t, on); }, move: async (rows?: Id[]) => { const t = await targetIds(rows); if (t.length) setMovePicker({ ids: t }); }, label: async (rows: Id[] | undefined, anchor: { x: number; y: number }) => { const t = await targetIds(rows); if (t.length) setLabelPicker({ ids: t, anchor }); }, moveTo: async (ids: Id[], mailboxId: Id) => { await useMail.getState().move(ids, mailboxId); afterAction(true); }, }), [targetIds, afterAction, settings.confirmDelete], ); // Keyboard shortcuts for the list/thread const focusRef = useRef(focusId); focusRef.current = focusId; useEffect(() => { const moveFocus = (delta: number) => { // A focused id that is no longer in the list gives -1, which must not be // read as "just before the first row" -- that is what sent `k` to the // top. Fall back to where the list thinks we are instead. const fromFocus = focusRef.current ? ids.indexOf(focusRef.current) : -1; const cur = fromFocus >= 0 ? fromFocus : currentRowIndex; const next = Math.max(0, Math.min(ids.length - 1, (cur < 0 ? (delta > 0 ? -1 : 0) : cur) + delta)); const id = ids[next]; if (!id) return; setFocusId(id); if (threadId && settings.readingPane !== "off") { const t = rowThreadId(id); if (t) openThread(t); } document.querySelector(`[data-row-id="${CSS.escape(id)}"]`)?.scrollIntoView({ block: "nearest" }); }; return keyboard.pushScope("mail", [ { keys: "j", description: "Next conversation", group: "Mail", handler: () => moveFocus(1) }, { keys: "k", description: "Previous conversation", group: "Mail", handler: () => moveFocus(-1) }, { keys: "arrowdown", description: "", group: "Mail", handler: () => moveFocus(1) }, { keys: "arrowup", description: "", group: "Mail", handler: () => moveFocus(-1) }, { keys: "o", description: "Open conversation", group: "Mail", handler: () => { const id = focusRef.current; const t = id ? rowThreadId(id) : undefined; if (t) openThread(t); } }, { keys: "enter", description: "", group: "Mail", handler: () => { const id = focusRef.current; const t = id ? rowThreadId(id) : undefined; if (t) { openThread(t); return; } return false; } }, { keys: "u", description: "Back to list", group: "Mail", handler: () => openThread(null) }, { keys: "esc", description: "Back to list / clear selection", group: "Mail", handler: () => { if (Object.keys(useMail.getState().selected).length) useMail.getState().clearSelection(); else openThread(null); } }, { keys: "x", description: "Select conversation", group: "Mail", handler: () => { const id = focusRef.current ?? ids[currentRowIndex]; if (id) useMail.getState().select([id], !useMail.getState().selected[id]); } }, { keys: "e", description: "Archive", group: "Actions", handler: () => void actions.archive() }, { keys: "y", description: "", group: "Actions", handler: () => void actions.archive() }, { keys: "#", description: "Delete", group: "Actions", handler: () => void actions.trash() }, { keys: "delete", description: "", group: "Actions", handler: () => void actions.trash() }, { keys: "!", description: "Report spam / not spam", group: "Actions", handler: () => void actions.spam() }, { keys: "s", description: "Star / unstar", group: "Actions", handler: () => { void (async () => { const t = await targetIds(); const on = !t.every((id) => emails[id]?.keywords.$flagged); void actions.star(on); })(); } }, { keys: "shift+i", description: "Mark as read", group: "Actions", handler: () => void actions.read(true) }, { keys: "shift+u", description: "Mark as unread", group: "Actions", handler: () => void actions.read(false) }, { keys: "v", description: "Move to…", group: "Actions", handler: () => actions.move() }, { keys: "l", description: "Label…", group: "Actions", handler: () => actions.label(undefined, { x: window.innerWidth / 2, y: 80 }) }, { keys: "*+a", description: "", group: "Actions", handler: () => useMail.getState().selectAll() }, { keys: "mod+a", description: "Select all", group: "Mail", handler: () => { useMail.getState().selectAll(); } }, { keys: "r", description: "Reply", group: "Conversation", handler: () => window.dispatchEvent(new CustomEvent("ihm:reply", { detail: "reply" })) }, { keys: "a", description: "Reply all", group: "Conversation", handler: () => window.dispatchEvent(new CustomEvent("ihm:reply", { detail: "replyAll" })) }, { keys: "f", description: "Forward", group: "Conversation", handler: () => window.dispatchEvent(new CustomEvent("ihm:reply", { detail: "forward" })) }, { keys: "n", description: "Next message in conversation", group: "Conversation", handler: () => window.dispatchEvent(new CustomEvent("ihm:msg-nav", { detail: 1 })) }, { keys: "p", description: "Previous message in conversation", group: "Conversation", handler: () => window.dispatchEvent(new CustomEvent("ihm:msg-nav", { detail: -1 })) }, { keys: "]", description: "Archive and next", group: "Conversation", handler: () => void actions.archive() }, ]); }, [ids, currentRowIndex, threadId, settings.readingPane, rowThreadId, openThread, actions, targetIds, emails]); const openDraft = useCompose((s) => s.openDraftEmail); const onOpenRow = useCallback( (rowId: Id) => { const e = emails[rowId]; if (!e) return; setFocusId(rowId); const mb = mailboxId ? mailboxes[mailboxId] : undefined; if (mb?.role === "drafts" && e.keywords.$draft) { void openDraft(e); return; } openThread(e.threadId, settings.conversationMode ? null : rowId); }, [emails, mailboxId, mailboxes, openThread, openDraft, settings.conversationMode], ); const title = search ? translate("Search: {query}", { query: listQuery?.label ?? q }) : (mailboxId && mailboxDisplayName(mailboxes[mailboxId])) || translate("Mail"); const reading = Boolean(threadId); const paneClass = settings.readingPane === "bottom" ? "pane-bottom" : settings.readingPane === "off" ? "pane-off" : "pane-right"; const showList = !(settings.readingPane === "off" && reading) && !(narrow && reading); const showReading = settings.readingPane !== "off" || reading; const layoutRef = useRef(null); const updateSettings = useSettings((s) => s.update); const [liveSize, setLiveSize] = useState(null); // Mirrors liveSize for the end of a key press, which follows the resize in // the same tick -- before the state could have come back round. const liveSizeRef = useRef(null); const paneSize = liveSize ?? (settings.readingPane === "bottom" ? settings.listPaneHeight : settings.listPaneWidth); const onSplit = (delta: number) => { const el = layoutRef.current; const total = el ? (settings.readingPane === "bottom" ? el.clientHeight : el.clientWidth) : 1200; const min = settings.readingPane === "bottom" ? 160 : 320; const max = Math.max(min, total - (settings.readingPane === "bottom" ? 200 : 420)); const next = Math.min(max, Math.max(min, (liveSizeRef.current ?? paneSize) + delta)); liveSizeRef.current = next; setLiveSize(next); }; const onSplitEnd = () => { const size = liveSizeRef.current; liveSizeRef.current = null; setLiveSize(null); if (size != null) updateSettings(settings.readingPane === "bottom" ? { listPaneHeight: size } : { listPaneWidth: size }); }; return (
{showList && ( )} {showList && showReading && settings.readingPane !== "off" && !narrow && ( updateSettings(settings.readingPane === "bottom" ? { listPaneHeight: 340 } : { listPaneWidth: 520 })} ariaLabel={translate("Resize message list")} /> )} {showReading && (
{threadId ? ( openThread(null)} actions={actions} onNavigate={(delta) => { const idx = currentRowIndex; const next = ids[idx + delta]; const t = next ? rowThreadId(next) : undefined; if (t) { setFocusId(next!); openThread(t, settings.conversationMode ? null : next!); } }} hasPrev={currentRowIndex > 0} hasNext={currentRowIndex >= 0 && currentRowIndex < ids.length - 1} /> ) : (
{list?.total ? settings.conversationMode ? plural(list.total, { one: "{n} conversation", other: "{n} conversations" }) : plural(list.total, { one: "{n} message", other: "{n} messages" }) : settings.conversationMode ? translate("No conversation selected") : translate("No message selected")}
{settings.conversationMode ? tNode("Select a conversation to read it here · Press {key} for shortcuts", { key: ? }) : tNode("Select a message to read it here · Press {key} for shortcuts", { key: ? })}
)}
)} {movePicker && ( setMovePicker(null)} onPick={(mbId) => { setMovePicker(null); void actions.moveTo(movePicker.ids, mbId); }} /> )} {labelPicker && ( setLabelPicker(null)} onApplied={() => toast.show(translate("Labels updated"))} /> )}
); }