import { useCallback, useMemo, useState } from 'react'; import { where, type QueryFieldFilterConstraint } from 'firebase/firestore'; import { useLiveParticleChildren } from '@/hooks/use-particle'; import { useAuthStore } from '@/stores/auth-store'; import { parseParticlePath, type ParticlePath } from '@/lib/particle-path'; import type { Particle, StreamProperties } from '@/api/types'; export type StreamParticle = Particle & { type: 'stream'; properties: StreamProperties; }; const CLOSED_INITIAL_PAGE_SIZE = 50; const CLOSED_PAGE_INCREMENT = 50; // Stable where-constraint references so the Firestore subscription only // re-attaches when the tab actually changes, not on every render. const OPEN_STATUS_FILTER = where('status', '==', 'open'); const CLOSED_STATUS_FILTER = where('status', '==', 'closed'); function useVisibilityScopes(userId?: string, networkId?: string) { return useMemo(() => { const scopes: string[] = []; if (userId) scopes.push(`human:${userId}`); if (networkId) scopes.push(`network:${networkId}`); return scopes; }, [userId, networkId]); } interface UseStreamParticlesOptions { /** * Which streams to subscribe to. Open streams are loaded in full (bounded * by active work — full realtime coverage is needed for autoplay/huddles). * Closed streams are paginated via `loadMore`. */ status: 'open' | 'closed'; } interface UseStreamParticlesResult { streams: StreamParticle[]; isLoading: boolean; error: Error | null; networkId: string; /** True when more closed streams may exist beyond the current window. */ canLoadMore: boolean; /** Extend the pagination window. No-op on the open tab. */ loadMore: () => void; } export function useStreamParticles( path: ParticlePath, { status }: UseStreamParticlesOptions, ): UseStreamParticlesResult { const { networkId } = parseParticlePath(path); const user = useAuthStore((s) => s.user); const visibilityScopes = useVisibilityScopes(user?.id, networkId); const [closedLimit, setClosedLimit] = useState(CLOSED_INITIAL_PAGE_SIZE); const [prevStatus, setPrevStatus] = useState(status); // Switching back to the closed tab starts a fresh window, avoiding an // ever-growing subscription across a long session. if (status !== prevStatus) { setPrevStatus(status); if (status === 'closed') { setClosedLimit(CLOSED_INITIAL_PAGE_SIZE); } } const whereFilter: QueryFieldFilterConstraint = status === 'open' ? OPEN_STATUS_FILTER : CLOSED_STATUS_FILTER; const limit = status === 'closed' ? closedLimit : undefined; const { children, isLoading, error } = useLiveParticleChildren(path, { orderByField: 'last_child_created_at', orderDirection: 'desc', visibilityScopes, whereFilter, limit, }); const streams = useMemo( () => children.filter((c): c is StreamParticle => c.type === 'stream'), [children], ); // Heuristic: if we got back as many items as we asked for, assume there // might be more. Clicking load-more when there are no more is a no-op. const canLoadMore = status === 'closed' && streams.length >= closedLimit; const loadMore = useCallback(() => { if (status !== 'closed') return; setClosedLimit((prev) => prev + CLOSED_PAGE_INCREMENT); }, [status]); return { streams, isLoading, error, networkId, canLoadMore, loadMore }; }