From f03868e65bcba1cbd6776a4a6ea889f90f41ac35 Mon Sep 17 00:00:00 2001 From: Arjun Patel Date: Sun, 12 Apr 2026 11:55:23 -0700 Subject: [PATCH] feat: paginate closed streams tab (#148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closed streams grow unbounded as teams age and can reach 1000s of items, while open streams stay bounded by active work. Switch the streams list to subscribe per-status so each tab only pulls what it renders, and cap the closed tab to a 50-item window that grows via a Load more action. The open tab remains unbounded so per-row realtime features — autoplay, huddle indicators — keep full coverage of the streams that matter. Closed streams are archived and don't need push behavior, which side- steps the autoplay baseline-reset and huddle-on-page-2 risks that made pagination complicated for the open tab. Refs flowy-live/llink#138. Co-authored-by: Claude --- js/src/features/network-root.tsx | 21 +++--- .../features/particles/particle-list-view.tsx | 20 +++++- js/src/hooks/use-particle.ts | 6 +- js/src/hooks/use-stream-particles.ts | 67 ++++++++++++++++--- js/src/lib/firestore-particles.ts | 6 ++ 5 files changed, 100 insertions(+), 20 deletions(-) diff --git a/js/src/features/network-root.tsx b/js/src/features/network-root.tsx index 794a258..baec43f 100644 --- a/js/src/features/network-root.tsx +++ b/js/src/features/network-root.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { CircleDot, CircleCheckBig } from "lucide-react"; import { particlePath } from "@/lib/particle-path"; @@ -18,17 +18,15 @@ export default function NetworkRoot() { const navigate = useNavigate(); const path = particlePath(networkId!, []); - const { streams, isLoading } = useStreamParticles(path); const [composeActive, setComposeActive] = useState(false); const [statusTab, setStatusTab] = useState<"open" | "closed">("open"); - const filteredStreams = useMemo( - () => streams.filter((s) => s.status === statusTab), - [streams, statusTab], - ); + const { streams, isLoading, canLoadMore, loadMore } = useStreamParticles(path, { + status: statusTab, + }); const { selectedIndex } = useStreamKeyboardNav({ - streams: filteredStreams, + streams, enabled: !composeActive, onNavigate: useCallback( (streamId: string) => navigate(`/${networkId}/${streamId}`), @@ -53,7 +51,14 @@ export default function NetworkRoot() { {/* Scrollable content */}
- +
diff --git a/js/src/features/particles/particle-list-view.tsx b/js/src/features/particles/particle-list-view.tsx index ffd48cf..3491f4d 100644 --- a/js/src/features/particles/particle-list-view.tsx +++ b/js/src/features/particles/particle-list-view.tsx @@ -22,6 +22,7 @@ import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Separator } from "@/components/ui/separator"; import { Progress } from "@/components/ui/progress"; import { Small } from "@/components/ui/typography"; +import { Button } from "@/components/ui/button"; import type { Particle, StreamProperties } from "@/api/types"; import type { StreamParticle } from "@/hooks/use-stream-particles"; import { useNetwork } from "@/hooks/use-networks"; @@ -241,12 +242,22 @@ interface ParticleListViewProps { networkId: string; isLoading: boolean; selectedIndex?: number | null; + /** When true, render a footer that invokes onLoadMore. */ + canLoadMore?: boolean; + onLoadMore?: () => void; } /** * List of stream particles for a container (network root, folder, etc.). */ -export function ParticleListView({ streams, networkId, isLoading, selectedIndex }: ParticleListViewProps) { +export function ParticleListView({ + streams, + networkId, + isLoading, + selectedIndex, + canLoadMore, + onLoadMore, +}: ParticleListViewProps) { const navigate = useNavigate(); const rowRefs = useRef<(HTMLDivElement | null)[]>([]); @@ -294,6 +305,13 @@ export function ParticleListView({ streams, networkId, isLoading, selectedIndex ))} + {canLoadMore && onLoadMore && ( +
+ +
+ )} ); } diff --git a/js/src/hooks/use-particle.ts b/js/src/hooks/use-particle.ts index 6055399..5e912f1 100644 --- a/js/src/hooks/use-particle.ts +++ b/js/src/hooks/use-particle.ts @@ -63,6 +63,8 @@ interface UseLiveParticleChildrenParams { 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( @@ -74,6 +76,7 @@ export function useLiveParticleChildren( onAdded, onRemoved, whereFilter, + limit, }: UseLiveParticleChildrenParams = {} ): UseLiveParticleChildrenResult { const [children, setChildren] = useState([]); @@ -110,11 +113,12 @@ export function useLiveParticleChildren( onAdded, onRemoved, whereFilter, + limit, } ); return unsubscribe; - }, [path]); + }, [path, whereFilter, limit]); return { children, isLoading, error }; } diff --git a/js/src/hooks/use-stream-particles.ts b/js/src/hooks/use-stream-particles.ts index 3e2ab64..373f63a 100644 --- a/js/src/hooks/use-stream-particles.ts +++ b/js/src/hooks/use-stream-particles.ts @@ -1,4 +1,5 @@ -import { useMemo } from "react"; +import { useCallback, useEffect, 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"; @@ -6,6 +7,14 @@ 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[] = []; @@ -15,30 +24,68 @@ function useVisibilityScopes(userId?: string, networkId?: string) { }, [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; 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): UseStreamParticlesResult { +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 { children, isLoading } = useLiveParticleChildren( - path, - { - orderByField: "last_child_created_at", - orderDirection: "desc", - visibilityScopes, + const [closedLimit, setClosedLimit] = useState(CLOSED_INITIAL_PAGE_SIZE); + + // Every time the user switches back to the closed tab, start with a fresh + // window. Avoids an ever-growing subscription across a long session. + useEffect(() => { + if (status === "closed") { + setClosedLimit(CLOSED_INITIAL_PAGE_SIZE); } - ); + }, [status]); + + const whereFilter: QueryFieldFilterConstraint = + status === "open" ? OPEN_STATUS_FILTER : CLOSED_STATUS_FILTER; + const limit = status === "closed" ? closedLimit : undefined; + + const { children, isLoading } = 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], ); - return { streams, isLoading, networkId }; + // 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, networkId, canLoadMore, loadMore }; } diff --git a/js/src/lib/firestore-particles.ts b/js/src/lib/firestore-particles.ts index 3943fca..e72ba94 100644 --- a/js/src/lib/firestore-particles.ts +++ b/js/src/lib/firestore-particles.ts @@ -161,6 +161,8 @@ export interface SubscribeToParticleChildrenOptions { onAdded?: (child: Particle) => void; onRemoved?: (child: Particle, updatedChildren: Particle[]) => void; whereFilter?: QueryFieldFilterConstraint; + /** Optional cap on results. Applied after order/where constraints. */ + limit?: number; } export function subscribeToParticleChildren( @@ -174,6 +176,7 @@ export function subscribeToParticleChildren( onAdded, onRemoved, whereFilter, + limit: limitValue, }: SubscribeToParticleChildrenOptions ): Unsubscribe { let q = query(typedCollection(collectionPath), orderBy(orderByField, orderDirection)); @@ -186,6 +189,9 @@ export function subscribeToParticleChildren( if (whereFilter) { q = query(q, whereFilter); } + if (limitValue !== undefined) { + q = query(q, limit(limitValue)); + } return onSnapshot( q, (snap) => {