feat: add grid view for streams

This commit is contained in:
talksik
2026-03-30 12:08:16 -07:00
parent 76c538dca2
commit 9f6633bf1f
10 changed files with 498 additions and 120 deletions
@@ -1,6 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import beepSound from "../../../assets/sound.wav";
import {
Radio,
MessageSquare,
@@ -13,10 +12,9 @@ import {
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useLiveParticleChildren, useLiveLatestChild } from "@/hooks/use-particle";
import { useLiveLatestChild } from "@/hooks/use-particle";
import { useAuthStore } from "@/stores/auth-store";
import {
parseParticlePath,
particlePath,
type ParticlePath,
} from "@/lib/particle-path";
@@ -27,10 +25,9 @@ import { Separator } from "@/components/ui/separator";
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";
import { useNetwork } from "@/hooks/use-networks";
import { useStreamParticles } from "@/hooks/use-stream-particles";
import { useStreamAutoplay } from "@/hooks/use-stream-autoplay";
function getParticleTypeIcon(particle: Particle): LucideIcon {
switch (particle.type) {
@@ -91,33 +88,7 @@ function StreamRow({
const userId = user?.id ?? "";
const network = useNetwork(networkId);
// Autoplay: trigger only when latestChild *changes* to a new media particle,
// not on initial data load. We track the "settled" id — the first non-null value
// we see — and only autoplay on subsequent changes from that baseline.
const settledIdRef = useRef<string | undefined>(undefined);
useEffect(() => {
if (!latestChild) return;
// First real value: record it as baseline, don't autoplay
if (settledIdRef.current === undefined) {
settledIdRef.current = latestChild.id;
return;
}
if (latestChild.id === settledIdRef.current) return;
settledIdRef.current = latestChild.id;
if (latestChild.created_by_human_id === userId) return;
if (latestChild.type === "text") {
new Audio(beepSound).play().catch(() => {});
return;
}
if (latestChild.type !== "media") return;
useAutoplayStore.getState().play(latestChild, particle.id);
}, [latestChild?.id]); // eslint-disable-line react-hooks/exhaustive-deps
useStreamAutoplay(latestChild, particle);
const isDM =
particle.visible_to.length === 2 &&
@@ -238,23 +209,6 @@ function StreamRow({
);
}
// Generates the scopes for filtering particles to those that the user has access to
function useVisibilityScopes(
userId?: string,
networkId?: string,
) {
return useMemo(() => {
let scopes: string[] = [];
if (userId) {
scopes.push(`human:${userId}`);
}
if (networkId) {
scopes.push(`network:${networkId}`);
}
return scopes;
}, [userId, networkId]);
}
interface ParticleListViewProps {
path: ParticlePath;
}
@@ -263,34 +217,9 @@ interface ParticleListViewProps {
* List of stream particles for a container (network root, folder, etc.).
*/
export function ParticleListView({ path }: ParticleListViewProps) {
const { networkId } = parseParticlePath(path);
const user = useAuthStore((s) => s.user);
const visibilityScopes = useVisibilityScopes(user?.id, networkId);
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 { streams, isLoading, networkId } = useStreamParticles(path);
const navigate = useNavigate();
const streams = useMemo(
() => children.filter((c) => c.type === "stream"),
[children],
);
if (isLoading) {
return <Progress />;
}
@@ -307,19 +236,17 @@ export function ParticleListView({ path }: ParticleListViewProps) {
}
return (
<div className="h-full overflow-clip">
<div className="py-1">
{streams.map((stream, index) => (
<div key={stream.id}>
<StreamRow
particle={stream}
networkId={networkId}
onClick={() => navigate(`/${networkId}/${stream.id}`)}
/>
{index < streams.length - 1 && <Separator className="mx-4" />}
</div>
))}
</div>
<div>
{streams.map((stream, index) => (
<div key={stream.id}>
<StreamRow
particle={stream}
networkId={networkId}
onClick={() => navigate(`/${networkId}/${stream.id}`)}
/>
{index < streams.length - 1 && <Separator className="mx-4" />}
</div>
))}
</div>
);
}