import { type QueryClient, useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query' import { createFileRoute, Link, Outlet, useNavigate, useRouterState } from '@tanstack/react-router' import { Loader2, Reply, Star } from 'lucide-react' import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { THREAD_ROW_CLASS, THREAD_ROW_LINK_CLASS, ThreadRowContent, threadRowLinkLabel, } from '#features/mail/components/ThreadRow' import { draftRecipientName, folderCount, mailFolderTitle, readableSnippet, threadTimestamp, } from '#features/mail/lib/mail-ui-model' import { useUpdateThreadMutation } from '#features/mail/state/mail-mutations' import { draftsQueryOptions, foldersQueryOptions, type MailDraft, type MailThread, type MailThreadPage, threadListQueryOptions, } from '#features/mail/state/mail-queries' import { getFolders, getThreads, listDrafts, updateThreadState } from '#server/fns' import { ClientListDate } from '#shared/components/ClientTime' import { PullToRefresh, RefreshButton } from '#shared/components/PullToRefresh' import { ScrollArea } from '#shared/components/ui/scroll-area' import { edgeCursor, listNavAction, moveCursor } from '#shared/lib/list-nav' import { cn } from '#shared/lib/utils' export const Route = createFileRoute('/mail/f/$folderId')({ validateSearch: (search): { baseFolderId?: string } => ({ ...(typeof search.baseFolderId === 'string' ? { baseFolderId: search.baseFolderId } : {}), }), loader: async ({ context, params }) => loadMailFolderData(params.folderId, context.queryClient), component: FolderView, }) export async function loadMailFolderData(folderId: string, queryClient: QueryClient) { const folders = await queryClient.ensureQueryData(foldersQueryOptions(() => getFolders())) if (folderId === 'drafts') { return { threads: [] as MailThread[], drafts: await queryClient.ensureQueryData(draftsQueryOptions(() => listDrafts())), folders, nextCursor: undefined as string | undefined, } } const filters = folderId === 'starred' ? { starred: true } : { folderId } const result = await queryClient.ensureInfiniteQueryData( threadListQueryOptions(filters, (input) => getThreads({ data: input })), ) const firstPage = result.pages.at(0) as MailThreadPage return { threads: firstPage.threads, nextCursor: firstPage.nextCursor, drafts: [] as MailDraft[], folders, } } type MailFolderRouteData = Awaited> type ComposeThreadSearch = ReturnType< typeof import('#features/mail/lib/mail-ui-model').composeBackdropThreadSearch > function FolderView() { const loaderData = Route.useLoaderData() const { folderId } = Route.useParams() const { baseFolderId } = Route.useSearch() const updateThread = useUpdateThreadMutation() const queryClient = useQueryClient() const folderQuery = useQuery({ ...foldersQueryOptions( /* v8 ignore next -- @preserve production query wiring is covered through the isolated route screen and query-option tests */ () => getFolders(), ), initialData: loaderData.folders, }) const draftsQuery = useQuery({ ...draftsQueryOptions( /* v8 ignore next -- @preserve production query wiring is covered through the isolated route screen and query-option tests */ () => listDrafts(), ), initialData: loaderData.drafts, enabled: folderId === 'drafts', }) const threadListOptions = useMemo( () => threadListQueryOptions(folderId === 'starred' ? { starred: true } : { folderId }, (input) => getThreads({ data: input }), ), [folderId], ) const threadsQuery = useInfiniteQuery({ ...threadListOptions, initialData: { pages: [ { threads: loaderData.threads, ...(loaderData.nextCursor ? { nextCursor: loaderData.nextCursor } : {}), }, ], pageParams: [undefined], }, enabled: folderId !== 'drafts', }) useEffect( () => () => { void queryClient .cancelQueries({ queryKey: threadListOptions.queryKey, exact: true }, { revert: true, silent: true }) .catch( /* v8 ignore next -- @preserve cancellation is a best-effort lifecycle cleanup with no user-facing failure */ () => {}, ) }, [queryClient, threadListOptions.queryKey], ) const threads = dedupeThreads(threadsQuery.data.pages.flatMap((page) => page.threads)) const nextCursor = threadsQuery.data.pages.at(-1)?.nextCursor async function loadMoreThreads() { try { await threadsQuery.fetchNextPage({ cancelRefetch: false }) } catch { // The route screen renders static retry guidance; never expose provider details. } } async function refreshThreads() { const activeListRefresh = folderId === 'drafts' ? draftsQuery.refetch({ throwOnError: true }) : threadsQuery.refetch({ throwOnError: true }) await Promise.all([activeListRefresh, folderQuery.refetch({ throwOnError: true })]) } return ( >} folderId={folderId} baseFolderId={baseFolderId} nextCursor={nextCursor} loadingMore={threadsQuery.isFetchingNextPage} loadMoreError={threadsQuery.isFetchNextPageError} onLoadMore={loadMoreThreads} onRefresh={refreshThreads} onUpdateThread={(input) => updateThread.mutateAsync(input).then(() => undefined)} /> ) } function dedupeThreads(threads: T[]): T[] { return [...new Map(threads.map((thread) => [thread.id, thread])).values()] } export function MailFolderRouteScreen({ threads: initialThreads, drafts, folders, folderId, baseFolderId, nextCursor: initialCursor, loadingMore: managedLoadingMore, loadMoreError: managedLoadMoreError, onLoadMore, onRefresh, onUpdateThread, activeThreadId, composeThreadSearch, children, }: MailFolderRouteData & { folderId: string baseFolderId?: string loadingMore?: boolean loadMoreError?: boolean onLoadMore?: () => Promise onRefresh?: () => Promise onUpdateThread?: (input: { threadId: string; starred: boolean }) => Promise activeThreadId?: string composeThreadSearch?: (threadId: string) => ComposeThreadSearch children?: ReactNode }) { const folderTitle = mailFolderTitle(folderId, folders) const navigate = useNavigate() const [extraThreads, setExtraThreads] = useState([]) const [nextCursor, setNextCursor] = useState(initialCursor) const [localLoadingMore, setLocalLoadingMore] = useState(false) const [localLoadMoreError, setLocalLoadMoreError] = useState(false) const [cursor, setCursor] = useState(-1) const listScrollRef = useRef(null) const moveFocusToCursorRef = useRef(false) const loadMorePendingRef = useRef(false) const folderIdentity = JSON.stringify([folderId, initialCursor]) const folderGenerationRef = useRef({ identity: folderIdentity, generation: 0 }) if (folderGenerationRef.current.identity !== folderIdentity) { folderGenerationRef.current = { identity: folderIdentity, generation: folderGenerationRef.current.generation + 1, } } const hasThreadRoute = useRouterState({ select: (state) => state.location.pathname.includes('/t/') || state.matches.some( /* v8 ignore next -- @preserve the direct pathname arm covers the mounted nested-thread route in screen tests */ (match) => match.routeId === '/mail/f/$folderId/t/$threadId', ), }) const hasThread = hasThreadRoute || Boolean(children) const loadingMore = Boolean(managedLoadingMore || localLoadingMore) const loadMoreFailed = !loadingMore && Boolean(managedLoadMoreError || localLoadMoreError) const threads = useMemo( () => (onLoadMore ? initialThreads : dedupeThreads([...initialThreads, ...extraThreads])), [extraThreads, initialThreads, onLoadMore], ) const sortedThreads = useMemo( () => [...threads].sort((a, b) => (threadTimestamp(b) ?? 0) - (threadTimestamp(a) ?? 0)), [threads], ) const unreadCount = folderCount(folders, folderId) // The keyboard cursor walks a flat list of the rows actually on screen — // drafts in the drafts folder, otherwise the sorted threads — so `j`/`k` // order matches render order and Enter opens the right conversation. const navItems = useMemo(() => { if (folderId === 'drafts') { return drafts.map((draft) => ({ draftId: draft.id })) } const search = baseFolderId ? { baseFolderId } : {} return sortedThreads.map((thread) => ({ folderId, threadId: thread.id, search })) }, [baseFolderId, drafts, folderId, sortedThreads]) const openItem = useCallback( (index: number) => { const item = navItems[index] if (!item) return if ('draftId' in item) { navigate({ to: '/mail/compose', search: { draft: item.draftId, folderId: 'drafts' } }) return } navigate({ to: '/mail/f/$folderId/t/$threadId', params: { folderId: item.folderId, threadId: item.threadId }, search: item.search, }) }, [navItems, navigate], ) // biome-ignore lint/correctness/useExhaustiveDependencies: reset paginated threads and the keyboard cursor when the folder changes useEffect(() => { loadMorePendingRef.current = false setExtraThreads([]) setNextCursor(initialCursor) setLocalLoadingMore(false) setLocalLoadMoreError(false) setCursor(-1) }, [folderId, initialCursor]) // Keep the cursored row visible as it walks past the fold. useEffect(() => { if (cursor < 0) return const rows = listScrollRef.current?.querySelectorAll('[data-nav-row]') rows?.[cursor]?.scrollIntoView({ block: 'nearest' }) if (moveFocusToCursorRef.current) { const row = rows?.[cursor] ;(row?.querySelector('.thread-row-link') ?? row)?.focus() moveFocusToCursorRef.current = false } }, [cursor]) // Global list navigation: j/k or arrows move the cursor, Enter/o opens it. // Skip while typing, while a dialog (command palette, compose, event) is up, // or when a modifier is held so app/browser shortcuts keep working. useEffect(() => { function onKeyDown(event: KeyboardEvent) { const target = event.target instanceof HTMLElement ? event.target : null const isTyping = target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA' || target?.isContentEditable if (isTyping || event.metaKey || event.ctrlKey || event.altKey) return const focusedRow = target?.closest('[data-nav-row]') const rows = listScrollRef.current?.querySelectorAll('[data-nav-row]') const focusedRowIndex = focusedRow && rows ? Array.from(rows).indexOf(focusedRow) : -1 // Keep nested row actions and unrelated links in control of their keys, // but let a focused thread row continue list navigation. if (target?.closest?.('button, select') || (target?.closest?.('a') && focusedRowIndex < 0)) return if (document.querySelector('[role="dialog"]')) return const action = listNavAction(event.key) if (!action) return event.preventDefault() if (action === 'open') { openItem(focusedRowIndex >= 0 ? focusedRowIndex : cursor) return } if (focusedRowIndex >= 0) moveFocusToCursorRef.current = true setCursor((current) => action === 'first' || action === 'last' ? edgeCursor(action, navItems.length) : moveCursor( focusedRowIndex >= 0 ? focusedRowIndex : current, action === 'down' ? 1 : -1, navItems.length, ), ) } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) }, [cursor, navItems.length, openItem]) async function loadMore() { if (!nextCursor || loadMorePendingRef.current || loadingMore || folderId === 'drafts') return const actionGeneration = folderGenerationRef.current.generation loadMorePendingRef.current = true setLocalLoadMoreError(false) setLocalLoadingMore(true) try { if (onLoadMore) { await onLoadMore() return } const res = await getThreads({ data: { ...(folderId === 'starred' ? { starred: true } : { folderId }), pageToken: nextCursor, }, }) if (folderGenerationRef.current.generation !== actionGeneration) return setExtraThreads((current) => [...current, ...res.threads]) setNextCursor(res.nextCursor) } catch { if (folderGenerationRef.current.generation === actionGeneration) setLocalLoadMoreError(true) } finally { if (folderGenerationRef.current.generation === actionGeneration) { loadMorePendingRef.current = false setLocalLoadingMore(false) } } } const paginationControls = nextCursor ? (
{loadMoreFailed ? ( ) : null}
) : null const threadList = ( {folderId === 'drafts' ? ( drafts.length === 0 ? ( ) : ( drafts.map((draft, index) => ) ) ) : sortedThreads.length === 0 ? ( {paginationControls} ) : ( <> {sortedThreads.map((thread, index) => ( ))} {paginationControls} )} ) return ( <>

{folderTitle}

{unreadCount > 0 ? ( {unreadCount} ) : null} {onRefresh ? : null}
{onRefresh ? ( {threadList} ) : ( threadList )}
{hasThread ? ( (children ?? ) ) : (

Select a conversation

Pick a message from the list, or press C to compose.

)}
) } function EmptyState({ moreAvailable = false, children }: { moreAvailable?: boolean; children?: ReactNode }) { return (

{moreAvailable ? 'More messages may be available' : 'All caught up'}

{moreAvailable ? 'Load the next page to keep looking.' : 'No messages in this folder.'}

{children}
) } function DraftRow({ draft, navActive }: { draft: MailDraft; navActive: boolean }) { const recipient = draftRecipientName(draft) return (
{recipient} {draft.date ? ( ) : null}

{draft.subject || '(no subject)'}

{readableSnippet(draft.snippet)}

) } function ThreadRow({ thread, folderId, baseFolderId, active, composeSearch, navActive, onUpdateThread, }: { thread: MailThread folderId: string baseFolderId?: string active?: boolean composeSearch?: ComposeThreadSearch navActive: boolean onUpdateThread?: (input: { threadId: string; starred: boolean }) => Promise }) { const [starred, setStarred] = useState(thread.starred) const [starPending, setStarPending] = useState(false) useEffect(() => { setStarred(thread.starred) }, [thread.starred]) async function toggleStar() { /* v8 ignore next -- the star control is disabled while its request is pending -- @preserve */ if (starPending) return const nextStarred = !starred setStarred(nextStarred) setStarPending(true) try { if (onUpdateThread) await onUpdateThread({ threadId: thread.id, starred: nextStarred }) else { // Compatibility seam for the isolated screen tests; the production route // always supplies the centralized optimistic mutation gateway. await updateThreadState({ data: { threadId: thread.id, starred: nextStarred } }) } } catch { /* v8 ignore next -- @preserve a failed optimistic mutation restores the rendered value before re-enabling the control */ setStarred(!nextStarred) } finally { setStarPending(false) } } const optimisticThread = starred === thread.starred ? thread : { ...thread, starred } const className = cn(THREAD_ROW_CLASS, optimisticThread.unread && 'bg-card/80') const rowState = { 'data-active': active ? ('true' as const) : undefined, 'data-nav-row': '', 'data-nav-cursor': navActive ? ('true' as const) : undefined, 'data-unread': optimisticThread.unread ? ('true' as const) : undefined, } if (composeSearch) { return (
) } return (
) }