import { useMemo, useRef, useEffect, useCallback, memo, createElement, } from 'react'; import { useNavigate } from 'react-router-dom'; import { Radio, MessageSquare, Video, Mic, Image, FileText, CircleCheck, StickyNote, Headphones, Trash2, type LucideIcon, } from 'lucide-react'; import { cn, getInitials } from '@/lib/utils'; import { useLiveLatestChild } from '@/hooks/use-particle'; import { useAuthStore } from '@/stores/auth-store'; import { particlePath } from '@/lib/particle-path'; import { resolveHumanDisplay } from '@/lib/humans'; import { RelativeTimestamp } from '@/components/relative-timestamp'; 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 { Button } from '@/components/ui/button'; import { isParticleDeleted, type Particle, type StreamProperties, } from '@/api/types'; import type { StreamParticle } from '@/hooks/use-stream-particles'; import { useNetwork } from '@/hooks/use-networks'; import { useStreamAutoplay } from '@/hooks/use-stream-autoplay'; import { useDownloadUrl } from '@/hooks/use-download-url'; import { StreamContextMenu } from '@/features/particles/stream-context-menu'; function VideoThumbnail({ objectId, isUnseen, }: { objectId: string; isUnseen: boolean; }) { const { data: url } = useDownloadUrl(objectId); return (
{url && (
); } function getParticleTypeIcon(particle: Particle): LucideIcon { if (isParticleDeleted(particle)) return Trash2; 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 { if (isParticleDeleted(particle)) return 'Deleted particle'; switch (particle.type) { case 'text': return particle.properties.content; case 'media': { const mime = particle.properties.mime_type; if (mime.startsWith('image/')) return 'Photo'; if (mime.startsWith('video/') || mime.startsWith('audio/')) { const transcriptText = particle.properties.transcript?.transcript; if (transcriptText) return transcriptText; return mime.startsWith('video/') ? 'Video clip' : 'Voice note'; } return 'Media'; } case 'file': return particle.properties.filename; case 'quest': return particle.properties.title; case 'paper': return particle.properties.title; default: return particle.type; } } const StreamRow = memo(function StreamRow({ particle, networkId, onNavigate, isSelected, shortcutKey, }: { particle: Particle & { type: 'stream'; properties: StreamProperties }; networkId: string; onNavigate: (streamId: string) => void; isSelected?: boolean; shortcutKey?: number; }) { const streamPath = particlePath(networkId, [particle.id]); const { latestChild } = useLiveLatestChild(streamPath); const user = useAuthStore((s) => s.user); const userId = user?.id ?? ''; const network = useNetwork(networkId); useStreamAutoplay(latestChild, particle, networkId, network ?? undefined); const hasActiveHuddle = particle.huddle_active_participants && particle.huddle_active_participants.length > 0; const huddleCount = particle.huddle_active_participants?.length ?? 0; 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:${userId}`, ); if (otherEntry) { const otherId = otherEntry.replace('human:', ''); const otherHuman = network?.humans?.find((h) => h.id === otherId); if (otherHuman) return getInitials(otherHuman.email); } } if (latestChild) { const creator = network?.humans?.find( (h) => h.id === latestChild.created_by_human_id, ); if (creator) return getInitials(creator.email); } return particle.properties.name.slice(0, 2).toUpperCase(); }, [ isDM, particle.visible_to, particle.properties.name, userId, latestChild, network, ]); 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_human_id === userId; if (isDM) { return isCurrentUser ? 'You: ' : null; } // Group stream if (isCurrentUser) return 'You: '; const { displayName } = resolveHumanDisplay( latestChild.created_by_human_id, network?.humans, ); const capitalized = displayName.charAt(0).toUpperCase() + displayName.slice(1); return `${capitalized}: `; }, [latestChild, userId, isDM, network]); const subtitle = latestChild ? getMessagePreview(latestChild) : particle.properties.name; // Rendered via createElement below: a call-result used directly as a JSX tag // is flagged as a dynamically-created component. const typeIcon = latestChild ? getParticleTypeIcon(latestChild) : Radio; const videoThumbObjectId = latestChild && !isParticleDeleted(latestChild) && latestChild.type === 'media' && latestChild.properties.mime_type.startsWith('video/') ? latestChild.properties.object_id : null; return (
onNavigate(particle.id)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onNavigate(particle.id); }} className={cn( 'flex w-full items-center gap-3 px-4 py-3 text-left cursor-pointer transition-colors hover:bg-accent', isSelected && 'bg-accent', hasActiveHuddle && 'bg-gradient-to-r from-red-500/10 to-transparent', )} > {shortcutKey && ( {shortcutKey} )} {videoThumbObjectId ? ( ) : ( {initials} )}

{particle.properties.name}

{hasActiveHuddle && ( {huddleCount} )} {latestChild && ( )}
{createElement(typeIcon, { className: cn( 'size-3.5 shrink-0', isUnseen ? 'text-foreground' : 'text-muted-foreground', ), })} {senderPrefix && ( {senderPrefix} )} {subtitle}
{isUnseen && }
); }); interface ParticleListViewProps { streams: StreamParticle[]; networkId: string; isLoading: boolean; 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.). */ export function ParticleListView({ streams, networkId, isLoading, selectedIndex, canLoadMore, onLoadMore, }: ParticleListViewProps) { const navigate = useNavigate(); const rowRefs = useRef<(HTMLDivElement | null)[]>([]); const navigateToStream = useCallback( (streamId: string) => navigate(`/${networkId}/${streamId}`), [navigate, networkId], ); useEffect(() => { if ( selectedIndex !== null && selectedIndex !== undefined && selectedIndex >= 0 ) { rowRefs.current[selectedIndex]?.scrollIntoView({ block: 'nearest' }); } }, [selectedIndex]); if (isLoading) { return ; } if (streams.length === 0) { return (

No streams here. Start a conversation using the keyboard shortcuts below.

); } return (
{streams.map((stream, index) => (
{ rowRefs.current[index] = el; }} > {index < streams.length - 1 && }
))} {canLoadMore && onLoadMore && (
)}
); }