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 { ComposeDock } from "../components/compose/ComposeDock"; import { Composer, type ComposerInit, draftToInit } from "../components/compose/Composer"; import { UndoSendToast } from "../components/compose/UndoSendToast"; import { usePendingSend } from "../components/compose/undoSend"; import { collapseExpanded, type ComposeWin, minimizeAll, minimizeWindow, openWindow, restoreWindow, toggleExpand, } from "../components/compose/windows"; 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 { useIsMobile } from "../lib/useIsMobile"; 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; // How long a row the list hid for a queued move waits for the move to land // before it is shown again. const HIDE_FALLBACK_MS = 30_000; // 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"); // Smartphone layout: one column at a time — the list, or the reading pane full // screen — with the sidebar as a slide-in drawer. Desktop is untouched by it. const mobile = useIsMobile(); const [drawerOpen, setDrawerOpen] = useState(false); 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()); // Rows a hover action is moving out of the folder: gone from the list the // moment the button is clicked, while the request runs behind it. const [hiddenIds, setHiddenIds] = 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; }); // Every open compose window, oldest first — the dock lays them out left to // right and keeps each one mounted for its whole life. const [composeWins, setComposeWins] = useState([]); const composeSeq = useRef(0); // A draft opened from the Drafts folder is edited in the reading pane, the // way a thread is read there — not in a floating window. const [paneDraft, setPaneDraft] = useState(null); const [replySignal, setReplySignal] = useState<{ kind: string; seq: number }>(); // The reading pane's live width, so an expanded composer can match it. Its // size answers to the two resizers and the right panel as well as the // viewport, so it is measured rather than derived. const paneRef = useRef(null); const [pane, setPane] = useState({ width: 0, right: 0 }); useEffect(() => { const el = paneRef.current; if (!el) return; const measure = () => { const r = el.getBoundingClientRect(); // `right` is the gap to the viewport edge, which is what the dock needs // to line an expanded window up with the pane — it is non-zero whenever // the right panel is open. setPane({ width: Math.round(r.width), right: Math.round(window.innerWidth - r.right) }); }; const ro = new ResizeObserver(measure); ro.observe(el); window.addEventListener("resize", measure); return () => { ro.disconnect(); window.removeEventListener("resize", measure); }; }, []); 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). The mobile drawer is always full. const [collapsedPref, setCollapsed] = useState( () => localStorage.getItem("panel.collapsed") === "1", ); const collapsed = collapsedPref && !mobile; // 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", collapsedPref ? "1" : "0"), [collapsedPref], ); 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 { const all = threadsQuery.data?.threads ?? []; pageThreads = hiddenIds.size ? all.filter((th) => !hiddenIds.has(th.id)) : all; total = Math.max(0, (threadsQuery.data?.total ?? 0) - (all.length - pageThreads.length)); } 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), }); // A thread whose last visible message left — marked as spam, trashed, moved — // should not linger in the reading panel. General on purpose: it closes the // panel whoever emptied the thread, core action or plugin. useEffect(() => { if (threadId && detailQuery.data && detailQuery.data.messages.length === 0) { navigate("/mail"); } }, [threadId, detailQuery.data, navigate]); 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); setDrawerOpen(false); 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); setDrawerOpen(false); navigate("/mail"); }; // Anything else that switches the view (a plugin's sidebar label link) or // leaves the phone layout also puts the drawer away. useEffect(() => setDrawerOpen(false), [view, mobile]); useEffect(() => { if (!drawerOpen) return; const onKey = (e: KeyboardEvent) => e.key === "Escape" && setDrawerOpen(false); window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [drawerOpen]); // 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]); // Opening a composer parks whatever was open in the dock instead of replacing // it: each Compose click adds a window and pushes the previous one to its // left. Nothing is ever remounted, so nothing typed is ever lost. const openComposer = (init: ComposerInit, expand = false) => { const seq = ++composeSeq.current; setComposeWins((prev) => openWindow(prev, `c${seq}`, init, expand, seq)); }; // `composeSeq` doubles as the activation clock the dock uses to decide which // windows stay open when they no longer all fit. const restoreComposer = (id: string) => setComposeWins((prev) => restoreWindow(prev, id, ++composeSeq.current)); const minimizeComposer = (id: string) => setComposeWins((prev) => minimizeWindow(prev, id)); const toggleComposerExpand = (id: string) => setComposeWins((prev) => toggleExpand(prev, id, ++composeSeq.current)); const refreshDrafts = () => { queryClient.invalidateQueries({ queryKey: ["drafts"] }); queryClient.invalidateQueries({ queryKey: ["drafts-remote"] }); queryClient.invalidateQueries({ queryKey: ["folder-counts"] }); }; const closeComposer = (id: string) => { setComposeWins((prev) => prev.filter((w) => w.id !== id)); refreshDrafts(); }; const openDraftInPane = (init: ComposerInit) => { setPaneDraft(init); if (threadId) navigate("/mail"); // the pane shows one thing at a time }; // Sent from a phone: back to the list, where the Undo offer waits. const pendingSend = usePendingSend(); useEffect(() => { if (pendingSend && mobile && threadId) navigate("/mail"); // eslint-disable-next-line react-hooks/exhaustive-deps }, [pendingSend?.outboxId]); // An undone send is a draft again: open it back up where it can be edited. const reopenUnsent = async (draftId: string) => { refreshDrafts(); try { const draft = (await api.listDrafts()).find((d) => d.id === draftId); if (draft) openComposer(draftToInit(draft)); } catch { /* it is still in Drafts */ } }; const closePaneDraft = () => { setPaneDraft(null); refreshDrafts(); }; // The pane belongs to whatever you navigated to: opening a thread or leaving // the folder puts the draft away. (It is saved either way — the composer // mirrors a dirty draft on unmount.) useEffect(() => { if (threadId) setPaneDraft(null); }, [threadId]); useEffect(() => { setPaneDraft(null); }, [view]); // Leaving a folder un-expands an expanded composer so the reading pane shows // the folder you navigated to — but the window itself stays, collapsed into // the dock at worst. Closing it here used to discard work in progress. const firstView = useRef(true); useEffect(() => { if (firstView.current) { firstView.current = false; return; } setComposeWins(collapseExpanded); // 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. // // The feedback is immediate and the request runs behind it. In a folder the // row is hidden straight away (it is leaving the folder's scope); in search it // may still match a global query, so it is greyed out instead. A failed // request puts the row back. const listMove = async (id: string, action: (id: string) => Promise) => { const mark = searching ? setDimmedIds : setHiddenIds; const unmark = () => mark((prev) => { const next = new Set(prev); next.delete(id); return next; }); mark((prev) => new Set(prev).add(id)); if (id === threadId) navigate("/mail"); try { await action(id); } catch { unmark(); return; } queryClient.invalidateQueries({ queryKey: ["threads"] }); queryClient.invalidateQueries({ queryKey: ["search"] }); // keep search results in sync queryClient.invalidateQueries({ queryKey: ["quota"] }); queryClient.invalidateQueries({ queryKey: ["folder-counts"] }); // The server only queues the move, so the refetch above can still carry the // row; the effect below un-hides it once a list arrives without it. Should // the move never land, the row comes back rather than staying lost. if (!searching) window.setTimeout(unmark, HIDE_FALLBACK_MS); }; // A row stays hidden until a fetched list no longer contains it. useEffect(() => { const rows = threadsQuery.data?.threads; if (!rows) return; setHiddenIds((prev) => { if (prev.size === 0) return prev; const present = new Set(rows.map((th) => th.id)); const next = new Set([...prev].filter((id) => present.has(id))); return next.size === prev.size ? prev : next; }); }, [threadsQuery.data]); const listArchive = (id: string) => listMove(id, api.archiveThread); const listTrash = (id: string) => listMove(id, api.trashThread); const listRestore = (id: string) => listMove(id, api.restoreThread); const listDeletePermanent = (id: string) => listMove(id, api.deleteThreadPermanent); 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]); useEffect(() => setHiddenIds(new Set()), [view]); // 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)); // Bulk-action buttons over the selected conversations: in the reading pane on // the desktop, above the list on a phone. const bulkButtons = currentFolder === "archive" || currentFolder === "trash" || currentFolder === "junk" ? ( <> {t("mail.restore")} {t("mail.delete")} ) : ( <> {t("mail.archive")} {t("mail.pin")} {t("mail.delete")} ); // "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: () => setComposeWins(minimizeAll), 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]); // On a phone the reading pane replaces the list rather than sitting beside it. const paneShown = Boolean(paneDraft || threadId); return (
{mobile && ( // Tapping outside the drawer closes it.
setDrawerOpen(false)} className={`fixed inset-0 z-40 bg-black/40 transition-opacity duration-200 ${ drawerOpen ? "opacity-100" : "pointer-events-none opacity-0" }`} /> )} {/* Sidebar (a slide-in drawer on phones) */} {!collapsed && !mobile && ( setSidebarW((w) => clampSidebar(w + dx))} /> )} {/* Message list (the whole screen on a phone, until a thread opens) */}
{mobile && Ultimo} {mobile && ( )}
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)} />
)}
)} {mobile && checkedIds.size > 0 && ( // The desktop shows bulk actions in the reading pane; a phone has none // beside the list, so they sit above it.
{checkedIds.size} {bulkButtons}
)}
{isDrafts && !term.trim() ? ( openDraftInPane(draftToInit(draft))} 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"] }); openDraftInPane(draftToInit(draft)); }} /> ) : ( navigate(`/mail/t/${id}`)} onArchive={listArchive} onRestore={listRestore} onTrash={listTrash} onDeletePermanent={listDeletePermanent} onTogglePin={listTogglePin} checkedIds={checkedIds} dimmedIds={dimmedIds} onToggleCheck={toggleCheck} compact={mobile} /> )}
{!isDrafts && total > 0 ? (
{pageStart + 1}–{pageEnd} of {total}
setPage(page - 1)} label={t("pager.prev")} dir="left" big={mobile} /> = total} onClick={() => setPage(page + 1)} label={t("pager.next")} dir="right" big={mobile} />
) : ( )}
{mobile && ( )}
{!mobile && setListW((w) => clampList(w + dx))} />} {/* Thread pane (full screen on a phone, shown only with something open) */}
{paneDraft ? ( // Keyed by draft: picking a different one must build a fresh editor, // because RichTextEditor seeds its body exactly once. ) : checkedIds.size > 0 && !mobile ? (
{bulkButtons}
{checkedIds.size === 1 ? t("mail.conversationSelected") : t("mail.conversationsSelected", { count: checkedIds.size })}
) : threadId && detailQuery.data ? (
{mobile && ( )} {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 })} {!mobile && ( )}
) : (
{!rightOpen && ( )} {t("mail.selectConversation")}
)}
{rightOpen && !mobile && ( <> 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, big = false, }: { disabled: boolean; onClick: () => void; label: string; dir: "left" | "right"; big?: boolean; }) { const size = big ? 21 : 14; return ( ); } function RefreshIcon({ spinning, size = 15 }: { spinning: boolean; size?: number }) { 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 ( ); }