2026-07-23 23:00:08 +02:00
|
|
|
|
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
2026-10-03 18:13:43 +02:00
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
2026-08-02 21:34:22 +02:00
|
|
|
|
import { Link, useMatch, useNavigate, useSearchParams } from "react-router-dom";
|
2026-07-17 23:20:34 +02:00
|
|
|
|
|
2026-08-05 18:04:49 +02:00
|
|
|
|
import signetLogo from "../../icons/ultimo-logo-signet.svg";
|
2026-07-28 16:33:34 +02:00
|
|
|
|
import { AppSwitcher } from "../components/AppSwitcher";
|
2026-09-21 21:32:41 +02:00
|
|
|
|
import { ComposeDock } from "../components/compose/ComposeDock";
|
2026-07-17 23:20:34 +02:00
|
|
|
|
import { Composer, type ComposerInit, draftToInit } from "../components/compose/Composer";
|
2026-09-30 19:37:54 +02:00
|
|
|
|
import { UndoSendToast } from "../components/compose/UndoSendToast";
|
|
|
|
|
|
import { usePendingSend } from "../components/compose/undoSend";
|
2026-09-21 21:32:41 +02:00
|
|
|
|
import {
|
|
|
|
|
|
collapseExpanded,
|
|
|
|
|
|
type ComposeWin,
|
|
|
|
|
|
minimizeAll,
|
|
|
|
|
|
minimizeWindow,
|
|
|
|
|
|
openWindow,
|
|
|
|
|
|
restoreWindow,
|
|
|
|
|
|
toggleExpand,
|
|
|
|
|
|
} from "../components/compose/windows";
|
2026-07-17 23:20:34 +02:00
|
|
|
|
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";
|
2026-07-22 07:40:29 +02:00
|
|
|
|
import { EmptyIllustration } from "../components/EmptyIllustration";
|
2026-07-23 23:00:08 +02:00
|
|
|
|
import { BackfillBanner } from "../components/BackfillBanner";
|
2026-07-17 23:20:34 +02:00
|
|
|
|
import { ThreadView } from "../components/thread/ThreadView";
|
2026-10-03 18:13:43 +02:00
|
|
|
|
import { MailboxDisplay } from "../components/mail/MailboxDisplay";
|
2026-08-03 08:38:44 +02:00
|
|
|
|
import { RightPanel, type PanelTab } from "../components/mail/RightPanel";
|
2026-07-17 23:20:34 +02:00
|
|
|
|
import { useT } from "../i18n";
|
|
|
|
|
|
import type { MessageKey } from "../i18n/en";
|
|
|
|
|
|
import { api } from "../lib/api";
|
2026-07-28 16:33:34 +02:00
|
|
|
|
import { setTimeFormat } from "../lib/timeFormat";
|
2026-07-23 23:00:08 +02:00
|
|
|
|
import type { QuotaOut, SavedSearch, ThreadSummary } from "../lib/types";
|
2026-10-03 18:13:43 +02:00
|
|
|
|
import { useReadingPane } from "../lib/readingPane";
|
2026-09-28 08:09:09 +02:00
|
|
|
|
import { useIsMobile } from "../lib/useIsMobile";
|
2026-07-17 23:20:34 +02:00
|
|
|
|
import { useKeyboardShortcuts } from "../lib/useKeyboardShortcuts";
|
2026-07-27 08:30:24 +02:00
|
|
|
|
import { PluginSlot } from "../plugins/PluginSlot";
|
2026-07-17 23:20:34 +02:00
|
|
|
|
|
2026-07-18 15:27:09 +02:00
|
|
|
|
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;
|
2026-09-30 19:55:18 +02:00
|
|
|
|
// 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;
|
2026-07-18 15:27:09 +02:00
|
|
|
|
|
2026-07-20 23:12:25 +02:00
|
|
|
|
// Collapsed sidebar width: just wide enough for the icons.
|
|
|
|
|
|
const COLLAPSED_W = 60;
|
|
|
|
|
|
|
2026-07-17 23:20:34 +02:00
|
|
|
|
// 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");
|
2026-09-28 08:09:09 +02:00
|
|
|
|
// 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();
|
2026-10-03 18:13:43 +02:00
|
|
|
|
// Desktop arrangement of list and reading pane (Settings → Appearance, or the
|
|
|
|
|
|
// list's ⋯ menu → Mailbox display). A phone always uses its own single panel.
|
|
|
|
|
|
const readingPane = useReadingPane();
|
|
|
|
|
|
const bottom = !mobile && readingPane === "bottom";
|
|
|
|
|
|
// One panel that shows either the list or the open conversation: always on a
|
|
|
|
|
|
// phone, and on a desktop with the reading pane turned off.
|
|
|
|
|
|
const single = mobile || readingPane === "off";
|
|
|
|
|
|
// Under the list or with no reading pane, the side-panel toggle sits in the
|
|
|
|
|
|
// list's header beside the search box — with no reading pane also in the
|
|
|
|
|
|
// conversation's toolbar, since the list is hidden while one is open.
|
|
|
|
|
|
const panelToggleInList = !mobile && readingPane !== "side";
|
|
|
|
|
|
// The reading pane's top bar (conversation or bulk actions). Under the list it
|
|
|
|
|
|
// takes the colour of the list's filter bar, so the two bars read as a pair.
|
|
|
|
|
|
const paneBarClass = `flex h-[52px] shrink-0 items-center gap-1 border-b border-app-border px-3 text-[15px] ${
|
|
|
|
|
|
bottom ? "bg-app-bg" : "bg-app-surface"
|
|
|
|
|
|
}`;
|
2026-09-28 08:09:09 +02:00
|
|
|
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
2026-07-17 23:20:34 +02:00
|
|
|
|
const [term, setTerm] = useState("");
|
2026-07-23 23:00:08 +02:00
|
|
|
|
// 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;
|
2026-07-17 23:20:34 +02:00
|
|
|
|
const [filter, setFilter] = useState<"all" | "unread" | "pinned">("all");
|
2026-07-18 15:27:09 +02:00
|
|
|
|
const [listOrder, setListOrder] = useState<"newest" | "oldest">(() =>
|
|
|
|
|
|
localStorage.getItem("mail.listOrder") === "oldest" ? "oldest" : "newest",
|
|
|
|
|
|
);
|
|
|
|
|
|
const [page, setPage] = useState(0);
|
|
|
|
|
|
const [refreshing, setRefreshing] = useState(false);
|
2026-07-22 21:49:48 +02:00
|
|
|
|
// 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());
|
2026-07-26 16:15:49 +02:00
|
|
|
|
// 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());
|
2026-09-30 19:48:12 +02:00
|
|
|
|
// 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());
|
2026-07-22 21:49:48 +02:00
|
|
|
|
const toggleCheck = (id: string) =>
|
|
|
|
|
|
setCheckedIds((prev) => {
|
|
|
|
|
|
const next = new Set(prev);
|
|
|
|
|
|
if (next.has(id)) next.delete(id);
|
|
|
|
|
|
else next.add(id);
|
|
|
|
|
|
return next;
|
|
|
|
|
|
});
|
2026-09-21 21:32:41 +02:00
|
|
|
|
// 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);
|
2026-07-17 23:20:34 +02:00
|
|
|
|
const [replySignal, setReplySignal] = useState<{ kind: string; seq: number }>();
|
2026-09-21 21:32:41 +02:00
|
|
|
|
// 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);
|
2026-10-03 18:13:43 +02:00
|
|
|
|
const listRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
const [pane, setPane] = useState<{ width: number; right: number; height?: number }>({
|
|
|
|
|
|
width: 0,
|
|
|
|
|
|
right: 0,
|
|
|
|
|
|
});
|
|
|
|
|
|
// With the reading pane off, the list and the conversation take turns in one
|
|
|
|
|
|
// panel; an expanded composer covers whichever of them is showing.
|
|
|
|
|
|
const listShowing = readingPane === "off" && !(paneDraft || threadId);
|
2026-09-21 21:32:41 +02:00
|
|
|
|
useEffect(() => {
|
2026-10-03 18:13:43 +02:00
|
|
|
|
const el = listShowing ? listRef.current : paneRef.current;
|
2026-09-21 21:32:41 +02:00
|
|
|
|
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
|
2026-10-03 18:13:43 +02:00
|
|
|
|
// the right panel is open. `height` only under the list: everywhere else
|
|
|
|
|
|
// the pane runs the full height of the screen.
|
|
|
|
|
|
setPane({
|
|
|
|
|
|
width: Math.round(r.width),
|
|
|
|
|
|
right: Math.round(window.innerWidth - r.right),
|
|
|
|
|
|
height: bottom ? Math.round(r.height) : undefined,
|
|
|
|
|
|
});
|
2026-09-21 21:32:41 +02:00
|
|
|
|
};
|
2026-10-03 18:13:43 +02:00
|
|
|
|
measure();
|
2026-09-21 21:32:41 +02:00
|
|
|
|
const ro = new ResizeObserver(measure);
|
|
|
|
|
|
ro.observe(el);
|
|
|
|
|
|
window.addEventListener("resize", measure);
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
ro.disconnect();
|
|
|
|
|
|
window.removeEventListener("resize", measure);
|
|
|
|
|
|
};
|
2026-10-03 18:13:43 +02:00
|
|
|
|
}, [listShowing, bottom]);
|
2026-07-17 23:20:34 +02:00
|
|
|
|
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);
|
2026-10-03 18:13:43 +02:00
|
|
|
|
// The list's height when the reading pane sits under it.
|
|
|
|
|
|
// Until dragged, the list takes 45% of the height and the reading pane 55%.
|
|
|
|
|
|
const [listH, setListH] = useState(
|
|
|
|
|
|
() => Number(localStorage.getItem("panel.listH")) || Math.round(window.innerHeight * 0.45),
|
|
|
|
|
|
);
|
2026-09-28 08:09:09 +02:00
|
|
|
|
// 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;
|
2026-08-03 08:38:44 +02:00
|
|
|
|
// 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);
|
2026-07-17 23:20:34 +02:00
|
|
|
|
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);
|
2026-08-03 08:38:44 +02:00
|
|
|
|
const clampRight = (w: number) => Math.min(Math.max(w, 280), 560);
|
2026-07-17 23:20:34 +02:00
|
|
|
|
useEffect(() => localStorage.setItem("panel.sidebar", String(sidebarW)), [sidebarW]);
|
|
|
|
|
|
useEffect(() => localStorage.setItem("panel.list", String(listW)), [listW]);
|
2026-10-03 18:13:43 +02:00
|
|
|
|
useEffect(() => localStorage.setItem("panel.listH", String(listH)), [listH]);
|
|
|
|
|
|
// Leave both the list and the reading pane at least a usable strip.
|
|
|
|
|
|
const clampListH = (h: number) => Math.min(Math.max(h, 160), window.innerHeight - 220);
|
2026-09-28 08:09:09 +02:00
|
|
|
|
useEffect(
|
|
|
|
|
|
() => localStorage.setItem("panel.collapsed", collapsedPref ? "1" : "0"),
|
|
|
|
|
|
[collapsedPref],
|
|
|
|
|
|
);
|
2026-08-03 08:38:44 +02:00
|
|
|
|
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); };
|
2026-09-30 20:35:29 +02:00
|
|
|
|
// Phone: the same panel as a drawer sliding in from the right. Its own state,
|
|
|
|
|
|
// so the desktop's remembered "open" never springs it onto a phone screen.
|
|
|
|
|
|
// Mounted on first use only (the Calendar tab loads a whole plugin bundle),
|
|
|
|
|
|
// then kept so it can slide out again.
|
|
|
|
|
|
const [sheetOpen, setSheetOpen] = useState(false);
|
|
|
|
|
|
const [sheetUsed, setSheetUsed] = useState(false);
|
|
|
|
|
|
const openSheet = () => {
|
|
|
|
|
|
setSheetUsed(true);
|
|
|
|
|
|
setSheetOpen(true);
|
|
|
|
|
|
};
|
2026-07-18 15:27:09 +02:00
|
|
|
|
useEffect(() => localStorage.setItem("mail.listOrder", listOrder), [listOrder]);
|
2026-07-17 23:20:34 +02:00
|
|
|
|
|
|
|
|
|
|
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 });
|
2026-07-19 18:58:01 +02:00
|
|
|
|
const mailboxesQuery = useQuery({ queryKey: ["mailboxes"], queryFn: api.mailboxes });
|
2026-07-23 23:00:08 +02:00
|
|
|
|
const orderParam: "asc" | "desc" = listOrder === "newest" ? "desc" : "asc";
|
2026-08-03 20:53:35 +02:00
|
|
|
|
const labelView = view.startsWith("label:") ? view.slice("label:".length) : null;
|
2026-07-17 23:20:34 +02:00
|
|
|
|
const threadsQuery = useQuery({
|
2026-07-23 23:00:08 +02:00
|
|
|
|
queryKey: ["threads", view, filter, orderParam, page],
|
2026-08-03 20:53:35 +02:00
|
|
|
|
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 });
|
|
|
|
|
|
},
|
2026-07-23 23:00:08 +02:00
|
|
|
|
enabled: !isDrafts && !searching,
|
|
|
|
|
|
placeholderData: keepPreviousData, // keep the current page visible while the next loads
|
2026-07-17 23:20:34 +02:00
|
|
|
|
});
|
|
|
|
|
|
const draftsQuery = useQuery({
|
|
|
|
|
|
queryKey: ["drafts"],
|
|
|
|
|
|
queryFn: api.listDrafts,
|
|
|
|
|
|
enabled: isDrafts,
|
|
|
|
|
|
});
|
2026-07-18 15:27:09 +02:00
|
|
|
|
const remoteDraftsQuery = useQuery({
|
|
|
|
|
|
queryKey: ["drafts-remote"],
|
|
|
|
|
|
queryFn: api.remoteDrafts,
|
|
|
|
|
|
enabled: isDrafts,
|
|
|
|
|
|
});
|
2026-07-26 16:15:49 +02:00
|
|
|
|
// 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;
|
2026-07-17 23:20:34 +02:00
|
|
|
|
const searchQuery = useQuery({
|
2026-07-26 16:15:49 +02:00
|
|
|
|
queryKey: ["search", effectiveQuery],
|
|
|
|
|
|
queryFn: () => api.search(effectiveQuery),
|
2026-07-23 23:00:08 +02:00
|
|
|
|
enabled: searching,
|
2026-07-17 23:20:34 +02:00
|
|
|
|
});
|
2026-07-23 23:00:08 +02:00
|
|
|
|
const savedSearchesQuery = useQuery({ queryKey: ["saved-searches"], queryFn: api.savedSearches });
|
2026-07-17 23:20:34 +02:00
|
|
|
|
const settingsQuery = useQuery({ queryKey: ["settings"], queryFn: api.settings });
|
|
|
|
|
|
const threadOrder: "asc" | "desc" = settingsQuery.data?.thread_order ?? "asc";
|
2026-07-28 16:33:34 +02:00
|
|
|
|
const timeFormat = settingsQuery.data?.time_format;
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (timeFormat) setTimeFormat(timeFormat);
|
|
|
|
|
|
}, [timeFormat]);
|
2026-07-18 15:27:09 +02:00
|
|
|
|
const quotaQuery = useQuery({ queryKey: ["quota"], queryFn: api.quota });
|
2026-07-22 07:40:29 +02:00
|
|
|
|
const countsQuery = useQuery({ queryKey: ["folder-counts"], queryFn: api.folderCounts });
|
2026-07-17 23:20:34 +02:00
|
|
|
|
|
2026-07-23 23:00:08 +02:00
|
|
|
|
// 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 {
|
2026-09-30 19:48:12 +02:00
|
|
|
|
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));
|
2026-07-23 23:00:08 +02:00
|
|
|
|
}
|
|
|
|
|
|
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]);
|
2026-07-18 15:27:09 +02:00
|
|
|
|
|
2026-07-22 21:49:48 +02:00
|
|
|
|
// 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)));
|
|
|
|
|
|
|
2026-07-23 23:00:08 +02:00
|
|
|
|
// Keyboard next/prev navigates within the currently visible page.
|
|
|
|
|
|
const threadsRef = useRef<ThreadSummary[]>(pageThreads);
|
|
|
|
|
|
threadsRef.current = pageThreads;
|
2026-07-17 23:20:34 +02:00
|
|
|
|
|
|
|
|
|
|
const detailQuery = useQuery({
|
2026-07-20 23:12:25 +02:00
|
|
|
|
queryKey: ["thread", threadId, threadOrder, view],
|
|
|
|
|
|
queryFn: () => api.thread(threadId!, threadOrder, view),
|
2026-07-17 23:20:34 +02:00
|
|
|
|
enabled: Boolean(threadId),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-17 19:47:42 +02:00
|
|
|
|
// 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]);
|
|
|
|
|
|
|
2026-07-17 23:20:34 +02:00
|
|
|
|
const markRead = useMutation({
|
|
|
|
|
|
mutationFn: (id: string) => api.threadFlags(id, ["\\Seen"], []),
|
2026-07-22 07:40:29 +02:00
|
|
|
|
onSuccess: () => {
|
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
|
|
|
|
|
|
},
|
2026-07-17 23:20:34 +02:00
|
|
|
|
});
|
2026-07-23 23:00:08 +02:00
|
|
|
|
// 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);
|
2026-09-28 08:09:09 +02:00
|
|
|
|
setDrawerOpen(false);
|
2026-07-23 23:00:08 +02:00
|
|
|
|
navigate("/mail");
|
|
|
|
|
|
};
|
2026-07-26 16:15:49 +02:00
|
|
|
|
// 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);
|
2026-09-28 08:09:09 +02:00
|
|
|
|
setDrawerOpen(false);
|
2026-07-26 16:15:49 +02:00
|
|
|
|
navigate("/mail");
|
|
|
|
|
|
};
|
2026-09-28 08:09:09 +02:00
|
|
|
|
// 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]);
|
2026-09-30 20:35:29 +02:00
|
|
|
|
// The side-panel drawer belongs to the conversation it was opened over.
|
|
|
|
|
|
useEffect(() => setSheetOpen(false), [view, mobile, threadId]);
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!sheetOpen) return;
|
|
|
|
|
|
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setSheetOpen(false);
|
|
|
|
|
|
window.addEventListener("keydown", onKey);
|
|
|
|
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
|
|
|
|
}, [sheetOpen]);
|
2026-07-23 23:00:08 +02:00
|
|
|
|
|
2026-07-22 07:40:29 +02:00
|
|
|
|
// 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.
|
2026-07-17 23:20:34 +02:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const detail = detailQuery.data;
|
2026-07-22 07:40:29 +02:00
|
|
|
|
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);
|
2026-07-17 23:20:34 +02:00
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
|
}, [detailQuery.data?.id]);
|
|
|
|
|
|
|
2026-09-21 21:32:41 +02:00
|
|
|
|
// 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.
|
2026-07-20 23:12:25 +02:00
|
|
|
|
const openComposer = (init: ComposerInit, expand = false) => {
|
2026-09-21 21:32:41 +02:00
|
|
|
|
const seq = ++composeSeq.current;
|
|
|
|
|
|
setComposeWins((prev) => openWindow(prev, `c${seq}`, init, expand, seq));
|
2026-07-20 23:12:25 +02:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-21 21:32:41 +02:00
|
|
|
|
// `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 = () => {
|
2026-07-20 23:12:25 +02:00
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["drafts"] });
|
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["drafts-remote"] });
|
2026-07-22 07:40:29 +02:00
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
|
2026-07-20 23:12:25 +02:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-21 21:32:41 +02:00
|
|
|
|
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
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-30 19:37:54 +02:00
|
|
|
|
// 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 */
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-21 21:32:41 +02:00
|
|
|
|
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.
|
2026-07-20 23:12:25 +02:00
|
|
|
|
const firstView = useRef(true);
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (firstView.current) {
|
|
|
|
|
|
firstView.current = false;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-09-21 21:32:41 +02:00
|
|
|
|
setComposeWins(collapseExpanded);
|
2026-07-20 23:12:25 +02:00
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
|
}, [view]);
|
|
|
|
|
|
|
2026-07-17 23:20:34 +02:00
|
|
|
|
const afterMove = () => {
|
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
2026-07-18 15:27:09 +02:00
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["quota"] });
|
2026-07-22 07:40:29 +02:00
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
|
2026-07-17 23:20:34 +02:00
|
|
|
|
navigate("/mail");
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-18 15:27:09 +02:00
|
|
|
|
// 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(() => {
|
2026-07-22 07:40:29 +02:00
|
|
|
|
for (const key of ["threads", "drafts", "drafts-remote", "quota", "folder-counts"]) {
|
2026-07-18 15:27:09 +02:00
|
|
|
|
queryClient.invalidateQueries({ queryKey: [key] });
|
|
|
|
|
|
}
|
|
|
|
|
|
setRefreshing(false);
|
|
|
|
|
|
}, 2500);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-17 23:20:34 +02:00
|
|
|
|
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"] });
|
2026-07-26 16:15:49 +02:00
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
2026-07-17 23:20:34 +02:00
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["thread", detail.id] });
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-22 07:40:29 +02:00
|
|
|
|
// 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.
|
2026-09-30 19:48:12 +02:00
|
|
|
|
//
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
2026-09-30 19:55:18 +02:00
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
2026-07-26 16:15:49 +02:00
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["search"] }); // keep search results in sync
|
2026-07-22 07:40:29 +02:00
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["quota"] });
|
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
|
2026-09-30 19:55:18 +02:00
|
|
|
|
// 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);
|
2026-07-22 07:40:29 +02:00
|
|
|
|
};
|
2026-09-30 19:55:18 +02:00
|
|
|
|
// 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]);
|
2026-09-30 19:48:12 +02:00
|
|
|
|
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);
|
2026-07-22 07:40:29 +02:00
|
|
|
|
const listTogglePin = async (id: string, pinned: boolean) => {
|
|
|
|
|
|
await api.threadFlags(id, pinned ? [] : ["\\Flagged"], pinned ? ["\\Flagged"] : []);
|
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
2026-07-26 16:15:49 +02:00
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["search"] }); // pin state on search results
|
2026-07-22 07:40:29 +02:00
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["thread", id] });
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-22 21:49:48 +02:00
|
|
|
|
// Selection resets when you leave the folder.
|
|
|
|
|
|
useEffect(() => setCheckedIds(new Set()), [view]);
|
2026-07-26 16:15:49 +02:00
|
|
|
|
useEffect(() => setDimmedIds(new Set()), [view, debouncedTerm]);
|
2026-09-30 19:55:18 +02:00
|
|
|
|
useEffect(() => setHiddenIds(new Set()), [view]);
|
2026-07-22 21:49:48 +02:00
|
|
|
|
|
|
|
|
|
|
// 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));
|
|
|
|
|
|
|
2026-09-28 08:09:09 +02:00
|
|
|
|
// 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>
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-07-22 07:40:29 +02:00
|
|
|
|
// "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);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-08-02 21:34:22 +02:00
|
|
|
|
const [moreOpen, setMoreOpen] = useState(false);
|
2026-10-03 18:13:43 +02:00
|
|
|
|
// The list's ⋯ menu → Mailbox display popover. Stable callback: the popover's
|
|
|
|
|
|
// outside-click listener re-binds whenever it changes.
|
|
|
|
|
|
const [displayOpen, setDisplayOpen] = useState(false);
|
|
|
|
|
|
const closeDisplay = useCallback(() => setDisplayOpen(false), []);
|
2026-08-02 21:34:22 +02:00
|
|
|
|
const markAllRead = useMutation({
|
|
|
|
|
|
mutationFn: () => api.markAllRead(view),
|
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-17 23:20:34 +02:00
|
|
|
|
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(),
|
2026-07-20 23:12:25 +02:00
|
|
|
|
onCompose: () => accountsQuery.data?.[0] && openComposer({ accountId: accountsQuery.data[0].id, kind: "new" }),
|
2026-07-17 23:20:34 +02:00
|
|
|
|
onReply: () => setReplySignal({ kind: "reply", seq: Date.now() }),
|
|
|
|
|
|
onReplyAll: () => setReplySignal({ kind: "reply_all", seq: Date.now() }),
|
|
|
|
|
|
onForward: () => setReplySignal({ kind: "forward", seq: Date.now() }),
|
|
|
|
|
|
onArchive: archive,
|
2026-07-26 16:15:49 +02:00
|
|
|
|
onGoInbox: () => selectView("folder:inbox"),
|
2026-09-21 21:32:41 +02:00
|
|
|
|
onEscape: () => setComposeWins(minimizeAll),
|
2026-07-17 23:20:34 +02:00
|
|
|
|
onNext: () => openRelative(1),
|
|
|
|
|
|
onPrev: () => openRelative(-1),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const accounts = accountsQuery.data ?? [];
|
|
|
|
|
|
|
2026-08-02 21:34:22 +02:00
|
|
|
|
// 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]);
|
|
|
|
|
|
|
2026-08-03 20:53:35 +02:00
|
|
|
|
// 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]);
|
|
|
|
|
|
|
2026-09-28 08:09:09 +02:00
|
|
|
|
// On a phone the reading pane replaces the list rather than sitting beside it.
|
|
|
|
|
|
const paneShown = Boolean(paneDraft || threadId);
|
|
|
|
|
|
|
2026-07-17 23:20:34 +02:00
|
|
|
|
return (
|
2026-09-28 08:09:09 +02:00
|
|
|
|
<div className={`flex bg-app-bg text-app-fg ${mobile ? "h-[100dvh]" : "h-screen"}`}>
|
2026-07-23 23:00:08 +02:00
|
|
|
|
<BackfillBanner />
|
2026-09-28 08:09:09 +02:00
|
|
|
|
{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) */}
|
2026-07-17 23:20:34 +02:00
|
|
|
|
<aside
|
2026-09-28 08:09:09 +02:00
|
|
|
|
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"
|
|
|
|
|
|
}
|
2026-07-17 23:20:34 +02:00
|
|
|
|
>
|
2026-07-20 23:12:25 +02:00
|
|
|
|
<div
|
2026-08-06 19:23:27 +02:00
|
|
|
|
className={`flex pb-1 pt-[11px] ${
|
2026-07-20 23:12:25 +02:00
|
|
|
|
collapsed ? "flex-col items-center gap-1 px-2" : "items-center justify-between px-3"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
2026-08-05 18:04:49 +02:00
|
|
|
|
{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" />
|
2026-09-28 08:09:09 +02:00
|
|
|
|
{/* Calendar/Contacts have no phone layout yet, so no way into them there. */}
|
|
|
|
|
|
{!mobile && <AppSwitcher />}
|
2026-08-05 18:04:49 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-07-20 23:12:25 +02:00
|
|
|
|
<div className={`flex items-center gap-1 ${collapsed ? "flex-col" : ""}`}>
|
2026-09-28 08:09:09 +02:00
|
|
|
|
{!mobile && (
|
2026-07-20 23:12:25 +02:00
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => setCollapsed((v) => !v)}
|
|
|
|
|
|
title={collapsed ? t("nav.expandSidebar") : t("nav.collapseSidebar")}
|
|
|
|
|
|
aria-label={collapsed ? t("nav.expandSidebar") : t("nav.collapseSidebar")}
|
2026-08-06 19:23:27 +02:00
|
|
|
|
className="inline-flex h-7 w-7 items-center justify-center rounded text-app-muted hover:bg-app-hover hover:text-app-fg"
|
2026-07-20 23:12:25 +02:00
|
|
|
|
>
|
|
|
|
|
|
<Icon name={collapsed ? "show" : "hide"} size={16} />
|
|
|
|
|
|
</button>
|
2026-09-28 08:09:09 +02:00
|
|
|
|
)}
|
2026-07-20 23:12:25 +02:00
|
|
|
|
<ThemeToggle />
|
|
|
|
|
|
</div>
|
2026-07-17 23:20:34 +02:00
|
|
|
|
</div>
|
2026-09-28 08:09:09 +02:00
|
|
|
|
<div className={`pb-2 pt-2.5 ${collapsed ? "px-2" : "px-3"} ${mobile ? "hidden" : ""}`}>
|
2026-07-17 23:20:34 +02:00
|
|
|
|
<button
|
2026-07-22 21:49:48 +02:00
|
|
|
|
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"
|
2026-07-20 23:12:25 +02:00
|
|
|
|
title={collapsed ? t("nav.compose") : undefined}
|
|
|
|
|
|
onClick={() => accounts[0] && openComposer({ accountId: accounts[0].id, kind: "new" })}
|
2026-07-17 23:20:34 +02:00
|
|
|
|
>
|
|
|
|
|
|
<Icon name="compose" size={16} />
|
2026-07-20 23:12:25 +02:00
|
|
|
|
{!collapsed && t("nav.compose")}
|
2026-07-17 23:20:34 +02:00
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2026-07-22 21:49:48 +02:00
|
|
|
|
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2 py-1 text-[15px]">
|
2026-07-22 07:40:29 +02:00
|
|
|
|
{FOLDERS.map((f) => {
|
|
|
|
|
|
const badge =
|
|
|
|
|
|
f.view === "folder:inbox"
|
|
|
|
|
|
? countsQuery.data?.inbox_unread
|
|
|
|
|
|
: f.view === "folder:drafts"
|
|
|
|
|
|
? countsQuery.data?.drafts
|
|
|
|
|
|
: undefined;
|
|
|
|
|
|
return (
|
2026-07-17 23:20:34 +02:00
|
|
|
|
<SidebarItem
|
|
|
|
|
|
key={f.view}
|
|
|
|
|
|
active={view === f.view}
|
2026-07-20 23:12:25 +02:00
|
|
|
|
collapsed={collapsed}
|
|
|
|
|
|
title={collapsed ? t(f.label) : undefined}
|
2026-07-26 16:15:49 +02:00
|
|
|
|
onClick={() => selectView(f.view)}
|
2026-07-17 23:20:34 +02:00
|
|
|
|
>
|
2026-07-22 07:40:29 +02:00
|
|
|
|
<Icon name={f.icon} size={f.icon === "trash" ? 20 : 18} />
|
|
|
|
|
|
{!collapsed && <span className="flex-1 truncate">{t(f.label)}</span>}
|
|
|
|
|
|
{!collapsed && badge ? (
|
2026-07-22 21:49:48 +02:00
|
|
|
|
<span className="ml-auto shrink-0 tabular-nums text-[13px] text-app-muted">{badge}</span>
|
2026-07-22 07:40:29 +02:00
|
|
|
|
) : null}
|
2026-07-17 23:20:34 +02:00
|
|
|
|
</SidebarItem>
|
2026-07-22 07:40:29 +02:00
|
|
|
|
);
|
|
|
|
|
|
})}
|
2026-07-20 23:12:25 +02:00
|
|
|
|
{/* Per-account views are hidden entirely when collapsed. */}
|
|
|
|
|
|
{!collapsed && (
|
|
|
|
|
|
<>
|
2026-07-22 21:49:48 +02:00
|
|
|
|
<div className="px-3 pb-1 pt-3 text-[12px] font-semibold uppercase tracking-wide text-app-faint">
|
2026-07-20 23:12:25 +02:00
|
|
|
|
{t("settings.accounts")}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{accounts.map((a) => (
|
|
|
|
|
|
<SidebarItem
|
|
|
|
|
|
key={a.id}
|
|
|
|
|
|
active={view === `account:${a.id}`}
|
2026-07-26 16:15:49 +02:00
|
|
|
|
onClick={() => selectView(`account:${a.id}`)}
|
2026-07-20 23:12:25 +02:00
|
|
|
|
>
|
2026-07-23 23:00:08 +02:00
|
|
|
|
<AccountDot
|
|
|
|
|
|
color={a.color}
|
2026-08-05 18:04:49 +02:00
|
|
|
|
syncing={
|
|
|
|
|
|
a.state === "backfilling" &&
|
2026-08-07 07:59:26 +02:00
|
|
|
|
(a.backfill_total === 0 || a.backfill_done < a.backfill_total)
|
2026-08-05 18:04:49 +02:00
|
|
|
|
}
|
2026-07-23 23:00:08 +02:00
|
|
|
|
total={a.backfill_total}
|
|
|
|
|
|
done={a.backfill_done}
|
|
|
|
|
|
/>
|
2026-07-20 23:12:25 +02:00
|
|
|
|
<span className="truncate">{a.email}</span>
|
|
|
|
|
|
</SidebarItem>
|
|
|
|
|
|
))}
|
2026-07-23 23:00:08 +02:00
|
|
|
|
{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>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-07-20 23:12:25 +02:00
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-07-27 08:30:24 +02:00
|
|
|
|
{/* Plugin-contributed sidebar sections (SPEC §11.3 sidebar.section). */}
|
|
|
|
|
|
{!collapsed && <PluginSlot name="sidebar.section" />}
|
2026-07-17 23:20:34 +02:00
|
|
|
|
</nav>
|
2026-07-22 21:49:48 +02:00
|
|
|
|
<div className="border-t border-app-border p-2 text-[15px]">
|
2026-09-28 08:09:09 +02:00
|
|
|
|
{/* Settings has no phone layout yet either. */}
|
2026-07-20 23:12:25 +02:00
|
|
|
|
<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"
|
2026-09-28 08:09:09 +02:00
|
|
|
|
} ${mobile ? "hidden" : ""}`}
|
2026-07-20 23:12:25 +02:00
|
|
|
|
title={collapsed ? t("nav.settings") : undefined}
|
|
|
|
|
|
to="/settings"
|
|
|
|
|
|
>
|
2026-07-22 07:40:29 +02:00
|
|
|
|
<Icon name="settings" size={22} />
|
2026-07-20 23:12:25 +02:00
|
|
|
|
{!collapsed && t("nav.settings")}
|
2026-07-17 23:20:34 +02:00
|
|
|
|
</Link>
|
|
|
|
|
|
<button
|
2026-07-20 23:12:25 +02:00
|
|
|
|
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}
|
2026-07-17 23:20:34 +02:00
|
|
|
|
onClick={async () => {
|
|
|
|
|
|
await api.logout();
|
|
|
|
|
|
queryClient.clear();
|
|
|
|
|
|
navigate("/login");
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-07-20 23:12:25 +02:00
|
|
|
|
<Icon name="log-out" size={16} />
|
|
|
|
|
|
{!collapsed && t("common.signOut")}
|
2026-07-17 23:20:34 +02:00
|
|
|
|
</button>
|
2026-07-20 23:12:25 +02:00
|
|
|
|
{!collapsed && quotaQuery.data && (
|
2026-07-18 15:27:09 +02:00
|
|
|
|
<div
|
2026-07-22 21:49:48 +02:00
|
|
|
|
className="mt-1 border-t border-app-border px-3 pb-1 pt-2 text-[13px] text-app-faint"
|
2026-07-18 15:27:09 +02:00
|
|
|
|
title={t("nav.storageQuota")}
|
|
|
|
|
|
>
|
|
|
|
|
|
{quotaLabel(quotaQuery.data)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-07-17 23:20:34 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</aside>
|
|
|
|
|
|
|
2026-09-28 08:09:09 +02:00
|
|
|
|
{!collapsed && !mobile && (
|
|
|
|
|
|
<Resizer onDelta={(dx) => setSidebarW((w) => clampSidebar(w + dx))} />
|
|
|
|
|
|
)}
|
2026-07-17 23:20:34 +02:00
|
|
|
|
|
2026-10-03 18:13:43 +02:00
|
|
|
|
{/* List and reading pane. Side by side, the wrapper is layout-neutral
|
|
|
|
|
|
(`contents`); with the pane at the bottom it stacks them. Keeping the
|
|
|
|
|
|
same wrapper in every arrangement keeps both mounted across a switch. */}
|
|
|
|
|
|
<div className={bottom ? "flex min-h-0 min-w-0 flex-1 flex-col" : "contents"}>
|
|
|
|
|
|
{/* Message list (the whole panel in single-panel mode, until a thread opens) */}
|
2026-07-17 23:20:34 +02:00
|
|
|
|
<div
|
2026-10-03 18:13:43 +02:00
|
|
|
|
ref={listRef}
|
|
|
|
|
|
style={single ? undefined : bottom ? { height: listH } : { width: listW }}
|
2026-09-28 08:09:09 +02:00
|
|
|
|
className={
|
2026-10-03 18:13:43 +02:00
|
|
|
|
single
|
2026-09-28 08:09:09 +02:00
|
|
|
|
? `relative min-w-0 flex-1 flex-col bg-app-surface ${paneShown ? "hidden" : "flex"}`
|
2026-10-03 18:13:43 +02:00
|
|
|
|
: bottom
|
|
|
|
|
|
? "flex shrink-0 flex-col bg-app-surface" // the divider below is the 3px resizer
|
|
|
|
|
|
: "flex shrink-0 flex-col border-r border-app-border bg-app-surface"
|
2026-09-28 08:09:09 +02:00
|
|
|
|
}
|
2026-07-17 23:20:34 +02:00
|
|
|
|
>
|
2026-09-28 08:09:09 +02:00
|
|
|
|
<div
|
|
|
|
|
|
className={`flex h-[52px] shrink-0 items-center border-b border-app-border ${
|
|
|
|
|
|
mobile ? "gap-2 px-3" : "px-6"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
2026-09-30 19:37:54 +02:00
|
|
|
|
{mobile && <img src={signetLogo} alt="Ultimo" className="h-7 w-auto shrink-0" />}
|
2026-09-28 08:09:09 +02:00
|
|
|
|
{mobile && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
title={t("nav.menu")}
|
|
|
|
|
|
aria-label={t("nav.menu")}
|
|
|
|
|
|
aria-expanded={drawerOpen}
|
|
|
|
|
|
onClick={() => setDrawerOpen(true)}
|
2026-09-30 19:37:54 +02:00
|
|
|
|
className="shrink-0 rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
|
2026-09-28 08:09:09 +02:00
|
|
|
|
>
|
|
|
|
|
|
<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>
|
|
|
|
|
|
)}
|
2026-07-22 07:40:29 +02:00
|
|
|
|
<div className="relative w-full">
|
|
|
|
|
|
<input
|
|
|
|
|
|
ref={searchRef}
|
2026-07-23 23:00:08 +02:00
|
|
|
|
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"
|
|
|
|
|
|
}`}
|
2026-07-26 16:15:49 +02:00
|
|
|
|
placeholder={
|
|
|
|
|
|
currentFolder === "trash"
|
|
|
|
|
|
? t("nav.searchInTrash")
|
|
|
|
|
|
: currentFolder === "junk"
|
|
|
|
|
|
? t("nav.searchInSpam")
|
|
|
|
|
|
: t("nav.search")
|
|
|
|
|
|
}
|
2026-07-22 07:40:29 +02:00
|
|
|
|
value={term}
|
|
|
|
|
|
onChange={(e) => setTerm(e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
{term && (
|
2026-07-23 23:00:08 +02:00
|
|
|
|
<>
|
|
|
|
|
|
<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>
|
|
|
|
|
|
</>
|
2026-07-22 07:40:29 +02:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-10-03 18:13:43 +02:00
|
|
|
|
{panelToggleInList && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
className={`ml-2 shrink-0 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>
|
|
|
|
|
|
)}
|
2026-07-17 23:20:34 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
{!isDrafts && (
|
2026-09-28 08:09:09 +02:00
|
|
|
|
<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"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
2026-07-22 21:49:48 +02:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
aria-label={t("mail.selectAll")}
|
|
|
|
|
|
title={t("mail.selectAll")}
|
|
|
|
|
|
onClick={toggleSelectAll}
|
2026-09-28 08:09:09 +02:00
|
|
|
|
className={`${mobile ? "hidden" : "flex"} ml-[28px] h-[17px] w-[17px] shrink-0 items-center justify-center rounded-md border-2 transition-colors ${
|
2026-07-22 21:49:48 +02:00
|
|
|
|
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>
|
2026-08-03 20:53:35 +02:00
|
|
|
|
{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>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-07-22 07:40:29 +02:00
|
|
|
|
<div className="-mr-1.5 ml-auto flex items-center gap-1">
|
|
|
|
|
|
{(currentFolder === "trash" || currentFolder === "junk") && (
|
|
|
|
|
|
<button
|
2026-07-22 21:49:48 +02:00
|
|
|
|
className="rounded px-2 py-1 text-[13px] font-medium text-red-500 hover:bg-app-hover disabled:opacity-50"
|
2026-07-22 07:40:29 +02:00
|
|
|
|
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>
|
2026-08-02 21:34:22 +02:00
|
|
|
|
<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>
|
2026-10-03 18:13:43 +02:00
|
|
|
|
{/* A phone always reads in a single panel: nothing to choose. */}
|
|
|
|
|
|
{!mobile && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="flex w-full items-center px-3 py-2 text-left text-[13px] text-app-fg hover:bg-app-hover"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setMoreOpen(false);
|
|
|
|
|
|
setDisplayOpen(true);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{t("display.title")}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
2026-08-02 21:34:22 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-10-03 18:13:43 +02:00
|
|
|
|
{displayOpen && <MailboxDisplay onClose={closeDisplay} />}
|
2026-08-02 21:34:22 +02:00
|
|
|
|
</div>
|
2026-07-22 07:40:29 +02:00
|
|
|
|
</div>
|
2026-07-17 23:20:34 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-10-03 18:13:43 +02:00
|
|
|
|
{single && checkedIds.size > 0 && (
|
|
|
|
|
|
// Side by side, bulk actions show in the reading pane; with a single
|
|
|
|
|
|
// panel there is none beside the list, so they sit above it.
|
2026-09-28 08:09:09 +02:00
|
|
|
|
<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>
|
|
|
|
|
|
)}
|
2026-07-17 23:20:34 +02:00
|
|
|
|
<div className="min-h-0 flex-1">
|
|
|
|
|
|
{isDrafts && !term.trim() ? (
|
|
|
|
|
|
<DraftsList
|
|
|
|
|
|
drafts={draftsQuery.data ?? []}
|
2026-07-18 15:27:09 +02:00
|
|
|
|
remoteDrafts={remoteDraftsQuery.data ?? []}
|
2026-09-21 21:32:41 +02:00
|
|
|
|
selectedId={paneDraft?.draftId}
|
|
|
|
|
|
onOpen={(draft) => openDraftInPane(draftToInit(draft))}
|
2026-07-18 15:27:09 +02:00
|
|
|
|
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"] });
|
2026-09-21 21:32:41 +02:00
|
|
|
|
openDraftInPane(draftToInit(draft));
|
2026-07-18 15:27:09 +02:00
|
|
|
|
}}
|
2026-07-17 23:20:34 +02:00
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
2026-07-22 07:40:29 +02:00
|
|
|
|
<MessageList
|
|
|
|
|
|
threads={pageThreads}
|
2026-09-21 21:32:15 +02:00
|
|
|
|
page={page}
|
2026-07-22 07:40:29 +02:00
|
|
|
|
selectedId={threadId}
|
|
|
|
|
|
folder={currentFolder}
|
|
|
|
|
|
onSelect={(id) => navigate(`/mail/t/${id}`)}
|
|
|
|
|
|
onArchive={listArchive}
|
|
|
|
|
|
onRestore={listRestore}
|
|
|
|
|
|
onTrash={listTrash}
|
|
|
|
|
|
onDeletePermanent={listDeletePermanent}
|
|
|
|
|
|
onTogglePin={listTogglePin}
|
2026-07-22 21:49:48 +02:00
|
|
|
|
checkedIds={checkedIds}
|
2026-07-26 16:15:49 +02:00
|
|
|
|
dimmedIds={dimmedIds}
|
2026-07-22 21:49:48 +02:00
|
|
|
|
onToggleCheck={toggleCheck}
|
2026-09-28 08:09:09 +02:00
|
|
|
|
compact={mobile}
|
2026-07-22 07:40:29 +02:00
|
|
|
|
/>
|
2026-07-18 15:27:09 +02:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-09-28 08:09:09 +02:00
|
|
|
|
<div
|
2026-09-30 19:37:54 +02:00
|
|
|
|
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]"
|
2026-09-28 08:09:09 +02:00
|
|
|
|
}`}
|
|
|
|
|
|
>
|
2026-07-18 15:27:09 +02:00
|
|
|
|
<button
|
2026-09-30 19:37:54 +02:00
|
|
|
|
className={`-ml-1 rounded text-app-muted ${mobile ? "p-1.5" : "p-1"} hover:bg-app-hover hover:text-app-fg disabled:opacity-60`}
|
2026-07-18 15:27:09 +02:00
|
|
|
|
title={t("nav.refresh")}
|
|
|
|
|
|
aria-label={t("nav.refresh")}
|
|
|
|
|
|
onClick={doRefresh}
|
|
|
|
|
|
disabled={refreshing}
|
|
|
|
|
|
>
|
2026-09-30 19:37:54 +02:00
|
|
|
|
<RefreshIcon spinning={refreshing} size={mobile ? 22.5 : 15} />
|
2026-07-18 15:27:09 +02:00
|
|
|
|
</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
|
2026-07-23 23:00:08 +02:00
|
|
|
|
disabled={page === 0}
|
|
|
|
|
|
onClick={() => setPage(page - 1)}
|
2026-07-18 15:27:09 +02:00
|
|
|
|
label={t("pager.prev")}
|
|
|
|
|
|
dir="left"
|
2026-09-30 19:37:54 +02:00
|
|
|
|
big={mobile}
|
2026-07-18 15:27:09 +02:00
|
|
|
|
/>
|
|
|
|
|
|
<PagerButton
|
|
|
|
|
|
disabled={pageEnd >= total}
|
2026-07-23 23:00:08 +02:00
|
|
|
|
onClick={() => setPage(page + 1)}
|
2026-07-18 15:27:09 +02:00
|
|
|
|
label={t("pager.next")}
|
|
|
|
|
|
dir="right"
|
2026-09-30 19:37:54 +02:00
|
|
|
|
big={mobile}
|
2026-07-18 15:27:09 +02:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span />
|
2026-07-17 23:20:34 +02:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-09-28 08:09:09 +02:00
|
|
|
|
{mobile && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
title={t("nav.compose")}
|
|
|
|
|
|
aria-label={t("nav.compose")}
|
|
|
|
|
|
onClick={() => accounts[0] && openComposer({ accountId: accounts[0].id, kind: "new" })}
|
2026-09-30 19:37:54 +02:00
|
|
|
|
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"
|
2026-09-28 08:09:09 +02:00
|
|
|
|
>
|
|
|
|
|
|
<Icon name="compose" size={22} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
2026-07-17 23:20:34 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-10-03 18:13:43 +02:00
|
|
|
|
{!single &&
|
|
|
|
|
|
(bottom ? (
|
|
|
|
|
|
<Resizer orientation="horizontal" onDelta={(dy) => setListH((h) => clampListH(h + dy))} />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Resizer onDelta={(dx) => setListW((w) => clampList(w + dx))} />
|
|
|
|
|
|
))}
|
2026-07-17 23:20:34 +02:00
|
|
|
|
|
2026-09-28 08:09:09 +02:00
|
|
|
|
{/* Thread pane (full screen on a phone, shown only with something open) */}
|
|
|
|
|
|
<main
|
|
|
|
|
|
ref={paneRef}
|
2026-10-03 18:13:43 +02:00
|
|
|
|
className={`relative min-h-0 min-w-0 flex-1 bg-app-surface ${single && !paneShown ? "hidden" : ""}`}
|
2026-09-28 08:09:09 +02:00
|
|
|
|
>
|
2026-09-21 21:32:41 +02:00
|
|
|
|
{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
|
|
|
|
|
|
/>
|
2026-10-03 18:13:43 +02:00
|
|
|
|
) : checkedIds.size > 0 && !single ? (
|
2026-07-17 23:20:34 +02:00
|
|
|
|
<div className="flex h-full flex-col">
|
2026-10-03 18:13:43 +02:00
|
|
|
|
<div className={paneBarClass}>
|
2026-09-28 08:09:09 +02:00
|
|
|
|
{bulkButtons}
|
2026-07-22 21:49:48 +02:00
|
|
|
|
</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">
|
2026-10-03 18:13:43 +02:00
|
|
|
|
<div className={paneBarClass}>
|
|
|
|
|
|
{single && (
|
2026-09-28 08:09:09 +02:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
title={t("common.back")}
|
|
|
|
|
|
aria-label={t("common.back")}
|
|
|
|
|
|
onClick={() => navigate("/mail")}
|
2026-09-30 19:37:54 +02:00
|
|
|
|
className="-ml-1 mr-1 rounded p-1.5 text-app-fg hover:bg-app-hover"
|
2026-09-28 08:09:09 +02:00
|
|
|
|
>
|
2026-09-30 19:37:54 +02:00
|
|
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
2026-09-28 08:09:09 +02:00
|
|
|
|
<path d="M15 5l-7 7 7 7" />
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
2026-07-17 23:20:34 +02:00
|
|
|
|
{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>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-09-28 08:09:09 +02:00
|
|
|
|
<span className={`ml-auto text-[13px] text-app-faint ${mobile ? "hidden" : ""}`}>
|
2026-07-17 23:20:34 +02:00
|
|
|
|
{t("mail.messageCount", { count: detailQuery.data.messages.length })}
|
|
|
|
|
|
</span>
|
2026-09-30 20:35:29 +02:00
|
|
|
|
{mobile ? (
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="ml-auto rounded p-1.5 text-app-fg hover:bg-app-hover"
|
|
|
|
|
|
title={t("panel.toggle")}
|
|
|
|
|
|
aria-label={t("panel.toggle")}
|
|
|
|
|
|
aria-expanded={sheetOpen}
|
|
|
|
|
|
onClick={openSheet}
|
|
|
|
|
|
>
|
|
|
|
|
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
|
|
|
|
<rect x="3" y="4" width="18" height="16" rx="2" />
|
|
|
|
|
|
<path d="M15 4v16" />
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
</button>
|
2026-10-03 18:13:43 +02:00
|
|
|
|
) : bottom ? null : ( // under the list it lives in the list's header only
|
2026-08-03 08:38:44 +02:00
|
|
|
|
<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>
|
2026-09-28 08:09:09 +02:00
|
|
|
|
)}
|
2026-07-17 23:20:34 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="min-h-0 flex-1 overflow-hidden">
|
|
|
|
|
|
<ThreadView
|
|
|
|
|
|
subject={detailQuery.data.subject}
|
|
|
|
|
|
messages={detailQuery.data.messages}
|
|
|
|
|
|
order={detailQuery.data.order}
|
2026-07-19 18:58:01 +02:00
|
|
|
|
mailboxes={mailboxesQuery.data ?? []}
|
|
|
|
|
|
accounts={accounts}
|
2026-07-17 23:20:34 +02:00
|
|
|
|
replyKindSignal={replySignal}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
2026-07-22 07:40:29 +02:00
|
|
|
|
<div className="flex h-full flex-col items-center justify-center gap-6 text-app-faint">
|
2026-10-03 18:13:43 +02:00
|
|
|
|
{!rightOpen && !panelToggleInList && (
|
2026-08-03 08:38:44 +02:00
|
|
|
|
<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>
|
|
|
|
|
|
)}
|
2026-10-03 18:13:43 +02:00
|
|
|
|
{/* Under the list the pane is short: a smaller picture and line. */}
|
|
|
|
|
|
<EmptyIllustration className={bottom ? "w-32 max-w-[40%]" : "w-56 max-w-[55%]"} />
|
|
|
|
|
|
<span className={bottom ? "text-xl" : "text-3xl"}>{t("mail.selectConversation")}</span>
|
2026-07-17 23:20:34 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</main>
|
2026-10-03 18:13:43 +02:00
|
|
|
|
</div>
|
2026-09-30 20:35:29 +02:00
|
|
|
|
{mobile && sheetUsed && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div
|
|
|
|
|
|
aria-hidden
|
|
|
|
|
|
onClick={() => setSheetOpen(false)}
|
|
|
|
|
|
className={`fixed inset-0 z-40 bg-black/40 transition-opacity duration-200 ${
|
|
|
|
|
|
sheetOpen ? "opacity-100" : "pointer-events-none opacity-0"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div
|
|
|
|
|
|
aria-hidden={!sheetOpen}
|
|
|
|
|
|
className={`fixed inset-y-0 right-0 z-50 w-[88vw] max-w-[420px] border-l border-app-border pb-[env(safe-area-inset-bottom)] pt-[env(safe-area-inset-top)] shadow-2xl transition-transform duration-200 ease-out bg-app-surface ${
|
|
|
|
|
|
sheetOpen ? "translate-x-0" : "pointer-events-none translate-x-full"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<RightPanel tab={rightTab} onTab={setRightTab} onClose={() => setSheetOpen(false)} thread={detailQuery.data} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-09-28 08:09:09 +02:00
|
|
|
|
{rightOpen && !mobile && (
|
2026-08-03 08:38:44 +02:00
|
|
|
|
<>
|
|
|
|
|
|
<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>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-09-21 21:32:41 +02:00
|
|
|
|
<ComposeDock
|
|
|
|
|
|
windows={composeWins}
|
|
|
|
|
|
pane={pane}
|
|
|
|
|
|
onMinimize={minimizeComposer}
|
|
|
|
|
|
onRestore={restoreComposer}
|
|
|
|
|
|
onToggleExpand={toggleComposerExpand}
|
|
|
|
|
|
onClose={closeComposer}
|
2026-09-28 08:09:09 +02:00
|
|
|
|
fullScreen={mobile}
|
2026-09-21 21:32:41 +02:00
|
|
|
|
/>
|
2026-09-30 19:37:54 +02:00
|
|
|
|
<UndoSendToast mobile={mobile} onUndone={reopenUnsent} />
|
2026-07-17 23:20:34 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-23 23:00:08 +02:00
|
|
|
|
// 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}%`}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-17 23:20:34 +02:00
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-18 15:27:09 +02:00
|
|
|
|
function PagerButton({
|
|
|
|
|
|
disabled,
|
|
|
|
|
|
onClick,
|
|
|
|
|
|
label,
|
|
|
|
|
|
dir,
|
2026-09-30 19:37:54 +02:00
|
|
|
|
big = false,
|
2026-07-18 15:27:09 +02:00
|
|
|
|
}: {
|
|
|
|
|
|
disabled: boolean;
|
|
|
|
|
|
onClick: () => void;
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
dir: "left" | "right";
|
2026-09-30 19:37:54 +02:00
|
|
|
|
big?: boolean;
|
2026-07-18 15:27:09 +02:00
|
|
|
|
}) {
|
2026-09-30 19:37:54 +02:00
|
|
|
|
const size = big ? 21 : 14;
|
2026-07-18 15:27:09 +02:00
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
|
onClick={onClick}
|
|
|
|
|
|
title={label}
|
|
|
|
|
|
aria-label={label}
|
2026-09-30 19:37:54 +02:00
|
|
|
|
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"
|
|
|
|
|
|
}`}
|
2026-07-18 15:27:09 +02:00
|
|
|
|
>
|
2026-09-30 19:37:54 +02:00
|
|
|
|
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
2026-07-18 15:27:09 +02:00
|
|
|
|
<path d={dir === "left" ? "M10 4l-4 4 4 4" : "M6 4l4 4-4 4"} />
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 19:37:54 +02:00
|
|
|
|
function RefreshIcon({ spinning, size = 15 }: { spinning: boolean; size?: number }) {
|
2026-07-18 15:27:09 +02:00
|
|
|
|
return (
|
|
|
|
|
|
<svg
|
2026-09-30 19:37:54 +02:00
|
|
|
|
width={size}
|
|
|
|
|
|
height={size}
|
2026-07-18 15:27:09 +02:00
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-17 23:20:34 +02:00
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-20 23:12:25 +02:00
|
|
|
|
function SidebarItem({
|
|
|
|
|
|
active,
|
|
|
|
|
|
onClick,
|
|
|
|
|
|
collapsed = false,
|
|
|
|
|
|
title,
|
|
|
|
|
|
children,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
active: boolean;
|
|
|
|
|
|
onClick: () => void;
|
|
|
|
|
|
collapsed?: boolean;
|
|
|
|
|
|
title?: string;
|
|
|
|
|
|
children: React.ReactNode;
|
|
|
|
|
|
}) {
|
2026-07-17 23:20:34 +02:00
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={onClick}
|
2026-07-20 23:12:25 +02:00
|
|
|
|
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"
|
|
|
|
|
|
} ${
|
2026-07-17 23:20:34 +02:00
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|