import { useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query' import { useShallow } from 'zustand/react/shallow' import { useFilterStore, filtersToParams } from '../store/filterStore' import { usePhotosBackgroundLoadingStore } from '../store/photosBackgroundLoadingStore' import api from '../services/api' import type { Photo } from '../types/photo' export function usePhotosLoadingMore(): boolean { return usePhotosBackgroundLoadingStore((s) => s.inFlight > 0) } /** * Optimistically strip the supplied photo ids from every cached * timeline list. Used by discard / delete flows so the photos vanish * from the grid the instant the user acts, without waiting for the * mutation round-trip + invalidation refetch. The subsequent invalidate * still runs and reconciles the cache with server truth. */ export function stripPhotosFromCache(queryClient: QueryClient, ids: string[]) { if (ids.length === 0) return const removed = new Set(ids) queryClient.setQueriesData({ queryKey: ['photos'] }, (prev) => { if (!prev) return prev return prev.filter((p) => !removed.has(p.id)) }) } interface CursorPage { photos: Photo[] next_cursor: string | null } async function fetchCursorPage( params: Record, signal?: AbortSignal, ): Promise { const resp = await api.get('/photos', { params, signal }) return resp.data } /** * Single source of truth for the timeline photos query. Both Timeline and * PreviewView call this so they share one cache entry — previously * PreviewView looked the cache up by key directly, which broke the moment * Timeline's key gained the filter params. */ export function usePhotosQuery() { // Single shallow-compared selector that returns just the filter // surface area. Previously this hook ran 14 individual selectors, // each a fresh subscription that could trigger a re-render and a // useMemo recompute on any unrelated filter-store update. useShallow // collapses them into one subscription that only fires when the // shape's values actually change. const filterParams = useFilterStore( useShallow((s) => filtersToParams({ dateFrom: s.dateFrom, dateTo: s.dateTo, mediaTypes: s.mediaTypes, ratingMin: s.ratingMin, ratingMax: s.ratingMax, colorLabel: s.colorLabel, flag: s.flag, heapId: s.heapId, folderId: s.folderId, tagIds: s.tagIds, duplicates: s.duplicates, groupBy: s.groupBy, sortBy: s.sortBy, sortOrder: s.sortOrder, }), ), ) const queryClient = useQueryClient() return useQuery({ queryKey: ['photos', filterParams], queryFn: async ({ signal }) => { // Two-phase fetch using cursor-based (keyset) pagination. // Phase 1 returns the first page (resolves the useQuery promise // so consumers exit loading state). Phase 2 chains a bounded // background loop — each response includes a `next_cursor` that // seeks directly to the next slice via an indexed range scan, // O(1) regardless of depth (no OFFSET skipping). // // First page is small (~one viewport) so the grid paints fast; // background pages are larger so we still cover ~10k photos in // a few round-trips without burning through a 100k library on // cold load. If a user scrolls past that horizon we'll add an // infinite-query trigger; for now the cap keeps cold-load // memory / network pressure sane. const PER_PAGE_INITIAL = 100 const PER_PAGE_BACKGROUND = 500 const MAX_PAGES = 20 const INTER_PAGE_DELAY_MS = 50 const first = await fetchCursorPage( { per_page: PER_PAGE_INITIAL, ...filterParams }, signal, ) const firstBatch = first.photos || [] let nextCursor: string | null = first.next_cursor if (nextCursor) { // Fire-and-forget background loop using cursor chaining. // start/stop balanced in try/finally so an aborted or erroring // loop can't leak the in-flight counter that drives the // bottom-of-grid spinner. usePhotosBackgroundLoadingStore.getState().start() void (async () => { try { for (let i = 0; i < MAX_PAGES && nextCursor; i++) { if (signal?.aborted) return try { const page = await fetchCursorPage( { per_page: PER_PAGE_BACKGROUND, cursor: nextCursor, ...filterParams }, signal, ) if (signal?.aborted) return const more = page.photos || [] nextCursor = page.next_cursor queryClient.setQueryData( ['photos', filterParams], (prev) => (prev ? [...prev, ...more] : more) ) if (!nextCursor || more.length < PER_PAGE_BACKGROUND) return // Yield a beat between pages so the main thread stays // responsive (thumbnail decode, scroll handling) while // we're back-filling in the background. await new Promise((r) => setTimeout(r, INTER_PAGE_DELAY_MS)) } catch { return } } } finally { usePhotosBackgroundLoadingStore.getState().stop() } })() } return firstBatch }, staleTime: 30_000, }) }