Files
llink/js/desktop/src/features/particles/particle-list-view.tsx
T
2026-06-01 14:17:58 -07:00

400 lines
12 KiB
TypeScript

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 (
<div
className={cn(
'size-8 shrink-0 overflow-hidden rounded-md bg-muted',
isUnseen && 'ring-2 ring-primary',
)}
>
{url && (
<video
// Seek ~15 frames in so we skip any initial black/fade-in frames
src={`${url}#t=0.5`}
muted
playsInline
preload="metadata"
className="h-full w-full object-cover"
/>
)}
</div>
);
}
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 (
<div
role="button"
tabIndex={0}
onClick={() => 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 && (
<kbd className="flex size-5 shrink-0 items-center justify-center rounded bg-white/10 font-mono text-xs text-muted-foreground">
{shortcutKey}
</kbd>
)}
{videoThumbObjectId ? (
<VideoThumbnail objectId={videoThumbObjectId} isUnseen={!!isUnseen} />
) : (
<Avatar className={cn(isUnseen && 'ring-2 ring-primary')}>
<AvatarFallback className="bg-primary/10 text-primary font-medium">
{initials}
</AvatarFallback>
</Avatar>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p
className={cn(
'truncate text-sm',
isUnseen
? 'font-semibold text-foreground'
: 'font-medium text-muted-foreground',
)}
>
{particle.properties.name}
</p>
<div className="flex shrink-0 items-center gap-1.5">
{hasActiveHuddle && (
<span className="flex items-center gap-1 rounded-full bg-red-500/15 px-1.5 py-0.5">
<Headphones className="size-3 text-red-400" />
<span className="text-[10px] font-medium text-red-400">
{huddleCount}
</span>
</span>
)}
{latestChild && (
<Small
className={cn(
'shrink-0',
isUnseen ? 'text-primary' : 'text-muted-foreground',
)}
>
<RelativeTimestamp date={latestChild.created_at} />
</Small>
)}
</div>
</div>
<div className="flex items-center gap-1">
{createElement(typeIcon, {
className: cn(
'size-3.5 shrink-0',
isUnseen ? 'text-foreground' : 'text-muted-foreground',
),
})}
<Small
className={cn(
'truncate',
isUnseen
? 'text-foreground font-medium'
: 'text-muted-foreground font-normal',
)}
>
{senderPrefix && (
<span className="text-muted-foreground">{senderPrefix}</span>
)}
{subtitle}
</Small>
</div>
</div>
{isUnseen && <span className="size-2 shrink-0 rounded-full bg-primary" />}
</div>
);
});
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 <Progress />;
}
if (streams.length === 0) {
return (
<div className="mx-auto flex h-full max-w-sm flex-col items-center justify-center gap-2 px-4 text-center">
<Radio className="text-muted-foreground size-8" />
<p className="text-muted-foreground text-sm">
No streams here. Start a conversation using the keyboard shortcuts
below.
</p>
</div>
);
}
return (
<div>
{streams.map((stream, index) => (
<StreamContextMenu
key={stream.id}
particle={stream}
networkId={networkId}
>
<div
ref={(el) => {
rowRefs.current[index] = el;
}}
>
<StreamRow
particle={stream}
networkId={networkId}
onNavigate={navigateToStream}
isSelected={index === selectedIndex}
shortcutKey={index < 9 ? index + 1 : undefined}
/>
{index < streams.length - 1 && <Separator className="px-4" />}
</div>
</StreamContextMenu>
))}
{canLoadMore && onLoadMore && (
<div className="flex justify-center p-3">
<Button variant="ghost" size="sm" onClick={onLoadMore}>
Load more
</Button>
</div>
)}
</div>
);
}