Search showed at most the first 50 matching messages ("1–50 of 50"). The
first page now carries the number of matching conversations, further pages
load as the list is paged through (a conversation spanning two appears once;
oldest-first loads them all, up to 40 pages), and the Unread/Pinned tabs filter
on the server, so their count covers every result.
Bulk Delete / Archive / Restore over selected results acts at once: the rows
leave the list (greyed instead when archived from a search, which still finds
them), the search results are refreshed, and the reading pane returns to
"Select a conversation". Each move landing on the server refreshes them again,
and a hidden row only comes back when no move has landed for 30 seconds — a
long batch no longer brings deleted results back.
Settings → Accounts → Notifications: shorter description.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1906 lines
80 KiB
TypeScript
1906 lines
80 KiB
TypeScript
import {
|
||
keepPreviousData,
|
||
useInfiniteQuery,
|
||
useMutation,
|
||
useQuery,
|
||
useQueryClient,
|
||
} from "@tanstack/react-query";
|
||
import { Fragment, useCallback, useEffect, useMemo, 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, localToInit } from "../components/compose/Composer";
|
||
import { UndoSendToast } from "../components/compose/UndoSendToast";
|
||
import { Toast } from "../components/Toast";
|
||
import { usePendingSend } from "../components/compose/undoSend";
|
||
import {
|
||
collapseExpanded,
|
||
type ComposeWin,
|
||
minimizeAll,
|
||
minimizeWindow,
|
||
openWindow,
|
||
restoreWindow,
|
||
toggleExpand,
|
||
} from "../components/compose/windows";
|
||
import { DraftsList, ScheduledList } from "../components/list/DraftsList";
|
||
import { Icon } from "../components/Icon";
|
||
import { InstallAppEntry } from "../components/InstallApp";
|
||
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 { Clock, NotSavedOffline, OfflineIndicator } from "../components/Offline";
|
||
import { ThreadView } from "../components/thread/ThreadView";
|
||
import { MailboxDisplay } from "../components/mail/MailboxDisplay";
|
||
import { RightPanel, type PanelTab } from "../components/mail/RightPanel";
|
||
import { ScheduledSendPane } from "../components/mail/ScheduledSendPane";
|
||
import { useT } from "../i18n";
|
||
import type { MessageKey } from "../i18n/en";
|
||
import { api, isOfflineError } from "../lib/api";
|
||
import { clearOfflineData, useOffline } from "../lib/offline";
|
||
import { disablePush, setAppBadge } from "../lib/push";
|
||
import { flushLocal, useLocalMessages, wipeLocal } from "../lib/scheduled";
|
||
import { formatClock, getDateLocale, setTimeFormat } from "../lib/timeFormat";
|
||
import type { AccountOut, QuotaOut, SavedSearch, ThreadSummary } from "../lib/types";
|
||
import { useReadingPane } from "../lib/readingPane";
|
||
import { useIsMobile } from "../lib/useIsMobile";
|
||
import { useKeyboardShortcuts } from "../lib/useKeyboardShortcuts";
|
||
import { subscribeWs } from "../lib/ws";
|
||
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;
|
||
// Search results load 50 matching messages at a time; this caps how many
|
||
// pages an oldest-first search (which needs them all) fetches.
|
||
const MAX_SEARCH_PAGES = 40;
|
||
|
||
// 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");
|
||
const offline = useOffline();
|
||
// 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();
|
||
// 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"
|
||
}`;
|
||
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 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);
|
||
useEffect(() => {
|
||
const el = listShowing ? listRef.current : 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. `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,
|
||
});
|
||
};
|
||
measure();
|
||
const ro = new ResizeObserver(measure);
|
||
ro.observe(el);
|
||
window.addEventListener("resize", measure);
|
||
return () => {
|
||
ro.disconnect();
|
||
window.removeEventListener("resize", measure);
|
||
};
|
||
}, [listShowing, bottom]);
|
||
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);
|
||
// 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),
|
||
);
|
||
// 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.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);
|
||
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); };
|
||
// 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);
|
||
};
|
||
useEffect(() => localStorage.setItem("mail.listOrder", listOrder), [listOrder]);
|
||
|
||
const isDrafts = view === "folder:drafts";
|
||
// Messages written offline: drafts kept on this device, and the Scheduled
|
||
// folder of ones sent offline (lib/scheduled.ts).
|
||
const isScheduled = view === "local:scheduled";
|
||
const localMessages = useLocalMessages();
|
||
const deviceDrafts = localMessages.filter((m) => m.state === "draft");
|
||
// Plus sends the mail server could not take, retrying on the server (or failed).
|
||
const outboxQuery = useQuery({ queryKey: ["outbox"], queryFn: api.outbox, retry: false });
|
||
const serverScheduled = outboxQuery.data ?? [];
|
||
const scheduled = localMessages.filter((m) => m.state !== "draft");
|
||
const scheduledCount = scheduled.length + serverScheduled.length;
|
||
// The waiting send open in the reading pane (its own pane, not a composer).
|
||
const [scheduledPaneId, setScheduledPaneId] = useState<string | null>(null);
|
||
const scheduledPane = serverScheduled.find((o) => o.outbox_id === scheduledPaneId) ?? null;
|
||
// 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 && !isScheduled && !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;
|
||
// Search comes in pages of matching messages (a conversation can span two);
|
||
// more are fetched as the list is paged through (effect below).
|
||
const searchFilter = filter === "unread" || filter === "pinned" ? filter : undefined;
|
||
const searchQuery = useInfiniteQuery({
|
||
queryKey: ["search", effectiveQuery, searchFilter],
|
||
queryFn: ({ pageParam }) => api.search(effectiveQuery, pageParam, searchFilter),
|
||
initialPageParam: null as string | null,
|
||
getNextPageParam: (last) => last.cursor,
|
||
enabled: searching,
|
||
});
|
||
const searchThreads = useMemo(() => {
|
||
const seen = new Set<string>();
|
||
const out: ThreadSummary[] = [];
|
||
for (const pg of searchQuery.data?.pages ?? []) {
|
||
for (const h of pg.threads) {
|
||
if (seen.has(h.thread.id)) continue;
|
||
seen.add(h.thread.id);
|
||
out.push(h.thread);
|
||
}
|
||
}
|
||
return out;
|
||
}, [searchQuery.data]);
|
||
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 });
|
||
// The installed app's icon shows unread Inbox mail (notifications set it too).
|
||
const inboxUnread = countsQuery.data?.inbox_unread;
|
||
useEffect(() => {
|
||
if (inboxUnread !== undefined) setAppBadge(inboxUnread);
|
||
}, [inboxUnread]);
|
||
|
||
// 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 shown = searchThreads.filter((th) => !hiddenIds.has(th.id));
|
||
const ordered = listOrder === "newest" ? shown : [...shown].reverse();
|
||
// The server's count of matching conversations; without one (or while
|
||
// still loading pages), what has been loaded.
|
||
const counted = searchQuery.data?.pages[0]?.total;
|
||
total =
|
||
counted != null
|
||
? Math.max(ordered.length, counted - (searchThreads.length - shown.length))
|
||
: 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;
|
||
// Search: fetch further pages of results as far as the page shown needs (one
|
||
// conversation more, to know whether a next page exists) — or all of them
|
||
// for oldest-first, whose first page is the end of the results.
|
||
const {
|
||
hasNextPage: searchHasMore,
|
||
isFetchingNextPage: searchFetchingMore,
|
||
fetchNextPage: fetchMoreSearch,
|
||
} = searchQuery;
|
||
const searchLoaded = searchThreads.length;
|
||
const searchPages = searchQuery.data?.pages.length ?? 0;
|
||
useEffect(() => {
|
||
if (!searching || !searchHasMore || searchFetchingMore) return;
|
||
const wanted = listOrder === "newest" ? (page + 1) * PAGE_SIZE : Infinity;
|
||
if (searchLoaded <= wanted && searchPages < MAX_SEARCH_PAGES) void fetchMoreSearch();
|
||
}, [searching, searchHasMore, searchFetchingMore, fetchMoreSearch, searchLoaded, searchPages, page, listOrder]);
|
||
// 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]);
|
||
// 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]);
|
||
|
||
// 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.
|
||
// Moves are queued on the server and land one by one (each announced as
|
||
// thread.updated). A hidden row comes back only when nothing has landed for
|
||
// a while — the move failed — not while a long batch is still going.
|
||
const lastMoveLanded = useRef(0);
|
||
useEffect(
|
||
() =>
|
||
subscribeWs((name) => {
|
||
if (name === "thread.updated") lastMoveLanded.current = Date.now();
|
||
}),
|
||
[],
|
||
);
|
||
const unhideIfStuck = (unmark: () => void) => {
|
||
const check = () => {
|
||
if (Date.now() - lastMoveLanded.current < HIDE_FALLBACK_MS) {
|
||
window.setTimeout(check, HIDE_FALLBACK_MS);
|
||
} else {
|
||
unmark();
|
||
}
|
||
};
|
||
window.setTimeout(check, HIDE_FALLBACK_MS);
|
||
};
|
||
|
||
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) unhideIfStuck(unmark);
|
||
};
|
||
// 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]);
|
||
// The same for search results (deleted conversations drop out of them).
|
||
useEffect(() => {
|
||
if (!searchQuery.data || !searching) return;
|
||
setHiddenIds((prev) => {
|
||
if (prev.size === 0) return prev;
|
||
const present = new Set(searchThreads.map((th) => th.id));
|
||
const next = new Set([...prev].filter((id) => present.has(id)));
|
||
return next.size === prev.size ? prev : next;
|
||
});
|
||
}, [searchQuery.data, searchThreads, searching]);
|
||
const listArchive = (id: string) => listMove(id, api.archiveThread);
|
||
const listTrash = (id: string) => listMove(id, api.trashThread);
|
||
// Phone: a conversation swiped right goes to Trash — after a short Undo
|
||
// window, like Undo send. Until then it is only hidden, so Undo just shows it
|
||
// again (moving it back from Trash could race the server's move).
|
||
const SWIPE_UNDO_MS = 5000;
|
||
const pendingSwipes = useRef(new Map<string, number>());
|
||
const swipeTrash = (id: string) => {
|
||
setHiddenIds((prev) => new Set(prev).add(id));
|
||
if (id === threadId) navigate("/mail");
|
||
const timer = window.setTimeout(() => {
|
||
pendingSwipes.current.delete(id);
|
||
void listMove(id, api.trashThread);
|
||
}, SWIPE_UNDO_MS);
|
||
pendingSwipes.current.set(id, timer);
|
||
window.dispatchEvent(
|
||
new CustomEvent("webmail:toast", {
|
||
detail: {
|
||
text: t("mail.movedToTrash"),
|
||
ms: SWIPE_UNDO_MS,
|
||
action: {
|
||
label: t("compose.undo"),
|
||
run: () => {
|
||
window.clearTimeout(pendingSwipes.current.get(id));
|
||
pendingSwipes.current.delete(id);
|
||
setHiddenIds((prev) => {
|
||
const next = new Set(prev);
|
||
next.delete(id);
|
||
return next;
|
||
});
|
||
},
|
||
},
|
||
},
|
||
}),
|
||
);
|
||
};
|
||
// Leaving before the toast is gone still deletes what was swiped.
|
||
useEffect(
|
||
() => () => {
|
||
for (const [id, timer] of pendingSwipes.current) {
|
||
window.clearTimeout(timer);
|
||
void api.trashThread(id);
|
||
}
|
||
},
|
||
[],
|
||
);
|
||
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).
|
||
//
|
||
// Like the row buttons, the result shows at once: conversations leaving the
|
||
// view (deleted; archived out of a folder) are hidden — in search too, which
|
||
// leaves out Trash — and ones still matching a search (archived) are greyed.
|
||
// The requests run behind; a failed one puts its row back.
|
||
const runBulk = async (
|
||
action: (id: string) => Promise<unknown>,
|
||
effect: "hide" | "dim" | null = null,
|
||
) => {
|
||
const ids = [...checkedIds];
|
||
const mark = effect === "hide" ? setHiddenIds : effect === "dim" ? setDimmedIds : null;
|
||
const unmark = (gone: string[]) =>
|
||
mark?.((prev) => {
|
||
const next = new Set(prev);
|
||
for (const id of gone) next.delete(id);
|
||
return next;
|
||
});
|
||
mark?.((prev) => new Set([...prev, ...ids]));
|
||
setCheckedIds(new Set());
|
||
// The conversations are gone from the list: the reading pane goes back to
|
||
// "Select a conversation" rather than to whatever was open before selecting.
|
||
if (threadId && (effect !== null || ids.includes(threadId))) navigate("/mail");
|
||
const failed: string[] = [];
|
||
for (const id of ids) {
|
||
try {
|
||
await action(id);
|
||
} catch {
|
||
failed.push(id);
|
||
}
|
||
}
|
||
if (failed.length) unmark(failed);
|
||
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||
queryClient.invalidateQueries({ queryKey: ["quota"] });
|
||
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
|
||
// The server only queues the moves, so a refetch can still carry the rows;
|
||
// they stay hidden until a list arrives without them — or come back should
|
||
// the moves never land.
|
||
if (effect === "hide") unhideIfStuck(() => unmark(ids));
|
||
};
|
||
const leaving = searching ? "dim" : "hide"; // archived: gone from a folder, still found by a search
|
||
const bulkArchive = () => runBulk((id) => api.archiveThread(id), leaving);
|
||
const bulkPin = () => runBulk((id) => api.threadFlags(id, ["\\Flagged"], []));
|
||
const bulkTrash = () => runBulk((id) => api.trashThread(id), "hide");
|
||
const bulkRestore = () => runBulk((id) => api.restoreThread(id), "hide");
|
||
const bulkDeletePermanent = () => runBulk((id) => api.deleteThreadPermanent(id), "hide");
|
||
|
||
// 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);
|
||
// 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), []);
|
||
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>
|
||
)}
|
||
<OfflineIndicator compact={collapsed || mobile} />
|
||
<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 ?? 0) + deviceDrafts.length || undefined
|
||
: undefined;
|
||
return (
|
||
<Fragment key={f.view}>
|
||
<SidebarItem
|
||
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>
|
||
{/* Scheduled: shown while something waits to be sent (or is open). */}
|
||
{f.view === "folder:drafts" && (scheduledCount > 0 || isScheduled) && (
|
||
<SidebarItem
|
||
active={isScheduled}
|
||
collapsed={collapsed}
|
||
title={collapsed ? t("nav.scheduled") : undefined}
|
||
onClick={() => selectView("local:scheduled")}
|
||
>
|
||
<span className="flex w-[18px] justify-center">
|
||
<Clock size={18} />
|
||
</span>
|
||
{!collapsed && <span className="flex-1 truncate">{t("nav.scheduled")}</span>}
|
||
{!collapsed && scheduledCount > 0 && (
|
||
<span className="ml-auto shrink-0 tabular-nums text-[13px] text-app-muted">{scheduledCount}</span>
|
||
)}
|
||
</SidebarItem>
|
||
)}
|
||
</Fragment>
|
||
);
|
||
})}
|
||
{/* 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="flex-1 truncate">{a.email}</span>
|
||
<AccountProblem account={a} />
|
||
</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>
|
||
{mobile && (
|
||
<div className="p-2 text-[15px]">
|
||
<InstallAppEntry onDone={() => setDrawerOpen(false)} />
|
||
</div>
|
||
)}
|
||
<div className="border-t border-app-border p-2 text-[15px]">
|
||
<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"
|
||
}`}
|
||
title={collapsed ? t("nav.settings") : undefined}
|
||
to="/settings"
|
||
>
|
||
<Icon name="settings" size={22} />
|
||
{!collapsed && t("nav.settings")}
|
||
</Link>
|
||
<button
|
||
className={`wm-online-only 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 () => {
|
||
// Anything still waiting in Scheduled goes out first: what is
|
||
// kept on this device is wiped with the session.
|
||
await flushLocal().catch(() => []);
|
||
await disablePush(); // while still signed in, so the server forgets this device
|
||
await api.logout();
|
||
await clearOfflineData();
|
||
await wipeLocal();
|
||
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))} />
|
||
)}
|
||
|
||
{/* 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) */}
|
||
<div
|
||
ref={listRef}
|
||
style={single ? undefined : bottom ? { height: listH } : { width: listW }}
|
||
className={
|
||
single
|
||
? `relative min-w-0 flex-1 flex-col bg-app-surface ${paneShown ? "hidden" : "flex"}`
|
||
: 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"
|
||
}
|
||
>
|
||
<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"
|
||
}`}
|
||
disabled={offline}
|
||
placeholder={
|
||
offline
|
||
? t("offline.searchPlaceholder")
|
||
: 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>
|
||
{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>
|
||
)}
|
||
</div>
|
||
{!isDrafts && !isScheduled && (
|
||
<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="wm-online-only 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>
|
||
{/* On a phone it holds only the dividing lines: it always reads in a
|
||
single panel, so there is no layout to choose. */}
|
||
<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>
|
||
</div>
|
||
</>
|
||
)}
|
||
{displayOpen && <MailboxDisplay onClose={closeDisplay} layouts={!mobile} />}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{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.
|
||
<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">
|
||
{isScheduled ? (
|
||
<ScheduledList
|
||
messages={scheduled}
|
||
serverItems={serverScheduled}
|
||
selectedId={paneDraft?.localId ?? scheduledPane?.outbox_id}
|
||
onOpen={(msg) => {
|
||
setScheduledPaneId(null);
|
||
openDraftInPane(localToInit(msg));
|
||
}}
|
||
onOpenServer={(item) => {
|
||
setPaneDraft(null);
|
||
setScheduledPaneId(item.outbox_id);
|
||
if (threadId) navigate("/mail");
|
||
}}
|
||
/>
|
||
) : isDrafts && !term.trim() ? (
|
||
<DraftsList
|
||
drafts={draftsQuery.data ?? []}
|
||
remoteDrafts={remoteDraftsQuery.data ?? []}
|
||
deviceDrafts={deviceDrafts}
|
||
note={
|
||
!draftsQuery.data && isOfflineError(draftsQuery.error) ? t("offline.draftsNotSaved") : undefined
|
||
}
|
||
selectedId={paneDraft?.localId ?? paneDraft?.draftId}
|
||
onOpen={(draft) => openDraftInPane(draftToInit(draft))}
|
||
onOpenDevice={(msg) => openDraftInPane(localToInit(msg))}
|
||
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));
|
||
}}
|
||
/>
|
||
) : !searching && !threadsQuery.data && isOfflineError(threadsQuery.error) ? (
|
||
<div className="p-6 text-[15px] text-app-faint">{t("offline.folderNotSaved")}</div>
|
||
) : (
|
||
<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}
|
||
// Phone: swipe right to delete — not in Trash (that would be for
|
||
// good) and not offline (nothing could be moved).
|
||
onSwipeDelete={mobile && currentFolder !== "trash" && !offline ? swipeTrash : undefined}
|
||
/>
|
||
)}
|
||
</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={`wm-online-only -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 && !isScheduled && 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>
|
||
|
||
{!single &&
|
||
(bottom ? (
|
||
<Resizer orientation="horizontal" onDelta={(dy) => setListH((h) => clampListH(h + dy))} />
|
||
) : (
|
||
<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-h-0 min-w-0 flex-1 bg-app-surface ${single && !paneShown ? "hidden" : ""}`}
|
||
>
|
||
{scheduledPane && isScheduled && !paneDraft ? (
|
||
<ScheduledSendPane
|
||
item={scheduledPane}
|
||
onClose={() => setScheduledPaneId(null)}
|
||
onEdit={(draft) => {
|
||
setScheduledPaneId(null);
|
||
openDraftInPane(draftToInit(draft));
|
||
}}
|
||
/>
|
||
) : paneDraft ? (
|
||
// Keyed by draft: picking a different one must build a fresh editor,
|
||
// because RichTextEditor seeds its body exactly once.
|
||
<Composer
|
||
key={paneDraft.localId ?? paneDraft.draftId}
|
||
init={paneDraft}
|
||
onClose={closePaneDraft}
|
||
fullWindow
|
||
/>
|
||
) : checkedIds.size > 0 && !single ? (
|
||
<div className="flex h-full flex-col">
|
||
<div className={paneBarClass}>
|
||
{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={paneBarClass}>
|
||
{single && (
|
||
<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="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>
|
||
) : bottom ? null : ( // under the list it lives in the list's header only
|
||
<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>
|
||
) : threadId && isOfflineError(detailQuery.error) ? (
|
||
<div className="flex h-full flex-col">
|
||
{single && (
|
||
<div className={paneBarClass}>
|
||
<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>
|
||
</div>
|
||
)}
|
||
<div className="min-h-0 flex-1">
|
||
<NotSavedOffline />
|
||
</div>
|
||
</div>
|
||
) : (
|
||
// Clipped to the pane: dragged very narrow, the picture and the line
|
||
// are cut at its edges instead of spilling over the list beside it.
|
||
<div className="flex h-full flex-col items-center justify-center gap-6 overflow-hidden text-app-faint">
|
||
{!rightOpen && !panelToggleInList && (
|
||
<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>
|
||
)}
|
||
{/* 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={`px-3 text-center ${bottom ? "text-xl" : "text-3xl"}`}>
|
||
{t("mail.selectConversation")}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</main>
|
||
</div>
|
||
{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>
|
||
</>
|
||
)}
|
||
{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} />
|
||
<Toast mobile={mobile} />
|
||
</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.
|
||
// An account's mail server not answering, or refusing the password: a small
|
||
// warning after its name, explained on hover.
|
||
function AccountProblem({ account }: { account: AccountOut }) {
|
||
const t = useT();
|
||
const authFailed = account.state === "auth_failed";
|
||
if (!authFailed && account.sync_error !== "unreachable") return null;
|
||
const title = authFailed
|
||
? t("account.authFailed")
|
||
: t("account.unreachable", { when: sinceLabel(account.sync_error_since) });
|
||
return (
|
||
<span
|
||
title={title}
|
||
aria-label={title}
|
||
className={`ml-auto shrink-0 ${authFailed ? "text-red-500" : "text-amber-500"}`}
|
||
>
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||
<path d="M12 3.5L2.5 20h19L12 3.5z" />
|
||
<path d="M12 10v4.5M12 17.5v.01" />
|
||
</svg>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function sinceLabel(iso: string | null | undefined): string {
|
||
if (!iso) return "";
|
||
const d = new Date(iso);
|
||
if (d.toDateString() === new Date().toDateString()) return formatClock(d);
|
||
return `${d.toLocaleDateString(getDateLocale(), { day: "numeric", month: "short" })}, ${formatClock(d)}`;
|
||
}
|
||
|
||
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={`wm-online-only 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>
|
||
);
|
||
}
|