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
+41
View File
@@ -0,0 +1,41 @@
import { useEffect, useRef } from "react";
import beepSound from "../../assets/sound.wav";
import type { Particle, StreamProperties } from "@/api/types";
import { useAuthStore } from "@/stores/auth-store";
import { useAutoplayStore } from "@/stores/autoplay-store";
/**
* Triggers autoplay when a stream's latest child changes to a new media particle.
* Plays a beep sound for new text particles from other users.
*/
export function useStreamAutoplay(
latestChild: Particle | null,
streamParticle: Particle & { type: "stream"; properties: StreamProperties },
) {
const userId = useAuthStore((s) => s.user?.id) ?? "";
const settledIdRef = useRef<string | undefined>(undefined);
useEffect(() => {
if (!latestChild) return;
// First real value: record 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, streamParticle.id);
}, [latestChild?.id]); // eslint-disable-line react-hooks/exhaustive-deps
}
+62
View File
@@ -0,0 +1,62 @@
import { useEffect, useMemo, useState } from "react";
import { useLiveParticleChildren } from "@/hooks/use-particle";
import { useAuthStore } from "@/stores/auth-store";
import { parseParticlePath, type ParticlePath } from "@/lib/particle-path";
import type { Particle, StreamProperties } from "@/api/types";
import { where, Timestamp } from "firebase/firestore";
import { RECENCY_WINDOW_HOURS } from "@/lib/constants";
type StreamParticle = Particle & { type: "stream"; properties: StreamProperties };
function useVisibilityScopes(userId?: string, networkId?: string) {
return useMemo(() => {
const scopes: string[] = [];
if (userId) scopes.push(`human:${userId}`);
if (networkId) scopes.push(`network:${networkId}`);
return scopes;
}, [userId, networkId]);
}
interface UseStreamParticlesResult {
streams: StreamParticle[];
isLoading: boolean;
networkId: string;
}
export function useStreamParticles(path: ParticlePath): UseStreamParticlesResult {
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);
});
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 = useMemo(
() => children.filter((c): c is StreamParticle => c.type === "stream"),
[children],
);
return { streams, isLoading, networkId };
}