feat: list streams and story-mode catchup
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
import type { StreamParticle } from "@/api/types";
|
||||
import { getParticleData } from "@/api/types";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { FileIcon, HelpCircleIcon, ScrollTextIcon, BookOpenIcon } from "lucide-react";
|
||||
|
||||
const TYPE_META: Record<string, { icon: typeof FileIcon; label: string }> = {
|
||||
quest: { icon: ScrollTextIcon, label: "Quest" },
|
||||
paper: { icon: BookOpenIcon, label: "Paper" },
|
||||
file: { icon: FileIcon, label: "File" },
|
||||
};
|
||||
|
||||
interface FallbackParticleViewProps {
|
||||
particle: StreamParticle;
|
||||
}
|
||||
|
||||
export function FallbackParticleView({ particle }: FallbackParticleViewProps) {
|
||||
const meta = TYPE_META[particle.type] ?? {
|
||||
icon: HelpCircleIcon,
|
||||
label: particle.type,
|
||||
};
|
||||
const Icon = meta.icon;
|
||||
const title = (() => {
|
||||
switch (particle.type) {
|
||||
case "quest":
|
||||
return getParticleData(particle, "quest").title;
|
||||
case "paper":
|
||||
return getParticleData(particle, "paper").title;
|
||||
case "file":
|
||||
return getParticleData(particle, "file").filename;
|
||||
case "folder":
|
||||
return getParticleData(particle, "folder").name;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center p-8">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader className="flex flex-row items-center gap-3">
|
||||
<Icon className="text-muted-foreground h-6 w-6 shrink-0" />
|
||||
<div>
|
||||
<CardTitle className="text-base">{meta.label}</CardTitle>
|
||||
{title && <CardDescription>{title}</CardDescription>}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
From {particle.created_by_email}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { MediaParticleData, StreamParticle } from "@/api/types";
|
||||
import { apiClient } from "@/api/client";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
interface MediaParticleViewProps {
|
||||
particle: StreamParticle;
|
||||
onEnded: () => void;
|
||||
}
|
||||
|
||||
export function MediaParticleView({
|
||||
particle,
|
||||
onEnded,
|
||||
}: MediaParticleViewProps) {
|
||||
const cachedUrl = usePlaybackStore(
|
||||
(s) => s.downloadUrlCache[particle.id],
|
||||
);
|
||||
const cacheDownloadUrl = usePlaybackStore((s) => s.cacheDownloadUrl);
|
||||
const [url, setUrl] = useState<string | null>(cachedUrl ?? null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (cachedUrl) {
|
||||
setUrl(cachedUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
apiClient
|
||||
.getParticleDownloadUrl(particle.id)
|
||||
.then((downloadUrl) => {
|
||||
if (cancelled) return;
|
||||
cacheDownloadUrl(particle.id, downloadUrl);
|
||||
setUrl(downloadUrl);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError("Failed to load media");
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [particle.id, cachedUrl, cacheDownloadUrl]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex items-center justify-center text-sm">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
return <Skeleton className="h-full w-full rounded-none" />;
|
||||
}
|
||||
|
||||
const data = particle.data as MediaParticleData;
|
||||
const isAudio = data.mime_type?.startsWith("audio/");
|
||||
|
||||
if (isAudio) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<audio src={url} autoPlay onEnded={onEnded} controls />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<video
|
||||
src={url}
|
||||
autoPlay
|
||||
playsInline
|
||||
onEnded={onEnded}
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { StreamParticle } from "@/api/types";
|
||||
import { apiClient } from "@/api/client";
|
||||
import { useAppStore } from "@/stores/app-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { MediaParticleView } from "./media-particle-view";
|
||||
import { TextParticleView } from "./text-particle-view";
|
||||
import { FallbackParticleView } from "./fallback-particle-view";
|
||||
|
||||
interface ParticleRendererProps {
|
||||
particle: StreamParticle;
|
||||
onNext: () => void;
|
||||
onPrev: () => void;
|
||||
}
|
||||
|
||||
export function ParticleRenderer({
|
||||
particle,
|
||||
onNext,
|
||||
onPrev,
|
||||
}: ParticleRendererProps) {
|
||||
const markParticlesSeen = useAppStore((s) => s.markParticlesSeen);
|
||||
const markedRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!particle.seen && markedRef.current !== particle.id) {
|
||||
markedRef.current = particle.id;
|
||||
markParticlesSeen([particle.id]);
|
||||
apiClient.markSeen(particle.id).catch(() => {});
|
||||
}
|
||||
}, [particle.id, particle.seen, markParticlesSeen]);
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / rect.width;
|
||||
if (x < 0.3) onPrev();
|
||||
else if (x > 0.7) onNext();
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
switch (particle.type) {
|
||||
case "media":
|
||||
return <MediaParticleView particle={particle} onEnded={onNext} />;
|
||||
case "text":
|
||||
return <TextParticleView particle={particle} />;
|
||||
default:
|
||||
return <FallbackParticleView particle={particle} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex h-full w-full cursor-pointer items-center justify-center"
|
||||
onClick={handleClick}
|
||||
>
|
||||
{renderContent()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
|
||||
interface PlaybackControlsProps {
|
||||
total: number;
|
||||
current: number;
|
||||
onGoTo: (index: number) => void;
|
||||
}
|
||||
|
||||
const DOT_THRESHOLD = 15;
|
||||
|
||||
export function PlaybackControls({
|
||||
total,
|
||||
current,
|
||||
onGoTo,
|
||||
}: PlaybackControlsProps) {
|
||||
if (total === 0) return null;
|
||||
|
||||
if (total <= DOT_THRESHOLD) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-1 py-2">
|
||||
{Array.from({ length: total }, (_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onGoTo(i);
|
||||
}}
|
||||
className="p-1"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-2.5 rounded-full transition-all",
|
||||
i === current
|
||||
? "bg-primary w-6"
|
||||
: "bg-muted-foreground/30 hover:bg-muted-foreground/50 w-2.5",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const percent = ((current + 1) / total) * 100;
|
||||
|
||||
return (
|
||||
<div className="px-4 py-2">
|
||||
<Progress value={percent} className="h-1" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { StreamParticle, TextParticleData } from "@/api/types";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
|
||||
interface TextParticleViewProps {
|
||||
particle: StreamParticle;
|
||||
}
|
||||
|
||||
export function TextParticleView({ particle }: TextParticleViewProps) {
|
||||
const data = particle.data as TextParticleData;
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full w-full">
|
||||
<div className="flex min-h-full items-center justify-center p-8">
|
||||
<p className="max-w-2xl text-center text-2xl leading-relaxed">
|
||||
{data.content}
|
||||
</p>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useRecordingStore } from "@/stores/recording-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function ReplyIndicator() {
|
||||
const status = useRecordingStore((s) => s.status);
|
||||
|
||||
if (status === "uploading") {
|
||||
return (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-xs">
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-yellow-500" />
|
||||
Uploading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "recording") {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs text-red-400">
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-red-500" />
|
||||
Recording... press Q to cancel
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Hold{" "}
|
||||
<kbd
|
||||
className={cn(
|
||||
"bg-muted rounded px-1.5 py-0.5 font-mono text-xs",
|
||||
)}
|
||||
>
|
||||
`
|
||||
</kbd>{" "}
|
||||
to reply
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { apiClient } from "@/api/client";
|
||||
import { useAppStore } from "@/stores/app-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { useRecordingStore } from "@/stores/recording-store";
|
||||
|
||||
const PREFERRED_MIME = "video/webm;codecs=vp9,opus";
|
||||
const FALLBACK_MIME = "video/webm";
|
||||
|
||||
function getMediaMime(): string {
|
||||
if (MediaRecorder.isTypeSupported(PREFERRED_MIME)) return PREFERRED_MIME;
|
||||
return FALLBACK_MIME;
|
||||
}
|
||||
|
||||
export function useRecorder(streamId: string | null) {
|
||||
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const startTimeRef = useRef<number>(0);
|
||||
|
||||
const status = useRecordingStore((s) => s.status);
|
||||
const setStatus = useRecordingStore((s) => s.setStatus);
|
||||
const setError = useRecordingStore((s) => s.setError);
|
||||
const resetRecording = useRecordingStore((s) => s.reset);
|
||||
const addParticleToStream = useAppStore((s) => s.addParticleToStream);
|
||||
|
||||
const stopTracks = useCallback(() => {
|
||||
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
streamRef.current = null;
|
||||
recorderRef.current = null;
|
||||
chunksRef.current = [];
|
||||
}, []);
|
||||
|
||||
const upload = useCallback(
|
||||
async (blob: Blob, durationMs: number) => {
|
||||
if (!streamId) return;
|
||||
|
||||
setStatus("uploading");
|
||||
|
||||
const mimeType = blob.type || FALLBACK_MIME;
|
||||
const fileName = `recording-${Date.now()}.webm`;
|
||||
|
||||
const { object, upload_url } = await apiClient.prepareUpload({
|
||||
file_name: fileName,
|
||||
content_type: mimeType,
|
||||
size_bytes: blob.size,
|
||||
});
|
||||
|
||||
await fetch(upload_url, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": mimeType },
|
||||
body: blob,
|
||||
});
|
||||
|
||||
await apiClient.confirmUpload(object.id);
|
||||
|
||||
const particle = await apiClient.createStreamParticle(streamId, {
|
||||
type: "media",
|
||||
data: {
|
||||
object_id: object.id,
|
||||
duration_ms: durationMs,
|
||||
mime_type: mimeType,
|
||||
},
|
||||
});
|
||||
|
||||
addParticleToStream(streamId, particle);
|
||||
|
||||
// Also add to playback store's particle list
|
||||
const playbackState = usePlaybackStore.getState();
|
||||
if (playbackState.streamId === streamId) {
|
||||
usePlaybackStore.setState({
|
||||
particles: [...playbackState.particles, particle],
|
||||
});
|
||||
}
|
||||
|
||||
resetRecording();
|
||||
},
|
||||
[streamId, setStatus, resetRecording, addParticleToStream],
|
||||
);
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
if (status !== "idle") return;
|
||||
|
||||
try {
|
||||
const mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
video: true,
|
||||
audio: true,
|
||||
});
|
||||
|
||||
streamRef.current = mediaStream;
|
||||
chunksRef.current = [];
|
||||
startTimeRef.current = Date.now();
|
||||
|
||||
const mime = getMediaMime();
|
||||
const recorder = new MediaRecorder(mediaStream, { mimeType: mime });
|
||||
recorderRef.current = recorder;
|
||||
|
||||
recorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) chunksRef.current.push(e.data);
|
||||
};
|
||||
|
||||
recorder.onstop = () => {
|
||||
const durationMs = Date.now() - startTimeRef.current;
|
||||
const blob = new Blob(chunksRef.current, { type: mime });
|
||||
stopTracks();
|
||||
|
||||
if (blob.size > 0) {
|
||||
upload(blob, durationMs).catch((err) => {
|
||||
setError(err instanceof Error ? err.message : "Upload failed");
|
||||
});
|
||||
} else {
|
||||
resetRecording();
|
||||
}
|
||||
};
|
||||
|
||||
recorder.start();
|
||||
setStatus("recording");
|
||||
} catch (err) {
|
||||
stopTracks();
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to start recording",
|
||||
);
|
||||
}
|
||||
}, [status, setStatus, setError, stopTracks, upload, resetRecording]);
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
if (recorderRef.current?.state === "recording") {
|
||||
recorderRef.current.stop();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const cancelRecording = useCallback(() => {
|
||||
if (recorderRef.current) {
|
||||
recorderRef.current.ondataavailable = null;
|
||||
recorderRef.current.onstop = null;
|
||||
if (recorderRef.current.state === "recording") {
|
||||
recorderRef.current.stop();
|
||||
}
|
||||
}
|
||||
stopTracks();
|
||||
resetRecording();
|
||||
}, [stopTracks, resetRecording]);
|
||||
|
||||
// Keyboard bindings: backtick to record, q to cancel
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "`" && !e.repeat) {
|
||||
e.preventDefault();
|
||||
startRecording();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === "`") {
|
||||
e.preventDefault();
|
||||
stopRecording();
|
||||
}
|
||||
if (e.key === "q" && status === "recording") {
|
||||
e.preventDefault();
|
||||
cancelRecording();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
};
|
||||
}, [startRecording, stopRecording, cancelRecording, status]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopTracks();
|
||||
};
|
||||
}, [stopTracks]);
|
||||
|
||||
return { status };
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { apiClient } from "@/api/client";
|
||||
import { useAppStore } from "@/stores/app-store";
|
||||
import type { CreateStreamRequest } from "@/api/types";
|
||||
|
||||
interface CreateStreamDialogProps {
|
||||
networkId: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function CreateStreamDialog({
|
||||
networkId,
|
||||
children,
|
||||
}: CreateStreamDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [visibility, setVisibility] =
|
||||
useState<CreateStreamRequest["visibility"]>("network_all");
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const addStream = useAppStore((s) => s.addStream);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const stream = await apiClient.createStream(networkId, {
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
visibility,
|
||||
});
|
||||
addStream(networkId, stream);
|
||||
setOpen(false);
|
||||
setName("");
|
||||
setDescription("");
|
||||
setVisibility("network_all");
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Stream</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="stream-name">Name</Label>
|
||||
<Input
|
||||
id="stream-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Stream name"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="stream-description">Description</Label>
|
||||
<Input
|
||||
id="stream-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Optional description"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Visibility</Label>
|
||||
<Select
|
||||
value={visibility}
|
||||
onValueChange={(v) =>
|
||||
setVisibility(v as CreateStreamRequest["visibility"])
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="network_all">Everyone in network</SelectItem>
|
||||
<SelectItem value="custom">Custom members</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button type="submit" disabled={!name.trim() || isCreating}>
|
||||
{isCreating ? "Creating..." : "Create Stream"}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useAppStore } from "@/stores/app-store";
|
||||
import { flattenStreams } from "@/lib/stream-utils";
|
||||
import { formatDistanceToNow } from "@/lib/time-utils";
|
||||
|
||||
export function StreamList() {
|
||||
const networks = useAppStore((s) => s.networks);
|
||||
const selectedNetworkId = useAppStore((s) => s.selectedNetworkId);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const streams = flattenStreams(networks, selectedNetworkId);
|
||||
|
||||
if (streams.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">No streams yet</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{streams.map((stream) => {
|
||||
const lastParticle =
|
||||
stream.particles.length > 0
|
||||
? stream.particles[stream.particles.length - 1]
|
||||
: null;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={stream.id}
|
||||
onClick={() => navigate(`/streams/${stream.id}`)}
|
||||
className="hover:bg-accent/50 flex items-center gap-3 border-b px-4 py-3 text-left transition-colors"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{stream.name}
|
||||
</span>
|
||||
{stream.unseen_count > 0 && (
|
||||
<Badge variant="default" className="shrink-0">
|
||||
{stream.unseen_count}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{stream.description && (
|
||||
<p className="text-muted-foreground mt-0.5 truncate text-xs">
|
||||
{stream.description}
|
||||
</p>
|
||||
)}
|
||||
{lastParticle && (
|
||||
<div className="text-muted-foreground mt-1 text-xs">
|
||||
{formatDistanceToNow(lastParticle.created_at)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user