import type { Message, Thread } from '@nylas-labs/cli-kit/v3' import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query' import { createFileRoute, Link, useRouter } from '@tanstack/react-router' import { Archive, ArrowLeft, Forward, Inbox, Loader2, Reply, ReplyAll, Star, Trash2 } from 'lucide-react' import { useEffect, useMemo, useRef, useState } from 'react' import { ThreadConversation } from '#features/mail/components/ThreadConversation' import { MobileThreadResponseActions } from '#features/mail/components/ThreadResponseActions' import { THREAD_ROW_CLASS, THREAD_ROW_LINK_CLASS, ThreadRowContent, threadRowLinkLabel, } from '#features/mail/components/ThreadRow' import { forwardDraftSearch, mailFolderTitle, replyAllDraftSearch, replyDraftSearch, STAR_FILLED_CLASS, searchListSearch, threadRouteFolderId, threadTimestamp, } from '#features/mail/lib/mail-ui-model' import { applyMailCacheEffect } from '#features/mail/state/mail-cache' import { useUpdateThreadMutation } from '#features/mail/state/mail-mutations' import { foldersQueryOptions, threadDetailQueryOptions, threadListQueryOptions, toMailFolder, toMailThread, toMailThreadDetail, } from '#features/mail/state/mail-queries' import { getFolders, getThreadMessages, getThreads } from '#server/fns' import { edgeCursor, listNavAction, moveCursor } from '#shared/lib/list-nav' import { cn } from '#shared/lib/utils' type PendingSearchThreadAction = 'archive' | 'restore' | 'delete' | 'star' export const Route = createFileRoute('/mail/search')({ validateSearch: (search): { q: string; folderId?: string; threadId?: string } => ({ q: String(search.q ?? ''), ...(typeof search.folderId === 'string' ? { folderId: search.folderId } : {}), ...(typeof search.threadId === 'string' ? { threadId: search.threadId } : {}), }), loaderDeps: ({ search }) => ({ q: search.q, folderId: search.folderId, threadId: search.threadId }), loader: async ({ deps }) => { const hasSearchQuery = deps.q.trim().length > 0 const emptyResults: Awaited> = { threads: [] } const [folders, res, selected] = await Promise.all([ getFolders(), hasSearchQuery ? getThreads({ data: { q: deps.q, ...(deps.folderId === 'starred' ? { starred: true } : deps.folderId ? { folderId: deps.folderId } : {}), }, }) : Promise.resolve(emptyResults), hasSearchQuery && deps.threadId ? getThreadMessages({ data: { threadId: deps.threadId } }) : null, ]) return { ...res, folders, folderId: deps.folderId, selected } }, component: SearchResults, }) function SearchResults() { const initial = Route.useLoaderData() const { q, threadId } = Route.useSearch() const hasSearchQuery = q.trim().length > 0 const router = useRouter() const queryClient = useQueryClient() const filters = { q, ...(initial.folderId === 'starred' ? { starred: true } : initial.folderId ? { folderId: initial.folderId } : {}), } const foldersQuery = useQuery({ ...foldersQueryOptions( /* v8 ignore next -- @preserve production query wiring is covered through the isolated search screen and query-option tests */ () => getFolders(), ), initialData: initial.folders.map(toMailFolder), }) const threadsQuery = useInfiniteQuery({ ...threadListQueryOptions( filters, /* v8 ignore next -- @preserve production query wiring is covered through the isolated search screen and query-option tests */ (input) => getThreads({ data: input }), ), enabled: hasSearchQuery, initialData: { pages: [ { threads: initial.threads.map(toMailThread), ...(initial.nextCursor ? { nextCursor: initial.nextCursor } : {}), }, ], pageParams: [undefined], }, }) const selectedQuery = useQuery({ ...threadDetailQueryOptions(threadId ?? '__no-selected-thread__', (id) => getThreadMessages({ data: { threadId: id } }), ), ...(initial.selected ? { initialData: toMailThreadDetail(initial.selected) } : {}), enabled: hasSearchQuery && Boolean(threadId), }) const threads = useMemo( () => hasSearchQuery ? ([ ...new Map( threadsQuery.data.pages.flatMap((page) => page.threads).map((thread) => [thread.id, thread]), ).values(), ] as Thread[]) : [], [hasSearchQuery, threadsQuery.data.pages], ) const folders = foldersQuery.data const folderId = initial.folderId const selected = hasSearchQuery ? (selectedQuery.data as typeof initial.selected) : null const [cursor, setCursor] = useState(-1) const listScrollRef = useRef(null) const moveFocusToCursorRef = useRef(false) const sortedThreads = useMemo( () => [...threads].sort((a, b) => (threadTimestamp(b) ?? 0) - (threadTimestamp(a) ?? 0)), [threads], ) const unreadCount = sortedThreads.filter((thread) => thread.unread).length const title = folderId ? mailFolderTitle(folderId, folders) : 'Search results' const canLoadMore = hasSearchQuery && threadsQuery.hasNextPage async function loadMoreSearchResults() { try { await threadsQuery.fetchNextPage({ cancelRefetch: false }) } catch { // The query state renders generic retry guidance; never expose provider details. } } /* v8 ignore start -- list navigation is exercised through the shared pure helpers -- @preserve */ useEffect(() => { setCursor(threadId ? sortedThreads.findIndex((thread) => thread.id === threadId) : -1) }, [sortedThreads, threadId]) 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]) 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]') as HTMLElement | null | undefined const focusedRowIndex = focusedRow ? Array.from(listScrollRef.current?.querySelectorAll('[data-nav-row]') ?? []).indexOf( focusedRow, ) : -1 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') { const thread = sortedThreads[focusedRowIndex >= 0 ? focusedRowIndex : cursor] if (thread) { router.navigate({ to: '/mail/search', search: { q, ...(folderId ? { folderId } : {}), threadId: thread.id }, }) } return } if (focusedRowIndex >= 0) moveFocusToCursorRef.current = true setCursor((current) => action === 'first' || action === 'last' ? edgeCursor(action, sortedThreads.length) : moveCursor( focusedRowIndex >= 0 ? focusedRowIndex : current, action === 'down' ? 1 : -1, sortedThreads.length, ), ) } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) }, [cursor, folderId, q, router, sortedThreads]) /* v8 ignore stop -- @preserve */ useEffect(() => { if (selected?.markedRead) { applyMailCacheEffect(queryClient, { type: 'thread.read', threadId: selected.thread.id, unread: false, thread: selected.thread, }) } }, [queryClient, selected]) return ( <>

{title}

{unreadCount > 0 ? ( {unreadCount} ) : null}
{sortedThreads.length === 0 ? (

{hasSearchQuery ? canLoadMore ? 'More messages may be available' : 'No messages found' : 'Search your mail'}

{hasSearchQuery ? canLoadMore ? 'Load the next page to continue searching.' : 'Try different keywords or clear the search.' : 'Enter keywords above to find messages.'}

) : ( sortedThreads.map((thread) => ( )) )} {canLoadMore ? (
{threadsQuery.isFetchNextPageError ? ( ) : null}
) : null}
{selected ? ( ) : (

Select a conversation

Choose a message from the list to read it here.

)}
) } function SearchThreadRow({ thread, q, searchFolderId, active, keyboardActive, }: { thread: Awaited>['threads'][number] q: string searchFolderId?: string active: boolean keyboardActive: boolean }) { const folderId = threadRouteFolderId(thread) const updateThread = useUpdateThreadMutation() 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 { await updateThread.mutateAsync({ 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 } return (
) } function SearchThreadDetail({ selected, q, folderId, }: { selected: { thread: Thread; messages: Message[]; mailboxEmail: string } q: string folderId?: string }) { const router = useRouter() const updateThread = useUpdateThreadMutation() const routeFolderId = threadRouteFolderId(selected.thread) const lastMessage = selected.messages.at(-1) const searchList = useMemo(() => searchListSearch(q, folderId), [folderId, q]) const isArchived = folderId === 'archive' || selected.thread.folders?.includes('archive') === true const [error, setError] = useState(null) const [starred, setStarred] = useState(selected.thread.starred) const [pendingAction, setPendingAction] = useState(null) const pendingActionRef = useRef(null) const currentReaderRef = useRef(true) useEffect(() => { currentReaderRef.current = true return () => { currentReaderRef.current = false } }, []) useEffect(() => setStarred(selected.thread.starred), [selected.thread.starred]) const reply = () => { /* v8 ignore next -- every exposed search reply entry point requires a latest message -- @preserve */ if (!lastMessage) return router.navigate({ to: '/mail/compose', search: { folderId: routeFolderId, threadId: selected.thread.id, ...replyDraftSearch(lastMessage), }, }) } const replyAll = () => { /* v8 ignore next -- every exposed search reply-all entry point requires a latest message -- @preserve */ if (!lastMessage) return router.navigate({ to: '/mail/compose', search: { folderId: routeFolderId, threadId: selected.thread.id, ...replyAllDraftSearch(lastMessage, selected.mailboxEmail), }, }) } const forward = () => { /* v8 ignore next -- every exposed search forward entry point requires a latest message -- @preserve */ if (!lastMessage) return router.navigate({ to: '/mail/compose', search: { folderId: routeFolderId, threadId: selected.thread.id, ...forwardDraftSearch(lastMessage), }, }) } useEffect(() => { function onKeyDown(event: KeyboardEvent) { const target = event.target as HTMLElement | null const isTyping = target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA' || target?.isContentEditable if (isTyping || event.repeat || event.metaKey || event.ctrlKey || event.altKey) return if (event.key === 'Escape') { event.preventDefault() router.navigate({ to: '/mail/search', search: searchList, }) } } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) }, [router, searchList]) async function act( action: PendingSearchThreadAction, input: { starred?: boolean; folder?: string }, leave = false, ) { if (pendingActionRef.current) return pendingActionRef.current = action setError(null) const previousStarred = starred if (typeof input.starred === 'boolean') setStarred(input.starred) setPendingAction(action) try { await updateThread.mutateAsync({ threadId: selected.thread.id, ...input }) if (!currentReaderRef.current) return if (leave) { await router.navigate({ to: '/mail/search', search: searchList, }) } } catch { if (!currentReaderRef.current) return if (typeof input.starred === 'boolean') setStarred(previousStarred) setError('Action failed') } finally { pendingActionRef.current = null if (currentReaderRef.current) setPendingAction(null) } } return (
act(isArchived ? 'restore' : 'archive', { folder: isArchived ? 'inbox' : 'archive' }, true) } > {pendingAction === 'archive' || pendingAction === 'restore' ? ( ) : isArchived ? ( ) : ( )} act('delete', { folder: 'trash' }, true)} > {pendingAction === 'delete' ? ( ) : ( )} act('star', { starred: !starred })} > {pendingAction === 'star' ? ( ) : ( )} {lastMessage ? (
) : null}
{error ? (

{error}

) : null}
{lastMessage ? (
) : null}
) } function IconButton({ label, onClick, disabled = false, loading = false, children, }: { label: string onClick?: () => void disabled?: boolean loading?: boolean children: React.ReactNode }) { return ( ) } function ActionButton({ label, onClick, children, }: { label: string onClick?: () => void children: React.ReactNode }) { return ( ) }