Files
llink/js/desktop/src/features/particles/stream-list-sidebar.tsx
T
2026-06-13 11:25:50 -07:00

225 lines
6.9 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="dark flex max-w-60 shrink-0 flex-col border-l border-white/10 bg-zinc-950">
<div className="flex shrink-0 items-center gap-2 border-b border-white/10 px-4 py-3">
<List className="size-3.5 text-white/40" />
<span className="truncate text-sm font-medium text-white/90 mr-auto">
{items.length} messages
</span>
<KeyHint
keys="L"
onClick={onToggle}
title="Hide list (or press L)"
className="text-xs text-white/40"
>
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="px-2 py-8 text-center text-sm text-white/40">
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 w-full cursor-pointer items-start gap-2.5 rounded-md px-2 py-2 text-left transition-colors',
isSelected ? 'bg-white/10' : 'hover:bg-white/5',
)}
>
<HumanAvatar
size="sm"
className="mt-0.5 shrink-0"
initials={sender.initials}
avatarObjectId={sender.avatarObjectId}
fallbackClassName="bg-white/10 text-white/80 text-[10px] font-medium"
/>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<span className="truncate text-xs font-semibold text-white/90">
{sender.displayName}
</span>
<span className="shrink-0 text-[10px] text-white/35">
<RelativeTimestamp date={particle.created_at} />
</span>
</div>
<ChatRowContent particle={particle} />
</div>
</div>
);
}
function ChatRowContent({ particle }: { particle: Particle }) {
if (isParticleDeleted(particle)) {
return (
<p className="text-xs italic text-white/35">This particle was deleted</p>
);
}
switch (particle.type) {
case 'text':
return (
<p className="line-clamp-2 text-xs leading-relaxed whitespace-pre-line text-white/70">
{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="flex items-center gap-1.5 text-xs text-white/70">
<Icon className="size-3.5 shrink-0 text-white/50" />
{label}
{duration}
</span>
{transcript && (
<p className="line-clamp-2 text-xs leading-relaxed text-white/45">
{transcript}
</p>
)}
</div>
);
}
case 'file':
return (
<span className="flex items-center gap-1.5 text-xs text-white/70">
<FileText className="size-3.5 shrink-0 text-white/50" />
<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="flex items-center gap-1.5 text-xs text-white/70">
<CircleCheck
className={cn(
'size-3.5 shrink-0',
done ? 'text-emerald-400' : 'text-white/50',
)}
/>
<span
className={cn('truncate', done && 'text-white/40 line-through')}
>
{title}
</span>
</span>
{checklist.length > 0 && (
<span className="pl-5 text-[10px] text-white/40">
{doneCount} / {checklist.length} subtasks
</span>
)}
</div>
);
}
case 'paper':
return (
<p className="truncate text-xs text-white/70">
{particle.properties.title}
</p>
);
default:
return <p className="text-xs text-white/45">{particle.type}</p>;
}
}