feat: show latest particle preview in stream list

This commit is contained in:
talksik
2026-03-18 18:00:12 -07:00
parent 1a695f9873
commit 6d84fbe20c
3 changed files with 108 additions and 10 deletions
@@ -1,8 +1,14 @@
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { Radio } from "lucide-react";
import { useLiveParticleChildren } from "@/hooks/use-particle";
import { parseParticlePath, type ParticlePath } from "@/lib/particle-path";
import { useLiveParticleChildren, useLiveLatestChild } from "@/hooks/use-particle";
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";
@@ -10,14 +16,42 @@ import { Progress } from "@/components/ui/progress";
import { Small } from "@/components/ui/typography";
import type { Particle, StreamProperties } from "@/api/types";
function getMessagePreview(particle: Particle): string {
switch (particle.type) {
case "text":
return particle.properties.content;
case "media":
return "Clip received";
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 initials = particle.properties.name.slice(0, 2).toUpperCase();
const streamPath = particlePath(networkId, [particle.id]);
const { latestChild } = useLiveLatestChild(streamPath);
const initials = latestChild
? getInitials(latestChild.created_by_email)
: particle.properties.name.slice(0, 2).toUpperCase();
const subtitle = latestChild
? getMessagePreview(latestChild)
: particle.properties.status;
return (
<button
@@ -31,13 +65,20 @@ function StreamRow({
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{particle.properties.name}
</p>
<div className="flex items-center justify-between gap-2">
<p className="truncate text-sm font-medium">
{particle.properties.name}
</p>
{latestChild && (
<Small className="text-muted-foreground shrink-0">
{formatDistanceToNow(latestChild.created_at.toISOString())}
</Small>
)}
</div>
<div className="text-muted-foreground flex items-center gap-1">
<Radio className="size-3" />
<Small className="text-muted-foreground">
{particle.properties.status}
{!latestChild && <Radio className="size-3" />}
<Small className="text-muted-foreground truncate">
{subtitle}
</Small>
</div>
</div>
@@ -73,6 +114,7 @@ export function ParticleListView({ path }: ParticleListViewProps) {
<div key={stream.id}>
<StreamRow
particle={stream}
networkId={networkId}
onClick={() => navigate(`/${networkId}/${stream.id}`)}
/>
{index < streams.length - 1 && <Separator className="mx-4" />}
+37 -1
View File
@@ -1,5 +1,9 @@
import { useState, useEffect, useMemo } from "react";
import { subscribeToParticle, subscribeToParticleChildren } from "@/lib/firestore-particles";
import {
subscribeToParticle,
subscribeToParticleChildren,
subscribeToLatestChild,
} from "@/lib/firestore-particles";
import type { Particle } from "@/api/types";
import {
type ParticlePath,
@@ -80,3 +84,35 @@ export function useLiveParticleChildren(
return { children, isLoading, error };
}
interface UseLiveLatestChildResult {
latestChild: Particle | null;
isLoading: boolean;
}
export function useLiveLatestChild(path: ParticlePath): UseLiveLatestChildResult {
const [latestChild, setLatestChild] = useState<Particle | null>(null);
const [isLoading, setIsLoading] = useState(true);
const collectionPath = useMemo(() => toFirestoreChildrenPath(path), [path]);
useEffect(() => {
setIsLoading(true);
setLatestChild(null);
const unsubscribe = subscribeToLatestChild(
collectionPath,
(data) => {
setLatestChild(data);
setIsLoading(false);
},
() => {
setIsLoading(false);
},
);
return unsubscribe;
}, [collectionPath]);
return { latestChild, isLoading };
}
+20
View File
@@ -7,6 +7,7 @@ import {
updateDoc,
query,
orderBy,
limit,
serverTimestamp,
Timestamp,
type DocumentData,
@@ -97,6 +98,25 @@ export function subscribeToParticleChildren(
);
}
export function subscribeToLatestChild(
collectionPath: string,
onData: (child: Particle | null) => void,
onError: (error: Error) => void,
): Unsubscribe {
const q = query(
typedCollection(collectionPath),
orderBy("created_at", "desc"),
limit(1),
);
return onSnapshot(
q,
(snap) => {
onData(snap.empty ? null : snap.docs[0].data());
},
onError,
);
}
// This creates a new particle document with the given properties and returns its ID.
export async function createParticle<T extends ParticleType>(
collectionPath: string,