diff --git a/js/src/features/particles/particle-list-view.tsx b/js/src/features/particles/particle-list-view.tsx index fc0c3a1..c39a03f 100644 --- a/js/src/features/particles/particle-list-view.tsx +++ b/js/src/features/particles/particle-list-view.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import beepSound from "../../../assets/sound.wav"; import { @@ -28,6 +28,8 @@ import { Progress } from "@/components/ui/progress"; import { Small } from "@/components/ui/typography"; import type { Particle, StreamProperties } from "@/api/types"; import { useAutoplayStore } from "@/stores/autoplay-store"; +import { where, Timestamp } from "firebase/firestore"; +import { RECENCY_WINDOW_HOURS } from "@/lib/constants"; function getParticleTypeIcon(particle: Particle): LucideIcon { switch (particle.type) { @@ -257,7 +259,23 @@ export function ParticleListView({ path }: ParticleListViewProps) { const user = useAuthStore((s) => s.user); const visibilityScopes = useVisibilityScopes(user?.email, networkId); - const { children, isLoading } = useLiveParticleChildren(path, "last_child_created_at", "desc", visibilityScopes); + const [recencyCutoff, setRecencyCutoff] = useState(() => { + const d = new Date(); + d.setHours(d.getHours() - RECENCY_WINDOW_HOURS); + return Timestamp.fromDate(d); + }); + + // Refresh the cutoff every hour so streams don't vanish/appear stale + useEffect(() => { + const interval = setInterval(() => { + const d = new Date(); + d.setHours(d.getHours() - RECENCY_WINDOW_HOURS); + setRecencyCutoff(Timestamp.fromDate(d)); + }, 60 * 60 * 1000); + return () => clearInterval(interval); + }, []); + + const { children, isLoading } = useLiveParticleChildren(path, "last_child_created_at", "desc", visibilityScopes, undefined, undefined, where("last_child_created_at", ">=", recencyCutoff)); const navigate = useNavigate(); const streams = useMemo( diff --git a/js/src/hooks/use-particle.ts b/js/src/hooks/use-particle.ts index 5e29b3b..a10bd54 100644 --- a/js/src/hooks/use-particle.ts +++ b/js/src/hooks/use-particle.ts @@ -12,6 +12,7 @@ import { toFirestoreChildrenPath, } from "@/lib/particle-path"; import { useQuery } from "@tanstack/react-query"; +import { QueryFieldFilterConstraint } from "firebase/firestore"; interface UseLiveParticleResult { particle: Particle | null; @@ -62,6 +63,7 @@ export function useLiveParticleChildren( visibilityScopes?: string[], onAdded?: (child: Particle) => void, onRemoved?: (child: Particle, updatedChildren: Particle[]) => void, + whereFilter?: QueryFieldFilterConstraint ): UseLiveParticleChildrenResult { const [children, setChildren] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -89,9 +91,11 @@ export function useLiveParticleChildren( orderDirection, onAdded, onRemoved, + whereFilter, ); return unsubscribe; + // FIX: do we need to listen to more deps? Would that cause side effects that break behavior }, [collectionPath]); return { children, isLoading, error }; diff --git a/js/src/hooks/use-stream-playback.ts b/js/src/hooks/use-stream-playback.ts index 2e44c53..8ea3e85 100644 --- a/js/src/hooks/use-stream-playback.ts +++ b/js/src/hooks/use-stream-playback.ts @@ -1,9 +1,11 @@ -import { useCallback, useEffect, useEffectEvent, useMemo, useReducer, useRef } from "react"; +import { useCallback, useEffect, useEffectEvent, useMemo, useReducer, useRef, useState } from "react"; import { useAuthStore } from "@/stores/auth-store"; import type { Particle } from "@/api/types"; import { useLiveParticleChildren } from "@/hooks/use-particle"; import { toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path"; import { updateStreamPlaybackMarker } from "@/lib/firestore-particles"; +import { where, Timestamp } from "firebase/firestore"; +import { RECENCY_WINDOW_HOURS } from "@/lib/constants"; // --- Playback reducer (ID-based) --- @@ -112,8 +114,15 @@ export function useStreamPlayback( }); }); + const [recencyCutoff] = useState(() => { + const d = new Date(); + d.setHours(d.getHours() - RECENCY_WINDOW_HOURS); + return Timestamp.fromDate(d); + }); + const { children } = useLiveParticleChildren( path, "created_at", "asc", undefined, onParticleAdded, onParticleRemoved, + where("created_at", ">=", recencyCutoff), ); // Derive current index and particle from ID diff --git a/js/src/lib/constants.ts b/js/src/lib/constants.ts new file mode 100644 index 0000000..afcee1b --- /dev/null +++ b/js/src/lib/constants.ts @@ -0,0 +1,2 @@ +/** How far back to look when filtering particles by recency. */ +export const RECENCY_WINDOW_HOURS = 24; diff --git a/js/src/lib/firestore-particles.ts b/js/src/lib/firestore-particles.ts index dc49eb0..12e7099 100644 --- a/js/src/lib/firestore-particles.ts +++ b/js/src/lib/firestore-particles.ts @@ -16,6 +16,7 @@ import { type QueryDocumentSnapshot, type SnapshotOptions, type Unsubscribe, + QueryFieldFilterConstraint, } from "firebase/firestore"; import { firestoreDb } from "@/firebase"; import { isContainerType, ParticleSchema } from "@/api/types"; @@ -134,6 +135,7 @@ export function subscribeToParticleChildren( orderDirection: "asc" | "desc" = "desc", onAdded?: (child: Particle) => void, onRemoved?: (child: Particle, updatedChildren: Particle[]) => void, + whereFilter?: QueryFieldFilterConstraint, ): Unsubscribe { let q = query(typedCollection(collectionPath), orderBy(orderByField, orderDirection)); if (visibilityScopes.length > 0) { @@ -142,6 +144,9 @@ export function subscribeToParticleChildren( where("visible_to", "array-contains-any", visibilityScopes), ); } + if (whereFilter) { + q = query(q, whereFilter); + } return onSnapshot( q, (snap) => {