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