M2: Meili search + saved searches, sync windows + backfill progress, threading & image fixes
Search: - Meilisearch backend (app/search/meili.py) + factory wiring; pinned SDK 2.7 - Search-as-you-type: debounced query + Postgres prefix matching, built from the DB tokenizer so full email addresses match (email stays one lexeme) - Saved searches API + sidebar virtual mailboxes; save affordance in the box Account sync windows (SPEC §12 M2): - Choose at setup: All (recent 30d first, then remainder), Last 3 months, New only - Per-account sync_window + "Sync remaining mail" from settings (idempotent re-run) - Provider windowed backfill (SINCE/BEFORE), message_count, newest_uid - migration 0012: accounts.sync_window + backfill_total/backfill_done Backfill progress UX: - Floating progress card (auto-dissolves after 10s) + spinning sidebar ring that fills with the account colour; driven by sync.progress over WS Thread list pagination: - Server-side offset + accurate grand total, with filter/order in SQL; working prev/next across all synced mail Reading panel: - "Load images" one-off now actually renders images (?images=1) without trusting the sender; envelope carries images_allowed so a trusted sender shows no prompt Threading: - Subject fallback no longer merges messages that declare an explicit (different) parent; true siblings still group via a shared-parent reference match. Fixes unrelated auto-replies with the same subject landing in one thread Backend 257 tests green, ruff + mypy --strict clean; frontend tsc/eslint/vitest clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
// Initial-sync progress UX (SPEC §12 M2). Shows a small floating card while any
|
||||
// account is importing its history; reads the accounts query, which the WS
|
||||
// `sync.progress` event invalidates so the bar advances live.
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useT } from "../i18n";
|
||||
import { api } from "../lib/api";
|
||||
|
||||
export function BackfillBanner() {
|
||||
const t = useT();
|
||||
const accounts = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
|
||||
|
||||
const importing = (accounts.data ?? []).filter(
|
||||
(a) => a.state === "backfilling" && a.backfill_total > 0,
|
||||
);
|
||||
const active = importing.length > 0;
|
||||
|
||||
// Dissolve the card 10s after it appears; the sidebar ring keeps showing
|
||||
// progress until the sync finishes.
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
setDismissed(false);
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => setDismissed(true), 10000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [active]);
|
||||
|
||||
if (!active) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed bottom-4 left-4 z-40 w-72 space-y-2 transition-opacity duration-700 ${
|
||||
dismissed ? "pointer-events-none opacity-0" : "opacity-100"
|
||||
}`}
|
||||
>
|
||||
{importing.map((a) => {
|
||||
const done = Math.min(a.backfill_done, a.backfill_total);
|
||||
const pct = Math.round((done / a.backfill_total) * 100);
|
||||
return (
|
||||
<div
|
||||
key={a.id}
|
||||
className="rounded-lg border border-app-border bg-app-surface p-3 shadow-lg"
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: a.color }} />
|
||||
<span className="truncate text-xs font-medium text-app-fg">{a.email}</span>
|
||||
</div>
|
||||
<div className="mb-1.5 text-xs text-app-muted">
|
||||
{t("backfill.importing", {
|
||||
done: done.toLocaleString(),
|
||||
total: a.backfill_total.toLocaleString(),
|
||||
})}
|
||||
</div>
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-app-hover">
|
||||
<div
|
||||
className="h-full rounded-full bg-app-accent transition-all duration-500"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,9 @@ import { Attachments } from "./Attachments";
|
||||
export function MessageBody({ message }: { message: MessageEnvelope }) {
|
||||
const t = useT();
|
||||
const queryClient = useQueryClient();
|
||||
const [allowed, setAllowed] = useState(false);
|
||||
// Seed from the server's trusted-sender flag so an already-approved sender
|
||||
// loads images and shows no "blocked" prompt on reopen.
|
||||
const [allowed, setAllowed] = useState(message.images_allowed);
|
||||
const frameRef = useRef<HTMLIFrameElement>(null);
|
||||
const observerRef = useRef<ResizeObserver | null>(null);
|
||||
const [height, setHeight] = useState(80);
|
||||
@@ -23,7 +25,10 @@ export function MessageBody({ message }: { message: MessageEnvelope }) {
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ["message-html", message.id, allowed],
|
||||
queryFn: async () => {
|
||||
const resp = await fetch(`/api/messages/${message.id}/html`, { credentials: "include" });
|
||||
// ?images=1 asks the server to render remote images for this one request
|
||||
// (the "Load images" one-off); "Always from this sender" persists separately.
|
||||
const url = `/api/messages/${message.id}/html${allowed ? "?images=1" : ""}`;
|
||||
const resp = await fetch(url, { credentials: "include" });
|
||||
if (!resp.ok) throw new Error("failed to load message");
|
||||
return resp.text();
|
||||
},
|
||||
@@ -89,6 +94,11 @@ export function MessageBody({ message }: { message: MessageEnvelope }) {
|
||||
if (persist) {
|
||||
await api.allowRemoteImages(message.id);
|
||||
queryClient.invalidateQueries({ queryKey: ["message-html", message.id] });
|
||||
// Refresh thread detail so the sender's images_allowed flag reflects the
|
||||
// new trust on reopen (suppresses the prompt next time).
|
||||
if (message.thread_id) {
|
||||
queryClient.invalidateQueries({ queryKey: ["thread", message.thread_id] });
|
||||
}
|
||||
}
|
||||
setAllowed(true);
|
||||
};
|
||||
|
||||
@@ -49,6 +49,9 @@ export const en = {
|
||||
"nav.spam": "Spam",
|
||||
"nav.trash": "Trash",
|
||||
"nav.search": "Search mail…",
|
||||
"search.save": "Save this search",
|
||||
"search.saved": "Saved searches",
|
||||
"search.saveNamePrompt": "Name this saved search:",
|
||||
"nav.storageQuota": "Storage used",
|
||||
"nav.refresh": "Refresh all folders",
|
||||
|
||||
@@ -176,11 +179,22 @@ export const en = {
|
||||
"wizard.startReading": "Start reading now",
|
||||
"wizard.manualConfig": "Enter settings manually",
|
||||
"wizard.displayName": "Display name",
|
||||
"wizard.syncWindow": "How much mail to sync",
|
||||
"wizard.syncWindow.all": "All mail",
|
||||
"wizard.syncWindow.all.hint": "Imports the last 30 days first, then the rest in the background.",
|
||||
"wizard.syncWindow.3months": "Last 3 months only",
|
||||
"wizard.syncWindow.3months.hint": "Older mail stays on the server until you download it.",
|
||||
"wizard.syncWindow.new": "New mail only",
|
||||
"wizard.syncWindow.new.hint":
|
||||
"Nothing from before now — download older mail anytime from account settings.",
|
||||
|
||||
"settings.title": "Settings",
|
||||
"settings.profile": "Profile",
|
||||
"settings.security": "Security",
|
||||
"settings.accounts": "Accounts",
|
||||
"settings.downloadAllMail": "Sync remaining mail",
|
||||
"settings.syncedPartially": "Synced partially",
|
||||
"backfill.importing": "Importing messages… {done} of {total}",
|
||||
"settings.appearance": "Appearance",
|
||||
"settings.handle": "Handle",
|
||||
"settings.recoveryEmail": "Recovery email",
|
||||
|
||||
+22
-2
@@ -13,10 +13,12 @@ import type {
|
||||
QuotaOut,
|
||||
RecipientSuggestion,
|
||||
RemoteDraftOut,
|
||||
SavedSearch,
|
||||
SearchOut,
|
||||
SendResponse,
|
||||
ServerConfig,
|
||||
SessionInfo,
|
||||
SyncWindow,
|
||||
ThreadDetail,
|
||||
ThreadListOut,
|
||||
TotpSetup,
|
||||
@@ -90,9 +92,15 @@ export const api = {
|
||||
mailboxes: () => apiFetch<MailboxOut[]>("/mailboxes"),
|
||||
syncNow: () => apiFetch<{ status: string }>("/accounts/sync", { method: "POST" }),
|
||||
|
||||
threads: (view = "unified", cursor?: string) => {
|
||||
threads: (
|
||||
view = "unified",
|
||||
opts?: { offset?: number; limit?: number; filter?: string; order?: "asc" | "desc" },
|
||||
) => {
|
||||
const params = new URLSearchParams({ view });
|
||||
if (cursor) params.set("cursor", cursor);
|
||||
if (opts?.offset) params.set("offset", String(opts.offset));
|
||||
if (opts?.limit) params.set("limit", String(opts.limit));
|
||||
if (opts?.filter && opts.filter !== "all") params.set("filter", opts.filter);
|
||||
if (opts?.order) params.set("order", opts.order);
|
||||
return apiFetch<ThreadListOut>(`/threads?${params.toString()}`);
|
||||
},
|
||||
thread: (id: string, sort: "asc" | "desc" = "asc", view = "unified") =>
|
||||
@@ -121,6 +129,7 @@ export const api = {
|
||||
imap: ServerConfig;
|
||||
smtp: ServerConfig;
|
||||
password: string;
|
||||
sync_window?: SyncWindow;
|
||||
}) => apiFetch<AccountDetail>("/accounts", { method: "POST", body: JSON.stringify(input) }),
|
||||
accountDetail: (id: string) => apiFetch<AccountDetail>(`/accounts/${id}`),
|
||||
updateAccount: (
|
||||
@@ -129,6 +138,7 @@ export const api = {
|
||||
display_name?: string;
|
||||
color?: string;
|
||||
signature?: string;
|
||||
sync_window?: SyncWindow;
|
||||
imap?: ServerConfig;
|
||||
smtp?: ServerConfig;
|
||||
password?: string;
|
||||
@@ -159,6 +169,16 @@ export const api = {
|
||||
quota: () => apiFetch<QuotaOut>("/settings/quota"),
|
||||
folderCounts: () => apiFetch<FolderCounts>("/mailboxes/counts"),
|
||||
|
||||
// --- saved searches (virtual mailboxes) ---
|
||||
savedSearches: () => apiFetch<SavedSearch[]>("/saved-searches"),
|
||||
createSavedSearch: (name: string, query: string) =>
|
||||
apiFetch<SavedSearch>("/saved-searches", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, query }),
|
||||
}),
|
||||
deleteSavedSearch: (id: string) =>
|
||||
apiFetch<{ status: string }>(`/saved-searches/${id}`, { method: "DELETE" }),
|
||||
|
||||
// --- compose ---
|
||||
suggestRecipients: (q: string) =>
|
||||
apiFetch<RecipientSuggestion[]>(`/recipients/suggest?q=${encodeURIComponent(q)}`),
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface ThreadSummary {
|
||||
export interface ThreadListOut {
|
||||
threads: ThreadSummary[];
|
||||
cursor: string | null;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface AttachmentOut {
|
||||
@@ -55,6 +56,7 @@ export interface MessageEnvelope {
|
||||
is_starred: boolean;
|
||||
has_attachments: boolean;
|
||||
has_remote_images: boolean;
|
||||
images_allowed: boolean;
|
||||
size_bytes: number;
|
||||
snippet: string;
|
||||
also_in_account_ids: string[];
|
||||
@@ -68,6 +70,8 @@ export interface ThreadDetail {
|
||||
messages: MessageEnvelope[];
|
||||
}
|
||||
|
||||
export type SyncWindow = "all" | "3months" | "new";
|
||||
|
||||
export interface AccountOut {
|
||||
id: string;
|
||||
email: string;
|
||||
@@ -75,6 +79,9 @@ export interface AccountOut {
|
||||
color: string;
|
||||
state: string;
|
||||
signature: string;
|
||||
sync_window: SyncWindow;
|
||||
backfill_total: number;
|
||||
backfill_done: number;
|
||||
}
|
||||
|
||||
export interface MailboxOut {
|
||||
@@ -157,6 +164,13 @@ export interface FolderCounts {
|
||||
drafts: number;
|
||||
}
|
||||
|
||||
export interface SavedSearch {
|
||||
id: string;
|
||||
name: string;
|
||||
query: string;
|
||||
pinned: boolean;
|
||||
}
|
||||
|
||||
// --- M1: compose -------------------------------------------------------------
|
||||
|
||||
export interface RecipientSuggestion {
|
||||
|
||||
@@ -48,6 +48,10 @@ export function connectWs(queryClient: QueryClient): () => void {
|
||||
queryClient.invalidateQueries({ queryKey: ["quota"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["drafts-remote"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
|
||||
// Backfill progress counters live on the account row (drives the banner).
|
||||
if (name === "sync.progress") {
|
||||
queryClient.invalidateQueries({ queryKey: ["accounts"] });
|
||||
}
|
||||
if (typeof payload.thread_id === "string") {
|
||||
queryClient.invalidateQueries({ queryKey: ["thread", payload.thread_id] });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link, useMatch, useNavigate } from "react-router-dom";
|
||||
|
||||
@@ -9,11 +9,12 @@ import { MessageList } from "../components/list/MessageList";
|
||||
import { Resizer } from "../components/Resizer";
|
||||
import { ThemeToggle } from "../components/ThemeToggle";
|
||||
import { EmptyIllustration } from "../components/EmptyIllustration";
|
||||
import { BackfillBanner } from "../components/BackfillBanner";
|
||||
import { ThreadView } from "../components/thread/ThreadView";
|
||||
import { useT } from "../i18n";
|
||||
import type { MessageKey } from "../i18n/en";
|
||||
import { api } from "../lib/api";
|
||||
import type { QuotaOut, ThreadSummary } from "../lib/types";
|
||||
import type { QuotaOut, SavedSearch, ThreadSummary } from "../lib/types";
|
||||
import { useKeyboardShortcuts } from "../lib/useKeyboardShortcuts";
|
||||
|
||||
function quotaLabel(q: QuotaOut): string {
|
||||
@@ -48,6 +49,13 @@ export function MailLayout() {
|
||||
const threadId = match?.params.threadId;
|
||||
const [view, setView] = useState<string>("folder:inbox");
|
||||
const [term, setTerm] = useState("");
|
||||
// Debounced copy of the search box for search-as-you-type (avoids a request per key).
|
||||
const [debouncedTerm, setDebouncedTerm] = useState("");
|
||||
useEffect(() => {
|
||||
const h = window.setTimeout(() => setDebouncedTerm(term), 180);
|
||||
return () => window.clearTimeout(h);
|
||||
}, [term]);
|
||||
const searching = debouncedTerm.trim().length > 0;
|
||||
const [filter, setFilter] = useState<"all" | "unread" | "pinned">("all");
|
||||
const [listOrder, setListOrder] = useState<"newest" | "oldest">(() =>
|
||||
localStorage.getItem("mail.listOrder") === "oldest" ? "oldest" : "newest",
|
||||
@@ -90,10 +98,13 @@ export function MailLayout() {
|
||||
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 threadsQuery = useQuery({
|
||||
queryKey: ["threads", view],
|
||||
queryFn: () => api.threads(view),
|
||||
enabled: !isDrafts,
|
||||
queryKey: ["threads", view, filter, orderParam, page],
|
||||
queryFn: () =>
|
||||
api.threads(view, { offset: page * PAGE_SIZE, limit: PAGE_SIZE, filter, order: orderParam }),
|
||||
enabled: !isDrafts && !searching,
|
||||
placeholderData: keepPreviousData, // keep the current page visible while the next loads
|
||||
});
|
||||
const draftsQuery = useQuery({
|
||||
queryKey: ["drafts"],
|
||||
@@ -106,33 +117,36 @@ export function MailLayout() {
|
||||
enabled: isDrafts,
|
||||
});
|
||||
const searchQuery = useQuery({
|
||||
queryKey: ["search", term],
|
||||
queryFn: () => api.search(term),
|
||||
enabled: term.trim().length > 0,
|
||||
queryKey: ["search", debouncedTerm],
|
||||
queryFn: () => api.search(debouncedTerm),
|
||||
enabled: searching,
|
||||
});
|
||||
const savedSearchesQuery = useQuery({ queryKey: ["saved-searches"], queryFn: api.savedSearches });
|
||||
const settingsQuery = useQuery({ queryKey: ["settings"], queryFn: api.settings });
|
||||
const threadOrder: "asc" | "desc" = settingsQuery.data?.thread_order ?? "asc";
|
||||
const quotaQuery = useQuery({ queryKey: ["quota"], queryFn: api.quota });
|
||||
const countsQuery = useQuery({ queryKey: ["folder-counts"], queryFn: api.folderCounts });
|
||||
|
||||
const allThreads: ThreadSummary[] = term.trim()
|
||||
? (searchQuery.data?.threads ?? []).map((h) => h.thread)
|
||||
: (threadsQuery.data?.threads ?? []);
|
||||
const filtered = allThreads.filter((th) =>
|
||||
filter === "unread" ? th.unread_count > 0 : filter === "pinned" ? th.pinned : true,
|
||||
);
|
||||
// The backend returns threads newest-first; reverse for an oldest-first view.
|
||||
const threads = listOrder === "newest" ? filtered : [...filtered].reverse();
|
||||
|
||||
// Client-side pagination of the (filtered, ordered) list.
|
||||
const total = threads.length;
|
||||
const maxPage = Math.max(0, Math.ceil(total / PAGE_SIZE) - 1);
|
||||
const curPage = Math.min(page, maxPage);
|
||||
const pageStart = curPage * PAGE_SIZE;
|
||||
const pageEnd = Math.min(pageStart + PAGE_SIZE, total);
|
||||
const pageThreads = threads.slice(pageStart, pageEnd);
|
||||
// Reset to the first page whenever the underlying list identity changes.
|
||||
useEffect(() => setPage(0), [view, filter, term, listOrder]);
|
||||
// Folder/inbox views paginate server-side with an accurate grand total; search
|
||||
// results (a single fetched page of hits) still paginate client-side.
|
||||
let pageThreads: ThreadSummary[];
|
||||
let total: number;
|
||||
if (searching) {
|
||||
const hits = (searchQuery.data?.threads ?? []).map((h) => h.thread);
|
||||
const f = hits.filter((th) =>
|
||||
filter === "unread" ? th.unread_count > 0 : filter === "pinned" ? th.pinned : true,
|
||||
);
|
||||
const ordered = listOrder === "newest" ? f : [...f].reverse();
|
||||
total = ordered.length;
|
||||
pageThreads = ordered.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE);
|
||||
} else {
|
||||
pageThreads = threadsQuery.data?.threads ?? [];
|
||||
total = threadsQuery.data?.total ?? 0;
|
||||
}
|
||||
const pageStart = page * PAGE_SIZE;
|
||||
const pageEnd = pageStart + pageThreads.length;
|
||||
// Reset to the first page whenever the list identity (not the page) changes.
|
||||
useEffect(() => setPage(0), [view, filter, debouncedTerm, listOrder]);
|
||||
|
||||
// Master "select all" state over the currently listed threads.
|
||||
const listedSelectedCount = pageThreads.filter((th) => checkedIds.has(th.id)).length;
|
||||
@@ -141,8 +155,9 @@ export function MailLayout() {
|
||||
const toggleSelectAll = () =>
|
||||
setCheckedIds(allSelected ? new Set() : new Set(pageThreads.map((th) => th.id)));
|
||||
|
||||
const threadsRef = useRef<ThreadSummary[]>(threads);
|
||||
threadsRef.current = threads;
|
||||
// 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],
|
||||
@@ -157,6 +172,27 @@ export function MailLayout() {
|
||||
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
|
||||
},
|
||||
});
|
||||
// Saved searches (virtual mailboxes, §12 M2).
|
||||
const savedSearches = savedSearchesQuery.data ?? [];
|
||||
const saveSearch = useMutation({
|
||||
mutationFn: (name: string) => api.createSavedSearch(name, term.trim()),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["saved-searches"] }),
|
||||
});
|
||||
const deleteSaved = useMutation({
|
||||
mutationFn: (id: string) => api.deleteSavedSearch(id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["saved-searches"] }),
|
||||
});
|
||||
const onSaveSearch = () => {
|
||||
const q = term.trim();
|
||||
if (!q) return;
|
||||
const name = window.prompt(t("search.saveNamePrompt"), q);
|
||||
if (name && name.trim()) saveSearch.mutate(name.trim());
|
||||
};
|
||||
const runSavedSearch = (s: SavedSearch) => {
|
||||
setTerm(s.query);
|
||||
navigate("/mail");
|
||||
};
|
||||
|
||||
// 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(() => {
|
||||
@@ -359,6 +395,7 @@ export function MailLayout() {
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-app-bg text-app-fg">
|
||||
<BackfillBanner />
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
style={{ width: collapsed ? COLLAPSED_W : sidebarW }}
|
||||
@@ -435,10 +472,50 @@ export function MailLayout() {
|
||||
active={view === `account:${a.id}`}
|
||||
onClick={() => { setView(`account:${a.id}`); navigate("/mail"); }}
|
||||
>
|
||||
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: a.color }} />
|
||||
<AccountDot
|
||||
color={a.color}
|
||||
syncing={a.state === "backfilling" && a.backfill_total > 0}
|
||||
total={a.backfill_total}
|
||||
done={a.backfill_done}
|
||||
/>
|
||||
<span className="truncate">{a.email}</span>
|
||||
</SidebarItem>
|
||||
))}
|
||||
{savedSearches.length > 0 && (
|
||||
<>
|
||||
<div className="px-3 pb-1 pt-3 text-[12px] font-semibold uppercase tracking-wide text-app-faint">
|
||||
{t("search.saved")}
|
||||
</div>
|
||||
{savedSearches.map((s) => (
|
||||
<div key={s.id} className="group/saved relative">
|
||||
<SidebarItem
|
||||
active={searching && debouncedTerm === s.query}
|
||||
onClick={() => runSavedSearch(s)}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
|
||||
<circle cx="7" cy="7" r="4.5" />
|
||||
<path d="M10.5 10.5L14 14" />
|
||||
</svg>
|
||||
<span className="flex-1 truncate">{s.name}</span>
|
||||
</SidebarItem>
|
||||
<button
|
||||
type="button"
|
||||
title={t("common.delete")}
|
||||
aria-label={t("common.delete")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteSaved.mutate(s.id);
|
||||
}}
|
||||
className="absolute right-2 top-1/2 hidden -translate-y-1/2 rounded p-1 text-app-faint hover:bg-app-hover hover:text-app-fg group-hover/saved:block"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
|
||||
<path d="M4 4l8 8M12 4l-8 8" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
@@ -489,26 +566,41 @@ export function MailLayout() {
|
||||
<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 pr-8 text-[15px] text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none"
|
||||
className={`w-full rounded-lg border border-app-border bg-app-bg px-3 py-1.5 text-[15px] text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none ${
|
||||
term ? "pr-14" : "pr-8"
|
||||
}`}
|
||||
placeholder={t("nav.search")}
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
/>
|
||||
{term && (
|
||||
<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>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
title={t("search.save")}
|
||||
aria-label={t("search.save")}
|
||||
onClick={onSaveSearch}
|
||||
className="absolute right-8 top-1/2 -translate-y-1/2 rounded p-1 text-app-faint hover:bg-app-hover hover:text-app-accent"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 2.5h8v11l-4-2.6-4 2.6z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={t("common.clear")}
|
||||
aria-label={t("common.clear")}
|
||||
onClick={() => {
|
||||
setTerm("");
|
||||
searchRef.current?.focus();
|
||||
}}
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded p-1 text-app-faint hover:bg-app-hover hover:text-app-fg"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
|
||||
<path d="M4 4l8 8M12 4l-8 8" />
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -612,14 +704,14 @@ export function MailLayout() {
|
||||
</span>
|
||||
<div className="-mr-1 flex items-center gap-0.5">
|
||||
<PagerButton
|
||||
disabled={curPage === 0}
|
||||
onClick={() => setPage(curPage - 1)}
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage(page - 1)}
|
||||
label={t("pager.prev")}
|
||||
dir="left"
|
||||
/>
|
||||
<PagerButton
|
||||
disabled={pageEnd >= total}
|
||||
onClick={() => setPage(curPage + 1)}
|
||||
onClick={() => setPage(page + 1)}
|
||||
label={t("pager.next")}
|
||||
dir="right"
|
||||
/>
|
||||
@@ -765,6 +857,42 @@ export function MailLayout() {
|
||||
);
|
||||
}
|
||||
|
||||
// Account bullet in the sidebar. While the account is backfilling it becomes a
|
||||
// spinning ring that fills with the account colour to show import progress
|
||||
// (SPEC §12 M2); once live it's the plain colour-filled dot.
|
||||
function AccountDot({
|
||||
color,
|
||||
syncing,
|
||||
total,
|
||||
done,
|
||||
}: {
|
||||
color: string;
|
||||
syncing: boolean;
|
||||
total: number;
|
||||
done: number;
|
||||
}) {
|
||||
if (!syncing) {
|
||||
return (
|
||||
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: color }} />
|
||||
);
|
||||
}
|
||||
const pct = total > 0 ? Math.min(100, Math.round((done / Math.max(done, total)) * 100)) : 0;
|
||||
const ring = "rgba(128,128,128,0.35)";
|
||||
return (
|
||||
<span
|
||||
className="h-3.5 w-3.5 shrink-0 animate-spin rounded-full"
|
||||
style={{
|
||||
animationDuration: "1.1s",
|
||||
background: `conic-gradient(${color} ${pct}%, ${ring} ${pct}% 100%)`,
|
||||
// Punch out the centre to leave a ~3px ring.
|
||||
WebkitMask: "radial-gradient(farthest-side, transparent calc(100% - 3px), #000 0)",
|
||||
mask: "radial-gradient(farthest-side, transparent calc(100% - 3px), #000 0)",
|
||||
}}
|
||||
title={`${pct}%`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterTab({
|
||||
active,
|
||||
onClick,
|
||||
|
||||
@@ -225,6 +225,10 @@ function AccountsSection() {
|
||||
mutationFn: (id: string) => api.deleteAccount(id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["accounts"] }),
|
||||
});
|
||||
const downloadAll = useMutation({
|
||||
mutationFn: (id: string) => api.updateAccount(id, { sync_window: "all" }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["accounts"] }),
|
||||
});
|
||||
|
||||
const close = () => {
|
||||
setEditing(null);
|
||||
@@ -257,8 +261,25 @@ function AccountsSection() {
|
||||
<span className="text-xs text-app-faint">
|
||||
{t("settings.syncState", { state: a.state })}
|
||||
</span>
|
||||
{a.sync_window !== "all" && (
|
||||
<>
|
||||
<span className="text-xs text-app-faint">·</span>
|
||||
<span className="text-xs text-app-faint">
|
||||
{t("settings.syncedPartially")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex items-center gap-3">
|
||||
{a.sync_window !== "all" && (
|
||||
<button
|
||||
className="text-xs text-app-accent disabled:opacity-50"
|
||||
disabled={downloadAll.isPending || a.state === "backfilling"}
|
||||
onClick={() => downloadAll.mutate(a.id)}
|
||||
>
|
||||
{t("settings.downloadAllMail")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="text-xs text-app-accent"
|
||||
onClick={() => setEditing({ id: a.id, mode: "signature" })}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import type { ServerConfig } from "../../lib/types";
|
||||
import type { ServerConfig, SyncWindow } from "../../lib/types";
|
||||
|
||||
const PALETTE = ["#2563eb", "#16a34a", "#d4762a", "#9333ea", "#dc2626", "#0891b2"];
|
||||
const EMPTY: ServerConfig = { host: "", port: 993, tls: "ssl", auth: "password" };
|
||||
@@ -22,6 +22,7 @@ export function AccountWizard() {
|
||||
const [imap, setImap] = useState<ServerConfig>(EMPTY);
|
||||
const [smtp, setSmtp] = useState<ServerConfig>({ ...EMPTY, port: 465 });
|
||||
const [source, setSource] = useState<string>("");
|
||||
const [syncWindow, setSyncWindow] = useState<SyncWindow>("all");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [tested, setTested] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
@@ -72,6 +73,7 @@ export function AccountWizard() {
|
||||
imap,
|
||||
smtp,
|
||||
password,
|
||||
sync_window: syncWindow,
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ["accounts"] });
|
||||
setStep(3);
|
||||
@@ -180,6 +182,28 @@ export function AccountWizard() {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<fieldset className="rounded-lg border border-app-border p-3">
|
||||
<legend className="px-1 text-xs text-app-muted">{t("wizard.syncWindow")}</legend>
|
||||
<div className="space-y-1">
|
||||
{(["all", "3months", "new"] as const).map((w) => (
|
||||
<label key={w} className="flex cursor-pointer items-start gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
name="sync-window"
|
||||
className="mt-1"
|
||||
checked={syncWindow === w}
|
||||
onChange={() => setSyncWindow(w)}
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium">{t(`wizard.syncWindow.${w}`)}</span>
|
||||
<span className="block text-xs text-app-faint">
|
||||
{t(`wizard.syncWindow.${w}.hint`)}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
{tested && <p className="text-sm text-emerald-500">{t("wizard.testOk")}</p>}
|
||||
<div className="flex gap-2">
|
||||
{!tested ? (
|
||||
|
||||
@@ -24,6 +24,7 @@ function msg(id: string, minutes: number, unread = false): MessageEnvelope {
|
||||
is_starred: false,
|
||||
has_attachments: false,
|
||||
has_remote_images: false,
|
||||
images_allowed: false,
|
||||
size_bytes: 1024,
|
||||
snippet: `snippet ${id}`,
|
||||
also_in_account_ids: [],
|
||||
|
||||
Reference in New Issue
Block a user