289 lines
8.5 KiB
TypeScript
289 lines
8.5 KiB
TypeScript
import { useEffect, useMemo, useRef } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import beepSound from "../../../assets/sound.wav";
|
|
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 { 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 { useAutoplayStore } from "@/stores/autoplay-store";
|
|
|
|
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 userId = user?.id ?? "";
|
|
const userEmail = user?.email ?? "";
|
|
|
|
// Autoplay: trigger only when latestChild *changes* to a new media particle,
|
|
// not on initial data load. We track the "settled" id — the first non-null value
|
|
// we see — and only autoplay on subsequent changes from that baseline.
|
|
const settledIdRef = useRef<string | undefined>(undefined);
|
|
useEffect(() => {
|
|
if (!latestChild) return;
|
|
|
|
// First real value: record it as baseline, don't autoplay
|
|
if (settledIdRef.current === undefined) {
|
|
settledIdRef.current = latestChild.id;
|
|
return;
|
|
}
|
|
|
|
if (latestChild.id === settledIdRef.current) return;
|
|
settledIdRef.current = latestChild.id;
|
|
|
|
if (latestChild.created_by_email === userEmail) return;
|
|
|
|
if (latestChild.type === "text") {
|
|
new Audio(beepSound).play().catch(() => {});
|
|
return;
|
|
}
|
|
|
|
if (latestChild.type !== "media") return;
|
|
|
|
useAutoplayStore.getState().play(latestChild, particle.id);
|
|
}, [latestChild?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
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.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_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 (
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={onClick}
|
|
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onClick(); }}
|
|
className="flex w-full items-center gap-3 px-4 py-3 text-left cursor-pointer 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" />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Generates the scopes for filtering particles to those that the user has access to
|
|
function useVisibilityScopes(
|
|
userEmail?: string,
|
|
networkId?: string,
|
|
) {
|
|
return useMemo(() => {
|
|
let scopes: string[] = [];
|
|
if (userEmail) {
|
|
scopes.push(`human:${userEmail}`);
|
|
}
|
|
if (networkId) {
|
|
scopes.push(`network:${networkId}`);
|
|
}
|
|
return scopes;
|
|
}, [userEmail, networkId]);
|
|
}
|
|
|
|
interface ParticleListViewProps {
|
|
path: ParticlePath;
|
|
}
|
|
|
|
/**
|
|
* List of stream particles for a container (network root, folder, etc.).
|
|
*/
|
|
export function ParticleListView({ path }: ParticleListViewProps) {
|
|
const { networkId } = parseParticlePath(path);
|
|
const user = useAuthStore((s) => s.user);
|
|
const visibilityScopes = useVisibilityScopes(user?.email, networkId);
|
|
|
|
const { children, isLoading } = useLiveParticleChildren(path, "last_child_created_at", "desc", visibilityScopes);
|
|
const navigate = useNavigate();
|
|
|
|
const streams = useMemo(
|
|
() => children.filter((c) => c.type === "stream"),
|
|
[children],
|
|
);
|
|
|
|
if (isLoading) {
|
|
return <Progress />;
|
|
}
|
|
|
|
return (
|
|
<div className="h-full overflow-clip">
|
|
<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>
|
|
</div>
|
|
);
|
|
}
|