import { useState, useEffect } from "react"; import { useQuery } from "@tanstack/react-query"; import type { QueryFieldFilterConstraint } from "firebase/firestore"; import { subscribeToParticle, subscribeToParticleChildren, subscribeToLatestChild, getParticle, getParticleChildren, } from "@/lib/firestore-particles"; import type { Particle } from "@/api/types"; import { type ParticlePath, toFirestoreDocPath, toFirestoreChildrenPath, } from "@/lib/particle-path"; import { logError } from "@/lib/errors"; interface UseLiveParticleResult { particle: Particle | null; isLoading: boolean; error: Error | null; } export function useLiveParticle(path: ParticlePath): UseLiveParticleResult { const [particle, setParticle] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { setIsLoading(true); setError(null); setParticle(null); const docPath = toFirestoreDocPath(path); const unsubscribe = subscribeToParticle( docPath, (data) => { setParticle(data); setIsLoading(false); }, (err) => { setError(err); setIsLoading(false); }, ); return unsubscribe; }, [path]); return { particle, isLoading, error }; } interface UseLiveParticleChildrenResult { children: Particle[]; isLoading: boolean; error: Error | null; } interface UseLiveParticleChildrenParams { orderByField?: string; orderDirection?: "asc" | "desc"; visibilityScopes?: string[]; onAdded?: (child: Particle) => void; onRemoved?: (child: Particle, updatedChildren: Particle[]) => void; whereFilter?: QueryFieldFilterConstraint; /** Optional cap on results. Changes trigger a re-subscription. */ limit?: number; } export function useLiveParticleChildren( path: ParticlePath | undefined, { orderByField = "created_at", orderDirection = "desc", visibilityScopes, onAdded, onRemoved, whereFilter, limit, }: UseLiveParticleChildrenParams = {}, ): UseLiveParticleChildrenResult { const [children, setChildren] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { if (!path) { setChildren([]); setIsLoading(false); return; } setIsLoading(true); setError(null); setChildren([]); const collectionPath = toFirestoreChildrenPath(path); const unsubscribe = subscribeToParticleChildren(collectionPath, { onData: (data) => { setChildren(data); setIsLoading(false); }, onError: (err) => { logError(err, { scope: "firestore.particle-children", path }); setError(err); setIsLoading(false); }, visibilityScopes, orderByField, orderDirection, onAdded, onRemoved, whereFilter, limit, }); return unsubscribe; // The hook intentionally keys only on path/whereFilter/limit — desktop // does the same. Visibility scope changes are absorbed by the active // listener; reordering causes a re-subscription. // eslint-disable-next-line react-hooks/exhaustive-deps }, [path, whereFilter, limit]); return { children, isLoading, error }; } interface UseLiveLatestChildResult { latestChild: Particle | null; isLoading: boolean; } export function useLiveLatestChild( path: ParticlePath, ): UseLiveLatestChildResult { const [latestChild, setLatestChild] = useState(null); const [isLoading, setIsLoading] = useState(true); useEffect(() => { setIsLoading(true); setLatestChild(null); const unsubscribe = subscribeToLatestChild( toFirestoreChildrenPath(path), (data) => { setLatestChild(data); setIsLoading(false); }, (err) => { logError(err, { scope: "firestore.latest-child", path }); setIsLoading(false); }, ); return unsubscribe; }, [path]); return { latestChild, isLoading }; } export function useParticle(path?: ParticlePath) { return useQuery({ queryKey: ["particle", path], queryFn: async () => { if (!path) return null; const docPath = toFirestoreDocPath(path); const particle = await getParticle(docPath); return particle; }, enabled: !!path, }); } export function useParticleChildren(path?: ParticlePath) { return useQuery({ queryKey: ["particle-children", path], queryFn: async () => { if (!path) return []; const collectionPath = toFirestoreChildrenPath(path); return getParticleChildren(collectionPath); }, enabled: !!path, staleTime: 1000 * 60 * 5, // 5 min — attachments don't change }); }