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) => {