232 lines
7.2 KiB
TypeScript
232 lines
7.2 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import { CircleCheck, FileText, Image, List, Mic, Video } from 'lucide-react';
|
|
import { isParticleDeleted, type Human, type Particle } from '@/api/types';
|
|
import { cn } from '@/lib/utils';
|
|
import { useNetwork } from '@/hooks/use-networks';
|
|
import { resolveHumanDisplay } from '@/lib/humans';
|
|
import { RelativeTimestamp } from '@/components/relative-timestamp';
|
|
import { HumanAvatar } from '@/components/human-avatar';
|
|
import { KeyHint } from '@/components/key-hint';
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
|
|
interface StreamListSidebarProps {
|
|
items: Particle[];
|
|
networkId: string;
|
|
currentIndex: number;
|
|
onSelect: (index: number) => void;
|
|
onToggle: () => void;
|
|
}
|
|
|
|
/**
|
|
* Browse-mode panel beside the stream: a chat-like timeline of every
|
|
* particle. Selecting a message plays it in the immersive stream view;
|
|
* nothing auto-advances.
|
|
*/
|
|
export function StreamListSidebar({
|
|
items,
|
|
networkId,
|
|
currentIndex,
|
|
onSelect,
|
|
onToggle,
|
|
}: StreamListSidebarProps) {
|
|
const network = useNetwork(networkId);
|
|
const rowRefs = useRef<Array<HTMLDivElement | null>>([]);
|
|
|
|
useEffect(() => {
|
|
if (currentIndex >= 0) {
|
|
rowRefs.current[currentIndex]?.scrollIntoView({ block: 'nearest' });
|
|
}
|
|
}, [currentIndex]);
|
|
|
|
return (
|
|
<aside className="bg-sidebar text-sidebar-foreground border-border flex w-60 shrink-0 flex-col overflow-hidden border-l">
|
|
<div className="border-border flex shrink-0 items-center gap-2 border-b px-4 py-3">
|
|
<List className="text-muted-foreground size-3.5" />
|
|
<span className="mr-auto truncate text-sm font-medium">
|
|
{items.length} messages
|
|
</span>
|
|
<KeyHint
|
|
keys="L"
|
|
onClick={onToggle}
|
|
title="Hide list (or press L)"
|
|
className="text-muted-foreground text-xs"
|
|
>
|
|
to close
|
|
</KeyHint>
|
|
</div>
|
|
<ScrollArea className="min-h-0 flex-1">
|
|
<div className="flex flex-col gap-0.5 px-2 py-2">
|
|
{items.map((item, index) => (
|
|
<div
|
|
key={item.id}
|
|
ref={(el) => {
|
|
rowRefs.current[index] = el;
|
|
}}
|
|
>
|
|
<ChatRow
|
|
particle={item}
|
|
humans={network?.humans}
|
|
isSelected={index === currentIndex}
|
|
onClick={() => onSelect(index)}
|
|
/>
|
|
</div>
|
|
))}
|
|
{items.length === 0 && (
|
|
<p className="text-muted-foreground px-2 py-8 text-center text-sm">
|
|
No particles in this stream yet.
|
|
</p>
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
function ChatRow({
|
|
particle,
|
|
humans,
|
|
isSelected,
|
|
onClick,
|
|
}: {
|
|
particle: Particle;
|
|
humans: Human[] | undefined;
|
|
isSelected: boolean;
|
|
onClick: () => void;
|
|
}) {
|
|
const sender = resolveHumanDisplay(particle.created_by_human_id, humans);
|
|
|
|
return (
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={onClick}
|
|
// Enter only — Space is reserved for play/pause in the stream view.
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') onClick();
|
|
}}
|
|
className={cn(
|
|
'flex cursor-pointer items-start gap-2.5 rounded-md px-2 py-2 text-left transition-colors',
|
|
isSelected ? 'bg-accent' : 'hover:bg-accent/50',
|
|
)}
|
|
>
|
|
<HumanAvatar
|
|
size="sm"
|
|
className="mt-0.5 shrink-0"
|
|
initials={sender.initials}
|
|
avatarObjectId={sender.avatarObjectId}
|
|
fallbackClassName="bg-muted text-muted-foreground text-[10px] font-medium"
|
|
/>
|
|
<div className="min-w-0 flex-1 overflow-hidden">
|
|
<div className="flex items-baseline justify-between gap-2">
|
|
<span className="truncate text-xs font-semibold">
|
|
{sender.displayName}
|
|
</span>
|
|
<span className="text-muted-foreground shrink-0 text-[10px]">
|
|
<RelativeTimestamp date={particle.created_at} />
|
|
</span>
|
|
</div>
|
|
<ChatRowContent particle={particle} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ChatRowContent({ particle }: { particle: Particle }) {
|
|
if (isParticleDeleted(particle)) {
|
|
return (
|
|
<p className="text-muted-foreground text-xs italic">
|
|
This particle was deleted
|
|
</p>
|
|
);
|
|
}
|
|
|
|
switch (particle.type) {
|
|
case 'text':
|
|
return (
|
|
<p className="text-muted-foreground line-clamp-2 whitespace-pre-line text-xs leading-relaxed [overflow-wrap:anywhere]">
|
|
{particle.properties.content}
|
|
</p>
|
|
);
|
|
case 'media': {
|
|
const mime = particle.properties.mime_type;
|
|
const transcript = particle.properties.transcript?.transcript;
|
|
const isVideo = mime.startsWith('video/');
|
|
const isAudio = mime.startsWith('audio/');
|
|
const isImage = mime.startsWith('image/');
|
|
const Icon = isVideo ? Video : isAudio ? Mic : isImage ? Image : Video;
|
|
const label = isVideo
|
|
? 'Video clip'
|
|
: isAudio
|
|
? 'Voice note'
|
|
: isImage
|
|
? 'Photo'
|
|
: 'Media';
|
|
const durationSec = Math.round(particle.properties.duration_ms / 1000);
|
|
const duration =
|
|
durationSec > 0
|
|
? ` · ${Math.floor(durationSec / 60)}:${String(durationSec % 60).padStart(2, '0')}`
|
|
: '';
|
|
return (
|
|
<div className="flex flex-col gap-0.5">
|
|
<span className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
|
<Icon className="text-muted-foreground size-3.5 shrink-0" />
|
|
{label}
|
|
{duration}
|
|
</span>
|
|
{transcript && (
|
|
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
|
|
{transcript}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
case 'file':
|
|
return (
|
|
<span className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
|
<FileText className="text-muted-foreground size-3.5 shrink-0" />
|
|
<span className="truncate">{particle.properties.filename}</span>
|
|
</span>
|
|
);
|
|
case 'task': {
|
|
const { title, done, checklist = [] } = particle.properties;
|
|
const doneCount = checklist.filter((item) => item.done).length;
|
|
return (
|
|
<div className="flex flex-col gap-0.5">
|
|
<span className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
|
<CircleCheck
|
|
className={cn(
|
|
'size-3.5 shrink-0',
|
|
done
|
|
? 'text-emerald-600 dark:text-emerald-400'
|
|
: 'text-muted-foreground',
|
|
)}
|
|
/>
|
|
<span
|
|
className={cn(
|
|
'truncate',
|
|
done && 'text-muted-foreground line-through',
|
|
)}
|
|
>
|
|
{title}
|
|
</span>
|
|
</span>
|
|
{checklist.length > 0 && (
|
|
<span className="text-muted-foreground pl-5 text-[10px]">
|
|
{doneCount} / {checklist.length} subtasks
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
case 'paper':
|
|
return (
|
|
<p className="text-muted-foreground truncate text-xs">
|
|
{particle.properties.title}
|
|
</p>
|
|
);
|
|
default:
|
|
return <p className="text-muted-foreground text-xs">{particle.type}</p>;
|
|
}
|
|
}
|