Something went wrong. Try again.
ATProto-based AI Assistant Chat interface.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255import { keepPreviousData, type QueryClient, useInfiniteQuery, useQuery, useQueryClient,} from '@tanstack/react-query'import { createContext, type ReactNode, useContext, useEffect, useSyncExternalStore } from 'react'import { MeContext } from '../features/auth/session.ts'import { reportUnauthorized } from '../shared/api.ts'import { messageOf } from '../shared/errors.ts'import { openStore, type StoreClient } from './client.ts'import type { ChatCursor, ChatListItem, Conversation, SyncStatus, TagCount } from './core.ts'import { isStoreClosed } from './errors.ts'import type { HandoverState } from './handover.ts'import type { SearchResult } from './search.ts'
const StoreContext = createContext<StoreClient | null>(null)
interface Props { store: StoreClient children: ReactNode}
/** * Provide the store, and turn its change events into stale queries. Its sync finding the session * has ended is reported as a request finding it would be. */export function StoreProvider(props: Props) { const queryClient = useQueryClient() useEffect( () => props.store.onChange((change) => { if (change.type === 'unauthorized') return reportUnauthorized() // A conversation's messages decide whether it shows as replying or new in the list. if (change.type === 'index' || change.type === 'conversation') void queryClient.invalidateQueries({ queryKey: ['conversations'] }) // Exactly the local read, not the refresh from the PDS under the same prefix, which a // change would otherwise set off again. if (change.type === 'conversation') void queryClient.invalidateQueries({ queryKey: ['conversation', change.skey], exact: true, }) void queryClient.invalidateQueries({ queryKey: ['sync-status'] }) // Any change can add results or finish a download. void queryClient.invalidateQueries({ queryKey: ['search'] }) }), [props.store, queryClient], ) return <StoreContext.Provider value={props.store}>{props.children}</StoreContext.Provider>}
/** * The user's store for an action outside the chat routes, like signing out: the provided one, or * opened when the action runs, so a page like settings does not take the store from another tab * just by being open. */export function useOpenStore(): () => StoreClient { const provided = useContext(StoreContext) const me = useContext(MeContext) return () => { if (provided) return provided if (!me) throw new Error('Opening the store needs a signed-in user') return openStore(me.did) }}
export function useStore(): StoreClient { const store = useContext(StoreContext) if (!store) throw new Error('useStore needs a StoreProvider') return store}
export function useStoreState(): HandoverState { const store = useStore() return useSyncExternalStore(store.onState, store.state)}
/** * The store, whether this tab holds it, which reading it waits for, and whether it is still * opening here. A read that waits on the store opening is loading, as a query's first fetch is, * while one that waits on another tab is not. */function useActiveStore() { const store = useStore() const state = useStoreState() return { store, active: state === 'active', opening: state === 'waiting' }}
/** A store failure's message, except for the store moving to another tab. */const storeError = (err: unknown) => (!err || isStoreClosed(err) ? null : messageOf(err))
/** * The chats, newest first, a page at a time, and only those tagged `tag` when given. A change in * the store refetches the pages already loaded, so the list keeps its length as it updates. */export function useConversations(tag: string | null = null): { conversations: ChatListItem[] /** Whether the list is on its way from the local copy for the first time. */ isLoading: boolean hasNextPage: boolean isFetchingNextPage: boolean fetchNextPage: () => void error: string | null} { const { store, active, opening } = useActiveStore() const query = useInfiniteQuery({ queryKey: ['conversations', 'list', tag], queryFn: ({ pageParam }) => store.calls.listConversations({ after: pageParam, tag }), initialPageParam: null as ChatCursor | null, getNextPageParam: (page) => page.next ?? undefined, enabled: active, // Picking a tag keeps the rows shown until its chats arrive, rather than a skeleton between. placeholderData: keepPreviousData, }) return { conversations: query.data?.pages.flatMap((page) => page.chats) ?? [], isLoading: opening || query.isLoading, hasNextPage: query.hasNextPage, isFetchingNextPage: query.isFetchingNextPage, fetchNextPage: () => void query.fetchNextPage(), error: storeError(query.error), }}
/** Every tag across the chats, newest use first, with how many chats carry it. */export function useTags(): TagCount[] { const { store, active } = useActiveStore() const { data } = useQuery({ queryKey: ['conversations', 'tags'], queryFn: () => store.calls.listTags(), enabled: active, }) return data ?? []}
/** * Every chat's newest finished reply, by chat key, including chats on pages not loaded yet, or * null until the local copy answers. */export function useLatestReplies(): Record<string, string | null> | null { const { store, active } = useActiveStore() const { data } = useQuery({ queryKey: ['conversations', 'replies'], queryFn: async () => Object.fromEntries( (await store.calls.latestReplies()).map((chat) => [chat.skey, chat.latestReply]), ), enabled: active, }) return data ?? null}
/** The query that refreshes a conversation from the PDS. Invalidating it refreshes again. */const conversationRefreshKey = (skey: string) => ['conversation', skey, 'refresh']
/** * Check an open conversation against the PDS again, as opening it does: fetch what changed, then * make sure nothing was missed. Resolves once the check is done. It reruns the open conversation's * own check, so a conversation that isn't open, with nothing reading it, isn't checked. For code * that holds a query client rather than a hook, like a query's own function. */export const refreshConversation = (client: QueryClient, skey: string) => client.invalidateQueries({ queryKey: conversationRefreshKey(skey) })
/** `refreshConversation`, for a component. */export function useRefreshConversation(): (skey: string) => Promise<void> { const client = useQueryClient() return (skey) => refreshConversation(client, skey)}
/** Sync the chat index now, as when another client may have changed it. */export function useSyncIndex(): () => Promise<void> { const store = useStore() return () => store.calls.syncIndex()}
/** A conversation from the local copy, shown at once and refreshed from the PDS when opened. */export function useConversation(skey: string): { conversation: Conversation | null /** * Whether its messages are on their way: the local copy hasn't answered, or it has none and the * PDS hasn't answered yet, so a conversation that has messages never shows as empty. */ waiting: boolean /** Whether the PDS has confirmed the conversation has no messages. */ empty: boolean error: string | null} { const { store, active, opening } = useActiveStore() const local = useQuery({ queryKey: ['conversation', skey], queryFn: () => store.calls.getConversation(skey), enabled: active, }) // The refresh's changes arrive as store events, which update the local query. const refreshed = useQuery({ queryKey: conversationRefreshKey(skey), queryFn: async () => { await store.calls.refreshConversation(skey) await store.calls.reconcileConversation(skey) return true }, enabled: active, }) const none = (local.data?.messages.length ?? 0) === 0 return { conversation: local.data ?? null, waiting: opening || local.isLoading || (none && refreshed.isLoading), empty: none && refreshed.isSuccess, error: storeError(local.error ?? refreshed.error), }}
/** Search the local copy, rerunning as the download and live changes add to it. */export function useChatSearch(query: string): { results: SearchResult[] remaining: number /** Whether the first search is on its way. Later ones keep the last results while they run. */ isLoading: boolean error: string | null} { const { store, active, opening } = useActiveStore() const { data, isLoading, error } = useQuery({ queryKey: ['search', query], queryFn: async () => { const [results, remaining] = await Promise.all([ store.calls.search(query), store.calls.remainingDownloads(), ]) return { results, remaining } }, enabled: active && query.trim() !== '', placeholderData: keepPreviousData, }) return { results: data?.results ?? [], remaining: data?.remaining ?? 0, isLoading: opening || isLoading, error: storeError(error), }}
/** How current this device's copy is, updated as the store syncs and downloads. */export function useSyncStatus(): SyncStatus | null { const { store, active } = useActiveStore() const { data } = useQuery({ queryKey: ['sync-status'], queryFn: () => store.calls.syncStatus(), enabled: active, }) return data ?? null}