import { useCallback, useEffect, useRef, useState } from 'react'; import { subscribeToParticleChildren } from '@/lib/firestore-particles'; import type { Particle } from '@/api/types'; import { toFirestoreChildrenPath, type ParticlePath, } from '@/lib/particle-path'; const DEFAULT_PAGE_SIZE = 30; interface UseWindowedStreamParticlesParams { /** * Resume anchor (the viewer's playback marker). The window grows backward * until it covers this timestamp so the resume particle is always loaded. * Captured once per stream — advancing the marker during playback does not * re-window. */ marker?: Date | null; /** How many particles to add per backward growth step. */ pageSize?: number; } export interface UseWindowedStreamParticlesResult { /** * Loaded window, ascending (oldest → newest). The newest particle in the * stream is always present — the window only ever grows backward. */ children: Particle[]; isLoading: boolean; error: Error | null; /** Whether older particles likely exist before the loaded window. */ hasMoreOlder: boolean; /** Extend the window backward (older history). No-op when nothing remains. */ loadOlder: () => void; isLoadingOlder: boolean; } /** Per-stream mutable tracking that must survive limit-driven re-subscriptions. */ interface WindowTracking { path: string | null; /** Newest created_at (ms) seen — tells new tail particles from backfill. */ newestMs: number | null; /** Oldest created_at (ms) currently loaded. */ oldestMs: number | null; /** Backward growth target (the marker, ms), frozen on first capture. */ coverageMs: number | null; } /** * Live, windowed view of a stream's particles. * * Instead of subscribing to every child (the old behaviour), this keeps a * `orderBy(created_at desc) limit(N)` window anchored at the newest particle * and grows it backward on demand. Because the window is anchored at the tail * it always contains the most recent particles, so new arrivals stream in and * forward playback never needs a fetch. The window grows backward to: * 1. cover the resume marker, so playback can start where the user left off; * 2. service `loadOlder()` when the list view scrolls up. * * Output is reversed to ascending order to match the rest of the playback code. */ export function useWindowedStreamParticles( path: ParticlePath | undefined, { marker = null, pageSize = DEFAULT_PAGE_SIZE, }: UseWindowedStreamParticlesParams = {}, ): UseWindowedStreamParticlesResult { const [children, setChildren] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [hasMoreOlder, setHasMoreOlder] = useState(false); const [isLoadingOlder, setIsLoadingOlder] = useState(false); const [limit, setLimit] = useState(pageSize); const collectionPath = path ? toFirestoreChildrenPath(path) : null; const trackingRef = useRef({ path: null, newestMs: null, oldestMs: null, coverageMs: null, }); // Latest marker, read lazily inside the snapshot callback so a late-resolving // marker (e.g. auth after first paint) still seeds backward coverage. const markerRef = useRef(marker); useEffect(() => { markerRef.current = marker; }, [marker]); // Reset window state when the stream changes (render-phase adjustment — the // blessed alternative to a reset effect, avoids cascading effect renders). const [trackedPath, setTrackedPath] = useState(collectionPath); if (trackedPath !== collectionPath) { setTrackedPath(collectionPath); setLimit(pageSize); setChildren([]); setIsLoading(true); setError(null); setHasMoreOlder(false); setIsLoadingOlder(false); } useEffect(() => { if (!collectionPath) return; // Reset per-stream tracking on a genuine stream change, but keep it across // limit-driven re-subscriptions (newest/coverage must persist). const tracking = trackingRef.current; if (tracking.path !== collectionPath) { tracking.path = collectionPath; tracking.newestMs = null; tracking.oldestMs = null; tracking.coverageMs = null; } const unsubscribe = subscribeToParticleChildren(collectionPath, { orderByField: 'created_at', orderDirection: 'desc', limit, onData: (descData) => { const t = trackingRef.current; // Lazily freeze the backward-coverage target from the marker. if (t.coverageMs === null && markerRef.current) { t.coverageMs = markerRef.current.getTime(); } // Firestore caps results at `limit`; a full window means more older // particles may exist beyond it. const saturated = descData.length === limit; const newest = descData[0]; const newestMs = newest ? newest.created_at.getTime() : null; // Anti-eviction: if the window is full and genuinely newer particles // arrived at the tail, grow the limit so the oldest loaded particles // aren't pushed out. Skip rendering the evicted snapshot — the regrown // query delivers the complete window a beat later. const prevNewest = t.newestMs; if ( saturated && newestMs !== null && prevNewest !== null && newestMs > prevNewest ) { const newerCount = descData.filter( (d) => d.created_at.getTime() > prevNewest, ).length; if (newerCount > 0) { t.newestMs = newestMs; setLimit((l) => l + newerCount); return; } } if (newestMs !== null) t.newestMs = newestMs; const ascData = descData.slice().reverse(); t.oldestMs = ascData.length > 0 ? ascData[0].created_at.getTime() : null; // Marker coverage: keep growing backward until the resume marker falls // within the window (or we reach the start of the stream). if ( saturated && t.coverageMs !== null && t.oldestMs !== null && t.oldestMs > t.coverageMs ) { setLimit((l) => l + pageSize); } setChildren(ascData); setHasMoreOlder(saturated); setIsLoading(false); setIsLoadingOlder(false); }, onError: (err) => { console.warn(err); setError(err); setIsLoading(false); setIsLoadingOlder(false); }, }); return () => unsubscribe(); }, [collectionPath, limit, pageSize]); const loadOlder = useCallback(() => { if (!hasMoreOlder || isLoadingOlder) return; setIsLoadingOlder(true); setLimit((l) => l + pageSize); }, [hasMoreOlder, isLoadingOlder, pageSize]); if (!path) { return { children: [], isLoading: false, error: null, hasMoreOlder: false, loadOlder: () => {}, isLoadingOlder: false, }; } return { children, isLoading, error, hasMoreOlder, loadOlder, isLoadingOlder, }; }