237 lines
6.8 KiB
TypeScript
237 lines
6.8 KiB
TypeScript
import { useMemo } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
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,
|
|
type ParticlePath,
|
|
} from "@/lib/particle-path";
|
|
import { getInitials } from "@/lib/utils";
|
|
import { formatDistanceToNow } from "@/lib/time-utils";
|
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
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";
|
|
|
|
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,
|
|
}: {
|
|
particle: Particle & { type: "stream"; properties: StreamProperties };
|
|
networkId: string;
|
|
onClick: () => void;
|
|
}) {
|
|
const streamPath = particlePath(networkId, [particle.id]);
|
|
const { latestChild } = useLiveLatestChild(streamPath);
|
|
const user = useAuthStore((s) => s.user);
|
|
const userEmail = user?.email ?? "";
|
|
|
|
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
|
|
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>
|
|
{latestChild && (
|
|
<Small
|
|
className={cn(
|
|
"shrink-0",
|
|
isUnseen ? "text-primary" : "text-muted-foreground",
|
|
)}
|
|
>
|
|
{formatDistanceToNow(latestChild.created_at.toISOString())}
|
|
</Small>
|
|
)}
|
|
</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" />
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
interface ParticleListViewProps {
|
|
path: ParticlePath;
|
|
}
|
|
|
|
/**
|
|
* List of stream particles for a container (network root, folder, etc.).
|
|
*/
|
|
export function ParticleListView({ path }: ParticleListViewProps) {
|
|
const { children, isLoading } = useLiveParticleChildren(path, "last_child_created_at", "desc");
|
|
const { networkId } = parseParticlePath(path);
|
|
const navigate = useNavigate();
|
|
|
|
const streams = useMemo(
|
|
() => children.filter((c) => c.type === "stream"),
|
|
[children],
|
|
);
|
|
|
|
if (isLoading) {
|
|
return <Progress />;
|
|
}
|
|
|
|
return (
|
|
<ScrollArea className="h-full">
|
|
<div className="py-1">
|
|
{streams.map((stream, index) => (
|
|
<div key={stream.id}>
|
|
<StreamRow
|
|
particle={stream}
|
|
networkId={networkId}
|
|
onClick={() => navigate(`/${networkId}/${stream.id}`)}
|
|
/>
|
|
{index < streams.length - 1 && <Separator className="mx-4" />}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</ScrollArea>
|
|
);
|
|
}
|