make the stream previews look alive

This commit is contained in:
talksik
2026-03-19 09:09:12 -07:00
parent 1bf5b466d1
commit 7c26e225b5
+122 -12
View File
@@ -1,7 +1,19 @@
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { Radio } from "lucide-react";
import {
Radio,
MessageSquare,
Video,
Mic,
Image,
FileText,
CircleCheck,
StickyNote,
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useLiveParticleChildren, useLiveLatestChild } from "@/hooks/use-particle";
import { useAuthStore } from "@/stores/auth-store";
import {
parseParticlePath,
particlePath,
@@ -16,12 +28,39 @@ import { Progress } from "@/components/ui/progress";
import { Small } from "@/components/ui/typography";
import type { Particle, StreamProperties } from "@/api/types";
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":
return "Clip received";
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":
@@ -44,44 +83,115 @@ function StreamRow({
}) {
const streamPath = particlePath(networkId, [particle.id]);
const { latestChild } = useLiveLatestChild(streamPath);
const user = useAuthStore((s) => s.user);
const userEmail = user?.email ?? "";
const initials = latestChild
? getInitials(latestChild.created_by_email)
: particle.properties.name.slice(0, 2).toUpperCase();
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:${userEmail}`,
);
if (otherEntry) {
const otherEmail = otherEntry.replace("human:", "");
return getInitials(otherEmail);
}
}
return particle.properties.name.slice(0, 2).toUpperCase();
}, [isDM, particle.visible_to, particle.properties.name, userEmail]);
const isUnseen = useMemo(() => {
if (!latestChild) return false;
const latestChildTimestamp = latestChild.created_at.getTime();
const userPlaybackPosition =
particle.markers?.[userEmail]?.getTime() ?? 0;
return latestChildTimestamp > userPlaybackPosition;
}, [latestChild, particle.markers, userEmail]);
const senderPrefix = useMemo(() => {
if (!latestChild) return null;
const isCurrentUser = latestChild.created_by_email === userEmail;
if (isDM) {
return isCurrentUser ? "You: " : null;
}
// Group stream
if (isCurrentUser) return "You: ";
const emailPrefix = latestChild.created_by_email.split("@")[0];
const capitalized =
emailPrefix.charAt(0).toUpperCase() + emailPrefix.slice(1);
return `${capitalized}: `;
}, [latestChild, userEmail, isDM]);
const subtitle = latestChild
? getMessagePreview(latestChild)
: particle.properties.status;
const TypeIcon = latestChild ? getParticleTypeIcon(latestChild) : Radio;
return (
<button
type="button"
onClick={onClick}
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-accent"
>
<Avatar>
<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="truncate text-sm font-medium">
<p
className={cn(
"truncate text-sm",
isUnseen
? "font-semibold text-foreground"
: "font-medium text-muted-foreground",
)}
>
{particle.properties.name}
</p>
{latestChild && (
<Small className="text-muted-foreground shrink-0">
<Small
className={cn(
"shrink-0",
isUnseen ? "text-primary" : "text-muted-foreground",
)}
>
{formatDistanceToNow(latestChild.created_at.toISOString())}
</Small>
)}
</div>
<div className="text-muted-foreground flex items-center gap-1">
{!latestChild && <Radio className="size-3" />}
<Small className="text-muted-foreground truncate">
<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" />
)}
</button>
);
}