feat: show latest particle preview in stream list
This commit is contained in:
@@ -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" />}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user