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 { useMemo } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Radio } from "lucide-react"; import { Radio } from "lucide-react";
import { useLiveParticleChildren } from "@/hooks/use-particle"; import { useLiveParticleChildren, useLiveLatestChild } from "@/hooks/use-particle";
import { parseParticlePath, type ParticlePath } from "@/lib/particle-path"; 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 { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
@@ -10,14 +16,42 @@ import { Progress } from "@/components/ui/progress";
import { Small } from "@/components/ui/typography"; import { Small } from "@/components/ui/typography";
import type { Particle, StreamProperties } from "@/api/types"; 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({ function StreamRow({
particle, particle,
networkId,
onClick, onClick,
}: { }: {
particle: Particle & { type: "stream"; properties: StreamProperties }; particle: Particle & { type: "stream"; properties: StreamProperties };
networkId: string;
onClick: () => void; 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 ( return (
<button <button
@@ -31,13 +65,20 @@ function StreamRow({
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium"> <div className="flex items-center justify-between gap-2">
{particle.properties.name} <p className="truncate text-sm font-medium">
</p> {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"> <div className="text-muted-foreground flex items-center gap-1">
<Radio className="size-3" /> {!latestChild && <Radio className="size-3" />}
<Small className="text-muted-foreground"> <Small className="text-muted-foreground truncate">
{particle.properties.status} {subtitle}
</Small> </Small>
</div> </div>
</div> </div>
@@ -73,6 +114,7 @@ export function ParticleListView({ path }: ParticleListViewProps) {
<div key={stream.id}> <div key={stream.id}>
<StreamRow <StreamRow
particle={stream} particle={stream}
networkId={networkId}
onClick={() => navigate(`/${networkId}/${stream.id}`)} onClick={() => navigate(`/${networkId}/${stream.id}`)}
/> />
{index < streams.length - 1 && <Separator className="mx-4" />} {index < streams.length - 1 && <Separator className="mx-4" />}
+37 -1
View File
@@ -1,5 +1,9 @@
import { useState, useEffect, useMemo } from "react"; 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 { Particle } from "@/api/types";
import { import {
type ParticlePath, type ParticlePath,
@@ -80,3 +84,35 @@ export function useLiveParticleChildren(
return { children, isLoading, error }; 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, updateDoc,
query, query,
orderBy, orderBy,
limit,
serverTimestamp, serverTimestamp,
Timestamp, Timestamp,
type DocumentData, 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. // This creates a new particle document with the given properties and returns its ID.
export async function createParticle<T extends ParticleType>( export async function createParticle<T extends ParticleType>(
collectionPath: string, collectionPath: string,