feat: list streams and story-mode catchup

This commit is contained in:
talksik
2026-02-21 09:46:04 -08:00
parent b5f90709de
commit 0cd74c0a8a
33 changed files with 2304 additions and 41 deletions
@@ -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>
);
}
+180
View File
@@ -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>
);
}
+63
View File
@@ -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>
);
}