import { useEffect, useMemo, useRef } from "react"; import { useNavigate } from "react-router-dom"; import beepSound from "../../../assets/sound.wav"; import { Radio, MessageSquare, Video, Mic, Image, FileText, CircleCheck, StickyNote, type LucideIcon, } from "lucide-react"; import { cn } from "@/lib/utils"; import { useLiveParticleChildren, useLiveLatestChild } from "@/hooks/use-particle"; import { useAuthStore } from "@/stores/auth-store"; import { parseParticlePath, particlePath, type ParticlePath, } from "@/lib/particle-path"; import { getInitials } from "@/lib/utils"; import { formatDistanceToNow } from "@/lib/time-utils"; 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 type { Particle, StreamProperties } from "@/api/types"; import { useAutoplayStore } from "@/stores/autoplay-store"; function getParticleTypeIcon(particle: Particle): LucideIcon { switch (particle.type) { case "text": return MessageSquare; case "media": { const mime = particle.properties.mime_type; if (mime.startsWith("video/")) return Video; if (mime.startsWith("audio/")) return Mic; if (mime.startsWith("image/")) return Image; return Video; } case "file": return FileText; case "quest": return CircleCheck; case "paper": return StickyNote; default: return Radio; } } function getMessagePreview(particle: Particle): string { switch (particle.type) { case "text": return particle.properties.content; case "media": { const mime = particle.properties.mime_type; if (mime.startsWith("video/")) return "Video clip"; if (mime.startsWith("audio/")) return "Voice note"; if (mime.startsWith("image/")) return "Photo"; return "Media"; } case "file": return particle.properties.filename; case "quest": return particle.properties.title; case "paper": return particle.properties.title; default: return particle.type; } } function StreamRow({ particle, networkId, onClick, }: { particle: Particle & { type: "stream"; properties: StreamProperties }; networkId: string; onClick: () => void; }) { const streamPath = particlePath(networkId, [particle.id]); const { latestChild } = useLiveLatestChild(streamPath); const user = useAuthStore((s) => s.user); const userId = user?.id ?? ""; const userEmail = user?.email ?? ""; // 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(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_email === userEmail) 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 const isDM = particle.visible_to.length === 2 && particle.visible_to.every((v) => v.startsWith("human:")); const initials = useMemo(() => { if (isDM) { const otherEntry = particle.visible_to.find( (v) => v !== `human:${userEmail}`, ); if (otherEntry) { const otherEmail = otherEntry.replace("human:", ""); return getInitials(otherEmail); } } return particle.properties.name.slice(0, 2).toUpperCase(); }, [isDM, particle.visible_to, particle.properties.name, userEmail]); const isUnseen = useMemo(() => { if (!latestChild) return false; const latestChildTimestamp = latestChild.created_at.getTime(); const userPlaybackPosition = particle.playback_markers?.[userId]?.getTime() ?? 0; return latestChildTimestamp > userPlaybackPosition; }, [latestChild, particle.playback_markers, userId]); const senderPrefix = useMemo(() => { if (!latestChild) return null; const isCurrentUser = latestChild.created_by_email === userEmail; if (isDM) { return isCurrentUser ? "You: " : null; } // Group stream if (isCurrentUser) return "You: "; const emailPrefix = latestChild.created_by_email.split("@")[0]; const capitalized = emailPrefix.charAt(0).toUpperCase() + emailPrefix.slice(1); return `${capitalized}: `; }, [latestChild, userEmail, isDM]); const subtitle = latestChild ? getMessagePreview(latestChild) : particle.properties.status; const TypeIcon = latestChild ? getParticleTypeIcon(latestChild) : Radio; return (
{ if (e.key === "Enter" || e.key === " ") onClick(); }} className="flex w-full items-center gap-3 px-4 py-3 text-left cursor-pointer transition-colors hover:bg-accent" > {initials}

{particle.properties.name}

{latestChild && ( {formatDistanceToNow(latestChild.created_at.toISOString())} )}
{senderPrefix && ( {senderPrefix} )} {subtitle}
{isUnseen && ( )}
); } // Generates the scopes for filtering particles to those that the user has access to function useVisibilityScopes( userEmail?: string, networkId?: string, ) { return useMemo(() => { let scopes: string[] = []; if (userEmail) { scopes.push(`human:${userEmail}`); } if (networkId) { scopes.push(`network:${networkId}`); } return scopes; }, [userEmail, networkId]); } interface ParticleListViewProps { path: ParticlePath; } /** * 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?.email, networkId); const { children, isLoading } = useLiveParticleChildren(path, "last_child_created_at", "desc", visibilityScopes); const navigate = useNavigate(); const streams = useMemo( () => children.filter((c) => c.type === "stream"), [children], ); if (isLoading) { return ; } return (
{streams.map((stream, index) => (
navigate(`/${networkId}/${stream.id}`)} /> {index < streams.length - 1 && }
))}
); }