170 lines
6.0 KiB
TypeScript
170 lines
6.0 KiB
TypeScript
import { forwardRef, useMemo } from "react";
|
|
import { Headphones } 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 type { Particle, StreamProperties } from "@/api/types";
|
|
import { useStreamAutoplay } from "@/hooks/use-stream-autoplay";
|
|
import { ParticlePreview } from "@/features/particles/particle-preview";
|
|
import { useNetwork } from "@/hooks/use-networks";
|
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
import { RelativeTimestamp } from "@/components/relative-timestamp";
|
|
import { Small } from "@/components/ui/typography";
|
|
|
|
interface StreamCardProps {
|
|
particle: Particle & { type: "stream"; properties: StreamProperties };
|
|
networkId: string;
|
|
onClick: () => void;
|
|
isSelected?: boolean;
|
|
shortcutKey?: number;
|
|
}
|
|
|
|
export const StreamCard = forwardRef<HTMLDivElement, StreamCardProps>(function StreamCard({ particle, networkId, onClick, isSelected, shortcutKey }, ref) {
|
|
const streamPath = particlePath(networkId, [particle.id]);
|
|
const { latestChild } = useLiveLatestChild(streamPath);
|
|
const userId = useAuthStore((s) => s.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]);
|
|
|
|
// For media particles with a transcript, show it as an overlay on the preview
|
|
const transcript =
|
|
latestChild?.type === "media"
|
|
? latestChild.properties.transcript?.transcript
|
|
: undefined;
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={onClick}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" || e.key === " ") onClick();
|
|
}}
|
|
className={cn(
|
|
"cursor-pointer overflow-hidden rounded-xl ring-1 ring-foreground/10 transition-all hover:ring-foreground/20",
|
|
isUnseen && "ring-2 ring-primary",
|
|
isSelected && "ring-2 ring-ring",
|
|
hasActiveHuddle && "ring-2 ring-red-500/70",
|
|
)}
|
|
>
|
|
{/* Preview area */}
|
|
<div className="relative aspect-[4/3] overflow-hidden bg-muted">
|
|
{hasActiveHuddle && (
|
|
<div className="pointer-events-none absolute inset-0 z-10 bg-gradient-to-b from-red-500/15 to-transparent" />
|
|
)}
|
|
{shortcutKey && (
|
|
<kbd className="absolute top-1.5 left-1.5 z-10 flex size-5 items-center justify-center rounded bg-black/50 font-mono text-xs text-white/70">
|
|
{shortcutKey}
|
|
</kbd>
|
|
)}
|
|
{latestChild ? (
|
|
<ParticlePreview particle={latestChild} />
|
|
) : (
|
|
<div className="flex h-full w-full items-center justify-center">
|
|
<p className="text-muted-foreground text-xs italic">
|
|
No messages yet
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Transcript overlay for media with transcripts */}
|
|
{transcript && (
|
|
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent px-2.5 pt-6 pb-2">
|
|
<p className="line-clamp-2 text-md leading-snug text-white/90">
|
|
{transcript}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Info bar */}
|
|
<div className="flex items-center gap-2 px-2.5 py-2">
|
|
<Avatar className={cn("size-6 shrink-0", isUnseen && "ring-2 ring-primary")}>
|
|
<AvatarFallback className="bg-primary/10 text-primary text-[10px] font-medium">
|
|
{initials}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<Small
|
|
className={cn(
|
|
"min-w-0 truncate",
|
|
isUnseen
|
|
? "font-semibold text-foreground"
|
|
: "font-medium text-muted-foreground",
|
|
)}
|
|
>
|
|
{particle.properties.name}
|
|
</Small>
|
|
<div className="ml-auto 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(
|
|
"text-[10px]",
|
|
isUnseen ? "text-primary" : "text-muted-foreground",
|
|
)}
|
|
>
|
|
<RelativeTimestamp date={latestChild.created_at} />
|
|
</Small>
|
|
)}
|
|
{isUnseen && (
|
|
<span className="size-2 shrink-0 rounded-full bg-primary" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|