295 lines
9.2 KiB
TypeScript
295 lines
9.2 KiB
TypeScript
import { useMemo } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import {
|
|
Radio,
|
|
MessageSquare,
|
|
Video,
|
|
Mic,
|
|
Image,
|
|
FileText,
|
|
CircleCheck,
|
|
StickyNote,
|
|
Timer,
|
|
Headphones,
|
|
type LucideIcon,
|
|
} from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { useLiveLatestChild } from "@/hooks/use-particle";
|
|
import { useAuthStore } from "@/stores/auth-store";
|
|
import {
|
|
particlePath,
|
|
type ParticlePath,
|
|
} from "@/lib/particle-path";
|
|
import { getInitials } from "@/lib/utils";
|
|
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 type { Particle, StreamProperties } from "@/api/types";
|
|
import { useNetwork } from "@/hooks/use-networks";
|
|
import { useExpiringSoon } from "@/hooks/use-expiring-soon";
|
|
import { useStreamParticles } from "@/hooks/use-stream-particles";
|
|
import { useStreamAutoplay } from "@/hooks/use-stream-autoplay";
|
|
|
|
function getParticleTypeIcon(particle: Particle): LucideIcon {
|
|
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 {
|
|
switch (particle.type) {
|
|
case "text":
|
|
return particle.properties.content;
|
|
case "media": {
|
|
const mime = particle.properties.mime_type;
|
|
if (mime.startsWith("video/")) return "Video clip";
|
|
if (mime.startsWith("audio/")) return "Voice note";
|
|
if (mime.startsWith("image/")) return "Photo";
|
|
return "Media";
|
|
}
|
|
case "file":
|
|
return particle.properties.filename;
|
|
case "quest":
|
|
return particle.properties.title;
|
|
case "paper":
|
|
return particle.properties.title;
|
|
default:
|
|
return particle.type;
|
|
}
|
|
}
|
|
|
|
function StreamRow({
|
|
particle,
|
|
networkId,
|
|
onClick,
|
|
isSelected,
|
|
shortcutKey,
|
|
}: {
|
|
particle: Particle & { type: "stream"; properties: StreamProperties };
|
|
networkId: string;
|
|
onClick: () => 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 expiringSoon = useExpiringSoon(
|
|
particle.last_child_created_at,
|
|
network?.message_retention_hours ?? 24,
|
|
);
|
|
|
|
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 creator = network?.humans?.find((h) => h.id === latestChild.created_by_human_id);
|
|
const name = creator?.email_prefix ?? latestChild.created_by_human_id;
|
|
const capitalized = name.charAt(0).toUpperCase() + name.slice(1);
|
|
return `${capitalized}: `;
|
|
}, [latestChild, userId, isDM, network]);
|
|
|
|
const subtitle = latestChild
|
|
? getMessagePreview(latestChild)
|
|
: particle.properties.status;
|
|
|
|
const TypeIcon = latestChild ? getParticleTypeIcon(latestChild) : Radio;
|
|
|
|
return (
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={onClick}
|
|
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onClick(); }}
|
|
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>
|
|
)}
|
|
<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>
|
|
)}
|
|
{expiringSoon && (
|
|
<Timer className="size-3 text-muted-foreground/60" />
|
|
)}
|
|
{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">
|
|
<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 {
|
|
path: ParticlePath;
|
|
selectedIndex?: number | null;
|
|
}
|
|
|
|
/**
|
|
* List of stream particles for a container (network root, folder, etc.).
|
|
*/
|
|
export function ParticleListView({ path, selectedIndex }: ParticleListViewProps) {
|
|
const { streams, isLoading, networkId } = useStreamParticles(path);
|
|
const navigate = useNavigate();
|
|
|
|
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 recent streams yet. Start a conversation using the keyboard shortcuts below.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
{streams.map((stream, index) => (
|
|
<div
|
|
key={stream.id}
|
|
ref={index === selectedIndex ? (el) => el?.scrollIntoView({ block: "nearest" }) : undefined}
|
|
>
|
|
<StreamRow
|
|
particle={stream}
|
|
networkId={networkId}
|
|
onClick={() => navigate(`/${networkId}/${stream.id}`)}
|
|
isSelected={index === selectedIndex}
|
|
shortcutKey={index < 9 ? index + 1 : undefined}
|
|
/>
|
|
{index < streams.length - 1 && <Separator className="px-4" />}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|