import { 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(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 {props.children} } /** * 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 | 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 { 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 { 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 }