Files
webmail-client/frontend/src/routes/MailLayout.tsx
T
lukasandClaude Opus 5.5 8c463921f7 Mail list: a hidden row flickered back before its move landed
The server only queues a thread move, so the refetch right after the request
still carried the row, and un-hiding it then brought it back until the live
update removed it for good. A row now stays hidden until a fetched list no
longer contains it, with a 30 s fallback in case the move never lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:55:18 +02:00

1477 lines
60 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string>("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<Set<string>>(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<Set<string>>(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<Set<string>>(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<ComposeWin[]>([]);
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<ComposerInit | null>(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<HTMLElement>(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<HTMLInputElement>(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<PanelTab>(() => (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<ThreadSummary[]>(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<unknown>) => {
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<unknown>) => {
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" ? (
<>
<ToolbarButton onClick={bulkRestore}>
<Icon name="restore" size={16} />
{t("mail.restore")}
</ToolbarButton>
<ToolbarButton onClick={currentFolder === "trash" ? bulkDeletePermanent : bulkTrash}>
<Icon name="trash" size={16} />
{t("mail.delete")}
</ToolbarButton>
</>
) : (
<>
<ToolbarButton onClick={bulkArchive}>
<Icon name="archive" size={16} />
{t("mail.archive")}
</ToolbarButton>
<ToolbarButton onClick={bulkPin}>
<Icon name="pin" size={16} />
{t("mail.pin")}
</ToolbarButton>
<ToolbarButton onClick={bulkTrash}>
<Icon name="trash" size={16} />
{t("mail.delete")}
</ToolbarButton>
</>
);
// "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=<email> 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=<text> 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=<name> 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 (
<div className={`flex bg-app-bg text-app-fg ${mobile ? "h-[100dvh]" : "h-screen"}`}>
<BackfillBanner />
{mobile && (
// Tapping outside the drawer closes it.
<div
aria-hidden
onClick={() => 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) */}
<aside
style={mobile ? undefined : { width: collapsed ? COLLAPSED_W : sidebarW }}
className={
mobile
? `fixed inset-y-0 left-0 z-50 flex w-[70vw] flex-col border-r border-app-border bg-app-bg shadow-2xl transition-transform duration-200 ease-out ${
drawerOpen ? "translate-x-0" : "-translate-x-full"
}`
: "flex shrink-0 flex-col border-r border-app-border bg-app-bg"
}
>
<div
className={`flex pb-1 pt-[11px] ${
collapsed ? "flex-col items-center gap-1 px-2" : "items-center justify-between px-3"
}`}
>
{collapsed ? (
<img src={signetLogo} alt="Ultimo" className="h-7 w-auto shrink-0" />
) : (
<div className="flex items-center gap-2">
<img src={signetLogo} alt="Ultimo" className="h-7 w-auto shrink-0" />
{/* Calendar/Contacts have no phone layout yet, so no way into them there. */}
{!mobile && <AppSwitcher />}
</div>
)}
<div className={`flex items-center gap-1 ${collapsed ? "flex-col" : ""}`}>
{!mobile && (
<button
onClick={() => setCollapsed((v) => !v)}
title={collapsed ? t("nav.expandSidebar") : t("nav.collapseSidebar")}
aria-label={collapsed ? t("nav.expandSidebar") : t("nav.collapseSidebar")}
className="inline-flex h-7 w-7 items-center justify-center rounded text-app-muted hover:bg-app-hover hover:text-app-fg"
>
<Icon name={collapsed ? "show" : "hide"} size={16} />
</button>
)}
<ThemeToggle />
</div>
</div>
<div className={`pb-2 pt-2.5 ${collapsed ? "px-2" : "px-3"} ${mobile ? "hidden" : ""}`}>
<button
className="flex w-full items-center justify-center gap-2 rounded-lg bg-app-accent py-2 text-[15px] font-medium text-app-accent-fg shadow-sm hover:opacity-90"
title={collapsed ? t("nav.compose") : undefined}
onClick={() => accounts[0] && openComposer({ accountId: accounts[0].id, kind: "new" })}
>
<Icon name="compose" size={16} />
{!collapsed && t("nav.compose")}
</button>
</div>
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2 py-1 text-[15px]">
{FOLDERS.map((f) => {
const badge =
f.view === "folder:inbox"
? countsQuery.data?.inbox_unread
: f.view === "folder:drafts"
? countsQuery.data?.drafts
: undefined;
return (
<SidebarItem
key={f.view}
active={view === f.view}
collapsed={collapsed}
title={collapsed ? t(f.label) : undefined}
onClick={() => selectView(f.view)}
>
<Icon name={f.icon} size={f.icon === "trash" ? 20 : 18} />
{!collapsed && <span className="flex-1 truncate">{t(f.label)}</span>}
{!collapsed && badge ? (
<span className="ml-auto shrink-0 tabular-nums text-[13px] text-app-muted">{badge}</span>
) : null}
</SidebarItem>
);
})}
{/* Per-account views are hidden entirely when collapsed. */}
{!collapsed && (
<>
<div className="px-3 pb-1 pt-3 text-[12px] font-semibold uppercase tracking-wide text-app-faint">
{t("settings.accounts")}
</div>
{accounts.map((a) => (
<SidebarItem
key={a.id}
active={view === `account:${a.id}`}
onClick={() => selectView(`account:${a.id}`)}
>
<AccountDot
color={a.color}
syncing={
a.state === "backfilling" &&
(a.backfill_total === 0 || a.backfill_done < a.backfill_total)
}
total={a.backfill_total}
done={a.backfill_done}
/>
<span className="truncate">{a.email}</span>
</SidebarItem>
))}
{savedSearches.length > 0 && (
<>
<div className="px-3 pb-1 pt-3 text-[12px] font-semibold uppercase tracking-wide text-app-faint">
{t("search.saved")}
</div>
{savedSearches.map((s) => (
<div key={s.id} className="group/saved relative">
<SidebarItem
active={searching && debouncedTerm === s.query}
onClick={() => runSavedSearch(s)}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<circle cx="7" cy="7" r="4.5" />
<path d="M10.5 10.5L14 14" />
</svg>
<span className="flex-1 truncate">{s.name}</span>
</SidebarItem>
<button
type="button"
title={t("common.delete")}
aria-label={t("common.delete")}
onClick={(e) => {
e.stopPropagation();
deleteSaved.mutate(s.id);
}}
className="absolute right-2 top-1/2 hidden -translate-y-1/2 rounded p-1 text-app-faint hover:bg-app-hover hover:text-app-fg group-hover/saved:block"
>
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</button>
</div>
))}
</>
)}
</>
)}
{/* Plugin-contributed sidebar sections (SPEC §11.3 sidebar.section). */}
{!collapsed && <PluginSlot name="sidebar.section" />}
</nav>
<div className="border-t border-app-border p-2 text-[15px]">
{/* Settings has no phone layout yet either. */}
<Link
className={`flex items-center gap-2.5 rounded-lg py-2 text-app-muted hover:bg-app-hover hover:text-app-fg ${
collapsed ? "justify-center px-0" : "px-3"
} ${mobile ? "hidden" : ""}`}
title={collapsed ? t("nav.settings") : undefined}
to="/settings"
>
<Icon name="settings" size={22} />
{!collapsed && t("nav.settings")}
</Link>
<button
className={`flex w-full items-center gap-2.5 rounded-lg py-2 text-left text-app-muted hover:bg-app-hover hover:text-app-fg ${
collapsed ? "justify-center px-0" : "px-3"
}`}
title={collapsed ? t("common.signOut") : undefined}
onClick={async () => {
await api.logout();
queryClient.clear();
navigate("/login");
}}
>
<Icon name="log-out" size={16} />
{!collapsed && t("common.signOut")}
</button>
{!collapsed && quotaQuery.data && (
<div
className="mt-1 border-t border-app-border px-3 pb-1 pt-2 text-[13px] text-app-faint"
title={t("nav.storageQuota")}
>
{quotaLabel(quotaQuery.data)}
</div>
)}
</div>
</aside>
{!collapsed && !mobile && (
<Resizer onDelta={(dx) => setSidebarW((w) => clampSidebar(w + dx))} />
)}
{/* Message list (the whole screen on a phone, until a thread opens) */}
<div
style={mobile ? undefined : { width: listW }}
className={
mobile
? `relative min-w-0 flex-1 flex-col bg-app-surface ${paneShown ? "hidden" : "flex"}`
: "flex shrink-0 flex-col border-r border-app-border bg-app-surface"
}
>
<div
className={`flex h-[52px] shrink-0 items-center border-b border-app-border ${
mobile ? "gap-2 px-3" : "px-6"
}`}
>
{mobile && <img src={signetLogo} alt="Ultimo" className="h-7 w-auto shrink-0" />}
{mobile && (
<button
type="button"
title={t("nav.menu")}
aria-label={t("nav.menu")}
aria-expanded={drawerOpen}
onClick={() => setDrawerOpen(true)}
className="shrink-0 rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" aria-hidden>
<path d="M4 7h16M4 12h16M4 17h16" />
</svg>
</button>
)}
<div className="relative w-full">
<input
ref={searchRef}
className={`w-full rounded-lg border border-app-border bg-app-bg px-3 py-1.5 text-[15px] text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none ${
term ? "pr-14" : "pr-8"
}`}
placeholder={
currentFolder === "trash"
? t("nav.searchInTrash")
: currentFolder === "junk"
? t("nav.searchInSpam")
: t("nav.search")
}
value={term}
onChange={(e) => setTerm(e.target.value)}
/>
{term && (
<>
<button
type="button"
title={t("search.save")}
aria-label={t("search.save")}
onClick={onSaveSearch}
className="absolute right-8 top-1/2 -translate-y-1/2 rounded p-1 text-app-faint hover:bg-app-hover hover:text-app-accent"
>
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 2.5h8v11l-4-2.6-4 2.6z" />
</svg>
</button>
<button
type="button"
title={t("common.clear")}
aria-label={t("common.clear")}
onClick={() => {
setTerm("");
searchRef.current?.focus();
}}
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded p-1 text-app-faint hover:bg-app-hover hover:text-app-fg"
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</button>
</>
)}
</div>
</div>
{!isDrafts && (
<div
className={`flex h-[52px] shrink-0 items-center gap-5 border-b border-app-border bg-app-bg text-[15px] ${
mobile ? "px-3" : "px-6"
}`}
>
<button
type="button"
aria-label={t("mail.selectAll")}
title={t("mail.selectAll")}
onClick={toggleSelectAll}
className={`${mobile ? "hidden" : "flex"} ml-[28px] h-[17px] w-[17px] shrink-0 items-center justify-center rounded-md border-2 transition-colors ${
allSelected || someSelected
? "border-app-accent bg-app-accent text-white"
: "border-app-faint hover:border-app-muted"
}`}
>
{allSelected ? (
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M3.5 8.5l3 3 6-7" />
</svg>
) : someSelected ? (
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round">
<path d="M4 8h8" />
</svg>
) : null}
</button>
{labelView !== null ? (
<span className="flex items-center gap-1.5 rounded bg-app-hover px-2 py-0.5 text-[13px] text-app-fg">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M20.6 13.4l-7.1 7.1a2 2 0 0 1-2.83 0l-6.17-6.17A2 2 0 0 1 3 12.93V5a2 2 0 0 1 2-2h7.93a2 2 0 0 1 1.4.59l6.17 6.17a2 2 0 0 1 0 2.83z" /><circle cx="7.5" cy="7.5" r="1.2" fill="currentColor" stroke="none" /></svg>
{labelView}
<button className="text-app-muted hover:text-app-fg" title={t("common.close")} onClick={() => selectView("folder:inbox")}>✕</button>
</span>
) : (
<>
<FilterTab active={filter === "all"} onClick={() => setFilter("all")}>
{t("filter.all")}
</FilterTab>
<FilterTab active={filter === "unread"} onClick={() => setFilter("unread")}>
{t("filter.unread")}
</FilterTab>
<FilterTab active={filter === "pinned"} onClick={() => setFilter("pinned")}>
{t("filter.pinned")}
</FilterTab>
</>
)}
<div className="-mr-1.5 ml-auto flex items-center gap-1">
{(currentFolder === "trash" || currentFolder === "junk") && (
<button
className="rounded px-2 py-1 text-[13px] font-medium text-red-500 hover:bg-app-hover disabled:opacity-50"
onClick={emptyCurrentFolder}
disabled={emptying}
>
{currentFolder === "trash" ? t("mail.emptyTrash") : t("mail.emptySpam")}
</button>
)}
<button
className="rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
title={listOrder === "newest" ? t("filter.sortOldest") : t("filter.sortNewest")}
aria-label={listOrder === "newest" ? t("filter.sortOldest") : t("filter.sortNewest")}
onClick={() => setListOrder((o) => (o === "newest" ? "oldest" : "newest"))}
>
<SortIcon />
</button>
<div className="relative">
<button
className="rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
title={t("mail.more")}
aria-label={t("mail.more")}
onClick={() => setMoreOpen((o) => !o)}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<circle cx="5" cy="12" r="1.6" />
<circle cx="12" cy="12" r="1.6" />
<circle cx="19" cy="12" r="1.6" />
</svg>
</button>
{moreOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setMoreOpen(false)} />
<div className="absolute right-0 z-20 mt-1 min-w-[180px] rounded-lg border border-app-border bg-app-surface py-1 shadow-lg">
<button
className="flex w-full items-center px-3 py-2 text-left text-[13px] text-app-fg hover:bg-app-hover disabled:opacity-50"
disabled={markAllRead.isPending}
onClick={() => {
setMoreOpen(false);
markAllRead.mutate();
}}
>
{t("mail.markAllRead")}
</button>
</div>
</>
)}
</div>
</div>
</div>
)}
{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.
<div className="flex h-[48px] shrink-0 items-center gap-1 border-b border-app-border bg-app-surface px-2 text-[15px]">
<span className="px-2 text-[13px] text-app-muted">{checkedIds.size}</span>
{bulkButtons}
</div>
)}
<div className="min-h-0 flex-1">
{isDrafts && !term.trim() ? (
<DraftsList
drafts={draftsQuery.data ?? []}
remoteDrafts={remoteDraftsQuery.data ?? []}
selectedId={paneDraft?.draftId}
onOpen={(draft) => 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));
}}
/>
) : (
<MessageList
threads={pageThreads}
page={page}
selectedId={threadId}
folder={currentFolder}
onSelect={(id) => navigate(`/mail/t/${id}`)}
onArchive={listArchive}
onRestore={listRestore}
onTrash={listTrash}
onDeletePermanent={listDeletePermanent}
onTogglePin={listTogglePin}
checkedIds={checkedIds}
dimmedIds={dimmedIds}
onToggleCheck={toggleCheck}
compact={mobile}
/>
)}
</div>
<div
className={`flex items-center justify-between border-t border-app-border py-1.5 text-app-muted ${
// Phone: everything half again as big, for the thumb.
mobile
? "px-4 pb-[max(0.375rem,env(safe-area-inset-bottom))] text-[19.5px]"
: "px-6 text-[13px]"
}`}
>
<button
className={`-ml-1 rounded text-app-muted ${mobile ? "p-1.5" : "p-1"} hover:bg-app-hover hover:text-app-fg disabled:opacity-60`}
title={t("nav.refresh")}
aria-label={t("nav.refresh")}
onClick={doRefresh}
disabled={refreshing}
>
<RefreshIcon spinning={refreshing} size={mobile ? 22.5 : 15} />
</button>
{!isDrafts && total > 0 ? (
<div className="flex items-center gap-3">
<span>
{pageStart + 1}–{pageEnd} of {total}
</span>
<div className="-mr-1 flex items-center gap-0.5">
<PagerButton
disabled={page === 0}
onClick={() => setPage(page - 1)}
label={t("pager.prev")}
dir="left"
big={mobile}
/>
<PagerButton
disabled={pageEnd >= total}
onClick={() => setPage(page + 1)}
label={t("pager.next")}
dir="right"
big={mobile}
/>
</div>
</div>
) : (
<span />
)}
</div>
{mobile && (
<button
type="button"
title={t("nav.compose")}
aria-label={t("nav.compose")}
onClick={() => accounts[0] && openComposer({ accountId: accounts[0].id, kind: "new" })}
className="absolute bottom-[calc(4rem+env(safe-area-inset-bottom))] right-4 z-30 flex h-14 w-14 items-center justify-center rounded-2xl bg-app-accent text-app-accent-fg shadow-lg active:opacity-90"
>
<Icon name="compose" size={22} />
</button>
)}
</div>
{!mobile && <Resizer onDelta={(dx) => setListW((w) => clampList(w + dx))} />}
{/* Thread pane (full screen on a phone, shown only with something open) */}
<main
ref={paneRef}
className={`relative min-w-0 flex-1 bg-app-surface ${mobile && !paneShown ? "hidden" : ""}`}
>
{paneDraft ? (
// Keyed by draft: picking a different one must build a fresh editor,
// because RichTextEditor seeds its body exactly once.
<Composer
key={paneDraft.draftId}
init={paneDraft}
onClose={closePaneDraft}
fullWindow
/>
) : checkedIds.size > 0 && !mobile ? (
<div className="flex h-full flex-col">
<div className="flex h-[52px] shrink-0 items-center gap-1 border-b border-app-border bg-app-surface px-3 text-[15px]">
{bulkButtons}
</div>
<div className="flex flex-1 items-center justify-center text-3xl text-app-faint">
{checkedIds.size === 1
? t("mail.conversationSelected")
: t("mail.conversationsSelected", { count: checkedIds.size })}
</div>
</div>
) : threadId && detailQuery.data ? (
<div className="flex h-full flex-col">
<div className="flex h-[52px] shrink-0 items-center gap-1 border-b border-app-border bg-app-surface px-3 text-[15px]">
{mobile && (
<button
type="button"
title={t("common.back")}
aria-label={t("common.back")}
onClick={() => navigate("/mail")}
className="-ml-1 mr-1 rounded p-1.5 text-app-fg hover:bg-app-hover"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M15 5l-7 7 7 7" />
</svg>
</button>
)}
{currentFolder === "archive" ? (
<>
<ToolbarButton onClick={restore}>
<Icon name="inbox" size={16} />
{t("mail.restore")}
</ToolbarButton>
<ToolbarButton onClick={trash}>
<Icon name="trash" size={16} />
{t("mail.delete")}
</ToolbarButton>
</>
) : currentFolder === "trash" ? (
<>
<ToolbarButton onClick={restore}>
<Icon name="inbox" size={16} />
{t("mail.restore")}
</ToolbarButton>
<ToolbarButton onClick={deletePermanent}>
<Icon name="trash" size={16} />
{t("mail.delete")}
</ToolbarButton>
</>
) : (
<>
<ToolbarButton onClick={archive}>
<Icon name="archive" size={16} />
{t("mail.archive")}
</ToolbarButton>
<ToolbarButton onClick={togglePin} active={detailQuery.data.pinned}>
<Icon
name="pin"
size={16}
className={detailQuery.data.pinned ? "text-[rgb(220_129_143)]" : ""}
/>
{detailQuery.data.pinned ? t("mail.unpin") : t("mail.pin")}
</ToolbarButton>
<ToolbarButton onClick={trash}>
<Icon name="trash" size={16} />
{t("mail.delete")}
</ToolbarButton>
</>
)}
<span className={`ml-auto text-[13px] text-app-faint ${mobile ? "hidden" : ""}`}>
{t("mail.messageCount", { count: detailQuery.data.messages.length })}
</span>
{!mobile && (
<button
className={`rounded p-1.5 hover:bg-app-hover hover:text-app-fg ${rightOpen ? "text-app-fg" : "text-app-muted"}`}
title={t("panel.toggle")}
aria-label={t("panel.toggle")}
aria-pressed={rightOpen}
onClick={() => (rightOpen ? setRightOpen(false) : openPanel(rightTab))}
>
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<rect x="3" y="4" width="18" height="16" rx="2" />
<path d="M15 4v16" />
</svg>
</button>
)}
</div>
<div className="min-h-0 flex-1 overflow-hidden">
<ThreadView
subject={detailQuery.data.subject}
messages={detailQuery.data.messages}
order={detailQuery.data.order}
mailboxes={mailboxesQuery.data ?? []}
accounts={accounts}
replyKindSignal={replySignal}
/>
</div>
</div>
) : (
<div className="flex h-full flex-col items-center justify-center gap-6 text-app-faint">
{!rightOpen && (
<button
className="absolute right-3 top-3 rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
title={t("panel.toggle")}
aria-label={t("panel.toggle")}
onClick={() => openPanel(rightTab)}
>
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<rect x="3" y="4" width="18" height="16" rx="2" />
<path d="M15 4v16" />
</svg>
</button>
)}
<EmptyIllustration className="w-56 max-w-[55%]" />
<span className="text-3xl">{t("mail.selectConversation")}</span>
</div>
)}
</main>
{rightOpen && !mobile && (
<>
<Resizer onDelta={(dx) => setRightW((w) => clampRight(w - dx))} />
<div style={{ width: rightW }} className="shrink-0 border-l border-app-border">
<RightPanel tab={rightTab} onTab={setRightTab} onClose={() => setRightOpen(false)} thread={detailQuery.data} />
</div>
</>
)}
<ComposeDock
windows={composeWins}
pane={pane}
onMinimize={minimizeComposer}
onRestore={restoreComposer}
onToggleExpand={toggleComposerExpand}
onClose={closeComposer}
fullScreen={mobile}
/>
<UndoSendToast mobile={mobile} onUndone={reopenUnsent} />
</div>
);
}
// 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 (
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: color }} />
);
}
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 (
<span
className="h-3.5 w-3.5 shrink-0 animate-spin rounded-full"
style={{
animationDuration: "1.1s",
background: `conic-gradient(${color} ${pct}%, ${ring} ${pct}% 100%)`,
// Punch out the centre to leave a ~3px ring.
WebkitMask: "radial-gradient(farthest-side, transparent calc(100% - 3px), #000 0)",
mask: "radial-gradient(farthest-side, transparent calc(100% - 3px), #000 0)",
}}
title={`${pct}%`}
/>
);
}
function FilterTab({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
className={`flex h-full items-center border-b-2 ${
active
? "border-app-accent font-semibold text-app-fg"
: "border-transparent text-app-muted hover:text-app-fg"
}`}
>
{children}
</button>
);
}
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 (
<button
disabled={disabled}
onClick={onClick}
title={label}
aria-label={label}
className={`rounded text-app-muted hover:bg-app-hover hover:text-app-fg disabled:pointer-events-none disabled:text-app-faint disabled:opacity-40 ${
big ? "p-1.5" : "p-1"
}`}
>
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d={dir === "left" ? "M10 4l-4 4 4 4" : "M6 4l4 4-4 4"} />
</svg>
</button>
);
}
function RefreshIcon({ spinning, size = 15 }: { spinning: boolean; size?: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
className={spinning ? "animate-spin" : ""}
>
<path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9" />
<path d="M13.5 2v3h-3" />
</svg>
);
}
function SortIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M5 3v10M3 5l2-2 2 2M11 13V3M9 11l2 2 2-2" />
</svg>
);
}
function SidebarItem({
active,
onClick,
collapsed = false,
title,
children,
}: {
active: boolean;
onClick: () => void;
collapsed?: boolean;
title?: string;
children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
title={title}
className={`flex w-full items-center gap-2.5 rounded-lg py-1.5 text-left ${
collapsed ? "justify-center px-0" : "px-3"
} ${
active ? "bg-app-active font-medium text-app-fg" : "text-app-muted hover:bg-app-hover hover:text-app-fg"
}`}
>
{children}
</button>
);
}
function ToolbarButton({
onClick,
active = false,
children,
}: {
onClick: () => void;
active?: boolean;
children: React.ReactNode;
}) {
return (
<button
className={`flex items-center gap-1.5 rounded px-2.5 py-1 hover:bg-app-hover hover:text-app-fg ${
active ? "text-app-fg" : "text-app-muted"
}`}
onClick={onClick}
>
{children}
</button>
);
}