import type { Message, Thread } from '@nylas-labs/cli-kit/v3' import { useQuery, useQueryClient } from '@tanstack/react-query' import { createFileRoute, useNavigate } from '@tanstack/react-router' import { Archive, ArrowLeft, Forward, Inbox, Loader2, MailOpen, Reply, ReplyAll, Star, Trash2, } from 'lucide-react' import { useCallback, useEffect, useState } from 'react' import { ThreadConversation } from '#features/mail/components/ThreadConversation' import { MobileThreadResponseActions } from '#features/mail/components/ThreadResponseActions' import { forwardDraftSearch, replyAllDraftSearch, replyDraftSearch, STAR_FILLED_CLASS, } from '#features/mail/lib/mail-ui-model' import { applyMailCacheEffect } from '#features/mail/state/mail-cache' import { useUpdateThreadMutation } from '#features/mail/state/mail-mutations' import { type MailThreadDetail, threadDetailQueryOptions, toMailThreadDetail, } from '#features/mail/state/mail-queries' import { getThreadMessages } from '#server/fns' import { ScrollArea } from '#shared/components/ui/scroll-area' import { useHorizontalSwipe } from '#shared/hooks/use-horizontal-swipe' import { cn } from '#shared/lib/utils' export const Route = createFileRoute('/mail/f/$folderId/t/$threadId')({ validateSearch: (search): { baseFolderId?: string } => ({ ...(typeof search.baseFolderId === 'string' ? { baseFolderId: search.baseFolderId } : {}), }), loader: async ({ context, params }) => context.queryClient.ensureQueryData( threadDetailQueryOptions(params.threadId, (threadId) => getThreadMessages({ data: { threadId } })), ), component: ThreadView, }) type PendingThreadAction = 'archive' | 'delete' | 'star' | 'unread' function normalizeInitialThreadDetail( detail: | MailThreadDetail | { thread: Thread messages: Message[] mailboxEmail: string markedRead?: boolean ownmailDraftMessageIds?: string[] }, ): MailThreadDetail { return 'ownmailDraftMessageIds' in detail ? toMailThreadDetail(detail) : detail } const REPLY_SHORTCUT_INTERACTIVE_SELECTOR = [ 'input', 'textarea', 'select', 'button', 'a', 'summary', '[contenteditable]:not([contenteditable="false"])', '[role="button"]', '[role="link"]', '[role="textbox"]', '[role="combobox"]', '[role="menuitem"]', '[role="option"]', '[role="switch"]', '[role="tab"]', 'ownmail-email', ].join(',') function shouldIgnoreReplyShortcut(event: KeyboardEvent): boolean { if (event.defaultPrevented) return true const composedPath = event.composedPath() const path = composedPath.length > 0 ? composedPath : [event.target] return path.some( (node) => node instanceof HTMLElement && (node.isContentEditable || node.matches(REPLY_SHORTCUT_INTERACTIVE_SELECTOR)), ) } function ThreadView() { const initialDetail = Route.useLoaderData() const { folderId, threadId } = Route.useParams() const { baseFolderId } = Route.useSearch() const queryClient = useQueryClient() const { data: detail } = useQuery({ ...threadDetailQueryOptions( threadId, /* v8 ignore next -- @preserve production query wiring is covered through the isolated route screen and query-option tests */ (id) => getThreadMessages({ data: { threadId: id } }), ), initialData: normalizeInitialThreadDetail(initialDetail), }) const { thread, messages, mailboxEmail, markedRead } = detail const updateThread = useUpdateThreadMutation() const navigate = useNavigate() const [error, setError] = useState(null) const [starred, setStarred] = useState(thread.starred) const [pendingAction, setPendingAction] = useState(null) const goBackToList = useCallback( () => navigate({ to: '/mail/f/$folderId', params: { folderId }, search: baseFolderId ? { baseFolderId } : {}, }), [baseFolderId, folderId, navigate], ) const swipeHandlers = useHorizontalSwipe(goBackToList) const lastMessage = messages.at(-1) const isArchived = folderId === 'archive' || thread.folders?.includes('archive') === true const reply = useCallback(() => { /* v8 ignore next -- every exposed reply entry point requires a latest message -- @preserve */ if (!lastMessage) return navigate({ to: '/mail/compose', search: { folderId, threadId, ...replyDraftSearch(lastMessage) }, }) }, [folderId, lastMessage, navigate, threadId]) const replyAll = useCallback(() => { /* v8 ignore next -- every exposed reply-all entry point requires a latest message -- @preserve */ if (!lastMessage) return navigate({ to: '/mail/compose', search: { folderId, threadId, ...replyAllDraftSearch(lastMessage, mailboxEmail), }, }) }, [folderId, lastMessage, mailboxEmail, navigate, threadId]) const forward = useCallback(() => { /* v8 ignore next -- every exposed forward entry point requires a latest message -- @preserve */ if (!lastMessage) return navigate({ to: '/mail/compose', search: { folderId, threadId, ...forwardDraftSearch(lastMessage) }, }) }, [folderId, lastMessage, navigate, threadId]) useEffect(() => { setStarred(thread.starred) }, [thread.starred]) const act = useCallback( async ( action: PendingThreadAction, input: { unread?: boolean; starred?: boolean; folder?: string }, leave = false, ) => { /* v8 ignore next -- every toolbar action is disabled while the request is pending -- @preserve */ if (pendingAction) return setError(null) const previousStarred = starred if (typeof input.starred === 'boolean') setStarred(input.starred) setPendingAction(action) try { await updateThread.mutateAsync({ threadId, ...input }) if (leave) { await navigate({ to: '/mail/f/$folderId', params: { folderId }, search: baseFolderId ? { baseFolderId } : {}, }) } } catch { /* v8 ignore start -- a failed optimistic star action restores the previous state before surfacing its error -- @preserve */ if (typeof input.starred === 'boolean') setStarred(previousStarred) setError('Action failed') /* v8 ignore stop -- @preserve */ } finally { setPendingAction(null) } }, [baseFolderId, folderId, navigate, pendingAction, starred, threadId, updateThread], ) useEffect(() => { function onKeyDown(event: KeyboardEvent) { const key = event.key.toLowerCase() if (key === 'r') { const isModified = event.repeat || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey if (isModified || shouldIgnoreReplyShortcut(event)) return if (document.querySelector('[role="dialog"]') || !lastMessage) return event.preventDefault() reply() return } 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 (document.querySelector('[role="dialog"]')) return if (key === 'e') { event.preventDefault() act('archive', { folder: isArchived ? 'inbox' : 'archive' }, true) } if (event.key === '#') { event.preventDefault() act('delete', { folder: 'trash' }, true) } if (key === 's') { event.preventDefault() act('star', { starred: !starred }) } if (key === 'u') { event.preventDefault() act('unread', { unread: true }, true) } if (event.key === 'Escape') { event.preventDefault() navigate({ to: '/mail/f/$folderId', params: { folderId }, search: baseFolderId ? { baseFolderId } : {}, }) } } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) }, [act, baseFolderId, folderId, isArchived, lastMessage, navigate, reply, starred]) useEffect(() => { if (markedRead) { applyMailCacheEffect(queryClient, { type: 'thread.read', threadId, unread: false, thread }) } }, [markedRead, queryClient, thread, threadId]) return (
{ if (event.touches.length > 1) swipeHandlers.onTouchCancel() }} onTouchEnd={(event) => { if (event.touches.length > 0) { swipeHandlers.onTouchCancel() return } swipeHandlers.onTouchEnd(event) }} data-testid="thread-reader" className="flex min-h-0 min-w-0 flex-1 flex-col bg-background" style={{ touchAction: 'pan-y pinch-zoom' }} >
act('archive', { folder: isArchived ? 'inbox' : 'archive' }, true)} > {pendingAction === 'archive' ? ( ) : isArchived ? ( ) : ( )} act('delete', { folder: 'trash' }, true)} > {pendingAction === 'delete' ? ( ) : ( )} act('star', { starred: !starred })} > {pendingAction === 'star' ? ( ) : ( )} act('unread', { unread: true }, true)} > {pendingAction === 'unread' ? ( ) : ( )} {lastMessage ? (
) : null}
{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 ( ) } export function ErrorBanner({ message }: { message: string }) { const isQuota = message.startsWith('QUOTA:') return (

{isQuota ? message.slice(6).trim() : message}

) }