import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useRef, useState } from "react"; import { Link, useMatch, useNavigate, useSearchParams } from "react-router-dom"; import signetLogo from "../../icons/ultimo-logo-signet.svg"; import { AppSwitcher } from "../components/AppSwitcher"; import { Composer, type ComposerInit, draftToInit } from "../components/compose/Composer"; import { DraftsList } from "../components/list/DraftsList"; import { Icon } from "../components/Icon"; import { MessageList } from "../components/list/MessageList"; import { Resizer } from "../components/Resizer"; import { ThemeToggle } from "../components/ThemeToggle"; import { EmptyIllustration } from "../components/EmptyIllustration"; import { BackfillBanner } from "../components/BackfillBanner"; import { ThreadView } from "../components/thread/ThreadView"; import { RightPanel, type PanelTab } from "../components/mail/RightPanel"; import { useT } from "../i18n"; import type { MessageKey } from "../i18n/en"; import { api } from "../lib/api"; import { setTimeFormat } from "../lib/timeFormat"; import type { QuotaOut, SavedSearch, ThreadSummary } from "../lib/types"; import { useKeyboardShortcuts } from "../lib/useKeyboardShortcuts"; import { PluginSlot } from "../plugins/PluginSlot"; function quotaLabel(q: QuotaOut): string { const pct = q.quota_bytes > 0 ? (q.used_bytes / q.quota_bytes) * 100 : 0; const pctStr = pct >= 10 ? String(Math.round(pct)) : String(Math.round(pct * 10) / 10); const gb = q.quota_bytes / 1024 ** 3; const gbStr = Number.isInteger(gb) ? String(gb) : gb.toFixed(1); return `${pctStr}% of ${gbStr} GB`; } // Threads shown per page in the mail list. const PAGE_SIZE = 50; // Collapsed sidebar width: just wide enough for the icons. const COLLAPSED_W = 60; // Unified per-folder views. "Spam" maps to the IMAP special-use "junk". const FOLDERS: { view: string; icon: string; label: MessageKey }[] = [ { view: "folder:inbox", icon: "inbox", label: "nav.inbox" }, { view: "folder:archive", icon: "archive", label: "nav.archive" }, { view: "folder:drafts", icon: "drafts", label: "nav.drafts" }, { view: "folder:sent", icon: "sent", label: "nav.sent" }, { view: "folder:junk", icon: "spam", label: "nav.spam" }, { view: "folder:trash", icon: "trash", label: "nav.trash" }, ]; export function MailLayout() { const t = useT(); const navigate = useNavigate(); const queryClient = useQueryClient(); const match = useMatch("/mail/t/:threadId"); const threadId = match?.params.threadId; const [view, setView] = useState("folder:inbox"); const [term, setTerm] = useState(""); // Debounced copy of the search box for search-as-you-type (avoids a request per key). const [debouncedTerm, setDebouncedTerm] = useState(""); useEffect(() => { const h = window.setTimeout(() => setDebouncedTerm(term), 180); return () => window.clearTimeout(h); }, [term]); const searching = debouncedTerm.trim().length > 0; const [filter, setFilter] = useState<"all" | "unread" | "pinned">("all"); const [listOrder, setListOrder] = useState<"newest" | "oldest">(() => localStorage.getItem("mail.listOrder") === "oldest" ? "oldest" : "newest", ); const [page, setPage] = useState(0); const [refreshing, setRefreshing] = useState(false); // Mail-list selection (checkboxes). Lives here so the reading pane can show the // bulk-action bar / count while any conversation is selected. const [checkedIds, setCheckedIds] = useState>(new Set()); // Rows just archived/trashed/deleted from the list: greyed out and unclickable // for immediate feedback (search results don't always drop the row on refetch). const [dimmedIds, setDimmedIds] = useState>(new Set()); const toggleCheck = (id: string) => setCheckedIds((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); const [composing, setComposing] = useState(null); const [composerExpanded, setComposerExpanded] = useState(false); // Bumped each time a composer is opened so the remounts (fresh state) // when switching between drafts instead of keeping the previous draft's editor. const [composeKey, setComposeKey] = useState(0); const [replySignal, setReplySignal] = useState<{ kind: string; seq: number }>(); const searchRef = useRef(null); // Resizable panel widths (persisted). Drag the dividers to adjust. const [sidebarW, setSidebarW] = useState(() => Number(localStorage.getItem("panel.sidebar")) || 240); const [listW, setListW] = useState(() => Number(localStorage.getItem("panel.list")) || 384); // Collapsed sidebar shows icons only (persisted). const [collapsed, setCollapsed] = useState(() => localStorage.getItem("panel.collapsed") === "1"); // Right info panel (Calendar / People / Attachments) — hidden by default. const [rightOpen, setRightOpen] = useState(() => localStorage.getItem("panel.right") === "1"); const [rightTab, setRightTab] = useState(() => (localStorage.getItem("panel.rightTab") as PanelTab) || "calendar"); const [rightW, setRightW] = useState(() => Number(localStorage.getItem("panel.rightW")) || 340); const clampSidebar = (w: number) => Math.min(Math.max(w, 180), 420); // The list can be dragged up to 75% of the window width. const clampList = (w: number) => Math.min(Math.max(w, 260), window.innerWidth * 0.75); const clampRight = (w: number) => Math.min(Math.max(w, 280), 560); useEffect(() => localStorage.setItem("panel.sidebar", String(sidebarW)), [sidebarW]); useEffect(() => localStorage.setItem("panel.list", String(listW)), [listW]); useEffect(() => localStorage.setItem("panel.collapsed", collapsed ? "1" : "0"), [collapsed]); useEffect(() => localStorage.setItem("panel.right", rightOpen ? "1" : "0"), [rightOpen]); useEffect(() => localStorage.setItem("panel.rightTab", rightTab), [rightTab]); useEffect(() => localStorage.setItem("panel.rightW", String(rightW)), [rightW]); const openPanel = (tab: PanelTab) => { setRightTab(tab); setRightOpen(true); }; useEffect(() => localStorage.setItem("mail.listOrder", listOrder), [listOrder]); const isDrafts = view === "folder:drafts"; // Which special-use folder the reading pane is showing (drives the toolbar). const currentFolder = view.startsWith("folder:") ? view.slice("folder:".length) : "inbox"; const accountsQuery = useQuery({ queryKey: ["accounts"], queryFn: api.accounts }); const mailboxesQuery = useQuery({ queryKey: ["mailboxes"], queryFn: api.mailboxes }); const orderParam: "asc" | "desc" = listOrder === "newest" ? "desc" : "asc"; const labelView = view.startsWith("label:") ? view.slice("label:".length) : null; const threadsQuery = useQuery({ queryKey: ["threads", view, filter, orderParam, page], queryFn: async () => { if (labelView !== null) { // Label view: the labels plugin supplies the matching thread ids. const ids = await api.labelThreads(labelView); return ids.length ? api.threadsByIds(ids) : { threads: [], total: 0, cursor: null }; } return api.threads(view, { offset: page * PAGE_SIZE, limit: PAGE_SIZE, filter, order: orderParam }); }, enabled: !isDrafts && !searching, placeholderData: keepPreviousData, // keep the current page visible while the next loads }); const draftsQuery = useQuery({ queryKey: ["drafts"], queryFn: api.listDrafts, enabled: isDrafts, }); const remoteDraftsQuery = useQuery({ queryKey: ["drafts-remote"], queryFn: api.remoteDrafts, enabled: isDrafts, }); // Trash/Spam are excluded from normal search, so while viewing them the search // box scopes to that folder instead (via an in: operator) and searches only it. const searchScope = currentFolder === "trash" ? "in:trash" : currentFolder === "junk" ? "in:junk" : ""; const effectiveQuery = searchScope ? `${searchScope} ${debouncedTerm}`.trim() : debouncedTerm; const searchQuery = useQuery({ queryKey: ["search", effectiveQuery], queryFn: () => api.search(effectiveQuery), enabled: searching, }); const savedSearchesQuery = useQuery({ queryKey: ["saved-searches"], queryFn: api.savedSearches }); const settingsQuery = useQuery({ queryKey: ["settings"], queryFn: api.settings }); const threadOrder: "asc" | "desc" = settingsQuery.data?.thread_order ?? "asc"; const timeFormat = settingsQuery.data?.time_format; useEffect(() => { if (timeFormat) setTimeFormat(timeFormat); }, [timeFormat]); const quotaQuery = useQuery({ queryKey: ["quota"], queryFn: api.quota }); const countsQuery = useQuery({ queryKey: ["folder-counts"], queryFn: api.folderCounts }); // Folder/inbox views paginate server-side with an accurate grand total; search // results (a single fetched page of hits) still paginate client-side. let pageThreads: ThreadSummary[]; let total: number; if (searching) { const hits = (searchQuery.data?.threads ?? []).map((h) => h.thread); const f = hits.filter((th) => filter === "unread" ? th.unread_count > 0 : filter === "pinned" ? th.pinned : true, ); const ordered = listOrder === "newest" ? f : [...f].reverse(); total = ordered.length; pageThreads = ordered.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE); } else { pageThreads = threadsQuery.data?.threads ?? []; total = threadsQuery.data?.total ?? 0; } const pageStart = page * PAGE_SIZE; const pageEnd = pageStart + pageThreads.length; // Reset to the first page whenever the list identity (not the page) changes. useEffect(() => setPage(0), [view, filter, debouncedTerm, listOrder]); // Master "select all" state over the currently listed threads. const listedSelectedCount = pageThreads.filter((th) => checkedIds.has(th.id)).length; const allSelected = pageThreads.length > 0 && listedSelectedCount === pageThreads.length; const someSelected = listedSelectedCount > 0 && !allSelected; const toggleSelectAll = () => setCheckedIds(allSelected ? new Set() : new Set(pageThreads.map((th) => th.id))); // Keyboard next/prev navigates within the currently visible page. const threadsRef = useRef(pageThreads); threadsRef.current = pageThreads; const detailQuery = useQuery({ queryKey: ["thread", threadId, threadOrder, view], queryFn: () => api.thread(threadId!, threadOrder, view), enabled: Boolean(threadId), }); const markRead = useMutation({ mutationFn: (id: string) => api.threadFlags(id, ["\\Seen"], []), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["threads"] }); queryClient.invalidateQueries({ queryKey: ["folder-counts"] }); }, }); // Saved searches (virtual mailboxes, §12 M2). const savedSearches = savedSearchesQuery.data ?? []; const saveSearch = useMutation({ mutationFn: (name: string) => api.createSavedSearch(name, term.trim()), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["saved-searches"] }), }); const deleteSaved = useMutation({ mutationFn: (id: string) => api.deleteSavedSearch(id), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["saved-searches"] }), }); const onSaveSearch = () => { const q = term.trim(); if (!q) return; const name = window.prompt(t("search.saveNamePrompt"), q); if (name && name.trim()) saveSearch.mutate(name.trim()); }; const runSavedSearch = (s: SavedSearch) => { setTerm(s.query); navigate("/mail"); }; // Switch folder/account view. Clears any active search so the folder actually // loads — otherwise the list stays stuck on (now-stale) search results. const selectView = (v: string) => { setTerm(""); setView(v); navigate("/mail"); }; // Auto-mark an opened thread read after a short dwell (4s), not instantly — and // cancel it if you leave before then, so a quick glance doesn't mark it read. useEffect(() => { const detail = detailQuery.data; if (!detail || !detail.messages.some((m) => m.is_unread)) return; const id = detail.id; const timer = window.setTimeout(() => markRead.mutate(id), 4000); return () => window.clearTimeout(timer); // eslint-disable-next-line react-hooks/exhaustive-deps }, [detailQuery.data?.id]); // Open the composer with fresh state, remounting it if one is already open // (e.g. clicking a different draft replaces the current editor). const openComposer = (init: ComposerInit, expand = false) => { setComposing(init); setComposerExpanded(expand); setComposeKey((k) => k + 1); }; const closeComposer = () => { setComposing(null); setComposerExpanded(false); queryClient.invalidateQueries({ queryKey: ["drafts"] }); queryClient.invalidateQueries({ queryKey: ["drafts-remote"] }); queryClient.invalidateQueries({ queryKey: ["folder-counts"] }); }; // Leaving a folder closes any open composer/draft editor: the reading pane // should reflect the folder you navigated to, not a leftover draft editor. const firstView = useRef(true); useEffect(() => { if (firstView.current) { firstView.current = false; return; } closeComposer(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [view]); const afterMove = () => { queryClient.invalidateQueries({ queryKey: ["threads"] }); queryClient.invalidateQueries({ queryKey: ["quota"] }); queryClient.invalidateQueries({ queryKey: ["folder-counts"] }); navigate("/mail"); }; // Trigger a server-side sync of all folders; refresh the views once the worker // has had a moment to poll (live WebSocket events also update them). const doRefresh = async () => { setRefreshing(true); try { await api.syncNow(); } catch { /* ignore */ } window.setTimeout(() => { for (const key of ["threads", "drafts", "drafts-remote", "quota", "folder-counts"]) { queryClient.invalidateQueries({ queryKey: [key] }); } setRefreshing(false); }, 2500); }; const archive = async () => { if (!threadId) return; await api.archiveThread(threadId); afterMove(); }; const trash = async () => { if (!threadId) return; await api.trashThread(threadId); afterMove(); }; const restore = async () => { if (!threadId) return; await api.restoreThread(threadId); afterMove(); }; const deletePermanent = async () => { if (!threadId) return; await api.deleteThreadPermanent(threadId); afterMove(); }; const togglePin = async () => { const detail = detailQuery.data; if (!detail) return; await api.threadFlags( detail.id, detail.pinned ? [] : ["\\Flagged"], detail.pinned ? ["\\Flagged"] : [], ); queryClient.invalidateQueries({ queryKey: ["threads"] }); queryClient.invalidateQueries({ queryKey: ["search"] }); queryClient.invalidateQueries({ queryKey: ["thread", detail.id] }); }; // Per-thread actions for the mail-list hover buttons (act on a given id, not the // currently-open thread). Close the reading pane only if the acted thread is open. const listAfterMove = (id: string) => { // In a folder the refetch below removes the row (it left the folder's scope). // In search the row may still match a global query, so grey it out for feedback. if (searching) setDimmedIds((prev) => new Set(prev).add(id)); queryClient.invalidateQueries({ queryKey: ["threads"] }); queryClient.invalidateQueries({ queryKey: ["search"] }); // keep search results in sync queryClient.invalidateQueries({ queryKey: ["quota"] }); queryClient.invalidateQueries({ queryKey: ["folder-counts"] }); if (id === threadId) navigate("/mail"); }; const listArchive = async (id: string) => { await api.archiveThread(id); listAfterMove(id); }; const listTrash = async (id: string) => { await api.trashThread(id); listAfterMove(id); }; const listRestore = async (id: string) => { await api.restoreThread(id); listAfterMove(id); }; const listDeletePermanent = async (id: string) => { await api.deleteThreadPermanent(id); listAfterMove(id); }; const listTogglePin = async (id: string, pinned: boolean) => { await api.threadFlags(id, pinned ? [] : ["\\Flagged"], pinned ? ["\\Flagged"] : []); queryClient.invalidateQueries({ queryKey: ["threads"] }); queryClient.invalidateQueries({ queryKey: ["search"] }); // pin state on search results queryClient.invalidateQueries({ queryKey: ["thread", id] }); }; // Selection resets when you leave the folder. useEffect(() => setCheckedIds(new Set()), [view]); useEffect(() => setDimmedIds(new Set()), [view, debouncedTerm]); // Bulk actions run the given operation over every selected thread, then clear the // selection and refresh (navigating away if the open thread was among them). const runBulk = async (action: (id: string) => Promise) => { const ids = [...checkedIds]; for (const id of ids) { try { await action(id); } catch { /* keep going */ } } setCheckedIds(new Set()); queryClient.invalidateQueries({ queryKey: ["threads"] }); queryClient.invalidateQueries({ queryKey: ["quota"] }); queryClient.invalidateQueries({ queryKey: ["folder-counts"] }); if (threadId && ids.includes(threadId)) navigate("/mail"); }; const bulkArchive = () => runBulk((id) => api.archiveThread(id)); const bulkPin = () => runBulk((id) => api.threadFlags(id, ["\\Flagged"], [])); const bulkTrash = () => runBulk((id) => api.trashThread(id)); const bulkRestore = () => runBulk((id) => api.restoreThread(id)); const bulkDeletePermanent = () => runBulk((id) => api.deleteThreadPermanent(id)); // "Empty" a Trash/Spam/Archive folder: permanently delete everything in it. The // work runs in a worker; refresh the views after it and via the WS message.deleted. const [emptying, setEmptying] = useState(false); const emptyCurrentFolder = async () => { if (!window.confirm(t("mail.emptyConfirm"))) return; setEmptying(true); try { await api.emptyFolder(currentFolder); } catch { /* ignore */ } navigate("/mail"); // an open thread from this folder is about to be deleted window.setTimeout(() => { queryClient.invalidateQueries({ queryKey: ["threads"] }); queryClient.invalidateQueries({ queryKey: ["folder-counts"] }); setEmptying(false); }, 1500); }; const [moreOpen, setMoreOpen] = useState(false); const markAllRead = useMutation({ mutationFn: () => api.markAllRead(view), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["threads"] }); queryClient.invalidateQueries({ queryKey: ["folder-counts"] }); }, }); const openRelative = (delta: number) => { const list = threadsRef.current; if (list.length === 0) return; const idx = list.findIndex((th) => th.id === threadId); const next = Math.min(Math.max((idx < 0 ? -1 : idx) + delta, 0), list.length - 1); navigate(`/mail/t/${list[next].id}`); }; useKeyboardShortcuts({ onSearch: () => searchRef.current?.focus(), onCompose: () => accountsQuery.data?.[0] && openComposer({ accountId: accountsQuery.data[0].id, kind: "new" }), onReply: () => setReplySignal({ kind: "reply", seq: Date.now() }), onReplyAll: () => setReplySignal({ kind: "reply_all", seq: Date.now() }), onForward: () => setReplySignal({ kind: "forward", seq: Date.now() }), onArchive: archive, onGoInbox: () => selectView("folder:inbox"), onEscape: () => setComposing(null), onNext: () => openRelative(1), onPrev: () => openRelative(-1), }); const accounts = accountsQuery.data ?? []; // Deep link (e.g. Contacts "Compose"): /mail?compose=1&to= opens a new, // maximized composer prefilled with the recipient, then strips the params. const [searchParams, setSearchParams] = useSearchParams(); useEffect(() => { if (searchParams.get("compose") !== "1") return; const first = accountsQuery.data?.[0]; if (!first) return; // wait until accounts have loaded openComposer({ accountId: first.id, kind: "new", to: searchParams.get("to") ?? "" }, true); const next = new URLSearchParams(searchParams); next.delete("compose"); next.delete("to"); setSearchParams(next, { replace: true }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchParams, accountsQuery.data]); // Deep link (e.g. Contacts "Find all conversations"): /mail?q= prefills the // search box (which drives the search), then strips the param. useEffect(() => { const q = searchParams.get("q"); if (q == null) return; setTerm(q); const next = new URLSearchParams(searchParams); next.delete("q"); setSearchParams(next, { replace: true }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchParams]); // Deep link from the sidebar: /mail?label= shows all mail with that label. useEffect(() => { const label = searchParams.get("label"); if (label == null) return; setView(`label:${label}`); setPage(0); navigate("/mail"); const next = new URLSearchParams(searchParams); next.delete("label"); setSearchParams(next, { replace: true }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchParams]); // Labels changed anywhere (Actions menu, sidebar +): refresh label-driven data. useEffect(() => { const onChanged = () => { queryClient.invalidateQueries({ queryKey: ["labels-all"] }); queryClient.invalidateQueries({ queryKey: ["thread-labels"] }); queryClient.invalidateQueries({ queryKey: ["message-labels"] }); queryClient.invalidateQueries({ queryKey: ["threads"] }); }; window.addEventListener("webmail:labels-changed", onChanged); return () => window.removeEventListener("webmail:labels-changed", onChanged); }, [queryClient]); return (
{/* Sidebar */} {!collapsed && setSidebarW((w) => clampSidebar(w + dx))} />} {/* Message list */}
setTerm(e.target.value)} /> {term && ( <> )}
{!isDrafts && (
{labelView !== null ? ( {labelView} ) : ( <> setFilter("all")}> {t("filter.all")} setFilter("unread")}> {t("filter.unread")} setFilter("pinned")}> {t("filter.pinned")} )}
{(currentFolder === "trash" || currentFolder === "junk") && ( )}
{moreOpen && ( <>
setMoreOpen(false)} />
)}
)}
{isDrafts && !term.trim() ? ( openComposer(draftToInit(draft), true)} onOpenRemote={async (remote) => { // Adopt the server draft into an editable local draft, then open it. const draft = await api.adoptDraft(remote.message_id); queryClient.invalidateQueries({ queryKey: ["drafts"] }); queryClient.invalidateQueries({ queryKey: ["drafts-remote"] }); openComposer(draftToInit(draft), true); }} /> ) : ( navigate(`/mail/t/${id}`)} onArchive={listArchive} onRestore={listRestore} onTrash={listTrash} onDeletePermanent={listDeletePermanent} onTogglePin={listTogglePin} checkedIds={checkedIds} dimmedIds={dimmedIds} onToggleCheck={toggleCheck} /> )}
{!isDrafts && total > 0 ? (
{pageStart + 1}–{pageEnd} of {total}
setPage(page - 1)} label={t("pager.prev")} dir="left" /> = total} onClick={() => setPage(page + 1)} label={t("pager.next")} dir="right" />
) : ( )}
setListW((w) => clampList(w + dx))} /> {/* Thread pane */}
{checkedIds.size > 0 ? (
{currentFolder === "archive" || currentFolder === "trash" || currentFolder === "junk" ? ( <> {t("mail.restore")} {t("mail.delete")} ) : ( <> {t("mail.archive")} {t("mail.pin")} {t("mail.delete")} )}
{checkedIds.size === 1 ? t("mail.conversationSelected") : t("mail.conversationsSelected", { count: checkedIds.size })}
) : threadId && detailQuery.data ? (
{currentFolder === "archive" ? ( <> {t("mail.restore")} {t("mail.delete")} ) : currentFolder === "trash" ? ( <> {t("mail.restore")} {t("mail.delete")} ) : ( <> {t("mail.archive")} {detailQuery.data.pinned ? t("mail.unpin") : t("mail.pin")} {t("mail.delete")} )} {t("mail.messageCount", { count: detailQuery.data.messages.length })}
) : (
{!rightOpen && ( )} {t("mail.selectConversation")}
)} {composing && (
setComposerExpanded((v) => !v)} onClose={closeComposer} />
)}
{rightOpen && ( <> setRightW((w) => clampRight(w - dx))} />
setRightOpen(false)} thread={detailQuery.data} />
)}
); } // Account bullet in the sidebar. While the account is backfilling it becomes a // spinning ring that fills with the account colour to show import progress // (SPEC §12 M2); once live it's the plain colour-filled dot. function AccountDot({ color, syncing, total, done, }: { color: string; syncing: boolean; total: number; done: number; }) { if (!syncing) { return ( ); } const pct = total > 0 ? Math.min(100, Math.round((done / Math.max(done, total)) * 100)) : 0; const ring = "rgba(128,128,128,0.35)"; return ( ); } function FilterTab({ active, onClick, children, }: { active: boolean; onClick: () => void; children: React.ReactNode; }) { return ( ); } function PagerButton({ disabled, onClick, label, dir, }: { disabled: boolean; onClick: () => void; label: string; dir: "left" | "right"; }) { return ( ); } function RefreshIcon({ spinning }: { spinning: boolean }) { return ( ); } function SortIcon() { return ( ); } function SidebarItem({ active, onClick, collapsed = false, title, children, }: { active: boolean; onClick: () => void; collapsed?: boolean; title?: string; children: React.ReactNode; }) { return ( ); } function ToolbarButton({ onClick, active = false, children, }: { onClick: () => void; active?: boolean; children: React.ReactNode; }) { return ( ); }