feat: paginate closed streams tab (#148)
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 <[email protected]>
This commit was merged in pull request #148.
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { CircleDot, CircleCheckBig } from "lucide-react";
|
import { CircleDot, CircleCheckBig } from "lucide-react";
|
||||||
import { particlePath } from "@/lib/particle-path";
|
import { particlePath } from "@/lib/particle-path";
|
||||||
@@ -18,17 +18,15 @@ export default function NetworkRoot() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const path = particlePath(networkId!, []);
|
const path = particlePath(networkId!, []);
|
||||||
|
|
||||||
const { streams, isLoading } = useStreamParticles(path);
|
|
||||||
const [composeActive, setComposeActive] = useState(false);
|
const [composeActive, setComposeActive] = useState(false);
|
||||||
const [statusTab, setStatusTab] = useState<"open" | "closed">("open");
|
const [statusTab, setStatusTab] = useState<"open" | "closed">("open");
|
||||||
|
|
||||||
const filteredStreams = useMemo(
|
const { streams, isLoading, canLoadMore, loadMore } = useStreamParticles(path, {
|
||||||
() => streams.filter((s) => s.status === statusTab),
|
status: statusTab,
|
||||||
[streams, statusTab],
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const { selectedIndex } = useStreamKeyboardNav({
|
const { selectedIndex } = useStreamKeyboardNav({
|
||||||
streams: filteredStreams,
|
streams,
|
||||||
enabled: !composeActive,
|
enabled: !composeActive,
|
||||||
onNavigate: useCallback(
|
onNavigate: useCallback(
|
||||||
(streamId: string) => navigate(`/${networkId}/${streamId}`),
|
(streamId: string) => navigate(`/${networkId}/${streamId}`),
|
||||||
@@ -53,7 +51,14 @@ export default function NetworkRoot() {
|
|||||||
|
|
||||||
{/* Scrollable content */}
|
{/* Scrollable content */}
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-14 py-2">
|
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-14 py-2">
|
||||||
<ParticleListView streams={filteredStreams} networkId={networkId!} isLoading={isLoading} selectedIndex={selectedIndex} />
|
<ParticleListView
|
||||||
|
streams={streams}
|
||||||
|
networkId={networkId!}
|
||||||
|
isLoading={isLoading}
|
||||||
|
selectedIndex={selectedIndex}
|
||||||
|
canLoadMore={canLoadMore}
|
||||||
|
onLoadMore={loadMore}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ComposeOverlay networkId={networkId!} onActiveChange={setComposeActive} />
|
<ComposeOverlay networkId={networkId!} onActiveChange={setComposeActive} />
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { Small } from "@/components/ui/typography";
|
import { Small } from "@/components/ui/typography";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import type { Particle, StreamProperties } from "@/api/types";
|
import type { Particle, StreamProperties } from "@/api/types";
|
||||||
import type { StreamParticle } from "@/hooks/use-stream-particles";
|
import type { StreamParticle } from "@/hooks/use-stream-particles";
|
||||||
import { useNetwork } from "@/hooks/use-networks";
|
import { useNetwork } from "@/hooks/use-networks";
|
||||||
@@ -241,12 +242,22 @@ interface ParticleListViewProps {
|
|||||||
networkId: string;
|
networkId: string;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
selectedIndex?: number | null;
|
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.).
|
* 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 navigate = useNavigate();
|
||||||
const rowRefs = useRef<(HTMLDivElement | null)[]>([]);
|
const rowRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
|
|
||||||
@@ -294,6 +305,13 @@ export function ParticleListView({ streams, networkId, isLoading, selectedIndex
|
|||||||
</div>
|
</div>
|
||||||
</StreamContextMenu>
|
</StreamContextMenu>
|
||||||
))}
|
))}
|
||||||
|
{canLoadMore && onLoadMore && (
|
||||||
|
<div className="flex justify-center p-3">
|
||||||
|
<Button variant="ghost" size="sm" onClick={onLoadMore}>
|
||||||
|
Load more
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ interface UseLiveParticleChildrenParams {
|
|||||||
onAdded?: (child: Particle) => void;
|
onAdded?: (child: Particle) => void;
|
||||||
onRemoved?: (child: Particle, updatedChildren: Particle[]) => void;
|
onRemoved?: (child: Particle, updatedChildren: Particle[]) => void;
|
||||||
whereFilter?: QueryFieldFilterConstraint;
|
whereFilter?: QueryFieldFilterConstraint;
|
||||||
|
/** Optional cap on results. Changes trigger a re-subscription. */
|
||||||
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useLiveParticleChildren(
|
export function useLiveParticleChildren(
|
||||||
@@ -74,6 +76,7 @@ export function useLiveParticleChildren(
|
|||||||
onAdded,
|
onAdded,
|
||||||
onRemoved,
|
onRemoved,
|
||||||
whereFilter,
|
whereFilter,
|
||||||
|
limit,
|
||||||
}: UseLiveParticleChildrenParams = {}
|
}: UseLiveParticleChildrenParams = {}
|
||||||
): UseLiveParticleChildrenResult {
|
): UseLiveParticleChildrenResult {
|
||||||
const [children, setChildren] = useState<Particle[]>([]);
|
const [children, setChildren] = useState<Particle[]>([]);
|
||||||
@@ -110,11 +113,12 @@ export function useLiveParticleChildren(
|
|||||||
onAdded,
|
onAdded,
|
||||||
onRemoved,
|
onRemoved,
|
||||||
whereFilter,
|
whereFilter,
|
||||||
|
limit,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
}, [path]);
|
}, [path, whereFilter, limit]);
|
||||||
|
|
||||||
return { children, isLoading, error };
|
return { children, isLoading, error };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { useLiveParticleChildren } from "@/hooks/use-particle";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { parseParticlePath, type ParticlePath } from "@/lib/particle-path";
|
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 };
|
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) {
|
function useVisibilityScopes(userId?: string, networkId?: string) {
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const scopes: string[] = [];
|
const scopes: string[] = [];
|
||||||
@@ -15,30 +24,68 @@ function useVisibilityScopes(userId?: string, networkId?: string) {
|
|||||||
}, [userId, networkId]);
|
}, [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 {
|
interface UseStreamParticlesResult {
|
||||||
streams: StreamParticle[];
|
streams: StreamParticle[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
networkId: string;
|
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 { networkId } = parseParticlePath(path);
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const visibilityScopes = useVisibilityScopes(user?.id, networkId);
|
const visibilityScopes = useVisibilityScopes(user?.id, networkId);
|
||||||
|
|
||||||
const { children, isLoading } = useLiveParticleChildren(
|
const [closedLimit, setClosedLimit] = useState(CLOSED_INITIAL_PAGE_SIZE);
|
||||||
path,
|
|
||||||
{
|
// Every time the user switches back to the closed tab, start with a fresh
|
||||||
orderByField: "last_child_created_at",
|
// window. Avoids an ever-growing subscription across a long session.
|
||||||
orderDirection: "desc",
|
useEffect(() => {
|
||||||
visibilityScopes,
|
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(
|
const streams = useMemo(
|
||||||
() => children.filter((c): c is StreamParticle => c.type === "stream"),
|
() => children.filter((c): c is StreamParticle => c.type === "stream"),
|
||||||
[children],
|
[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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,6 +161,8 @@ export interface SubscribeToParticleChildrenOptions {
|
|||||||
onAdded?: (child: Particle) => void;
|
onAdded?: (child: Particle) => void;
|
||||||
onRemoved?: (child: Particle, updatedChildren: Particle[]) => void;
|
onRemoved?: (child: Particle, updatedChildren: Particle[]) => void;
|
||||||
whereFilter?: QueryFieldFilterConstraint;
|
whereFilter?: QueryFieldFilterConstraint;
|
||||||
|
/** Optional cap on results. Applied after order/where constraints. */
|
||||||
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function subscribeToParticleChildren(
|
export function subscribeToParticleChildren(
|
||||||
@@ -174,6 +176,7 @@ export function subscribeToParticleChildren(
|
|||||||
onAdded,
|
onAdded,
|
||||||
onRemoved,
|
onRemoved,
|
||||||
whereFilter,
|
whereFilter,
|
||||||
|
limit: limitValue,
|
||||||
}: SubscribeToParticleChildrenOptions
|
}: SubscribeToParticleChildrenOptions
|
||||||
): Unsubscribe {
|
): Unsubscribe {
|
||||||
let q = query(typedCollection(collectionPath), orderBy(orderByField, orderDirection));
|
let q = query(typedCollection(collectionPath), orderBy(orderByField, orderDirection));
|
||||||
@@ -186,6 +189,9 @@ export function subscribeToParticleChildren(
|
|||||||
if (whereFilter) {
|
if (whereFilter) {
|
||||||
q = query(q, whereFilter);
|
q = query(q, whereFilter);
|
||||||
}
|
}
|
||||||
|
if (limitValue !== undefined) {
|
||||||
|
q = query(q, limit(limitValue));
|
||||||
|
}
|
||||||
return onSnapshot(
|
return onSnapshot(
|
||||||
q,
|
q,
|
||||||
(snap) => {
|
(snap) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user