diff --git a/package-lock.json b/package-lock.json index 732df26..4bfcf8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,8 +14,10 @@ "@mui/material": "^7.3.7", "@mui/styled-engine-sc": "^7.3.7", "@mui/x-charts": "^8.27.0", + "@tanstack/react-virtual": "^3.14.2", "@types/dompurify": "^3.0.5", "date-fns": "^4.1.0", + "dexie": "^4.4.3", "dompurify": "^3.4.3", "react": "^19.2.4", "react-dom": "^19.2.4", @@ -1624,6 +1626,33 @@ "win32" ] }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz", + "integrity": "sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.17.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.0.tgz", + "integrity": "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2161,6 +2190,12 @@ } } }, + "node_modules/dexie": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.4.3.tgz", + "integrity": "sha512-N+3IGQ3HPlyO2YAkntGAwitm42BpBGV86MttzUMiRzWLa4NGh0pltVRcUVF4ybL/OnXjCrr9k7SDPIKkFYP2Lg==", + "license": "Apache-2.0" + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", diff --git a/package.json b/package.json index 7e8bdb1..f0a00fa 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,10 @@ "@mui/material": "^7.3.7", "@mui/styled-engine-sc": "^7.3.7", "@mui/x-charts": "^8.27.0", + "@tanstack/react-virtual": "^3.14.2", "@types/dompurify": "^3.0.5", "date-fns": "^4.1.0", + "dexie": "^4.4.3", "dompurify": "^3.4.3", "react": "^19.2.4", "react-dom": "^19.2.4", diff --git a/server/internal/http/handlers/mailboxes.go b/server/internal/http/handlers/mailboxes.go index b5943b8..3e951b1 100644 --- a/server/internal/http/handlers/mailboxes.go +++ b/server/internal/http/handlers/mailboxes.go @@ -63,12 +63,12 @@ func (h *Mailboxes) ListMessages(w http.ResponseWriter, r *http.Request) { httpx.WriteError(w, r, h.Logger, httpx.NewAPIError(http.StatusBadGateway, httpx.CodeUpstreamFailed, "imap connect", err)) return } - envelopes, err := c.ListMessages(r.Context(), mailbox, limit, uint32(before)) + envelopes, total, err := c.ListMessages(r.Context(), mailbox, limit, uint32(before)) if err != nil { httpx.WriteError(w, r, h.Logger, httpx.NewAPIError(http.StatusBadGateway, httpx.CodeUpstreamFailed, "list messages", err)) return } - resp := map[string]any{"messages": envelopes} + resp := map[string]any{"messages": envelopes, "total": total} if len(envelopes) > 0 { resp["next_before"] = envelopes[len(envelopes)-1].UID } diff --git a/server/internal/imap/client.go b/server/internal/imap/client.go index 73be2d7..2e285d9 100644 --- a/server/internal/imap/client.go +++ b/server/internal/imap/client.go @@ -175,24 +175,27 @@ func (c *Client) selectMailbox(name string, readOnly bool) error { // ListMessages returns up to limit envelopes from the given mailbox, newest first. // If before > 0, only messages with UID < before are returned (cursor-style paging). -func (c *Client) ListMessages(ctx context.Context, mailbox string, limit int, before uint32) ([]hmail.Envelope, error) { +// The returned total is the mailbox's full message count (independent of the +// page), suitable for a "1–50 of N" pager. +func (c *Client) ListMessages(ctx context.Context, mailbox string, limit int, before uint32) ([]hmail.Envelope, uint32, error) { if limit <= 0 || limit > 200 { limit = 50 } c.mu.Lock() defer c.mu.Unlock() if err := c.ensureLive(ctx); err != nil { - return nil, err + return nil, 0, err } if err := c.selectMailbox(mailbox, true); err != nil { - return nil, err + return nil, 0, err } mbox, err := c.conn.Select(mailbox, true) if err != nil { - return nil, err + return nil, 0, err } - if mbox.Messages == 0 { - return []hmail.Envelope{}, nil + total := mbox.Messages + if total == 0 { + return []hmail.Envelope{}, 0, nil } // Fetch the highest UID first; if before is set, cap the upper bound there. @@ -204,10 +207,10 @@ func (c *Client) ListMessages(ctx context.Context, mailbox string, limit int, be } uids, err := c.conn.UidSearch(criteria) if err != nil { - return nil, fmt.Errorf("uid search: %w", err) + return nil, 0, fmt.Errorf("uid search: %w", err) } if len(uids) == 0 { - return []hmail.Envelope{}, nil + return []hmail.Envelope{}, total, nil } // Newest first; take the last `limit` UIDs. if len(uids) > limit { @@ -226,13 +229,13 @@ func (c *Client) ListMessages(ctx context.Context, mailbox string, limit int, be envelopes = append(envelopes, envelopeFrom(m)) } if err := <-fetchDone; err != nil { - return nil, fmt.Errorf("fetch envelopes: %w", err) + return nil, 0, fmt.Errorf("fetch envelopes: %w", err) } // Sort newest-first by UID descending. for i, j := 0, len(envelopes)-1; i < j; i, j = i+1, j-1 { envelopes[i], envelopes[j] = envelopes[j], envelopes[i] } - return envelopes, nil + return envelopes, total, nil } func envelopeFrom(m *imap.Message) hmail.Envelope { diff --git a/src/nonview/api/types.ts b/src/nonview/api/types.ts index 9615166..90fb1b1 100644 --- a/src/nonview/api/types.ts +++ b/src/nonview/api/types.ts @@ -77,6 +77,8 @@ export interface MailboxListResponse { export interface MessageListResponse { messages: Envelope[]; next_before?: number; + // Total messages in the mailbox (independent of the page), for "1–50 of N". + total?: number; } export interface APIErrorBody { diff --git a/src/nonview/cache/db.ts b/src/nonview/cache/db.ts new file mode 100644 index 0000000..c7617a9 Binary files /dev/null and b/src/nonview/cache/db.ts differ diff --git a/src/nonview/core/DataContext.tsx b/src/nonview/core/DataContext.tsx index 2bc9315..6f67734 100644 --- a/src/nonview/core/DataContext.tsx +++ b/src/nonview/core/DataContext.tsx @@ -9,11 +9,20 @@ import React, { } from "react"; import { useAuth } from "./AuthContext"; import { mailboxes as mailboxesAPI, messages as messagesAPI } from "../api/endpoints"; +import { + readThreads, + writeThreads, + removeThread, + patchThread, + readBody, + writeBody, +} from "../cache/db"; import type { Address, Envelope, Mailbox, Message, + MessageListResponse, } from "../api/types"; // Thread is the shape consumed by existing UI components. Each backend @@ -77,9 +86,21 @@ interface DataContextValue { loading: boolean; error: string | null; unreadCount: number; + // Page-based pagination keyed by role ("inbox" | "sent" | "drafts" | "trash"). + // page[role] is the 0-based current page; total[role] is the mailbox's full + // message count; pageLoading[role] is true while a page is being fetched. + page: Record; + total: Record; + pageLoading: Record; + pageSize: number; + // Move one page older / newer, replacing the folder's visible messages. + nextPage: (role: string) => Promise; + prevPage: (role: string) => Promise; refresh: () => Promise; getThread: (id: string) => Thread | undefined; getMessages: (threadId: string) => Promise; + // Cache-first body read; returns null if nothing is cached for the thread. + getCachedMessages: (threadId: string) => Promise; sendEmail: (data: EmailData) => Promise<{ status: string }>; saveDraft: (data: DraftData) => Promise; deleteThread: (threadId: string) => Promise; @@ -105,6 +126,9 @@ const ROLE_NAMES: Record = { trash: ["Trash", "Deleted Items", "[Gmail]/Trash"], }; +// How many envelopes to show per page (matches the backend default). +const PAGE_SIZE = 50; + // Resolves the actual mailbox names served by the user's provider against // well-known role hints (set by the IMAP \\Special-Use flags or, failing that, // common folder names). @@ -187,7 +211,11 @@ interface DataProviderProps { } export const DataProvider: React.FC = ({ children }) => { - const { apiClient, isAuthenticated } = useAuth(); + const { apiClient, isAuthenticated, currentUser } = useAuth(); + + // Cache scope. Every IndexedDB read/write is namespaced by the signed-in + // address so a shared browser never mixes two accounts' mail. + const account = currentUser?.email || ""; const [mailboxList, setMailboxList] = useState([]); const [threads, setThreads] = useState([]); @@ -197,13 +225,45 @@ export const DataProvider: React.FC = ({ children }) => { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + // Pagination bookkeeping per folder role. + const [page, setPage] = useState>({}); + const [total, setTotal] = useState>({}); + const [pageLoading, setPageLoading] = useState>({}); + // pageCursors[role][i] is the `before` UID used to fetch page i (index 0 is + // undefined = newest). Discovered as the user pages forward, reused for back. + const pageCursors = useRef>({}); + // Cache of role → mailbox name resolution so handlers can post to the right // folder without a fresh mailboxes call each time. const rolesRef = useRef>({}); + // Paint the mailbox list from IndexedDB before any network call. Returns + // whether the cache held anything, so the network load knows whether it + // still needs to show a blocking spinner (cold start) or can refresh quietly. + const hydrateFromCache = useCallback(async (): Promise => { + if (!account) return false; + const [inbox, sent, draftsC, trash] = await Promise.all([ + readThreads(account, "inbox"), + readThreads(account, "sent"), + readThreads(account, "drafts"), + readThreads(account, "trash"), + ]); + if (inbox.length) setThreads(inbox); + if (sent.length) setSentThreads(sent); + if (draftsC.length) setDrafts(draftsC); + if (trash.length) setTrashedThreads(trash); + return ( + inbox.length + sent.length + draftsC.length + trash.length > 0 + ); + }, [account]); + const loadAll = useCallback( - async (signal?: AbortSignal): Promise => { - setLoading(true); + async ( + signal?: AbortSignal, + opts: { showSpinner?: boolean } = {}, + ): Promise => { + // On a warm cache we refresh in the background — no blocking spinner. + if (opts.showSpinner !== false) setLoading(true); setError(null); try { const resp = await mailboxesAPI.list(apiClient, signal); @@ -216,30 +276,44 @@ export const DataProvider: React.FC = ({ children }) => { // the successful folders' data. const settled = await Promise.allSettled([ roles.inbox - ? mailboxesAPI.listMessages(apiClient, roles.inbox, { limit: 50 }, signal) + ? mailboxesAPI.listMessages(apiClient, roles.inbox, { limit: PAGE_SIZE }, signal) : Promise.resolve({ messages: [] }), roles.sent - ? mailboxesAPI.listMessages(apiClient, roles.sent, { limit: 50 }, signal) + ? mailboxesAPI.listMessages(apiClient, roles.sent, { limit: PAGE_SIZE }, signal) : Promise.resolve({ messages: [] }), roles.drafts - ? mailboxesAPI.listMessages(apiClient, roles.drafts, { limit: 50 }, signal) + ? mailboxesAPI.listMessages(apiClient, roles.drafts, { limit: PAGE_SIZE }, signal) : Promise.resolve({ messages: [] }), roles.trash - ? mailboxesAPI.listMessages(apiClient, roles.trash, { limit: 50 }, signal) + ? mailboxesAPI.listMessages(apiClient, roles.trash, { limit: PAGE_SIZE }, signal) : Promise.resolve({ messages: [] }), ]); - const fold = (idx: number, mailbox: string | undefined) => - settled[idx].status === "fulfilled" - ? ((settled[idx] as PromiseFulfilledResult<{ messages: Envelope[] }>).value - .messages || [] - ).map((e) => envelopeToThread(e, mailbox || "")) - : []; - - setThreads(fold(0, roles.inbox)); - setSentThreads(fold(1, roles.sent)); - setDrafts(fold(2, roles.drafts)); - setTrashedThreads(fold(3, roles.trash)); + // Apply a folder's fresh first page (page 0) to React state and the + // cache, and seed its pagination bookkeeping — but only when the fetch + // succeeded, so a rejected fetch never clobbers good cached rows. + const apply = ( + idx: number, + role: string, + mailbox: string | undefined, + set: React.Dispatch>, + ) => { + if (settled[idx].status !== "fulfilled") return; // keep cached rows + const val = (settled[idx] as PromiseFulfilledResult).value; + const msgs = val.messages || []; + const fresh = msgs.map((e) => envelopeToThread(e, mailbox || "")); + set(fresh); + void writeThreads(account, role, fresh); + // Reset to page 0; cursor for page 1 is this page's next_before. + pageCursors.current[role] = [undefined, val.next_before]; + setPage((p) => ({ ...p, [role]: 0 })); + setTotal((t) => ({ ...t, [role]: val.total ?? msgs.length })); + }; + + apply(0, "inbox", roles.inbox, setThreads); + apply(1, "sent", roles.sent, setSentThreads); + apply(2, "drafts", roles.drafts, setDrafts); + apply(3, "trash", roles.trash, setTrashedThreads); const failed = settled .map((s, i) => ({ s, i })) @@ -257,7 +331,7 @@ export const DataProvider: React.FC = ({ children }) => { setLoading(false); } }, - [apiClient], + [apiClient, account], ); useEffect(() => { @@ -270,12 +344,100 @@ export const DataProvider: React.FC = ({ children }) => { return; } const ctrl = new AbortController(); - void loadAll(ctrl.signal); + // Cache-first: paint from IndexedDB immediately, then refresh from the + // network without a blocking spinner if the cache already had something. + void (async () => { + const hadCache = await hydrateFromCache(); + await loadAll(ctrl.signal, { showSpinner: !hadCache }); + })(); return () => ctrl.abort(); - }, [isAuthenticated, loadAll]); + }, [isAuthenticated, loadAll, hydrateFromCache]); const refresh = useCallback(() => loadAll(), [loadAll]); + // Maps a role to its current thread array's state setter. + const setterForRole = useCallback( + (role: string): React.Dispatch> | null => { + switch (role) { + case "inbox": + return setThreads; + case "sent": + return setSentThreads; + case "trash": + return setTrashedThreads; + case "drafts": + return setDrafts; + default: + return null; + } + }, + [], + ); + + // Fetch one page of a folder and REPLACE the visible messages with it (unlike + // infinite scroll, which appends). `target` is the destination page index; + // `before` is the cursor for that page (undefined = newest/page 0). + const fetchPage = useCallback( + async (role: string, target: number, before: number | undefined): Promise => { + const mailbox = rolesRef.current[role]; + const set = setterForRole(role); + if (!mailbox || !set) return; + + setPageLoading((m) => ({ ...m, [role]: true })); + try { + const resp = await mailboxesAPI.listMessages(apiClient, mailbox, { + limit: PAGE_SIZE, + ...(before ? { before } : {}), + }); + const msgs = resp.messages || []; + const fresh = msgs.map((e) => envelopeToThread(e, mailbox)); + set(fresh); + setPage((p) => ({ ...p, [role]: target })); + if (resp.total !== undefined) { + setTotal((t) => ({ ...t, [role]: resp.total as number })); + } + // Record the cursor for the *next* page so a later nextPage knows it. + const cursors = pageCursors.current[role] || [undefined]; + cursors[target + 1] = resp.next_before; + pageCursors.current[role] = cursors; + // Only page 0 is mirrored to the offline cache (the "newest" view). + if (target === 0) void writeThreads(account, role, fresh); + } catch (err) { + if ((err as { name?: string })?.name !== "AbortError") { + setError((err as Error).message || "Failed to load page"); + } + } finally { + setPageLoading((m) => ({ ...m, [role]: false })); + } + }, + [apiClient, account, setterForRole], + ); + + const nextPage = useCallback( + async (role: string): Promise => { + if (pageLoading[role]) return; + const cur = page[role] ?? 0; + const target = cur + 1; + // Don't page past the end. + if (target * PAGE_SIZE >= (total[role] ?? 0)) return; + const before = (pageCursors.current[role] || [])[target]; + await fetchPage(role, target, before); + }, + [page, total, pageLoading, fetchPage], + ); + + const prevPage = useCallback( + async (role: string): Promise => { + if (pageLoading[role]) return; + const cur = page[role] ?? 0; + if (cur <= 0) return; + const target = cur - 1; + const before = (pageCursors.current[role] || [])[target]; + await fetchPage(role, target, before); + }, + [page, pageLoading, fetchPage], + ); + const allThreads = useMemo( () => [...threads, ...sentThreads, ...drafts, ...trashedThreads], [threads, sentThreads, drafts, trashedThreads], @@ -290,10 +452,24 @@ export const DataProvider: React.FC = ({ children }) => { async (threadId: string): Promise => { const parsed = parseThreadID(threadId); if (!parsed) return []; + // Fetch from the network and refresh the cache. const msg = await messagesAPI.get(apiClient, parsed.mailbox, parsed.uid); - return [messageToThreadMessage(msg)]; + const tm = messageToThreadMessage(msg); + void writeBody(account, threadId, tm); + return [tm]; }, - [apiClient], + [apiClient, account], + ); + + // Cache-first body read for the read view: returns the cached body instantly + // (or null on a cold thread) so ThreadPage can paint before the network + // round-trip completes, then revalidate via getMessages. + const getCachedMessages = useCallback( + async (threadId: string): Promise => { + const cached = await readBody(account, threadId); + return cached ? [cached] : null; + }, + [account], ); const sendEmail = useCallback( @@ -357,31 +533,61 @@ export const DataProvider: React.FC = ({ children }) => { const parsed = parseThreadID(threadId); if (!parsed) return; const trash = rolesRef.current.trash || "Trash"; - await messagesAPI.remove(apiClient, parsed.mailbox, parsed.uid, trash); + + // Optimistic: remove from the list (and cache) immediately, then call the + // server. Snapshot first so we can roll back if the delete fails. + const snapshot = { threads, sentThreads, drafts }; const remove = (list: Thread[]) => list.filter((t) => t.id !== threadId); setThreads(remove); setSentThreads(remove); setDrafts(remove); + void removeThread(account, threadId); + + try { + await messagesAPI.remove(apiClient, parsed.mailbox, parsed.uid, trash); + } catch (err) { + // Roll back to the pre-delete state on failure. + setThreads(snapshot.threads); + setSentThreads(snapshot.sentThreads); + setDrafts(snapshot.drafts); + void writeThreads(account, "inbox", snapshot.threads); + throw err; + } }, - [apiClient], + [apiClient, account, threads, sentThreads, drafts], ); const markAsRead = useCallback( async (threadId: string): Promise => { const parsed = parseThreadID(threadId); if (!parsed) return; - await messagesAPI.setFlags( - apiClient, - parsed.mailbox, - parsed.uid, - ["\\Seen"], - true, - ); + + // Optimistic: clear the unread badge instantly in state and cache. setThreads((prev) => prev.map((t) => (t.id === threadId ? { ...t, unreadCount: 0 } : t)), ); + void patchThread(account, threadId, { unreadCount: 0 }); + + try { + await messagesAPI.setFlags( + apiClient, + parsed.mailbox, + parsed.uid, + ["\\Seen"], + true, + ); + } catch (err) { + // Restore the unread badge if the flag update didn't stick. + setThreads((prev) => + prev.map((t) => + t.id === threadId ? { ...t, unreadCount: 1 } : t, + ), + ); + void patchThread(account, threadId, { unreadCount: 1 }); + throw err; + } }, - [apiClient], + [apiClient, account], ); const sendMessage = useCallback( @@ -427,9 +633,16 @@ export const DataProvider: React.FC = ({ children }) => { loading, error, unreadCount, + page, + total, + pageLoading, + pageSize: PAGE_SIZE, + nextPage, + prevPage, refresh, getThread, getMessages, + getCachedMessages, sendEmail, saveDraft, deleteThread, diff --git a/src/view/moles/ThreadList.tsx b/src/view/moles/ThreadList.tsx index 41f0bfd..31482a0 100644 --- a/src/view/moles/ThreadList.tsx +++ b/src/view/moles/ThreadList.tsx @@ -1,16 +1,48 @@ -import React from "react"; -import { Box, CircularProgress } from "@mui/material"; +import React, { useEffect, useRef } from "react"; +import { Box, CircularProgress, IconButton, Typography } from "@mui/material"; +import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; +import ChevronRightIcon from "@mui/icons-material/ChevronRight"; import { useNavigate } from "react-router-dom"; +import { useVirtualizer } from "@tanstack/react-virtual"; import ThreadListItem from "./ThreadListItem"; import EmptyState from "../atoms/EmptyState"; +// Approximate height of one ThreadListItem (avatar + three text rows + padding). +// react-virtual uses this only for the initial layout estimate; real heights +// are measured per row via measureElement, so variable-height items are fine. +const ESTIMATED_ROW_HEIGHT = 92; + const ThreadList = ({ threads, loading = false, emptyMessage = "No emails found", - selectedThreadId, + selectedThreadId = null, + // Gmail-style pager. When onNext/onPrev are provided and total > 0, a + // "start–end of total" bar with prev/next arrows is shown above the list. + page = 0, + total = 0, + pageSize = 50, + onNext = undefined, + onPrev = undefined, + pageLoading = false, }) => { const navigate = useNavigate(); + const parentRef = useRef(null); + + // Jump back to the top of the list whenever the page changes, so a new page + // starts at its first message rather than wherever the previous one scrolled. + useEffect(() => { + if (parentRef.current) parentRef.current.scrollTop = 0; + }, [page]); + + const rowVirtualizer = useVirtualizer({ + count: threads?.length || 0, + getScrollElement: () => parentRef.current, + estimateSize: () => ESTIMATED_ROW_HEIGHT, + // Render a few extra rows above/below the viewport so fast scrolling + // doesn't flash blank space. + overscan: 8, + }); if (loading) { return ( @@ -38,16 +70,97 @@ const ThreadList = ({ navigate(`/thread/${encodeURIComponent(threadId)}`); }; + const items = rowVirtualizer.getVirtualItems(); + + // Pager math. start/end are 1-based and reflect the rows actually shown. + const showPager = !!(onNext || onPrev) && total > 0; + const start = page * pageSize + 1; + const end = page * pageSize + threads.length; + const canPrev = page > 0 && !pageLoading; + const canNext = end < total && !pageLoading; + return ( - - {threads.map((thread) => ( - - ))} + + {showPager && ( + + {pageLoading && } + + {start.toLocaleString()}–{end.toLocaleString()} of{" "} + {total.toLocaleString()} + + onPrev && onPrev()} + > + + + onNext && onNext()} + > + + + + )} + + {/* The scroll container. Only the rows inside the viewport are mounted, + so this stays at 60fps even over a large mailbox. */} + + + {items.map((virtualRow) => { + const thread = threads[virtualRow.index]; + return ( + + + + ); + })} + + ); }; diff --git a/src/view/pages/InboxPage.tsx b/src/view/pages/InboxPage.tsx index aca76fa..7ed3dc1 100644 --- a/src/view/pages/InboxPage.tsx +++ b/src/view/pages/InboxPage.tsx @@ -8,7 +8,8 @@ import FloatingActionButton from "../atoms/FloatingActionButton"; import { useData } from "../../nonview/core/DataContext"; function InboxPage() { - const { threads, loading } = useData(); + const { threads, loading, page, total, pageSize, pageLoading, nextPage, prevPage } = + useData(); const navigate = useNavigate(); const [searchQuery, setSearchQuery] = useState(""); @@ -35,6 +36,12 @@ function InboxPage() { emptyMessage={ searchQuery ? "No emails match your search" : "Your inbox is empty" } + page={page.inbox || 0} + total={total.inbox || 0} + pageSize={pageSize} + pageLoading={pageLoading.inbox} + onNext={searchQuery ? undefined : () => nextPage("inbox")} + onPrev={searchQuery ? undefined : () => prevPage("inbox")} /> { @@ -31,6 +40,12 @@ function SentPage() { emptyMessage={ searchQuery ? "No sent emails match your search" : "No sent emails" } + page={page.sent || 0} + total={total.sent || 0} + pageSize={pageSize} + pageLoading={pageLoading.sent} + onNext={searchQuery ? undefined : () => nextPage("sent")} + onPrev={searchQuery ? undefined : () => prevPage("sent")} /> ); diff --git a/src/view/pages/ThreadPage.tsx b/src/view/pages/ThreadPage.tsx index eca8588..b3bbe1d 100644 --- a/src/view/pages/ThreadPage.tsx +++ b/src/view/pages/ThreadPage.tsx @@ -13,6 +13,7 @@ function ThreadPage() { const { getThread, getMessages, + getCachedMessages, sendMessage, markAsRead, loading, @@ -30,14 +31,30 @@ function ThreadPage() { useEffect(() => { if (!threadId) return; let cancelled = false; - setMessagesLoading(true); + let paintedFromCache = false; setMessagesError(null); + + // Cache-first: paint the cached body immediately (no spinner), then + // revalidate from the network in the background. On a cold thread we show + // the spinner until the network fetch lands. + getCachedMessages(threadId).then((cached) => { + if (cancelled || !cached) return; + paintedFromCache = true; + setMessages(cached); + setMessagesLoading(false); + }); + setMessagesLoading(true); + getMessages(threadId) .then((m) => { if (!cancelled) setMessages(m); }) .catch((e) => { - if (!cancelled) setMessagesError(e?.message || "Failed to load message"); + // A revalidation failure shouldn't blank out a body we already painted + // from cache; only surface the error when we have nothing to show. + if (!cancelled && !paintedFromCache) { + setMessagesError(e?.message || "Failed to load message"); + } }) .finally(() => { if (!cancelled) setMessagesLoading(false); @@ -45,7 +62,7 @@ function ThreadPage() { return () => { cancelled = true; }; - }, [threadId, getMessages]); + }, [threadId, getMessages, getCachedMessages]); // Mark the thread as read once we know it exists. Best-effort — surface // failures only in the console so a flag-update glitch doesn't break the read view. diff --git a/src/view/pages/TrashPage.tsx b/src/view/pages/TrashPage.tsx index 900178c..35f3541 100644 --- a/src/view/pages/TrashPage.tsx +++ b/src/view/pages/TrashPage.tsx @@ -5,7 +5,16 @@ import ThreadList from "../moles/ThreadList"; import { useData } from "../../nonview/core/DataContext"; function TrashPage() { - const { trashedThreads, loading } = useData(); + const { + trashedThreads, + loading, + page, + total, + pageSize, + pageLoading, + nextPage, + prevPage, + } = useData(); const [searchQuery, setSearchQuery] = useState(""); const filteredThreads = trashedThreads.filter((thread) => { @@ -31,6 +40,12 @@ function TrashPage() { emptyMessage={ searchQuery ? "No trash items match your search" : "Trash is empty" } + page={page.trash || 0} + total={total.trash || 0} + pageSize={pageSize} + pageLoading={pageLoading.trash} + onNext={searchQuery ? undefined : () => nextPage("trash")} + onPrev={searchQuery ? undefined : () => prevPage("trash")} /> );