import { useState, useEffect, useEffectEvent, useCallback, useReducer, useRef } from "react"; import { useNavigate } from "react-router-dom"; import { useAuthStore } from "@/stores/auth-store"; import type { Particle } from "@/api/types"; import { useLiveParticleChildren } from "@/hooks/use-particle"; import { parseParticlePath, toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path"; import { ComposeOverlay } from "@/features/compose/compose-overlay"; import { PlaybackPageIndicator } from "@/features/particles/playback-page-indicator"; import { MediaParticleView } from "@/features/particles/media-particle-view"; import { TextParticleView } from "@/features/particles/text-particle-view"; import { FallbackParticleView } from "@/features/particles/fallback-particle-view"; import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar"; import ControlsIndicator from "@/features/compose/controls-indicator"; import { updateStreamPlaybackMarker } from "@/lib/firestore-particles"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useNetwork, useNetworks } from "@/hooks/use-networks"; import { Button } from "@/components/ui/button"; import { Home, Settings } from "lucide-react"; import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"; import { WindowControls } from "@/components/window-controls"; import { formatDistanceToNow } from "@/lib/time-utils"; function getParticleDisplayName(particle: Particle): string { switch (particle.type) { case "stream": case "folder": return particle.properties.name; case "quest": return particle.properties.title; case "paper": return particle.properties.title; case "file": return particle.properties.filename; case "text": return particle.properties.content.slice(0, 30); case "media": return particle.type; } } // --- Playback reducer --- type PlaybackStatus = "idle" | "playing" | "ended"; interface PlaybackState { currentIndex: number; status: PlaybackStatus; paused: boolean; } type PlaybackAction = | { type: "INIT"; particleCount: number, initialIndex?: number } | { type: "NEXT"; particleCount: number } | { type: "PREV" } | { type: "GO_TO"; index: number; particleCount: number } | { type: "PAUSE" } | { type: "RESUME" } | { type: "SYNC_PARTICLES"; particleCount: number }; function playbackReducer( state: PlaybackState, action: PlaybackAction, ): PlaybackState { switch (action.type) { case "INIT": return { currentIndex: action.initialIndex ?? 0, status: action.particleCount > 0 ? "playing" : "idle", paused: false, }; case "NEXT": if (state.currentIndex < action.particleCount - 1) { return { ...state, currentIndex: state.currentIndex + 1, paused: false }; } return { ...state, status: "ended", paused: false }; case "PREV": if (state.currentIndex > 0) { return { ...state, currentIndex: state.currentIndex - 1, status: "playing", paused: false, }; } return state; case "GO_TO": if (action.index >= 0 && action.index < action.particleCount) { return { ...state, currentIndex: action.index, status: "playing", paused: false, }; } return state; case "PAUSE": return { ...state, paused: true }; case "RESUME": return { ...state, paused: false }; case "SYNC_PARTICLES": // Clamp index if particles were removed; don't reset position if (action.particleCount === 0) { return { currentIndex: 0, status: "idle", paused: state.paused }; } if (state.status === "ended" && state.currentIndex < action.particleCount - 1) { // New particle appended — resume and advance to it return { ...state, currentIndex: state.currentIndex + 1, status: "playing", paused: false }; } if (state.currentIndex >= action.particleCount) { return { ...state, currentIndex: action.particleCount - 1 }; } return state; } } const initialState: PlaybackState = { currentIndex: 0, status: "idle", paused: false, }; // --- Exit countdown hook --- const EXIT_DELAY_MS = 5000; const EXIT_TICK_MS = 100; function useExitCountdown( status: PlaybackStatus, composeActive: boolean, onExit: () => void, ) { const [remainingMs, setRemainingMs] = useState(null); const handleExit = useEffectEvent(() => { onExit(); }); // Start/cancel countdown based on playback status useEffect(() => { if (status === "ended") { setRemainingMs(EXIT_DELAY_MS); } else { setRemainingMs(null); } }, [status]); // Tick the countdown down (pauses when compose is active) useEffect(() => { if (remainingMs === null || remainingMs <= 0 || composeActive) return; const interval = setInterval(() => { setRemainingMs((prev) => { if (prev === null) return null; const next = prev - EXIT_TICK_MS; return next <= 0 ? 0 : next; }); }, EXIT_TICK_MS); return () => clearInterval(interval); }, [remainingMs !== null && remainingMs > 0, composeActive]); // Navigate once countdown hits zero useEffect(() => { if (remainingMs !== null && remainingMs <= 0) { handleExit(); } }, [remainingMs]); return remainingMs; } // --- StreamView --- interface StreamViewProps { streamParticle: Particle & { type: "stream" }; path: ParticlePath; } export function StreamView({ path, streamParticle }: StreamViewProps) { const { networkId } = parseParticlePath(path); const navigate = useNavigate(); const { children } = useLiveParticleChildren(path, "created_at", "asc"); const [state, dispatch] = useReducer(playbackReducer, initialState); const [composeActive, setComposeActive] = useState(false); const [progress, setProgress] = useState(0); const hasInitializedRef = useRef(null); const handleExitNavigate = useCallback(() => { navigate(`/${networkId}`); }, [navigate, networkId]); const exitRemainingMs = useExitCountdown( state.status, composeActive, handleExitNavigate, ); const userId = useAuthStore((s) => s.user?.id); // Reset progress when particle changes useEffect(() => { setProgress(0); }, [state.currentIndex]); // Init playback once per stream entry, only after children have loaded useEffect(() => { if (children.length === 0) return; if (hasInitializedRef.current === streamParticle.id) return; hasInitializedRef.current = streamParticle.id; const playbackPosition = streamParticle.playback_markers?.[userId ?? ""]; let initialIndex = 0; if (playbackPosition) { const foundIndex = children.findIndex( (c) => c.created_at.getTime() === playbackPosition.getTime(), ); if (foundIndex !== -1) { initialIndex = foundIndex; } } dispatch({ type: "INIT", particleCount: children.length, initialIndex }); }, [streamParticle.id, userId, children]); // Sync on subsequent changes (new particle appended, removed, etc.) useEffect(() => { if (hasInitializedRef.current !== streamParticle.id) return; dispatch({ type: "SYNC_PARTICLES", particleCount: children.length }); }, [children.length, streamParticle.id]); // Pause/resume playback when compose overlay opens/closes useEffect(() => { if (composeActive) dispatch({ type: "PAUSE" }); else dispatch({ type: "RESUME" }); }, [composeActive]); const next = useCallback(() => { dispatch({ type: "NEXT", particleCount: children.length }); }, [children.length]); const prev = useCallback(() => { dispatch({ type: "PREV" }); }, []); const goTo = useCallback( (index: number) => { dispatch({ type: "GO_TO", index, particleCount: children.length }); }, [children.length], ); // Click-to-navigate: left 30% = prev, right 70% = next const handlePlaybackClick = useCallback( (e: React.MouseEvent) => { const rect = e.currentTarget.getBoundingClientRect(); const x = (e.clientX - rect.left) / rect.width; if (x < 0.3) prev(); else if (x > 0.7) next(); }, [prev, next], ); // Playback keyboard: arrows, escape useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (composeActive) return; const target = e.target as HTMLElement; if ( target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable ) { return; } switch (e.key) { case "ArrowRight": case "ArrowDown": e.preventDefault(); next(); break; case "ArrowLeft": case "ArrowUp": e.preventDefault(); prev(); break; case "Escape": e.preventDefault(); navigate(`/${networkId}`); break; } } window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [composeActive, next, prev, navigate, networkId], ); const currentParticle = children[state.currentIndex] ?? null; useEffect(() => { if (!userId || !currentParticle) return; const streamDocPath = toFirestoreDocPath(path); updateStreamPlaybackMarker(streamDocPath, userId, currentParticle.created_at); }, [currentParticle?.id, path]) // Author info from current particle const authorEmail = currentParticle?.created_by_email ?? ""; const authorInitials = authorEmail.split("@")[0]?.slice(0, 2).toUpperCase() ?? ""; if (children.length === 0) { return (

No particles in this stream yet

); } // Render particle content inline (replaces ParticleRenderer) function renderParticle(particle: Particle) { switch (particle.type) { case "media": return ( ); case "text": return ( ); default: return ; } } return (
{/* Progress indicator */}
{/* Main playback area */}
{currentParticle && (
{renderParticle(currentParticle)}
)}
{/* Bottom overlay: stream info + reply */}
{/* Exit countdown */} {exitRemainingMs !== null && ( Closing in {Math.ceil(exitRemainingMs / 1000)}s )}
); } function TopBar({ networkId, particle, streamParticle }: { networkId: string; particle: Particle; streamParticle: Particle & { type: "stream" } }) { const navigate = useNavigate(); return (
navigate("/")} > navigate(`/${networkId}`)} > {streamParticle && ( <> {getParticleDisplayName(streamParticle)} )} {particle && ( <> )}
); } function NetworkBreadcrumbContent({ networkId }: { networkId: string }) { const { data: networks } = useNetworks(); const network = networks?.find((n) => n.id === networkId); const name = network?.name ?? networkId; const initials = name.slice(0, 2).toUpperCase(); return ( {initials} {name} ); } function ParticleBreadcrumbContent({ particle }: { particle: Particle }) { const createdByEmail = particle.created_by_email; const prefix = createdByEmail.split('@')[0]; const initials = createdByEmail.slice(0, 2).toUpperCase(); return ( {initials} {prefix} - {formatDistanceToNow(particle.created_at.toISOString())} ) } // Shows a list of avatars of users who have seen the current particle, based on playback markers in the stream particle. const SeenIndicator = ({ stream, currentParticle, networkId }: { stream: Particle & { type: "stream" }, currentParticle: Particle, networkId: string }) => { const authedUser = useAuthStore((s) => s.user); const network = useNetwork(networkId); const playbackMarkers = stream.playback_markers ?? {}; const seenUserIds = Object.entries(playbackMarkers) .filter(([userId, timestamp]) => timestamp.getTime() >= currentParticle.created_at.getTime() && userId !== authedUser?.id) .map(([userId, _]) => userId); const seenUserEmails = seenUserIds .map((userId) => network?.humans?.find((h) => h.id === userId)?.email) .filter((email): email is string => !!email); if (seenUserIds.length === 0) return null; return ( <> {seenUserEmails.length > 0 && "Seen by"} {seenUserEmails.map((email) => ( {email.split("@")[0].slice(0, 2)}

Seen by {email.split("@")[0]}

))}
); }