feat: stream list view and tasks (#279)
* first attempt at stream sidebar, tasks, and events * fix folder from root * cleanup folders and events, and condense changes * cleanup and add toggle for sidebar * cleanup * fix nits
This commit was merged in pull request #279.
This commit is contained in:
@@ -0,0 +1,492 @@
|
||||
import {
|
||||
Fragment,
|
||||
useMemo,
|
||||
useRef,
|
||||
useEffect,
|
||||
memo,
|
||||
createElement,
|
||||
} from 'react';
|
||||
import { Headphones, Radio, FolderIcon } 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 { HumanAvatar } from '@/components/human-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 { useNetwork } from '@/hooks/use-networks';
|
||||
import { useStreamAutoplay } from '@/hooks/use-stream-autoplay';
|
||||
import { useDownloadUrl } from '@/hooks/use-download-url';
|
||||
import { getMessagePreview, getParticleTypeIcon } from '@/lib/particle-display';
|
||||
import { StreamContextMenu } from '@/features/particles/stream-context-menu';
|
||||
|
||||
type StreamParticle = Particle & {
|
||||
type: 'stream';
|
||||
properties: StreamProperties;
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const StreamRow = memo(function StreamRow({
|
||||
particle,
|
||||
networkId,
|
||||
onOpen,
|
||||
isSelected,
|
||||
shortcutKey,
|
||||
}: {
|
||||
particle: StreamParticle;
|
||||
networkId: string;
|
||||
onOpen: (particleId: 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 avatar = 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 {
|
||||
initials: getInitials(otherHuman.email),
|
||||
avatarObjectId: otherHuman.avatar_object_id ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (latestChild) {
|
||||
const creator = network?.humans?.find(
|
||||
(h) => h.id === latestChild.created_by_human_id,
|
||||
);
|
||||
if (creator) {
|
||||
return {
|
||||
initials: getInitials(creator.email),
|
||||
avatarObjectId: creator.avatar_object_id ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
initials: particle.properties.name.slice(0, 2).toUpperCase(),
|
||||
avatarObjectId: null,
|
||||
};
|
||||
}, [
|
||||
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={() => onOpen(particle.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') onOpen(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} />
|
||||
) : (
|
||||
<HumanAvatar
|
||||
className={cn(isUnseen && 'ring-2 ring-primary')}
|
||||
avatarObjectId={avatar.avatarObjectId}
|
||||
initials={avatar.initials}
|
||||
fallbackClassName="bg-primary/10 text-primary font-medium"
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
});
|
||||
|
||||
const FolderRow = memo(function FolderRow({
|
||||
particle,
|
||||
networkId,
|
||||
onOpen,
|
||||
isSelected,
|
||||
shortcutKey,
|
||||
}: {
|
||||
particle: Particle & { type: 'folder' };
|
||||
networkId: string;
|
||||
onOpen: (particleId: string) => void;
|
||||
isSelected?: boolean;
|
||||
shortcutKey?: number;
|
||||
}) {
|
||||
const network = useNetwork(networkId);
|
||||
const creator = resolveHumanDisplay(
|
||||
particle.created_by_human_id,
|
||||
network?.humans,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onOpen(particle.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') onOpen(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',
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-amber-500/15">
|
||||
<FolderIcon className="size-4 text-amber-500" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{particle.properties.name}
|
||||
</p>
|
||||
<Small className="text-muted-foreground font-normal">
|
||||
Folder · {creator.displayName}
|
||||
</Small>
|
||||
</div>
|
||||
<Small className="shrink-0 text-muted-foreground">
|
||||
<RelativeTimestamp date={particle.created_at} />
|
||||
</Small>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const LeafRow = memo(function LeafRow({
|
||||
particle,
|
||||
networkId,
|
||||
onOpen,
|
||||
isSelected,
|
||||
shortcutKey,
|
||||
}: {
|
||||
particle: Particle;
|
||||
networkId: string;
|
||||
onOpen: (particleId: string) => void;
|
||||
isSelected?: boolean;
|
||||
shortcutKey?: number;
|
||||
}) {
|
||||
const network = useNetwork(networkId);
|
||||
const creator = resolveHumanDisplay(
|
||||
particle.created_by_human_id,
|
||||
network?.humans,
|
||||
);
|
||||
const typeIcon = getParticleTypeIcon(particle);
|
||||
const deleted = isParticleDeleted(particle);
|
||||
const taskDone = particle.type === 'task' && particle.properties.done;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onOpen(particle.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') onOpen(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',
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
{createElement(typeIcon, {
|
||||
className: cn(
|
||||
'size-4 shrink-0 text-muted-foreground',
|
||||
taskDone && 'text-emerald-500',
|
||||
),
|
||||
})}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={cn(
|
||||
'truncate text-sm',
|
||||
deleted || taskDone
|
||||
? 'text-muted-foreground line-through'
|
||||
: 'text-foreground',
|
||||
)}
|
||||
>
|
||||
{getMessagePreview(particle)}
|
||||
</p>
|
||||
<Small className="text-muted-foreground font-normal">
|
||||
{creator.displayName}
|
||||
</Small>
|
||||
</div>
|
||||
<Small className="shrink-0 text-muted-foreground">
|
||||
<RelativeTimestamp date={particle.created_at} />
|
||||
</Small>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface ParticleChildrenListProps {
|
||||
items: Particle[];
|
||||
networkId: string;
|
||||
isLoading: boolean;
|
||||
/** Open (navigate into / select) a particle by id. */
|
||||
onOpen: (particleId: string) => void;
|
||||
selectedIndex?: number | null;
|
||||
/** Render 1–9 shortcut badges next to the first nine rows. */
|
||||
showShortcuts?: boolean;
|
||||
emptyMessage?: string;
|
||||
/** When true, render a footer that invokes onLoadMore. */
|
||||
canLoadMore?: boolean;
|
||||
onLoadMore?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browsable list of a container's children, any particle type. Used by the
|
||||
* network root and folder views.
|
||||
*/
|
||||
export function ParticleChildrenList({
|
||||
items,
|
||||
networkId,
|
||||
isLoading,
|
||||
onOpen,
|
||||
selectedIndex,
|
||||
showShortcuts = true,
|
||||
emptyMessage = 'Nothing here yet. Create something using the keyboard shortcuts below.',
|
||||
canLoadMore,
|
||||
onLoadMore,
|
||||
}: ParticleChildrenListProps) {
|
||||
const rowRefs = useRef<Array<HTMLDivElement | null>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedIndex !== null &&
|
||||
selectedIndex !== undefined &&
|
||||
selectedIndex >= 0
|
||||
) {
|
||||
rowRefs.current[selectedIndex]?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Progress />;
|
||||
}
|
||||
|
||||
if (items.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">{emptyMessage}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{items.map((item, index) => {
|
||||
const rowProps = {
|
||||
networkId,
|
||||
onOpen,
|
||||
isSelected: index === selectedIndex,
|
||||
shortcutKey: showShortcuts && index < 9 ? index + 1 : undefined,
|
||||
};
|
||||
const row = (
|
||||
<div
|
||||
ref={(el) => {
|
||||
rowRefs.current[index] = el;
|
||||
}}
|
||||
>
|
||||
{item.type === 'stream' ? (
|
||||
<StreamRow particle={item} {...rowProps} />
|
||||
) : item.type === 'folder' ? (
|
||||
<FolderRow particle={item} {...rowProps} />
|
||||
) : (
|
||||
<LeafRow particle={item} {...rowProps} />
|
||||
)}
|
||||
{index < items.length - 1 && <Separator className="px-4" />}
|
||||
</div>
|
||||
);
|
||||
return item.type === 'stream' ? (
|
||||
<StreamContextMenu
|
||||
key={item.id}
|
||||
particle={item}
|
||||
networkId={networkId}
|
||||
>
|
||||
{row}
|
||||
</StreamContextMenu>
|
||||
) : (
|
||||
<Fragment key={item.id}>{row}</Fragment>
|
||||
);
|
||||
})}
|
||||
{canLoadMore && onLoadMore && (
|
||||
<div className="flex justify-center p-3">
|
||||
<Button variant="ghost" size="sm" onClick={onLoadMore}>
|
||||
Load more
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user