import type { Contact } from '@nylas-labs/cli-kit/v3' import { createFileRoute, Link, Outlet, useNavigate, useRouterState } from '@tanstack/react-router' import { Loader2, Menu, Plus, Search } from 'lucide-react' import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { AppRailLogo, AppRailMobileNav, AppRailNav, type MailboxAccountOption } from '#app/components/AppRail' import { CommandPalette, useCommandPaletteShortcut } from '#app/components/CommandPalette' import { MobileTabBar } from '#app/components/MobileTabBar' import { CHROME_ROW_CLASS, CHROME_ROW_SHELL_CLASS } from '#app/config/layout' import { contactDisplayName, contactIdFromPath, contactSubtitle, filterContacts, sortContacts, } from '#features/contacts/lib/contacts-model' import { flattenContactPages, useContactsPages } from '#features/contacts/state/contacts-state' import { getContacts, getMailboxInfo } from '#server/fns' import { PullToRefresh, RefreshButton } from '#shared/components/PullToRefresh' import { Sheet } from '#shared/components/Sheet' import { edgeCursor, listNavAction, moveCursor } from '#shared/lib/list-nav' import { initials } from '#shared/lib/presentation' import { cn } from '#shared/lib/utils' export const Route = createFileRoute('/contacts')({ validateSearch: (search): { q?: string } => typeof search.q === 'string' && search.q ? { q: search.q } : {}, loader: async () => { const [info, page] = await Promise.all([getMailboxInfo(), getContacts({ data: {} })]) return { info, contacts: page.contacts, ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}) } }, staleTime: 30_000, component: ContactsLayout, }) type ContactsInfo = { email: string displayName?: string appName: string accounts?: MailboxAccountOption[] } function ContactsLayout() { const { info, contacts, nextCursor } = Route.useLoaderData() const initialPage = useMemo( () => ({ contacts, ...(nextCursor ? { nextCursor } : {}) }), [contacts, nextCursor], ) const contactsQuery = useContactsPages(initialPage) const { q } = Route.useSearch() const navigate = useNavigate() const pathname = useRouterState({ select: (state) => state.location.pathname }) async function loadMoreContacts() { await contactsQuery.fetchNextPage({ cancelRefetch: false }) } return ( contactsQuery.refetch({ throwOnError: true })} query={q ?? ''} selectedId={contactIdFromPath(pathname)} onQueryChange={(next) => navigate({ to: '/contacts', search: next ? { q: next } : {}, replace: true })} /> ) } export function ContactsShell({ info, contacts, nextCursor: initialCursor, query, selectedId, onQueryChange, loadingMore: controlledLoadingMore, loadMoreError: controlledLoadMoreError, onLoadMore, onRefresh, }: { info: ContactsInfo contacts: Contact[] nextCursor?: string query: string selectedId?: string onQueryChange: (query: string) => void loadingMore?: boolean loadMoreError?: boolean onLoadMore?: () => Promise onRefresh?: () => Promise }) { const [extra, setExtra] = useState([]) const [nextCursor, setNextCursor] = useState(initialCursor) const [localLoadingMore, setLocalLoadingMore] = useState(false) const [localLoadMoreError, setLocalLoadMoreError] = useState(false) const loadingMore = Boolean(controlledLoadingMore || localLoadingMore) const loadMoreFailed = !loadingMore && Boolean(controlledLoadMoreError || localLoadMoreError) const [paletteOpen, setPaletteOpen] = useState(false) const [navigationOpen, setNavigationOpen] = useState(false) const [cursor, setCursor] = useState(-1) const loadMorePendingRef = useRef(false) const listScrollRef = useRef(null) const listGenerationRef = useRef({ contacts, initialCursor, generation: 0 }) if ( listGenerationRef.current.contacts !== contacts || listGenerationRef.current.initialCursor !== initialCursor ) { listGenerationRef.current = { contacts, initialCursor, generation: listGenerationRef.current.generation + 1, } } const openPalette = useCallback(() => setPaletteOpen(true), []) const closePalette = useCallback(() => setPaletteOpen(false), []) useCommandPaletteShortcut(openPalette) // A fresh loader run (after a mutation) replaces `contacts`; drop the paged-in // extras and reset the cursor so we don't show stale or duplicated rows. The // `contacts` dep is the trigger even though the body doesn't read it. // biome-ignore lint/correctness/useExhaustiveDependencies: reset when a new contacts page arrives useEffect(() => { loadMorePendingRef.current = false setExtra([]) setNextCursor(initialCursor) setLocalLoadingMore(false) setLocalLoadMoreError(false) }, [contacts, initialCursor]) const all = useMemo(() => sortContacts(dedupeContacts([...contacts, ...extra])), [contacts, extra]) const filtered = useMemo(() => filterContacts(all, query), [all, query]) // Preserve the active search when following a contact link so the list stays filtered. const linkSearch = query ? { q: query } : {} // Contacts is an arrow-key list as well as a set of ordinary tab stops. /* v8 ignore start -- list navigation is exercised through the shared pure helpers -- @preserve */ useEffect(() => { setCursor(selectedId ? filtered.findIndex((contact) => contact.id === selectedId) : -1) }, [filtered, selectedId]) 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.metaKey || event.ctrlKey || event.altKey || target?.closest?.('button, a, select') ) return if (document.querySelector('[role="dialog"]')) return const action = listNavAction(event.key) if (!action) return event.preventDefault() if (action === 'open') { const contact = filtered[cursor] if (contact) { const element = Array.from(document.querySelectorAll('[data-contact-id]')).find( (link) => link.dataset.contactId === contact.id, ) element?.click() } return } setCursor((current) => action === 'first' || action === 'last' ? edgeCursor(action, filtered.length) : moveCursor(current, action === 'down' ? 1 : -1, filtered.length), ) } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) }, [cursor, filtered]) /* v8 ignore stop -- @preserve */ async function loadMore() { if (!nextCursor || loadMorePendingRef.current || loadingMore) return const actionGeneration = listGenerationRef.current.generation loadMorePendingRef.current = true setLocalLoadMoreError(false) setLocalLoadingMore(true) try { if (onLoadMore) { await onLoadMore() return } const res = await getContacts({ data: { pageToken: nextCursor } }) if (listGenerationRef.current.generation !== actionGeneration) return setExtra((prev) => [...prev, ...res.contacts]) setNextCursor(res.nextCursor) } catch { if (listGenerationRef.current.generation === actionGeneration) setLocalLoadMoreError(true) } finally { if (listGenerationRef.current.generation === actionGeneration) { loadMorePendingRef.current = false setLocalLoadingMore(false) } } } const paginationControls = nextCursor ? (
{loadMoreFailed ? ( ) : null}
) : null const railNavProps = { email: info.email, displayName: info.displayName, accounts: info.accounts, active: 'contacts' as const, onOpenCommandPalette: openPalette, } const contactsList = filtered.length === 0 ? ( {paginationControls} ) : ( <>
    {filtered.map((contact, index) => (
  • ))}
{paginationControls} ) return (
onQueryChange(event.target.value)} placeholder="Search contacts" className="h-full w-full border-0 bg-transparent py-2 pr-3 pl-7 text-sm text-foreground placeholder:text-muted-foreground" aria-label="Search contacts" autoCapitalize="none" />
{onRefresh ? : null} New contact
{onRefresh ? ( {contactsList} ) : ( contactsList )}
setNavigationOpen(false)} title="Navigation"> setNavigationOpen(false)} showDestinations={false} />
) } function dedupeContacts(contacts: Contact[]): Contact[] { return [...new Map(contacts.map((contact) => [contact.id, contact])).values()] } function ContactsEmptyState({ query, moreAvailable, children, }: { query: string moreAvailable: boolean children?: ReactNode }) { return (

{query ? 'No contacts match your search.' : moreAvailable ? 'More contacts may be available' : 'No contacts yet.'}

{moreAvailable ? (

Load the next page to keep looking.

) : null} {children}
) } function ContactListItem({ contact, active, keyboardActive, search, }: { contact: Contact active: boolean keyboardActive: boolean search: { q?: string } }) { const name = contactDisplayName(contact) const subtitle = contactSubtitle(contact) return ( {name} {subtitle ? {subtitle} : null} ) } export function ContactAvatar({ name, className }: { name: string; className?: string }) { return ( ) }