400 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|