import { useEffect } from 'react'; import z from 'zod'; import { getNextLink, getPrevLink } from 'soapbox/api'; import { useApi, useAppDispatch, useAppSelector, useGetState } from 'soapbox/hooks'; import { filteredArray } from 'soapbox/schemas/utils'; import { realNumberSchema } from 'soapbox/utils/numbers'; import { entitiesFetchFail, entitiesFetchRequest, entitiesFetchSuccess, invalidateEntityList } from '../actions'; import { parseEntitiesPath } from './utils'; import type { Entity, EntityListState } from '../types'; import type { EntitiesPath, EntityFn, EntitySchema, ExpandedEntitiesPath } from './types'; import type { RootState } from 'soapbox/store'; /** Additional options for the hook. */ interface UseEntitiesOpts { /** A zod schema to parse the API entities. */ schema?: EntitySchema /** * Time (milliseconds) until this query becomes stale and should be refetched. * It is 1 minute by default, and can be set to `Infinity` to opt-out of automatic fetching. */ staleTime?: number /** A flag to potentially disable sending requests to the API. */ enabled?: boolean } /** A hook for fetching and displaying API entities. */ function useEntities( /** Tells us where to find/store the entity in the cache. */ expandedPath: ExpandedEntitiesPath, /** API route to GET, eg `'/api/v1/notifications'`. If undefined, nothing will be fetched. */ entityFn: EntityFn, /** Additional options for the hook. */ opts: UseEntitiesOpts = {}, ) { const api = useApi(); const dispatch = useAppDispatch(); const getState = useGetState(); const { entityType, listKey, path } = parseEntitiesPath(expandedPath); const entities = useAppSelector(state => selectEntities(state, path)); const isEnabled = opts.enabled ?? true; const isFetching = useListState(path, 'fetching'); const lastFetchedAt = useListState(path, 'lastFetchedAt'); const isFetched = useListState(path, 'fetched'); const isError = !!useListState(path, 'error'); const totalCount = useListState(path, 'totalCount'); const isInvalid = useListState(path, 'invalid'); const next = useListState(path, 'next'); const prev = useListState(path, 'prev'); const fetchPage = async(req: EntityFn, overwrite = false): Promise => { // Get `isFetching` state from the store again to prevent race conditions. const isFetching = selectListState(getState(), path, 'fetching'); if (isFetching) return; dispatch(entitiesFetchRequest(entityType, listKey)); try { const response = await req(); const schema = opts.schema || z.custom(); const entities = filteredArray(schema).parse(response.data); const parsedCount = realNumberSchema.safeParse(response.headers['x-total-count']); dispatch(entitiesFetchSuccess(entities, entityType, listKey, { next: getNextLink(response), prev: getPrevLink(response), totalCount: parsedCount.success ? parsedCount.data : undefined, fetching: false, fetched: true, error: null, lastFetchedAt: new Date(), invalid: false, }, overwrite)); } catch (error) { dispatch(entitiesFetchFail(entityType, listKey, error)); } }; const fetchEntities = async(): Promise => { await fetchPage(entityFn, true); }; const fetchNextPage = async(): Promise => { if (next) { await fetchPage(() => api.get(next)); } }; const fetchPreviousPage = async(): Promise => { if (prev) { await fetchPage(() => api.get(prev)); } }; const invalidate = () => { dispatch(invalidateEntityList(entityType, listKey)); }; const staleTime = opts.staleTime ?? 60000; useEffect(() => { if (!isEnabled) return; if (isFetching) return; const isUnset = !lastFetchedAt; const isStale = lastFetchedAt ? Date.now() >= lastFetchedAt.getTime() + staleTime : false; if (isInvalid || isUnset || isStale) { fetchEntities(); } }, [isEnabled]); return { entities, fetchEntities, fetchNextPage, fetchPreviousPage, hasNextPage: !!next, hasPreviousPage: !!prev, totalCount, isError, isFetched, isFetching, isLoading: isFetching && entities.length === 0, invalidate, /** The `X-Total-Count` from the API if available, or the length of items in the store. */ count: typeof totalCount === 'number' ? totalCount : entities.length, }; } /** Get cache at path from Redux. */ const selectCache = (state: RootState, path: EntitiesPath) => state.entities[path[0]]; /** Get list at path from Redux. */ const selectList = (state: RootState, path: EntitiesPath) => { const [, ...listKeys] = path; const listKey = listKeys.join(':'); return selectCache(state, path)?.lists[listKey]; }; /** Select a particular item from a list state. */ function selectListState(state: RootState, path: EntitiesPath, key: K) { const listState = selectList(state, path)?.state; return listState ? listState[key] : undefined; } /** Hook to get a particular item from a list state. */ function useListState(path: EntitiesPath, key: K) { return useAppSelector(state => selectListState(state, path, key)); } /** Get list of entities from Redux. */ function selectEntities(state: RootState, path: EntitiesPath): readonly TEntity[] { const cache = selectCache(state, path); const list = selectList(state, path); const entityIds = list?.ids; return entityIds ? ( Array.from(entityIds).reduce((result, id) => { const entity = cache?.store[id]; if (entity) { result.push(entity as TEntity); } return result; }, []) ) : []; } export { useEntities, };