feat: initial conversational flow (#37)

* chore: only set visibility for container particles

* create reusable controls indicator for reply or new

* compress the size of top bar

* refactor: restructure state, routing, and more

* introduce stream compose flow

* feat: compose new stream full flow

* implement stream player

* fix: prevent redirect for signed object urls

* fix: implement stream playback cleaner structure

* refactor: layout file name

* feat: show stream name in breadcrumbs

* chore: tweak padding

* chore: adjust position of audio bars

* feat: show latest particle preview in stream list

* fix: remove console log

* refactor: reorder classes

* fix: avoid passing in updated_at to firestore particle

* refactor: extract properties for container particles to flat fields in firestore

* make the stream previews look alive

* feat: show audio bars during audio clip playback

* feat: order streams by last child creation

* feat: playback where I left off

* chore: remove unused store

* fix: recording mode not using shared state

* chore: clean unused variable

* remove unused imports

* fix: improve controls indicator immersion

* feat: show playback progress in bar & auto-play text

* feat: auto-exit stream on playback completion

* fix: jittery media playback progress

* fix: navigate during state change is invalid with react router

* fix: buggy exit progress when changing clips

* feat: add app icon

* update package.json info

* feat: only show streams visible to me

* feat: show seen indicator on particles

* fix: prevent unnecessary effects

* fix: play new particle after playback is ended

* use contols indicator for exit timer
This commit was merged in pull request #37.
This commit is contained in:
Arjun Patel
2026-03-19 16:29:40 -07:00
committed by GitHub
parent 990137b829
commit 4b66d8e185
48 changed files with 2112 additions and 1482 deletions
+284
View File
@@ -0,0 +1,284 @@
import { useCallback, useEffect, useEffectEvent, useRef, useState } from "react";
import { useAuthStore } from "@/stores/auth-store";
import { useCreateParticle, useCreateStreamParticle } from "@/hooks/use-create-particle";
import { useRecorder } from "@/features/compose/use-recorder";
import { particlePath } from "@/lib/particle-path";
import type { ParticlePath } from "@/lib/particle-path";
import { RecordingOverlay } from "@/features/compose/recording-overlay";
import { TextComposeStep } from "@/features/compose/text-compose-step";
import { ConfigureStreamStep } from "@/features/compose/configure-stream-step";
import { apiClient } from "@/api/client";
import { useMediaSettingsStore } from "@/stores/media-settings-store";
type ComposeStep = "idle" | "recording" | "reviewing" | "typing" | "configuring";
interface ComposeOverlayProps {
networkId: string;
// Optional target path for reply mode. If not provided, compose creates a new stream.
targetPath?: ParticlePath;
onActiveChange?: (active: boolean) => void;
}
/**
* Self-contained compose overlay. Each consumer renders its own instance
* with props that determine the mode (new stream vs. reply).
*/
export function ComposeOverlay({
networkId,
targetPath,
onActiveChange,
}: ComposeOverlayProps) {
const [step, setStep] = useState<ComposeStep>("idle");
const [error, setError] = useState<string | null>(null);
const [textContent, setTextContent] = useState("");
const [mediaStream, setMediaStream] = useState<MediaStream | null>(null);
const [reviewBlob, setReviewBlob] = useState<Blob | null>(null);
const [reviewDurationMs, setReviewDurationMs] = useState(0);
const [reviewMimeType, setReviewMimeType] = useState<string | null>(null);
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
const userEmail = useAuthStore((s) => s.user?.email);
const createParticle = useCreateParticle();
const createStream = useCreateStreamParticle();
// Refs to avoid stale closures in keyboard handler
const stepRef = useRef(step);
stepRef.current = step;
// Notify parent when active state changes
useEffect(() => {
onActiveChange?.(step !== "idle");
}, [step, onActiveChange]);
const cancel = useCallback(() => {
setStep("idle");
setError(null);
setTextContent("");
setMediaStream(null);
setReviewBlob(null);
setReviewDurationMs(0);
setReviewMimeType(null);
}, []);
const { startRecording, stopRecording, cancelRecording } = useRecorder({
mode: recordingMode,
onStreamReady: (stream) => setMediaStream(stream),
onStreamCleanup: () => setMediaStream(null),
onFinish: (blob, durationMs, mimeType) => {
setStep("reviewing");
setReviewBlob(blob);
setReviewDurationMs(durationMs);
setReviewMimeType(mimeType);
},
onError: (message) => setError(message),
});
// --- Submission ---
const uploadMedia = useCallback(
async (blob: Blob, mimeType: string) => {
const ext = "webm";
const fileName = `recording-${Date.now()}.${ext}`;
const { object_id, upload_url, upload_headers } =
await apiClient.prepareUpload({
network_id: networkId,
name: fileName,
content_type: mimeType,
content_length: blob.size,
});
await fetch(upload_url, {
method: "PUT",
headers: upload_headers,
body: blob,
});
await apiClient.confirmUpload(object_id);
return { object_id, size_bytes: blob.size };
},
[networkId],
);
const createChildParticle = useCallback(
async (path: ParticlePath) => {
if (!userEmail) return;
if (textContent.trim()) {
await createParticle.mutateAsync({
path,
type: "text",
properties: { content: textContent },
createdByEmail: userEmail,
});
} else if (reviewBlob && reviewMimeType) {
const { object_id, size_bytes } = await uploadMedia(
reviewBlob,
reviewMimeType,
);
await createParticle.mutateAsync({
path,
type: "media",
properties: {
object_id,
mime_type: reviewMimeType,
duration_ms: reviewDurationMs,
size_bytes,
},
createdByEmail: userEmail,
});
}
},
[
userEmail,
textContent,
reviewBlob,
reviewMimeType,
reviewDurationMs,
createParticle,
uploadMedia,
],
);
// Reply mode: create particle directly under targetPath
const onSubmitReply = useEffectEvent(async () => {
if (!targetPath || !userEmail) return;
await createChildParticle(targetPath);
cancel();
});
// New stream mode: create stream + first child
const handleStreamSubmit = useCallback(
async (streamName: string, visibleTo: string[]) => {
if (!userEmail) return;
const streamId = await createStream.mutateAsync({
networkId,
properties: {
name: streamName,
status: "open",
},
createdByEmail: userEmail,
visibleTo,
});
const streamChildrenPath = particlePath(networkId, [streamId]);
await createChildParticle(streamChildrenPath);
cancel();
},
[networkId, userEmail, createParticle, createChildParticle, cancel],
);
// --- Keyboard handling ---
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const currentStep = stepRef.current;
if (currentStep === "typing" || currentStep === "configuring") return;
const target = e.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
) {
return;
}
switch (currentStep) {
case "idle": {
if (e.key === "`" && !e.repeat) {
e.preventDefault();
setStep("recording");
startRecording();
} else if (e.key === "t" || e.key === "T") {
e.preventDefault();
setStep("typing");
}
break;
}
case "recording": {
if (e.key === "q" || e.key === "Q" || e.key === "Escape") {
e.preventDefault();
cancelRecording();
cancel();
}
break;
}
case "reviewing": {
if (e.key === "q" || e.key === "Q" || e.key === "Escape") {
e.preventDefault();
cancelRecording();
cancel();
} else if (e.key === "Enter") {
e.preventDefault();
if (targetPath) {
onSubmitReply();
} else {
setStep("configuring");
}
}
break;
}
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (stepRef.current === "recording" && e.key === "`") {
e.preventDefault();
stopRecording();
}
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, [targetPath, startRecording, stopRecording, cancelRecording, cancel]);
// --- Render ---
if (step === "idle") return null;
const handleTextAdvance = targetPath
? onSubmitReply
: () => setStep("configuring");
return (
<>
{(step === "recording" || step === "reviewing") && (
<RecordingOverlay
step={step}
mediaStream={mediaStream}
recordingMode={recordingMode}
reviewBlob={reviewBlob}
error={error}
onClose={cancel}
/>
)}
{step === "typing" && (
<TextComposeStep
textContent={textContent}
onTextChange={setTextContent}
onAdvance={handleTextAdvance}
onCancel={cancel}
/>
)}
{!targetPath && step === "configuring" && (
<ConfigureStreamStep
networkId={networkId}
onCancel={cancel}
onSubmit={handleStreamSubmit}
/>
)}
</>
);
}
@@ -0,0 +1,168 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useNetworks } from "@/hooks/use-networks";
import { cn } from "@/lib/utils";
import { generateRandomName } from "@/lib/random-name";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area";
interface ConfigureStreamStepProps {
networkId: string | null;
onCancel: () => void;
onSubmit: (streamName: string, visibleTo: string[]) => void;
}
export function ConfigureStreamStep({
networkId,
onCancel,
onSubmit,
}: ConfigureStreamStepProps) {
const { data: networks } = useNetworks();
const network = networks?.find((n) => n.id === networkId);
const members = network?.humans ?? [];
const [name, setName] = useState(() => generateRandomName());
const [everyone, setEveryone] = useState(true);
const [selectedEmails, setSelectedEmails] = useState<Set<string>>(new Set());
const toggleMember = useCallback((email: string) => {
setSelectedEmails((prev) => {
const next = new Set(prev);
if (next.has(email)) next.delete(email);
else next.add(email);
return next;
});
}, []);
const buildVisibleTo = useCallback((): string[] => {
if (everyone && networkId) return [`network:${networkId}`];
return Array.from(selectedEmails).map((e) => `human:${e}`);
}, [everyone, networkId, selectedEmails]);
const handleSubmit = useCallback(() => {
if (!name.trim() || !networkId) return;
onSubmit(name.trim(), buildVisibleTo());
}, [name, networkId, onSubmit, buildVisibleTo]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
switch (e.key) {
case "Escape":
e.preventDefault();
onCancel();
return;
case "Enter":
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
handleSubmit();
}
return;
}
},
[onCancel, handleSubmit, members, name, toggleMember],
);
return (
<div
className="absolute inset-0 z-50 flex flex-col bg-black/90 pt-16"
onKeyDown={handleKeyDown}
tabIndex={-1}
>
<div className="mx-auto w-full max-w-sm space-y-4 px-6">
{/* Stream name */}
<div>
<Label className="mb-1 text-xs text-white/50">Stream name</Label>
<Input
type="text"
value={name}
onChange={(e) => {
setName(e.target.value);
}}
placeholder="Give it a name..."
className="border-white/10 bg-white/5 text-white placeholder-white/30 focus-visible:border-white/30 focus-visible:ring-0"
/>
</div>
{/* Visibility */}
<div>
<Label className="mb-1 text-xs text-white/50">Visible to</Label>
<div className="rounded-md border border-white/10">
{/* Everyone in network */}
<div
role="button"
onClick={() => setEveryone((prev) => !prev)}
className={cn(
"flex w-full cursor-pointer items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors",
"text-white/70 hover:bg-white/5",
)}
>
<Checkbox
checked={everyone}
onCheckedChange={(checked) => setEveryone(checked === true)}
tabIndex={-1}
className="pointer-events-none"
/>
<span className="font-medium">Everyone in network</span>
</div>
{/* Per-member selection */}
{!everyone && members.length > 0 && (
<ScrollArea className="max-h-48">
<div className="space-y-0.5 p-1">
{members.map((member, index) => {
const isSelected = selectedEmails.has(member.email);
const initials = member.email_prefix
.slice(0, 2)
.toUpperCase();
return (
<div
key={member.email}
role="button"
onClick={() => toggleMember(member.email)}
className={cn(
"flex w-full cursor-pointer items-center gap-2.5 rounded px-3 py-1.5 text-left text-sm transition-colors",
"text-white/70 hover:bg-white/5",
)}
>
<Checkbox
checked={isSelected}
tabIndex={-1}
className="pointer-events-none"
/>
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-white/10 text-[10px] font-medium">
{initials}
</span>
<span className="flex-1 truncate">
{member.email_prefix}
</span>
</div>
);
})}
</div>
</ScrollArea>
)}
</div>
</div>
</div>
{/* Keyboard hints */}
<div className="absolute bottom-8 left-0 right-0 flex items-center justify-center gap-4 text-sm text-white/50">
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc
</kbd>{" "}
cancel
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
+Enter
</kbd>{" "}
create
</span>
</div>
</div >
);
}
@@ -0,0 +1,69 @@
import { Video, Mic } from "lucide-react";
import { cn } from "@/lib/utils";
import { useMediaSettingsStore } from "@/stores/media-settings-store";
import { Button } from "@/components/ui/button";
import { PropsWithChildren } from "react";
interface ControlsIndicatorProps extends PropsWithChildren {
type: "reply" | "new";
}
export default function ControlsIndicator({ type, children }: ControlsIndicatorProps) {
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode);
return (
<div className="flex items-center gap-2 text-xs rounded-full pl-1 pr-3 py-1 bg-black/30 backdrop-blur-sm m-2">
<Button
onClick={() =>
setRecordingMode(recordingMode === "video" ? "audio" : "video")
}
title={
recordingMode === "video"
? "Switch to audio-only"
: "Switch to video"
}
variant="secondary"
className={cn(
"text-xs rounded-full",
"text-muted-foreground hover:text-white/90",
)}
>
{recordingMode === "video" ? (
<>
<Video className="h-3.5 w-3.5" />
<p>Video</p>
</>
) : (
<>
<Mic className="h-3.5 w-3.5" />
<span>Audio</span>
</>
)}
</Button>
{children ? <div className="flex-1">{children}</div> :
<div className="flex-1" />
}
{children && <span className="text-muted-foreground">·</span>}
<div className="text-muted-foreground">
Hold{" "}
<kbd
className={cn(
"bg-muted rounded px-1.5 py-0.5 font-mono",
)}
>
`
</kbd>{" "}
to {type === "reply" ? "reply " : "start "}
· Press{" "}
<kbd
className={cn(
"bg-muted rounded px-1.5 py-0.5 font-mono",
)}
>
T
</kbd>{" "}
for text
</div>
</div>
);
}
@@ -1,9 +1,14 @@
import { useEffect, useRef, useState } from "react";
import { useRecordingStore } from "@/stores/recording-store";
import type { RecordingMode } from "@/hooks/use-recording-mode";
import { AudioLevelBars } from "@/components/audio/audio-level-bars";
import { useAudioSource } from "@/components/audio/use-audio-source";
interface RecordingOverlayProps {
step: "recording" | "reviewing";
mediaStream: MediaStream | null;
recordingMode: RecordingMode;
reviewBlob: Blob | null;
error: string | null;
onClose: () => void;
}
@@ -87,24 +92,17 @@ function ReviewPlayback({
);
}
export function RecordingOverlay({ onClose }: RecordingOverlayProps) {
const status = useRecordingStore((s) => s.status);
const mediaStream = useRecordingStore((s) => s.mediaStream);
const recordingMode = useRecordingStore((s) => s.recordingMode);
const reviewBlob = useRecordingStore((s) => s.reviewBlob);
export function RecordingOverlay({
step,
mediaStream,
recordingMode,
reviewBlob,
error,
onClose,
}: RecordingOverlayProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const hasBeenActiveRef = useRef(false);
const recordingAudioSource = useAudioSource(mediaStream ?? null);
// Track whether we've entered an active state at least once
if (
status === "recording" ||
status === "uploading" ||
status === "reviewing"
) {
hasBeenActiveRef.current = true;
}
// Set video srcObject for live preview
useEffect(() => {
if (videoRef.current && mediaStream && recordingMode === "video") {
@@ -112,26 +110,15 @@ export function RecordingOverlay({ onClose }: RecordingOverlayProps) {
}
}, [mediaStream, recordingMode]);
// Auto-close when status returns to idle after being active
useEffect(() => {
if (!hasBeenActiveRef.current) return;
if (status === "idle") {
onClose();
}
}, [status, onClose]);
// Auto-close after error with a brief delay
useEffect(() => {
if (status !== "error") return;
if (!error) return;
const timeout = setTimeout(onClose, 1500);
return () => clearTimeout(timeout);
}, [status, onClose]);
}, [error, onClose]);
const isUploading = status === "uploading";
const isReviewing = status === "reviewing";
const isRecording = status === "recording";
// Loading: status is recording but media stream hasn't arrived yet
const isReviewing = step === "reviewing";
const isRecording = step === "recording";
const isLoading = isRecording && !mediaStream;
return (
@@ -166,17 +153,9 @@ export function RecordingOverlay({ onClose }: RecordingOverlayProps) {
/>
)}
{/* Dimmed overlay when uploading */}
{isUploading && <div className="absolute inset-0 bg-black/60" />}
{/* Top center: recording indicator / uploading */}
{/* Top center: recording indicator */}
<div className="absolute top-8 z-10">
{isUploading ? (
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-yellow-500" />
<span className="text-sm text-white/80">Sending...</span>
</div>
) : isRecording && !isLoading ? (
{isRecording && !isLoading ? (
<RecordingTimer />
) : isReviewing ? (
<div className="flex items-center gap-2">
@@ -185,9 +164,9 @@ export function RecordingOverlay({ onClose }: RecordingOverlayProps) {
) : null}
</div>
{/* Center: audio level bars (recording with active stream) */}
{/* Bottom center: audio level bars (recording with active stream) */}
{isRecording && recordingAudioSource && (
<div className="z-10">
<div className="z-10 absolute bottom-15">
<AudioLevelBars sourceNode={recordingAudioSource.sourceNode} />
</div>
)}
@@ -217,7 +196,7 @@ export function RecordingOverlay({ onClose }: RecordingOverlayProps) {
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Enter
</kbd>{" "}
to send
next
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
@@ -229,9 +208,9 @@ export function RecordingOverlay({ onClose }: RecordingOverlayProps) {
)}
{/* Error state */}
{status === "error" && (
{error && (
<div className="z-10 text-sm text-red-400">
{useRecordingStore.getState().error ?? "Recording failed"}
{error}
</div>
)}
</div>
@@ -1,11 +1,11 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { useEffect, useRef, useCallback } from "react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/api/client";
import { useAppStore } from "@/stores/app-store";
interface TextComposeOverlayProps {
streamId: string;
onClose: () => void;
interface TextComposeStepProps {
textContent: string;
onTextChange: (text: string) => void;
onAdvance: () => void;
onCancel: () => void;
}
function getTextStyle(length: number) {
@@ -15,61 +15,41 @@ function getTextStyle(length: number) {
return { size: "text-lg", weight: "font-normal" };
}
export function TextComposeOverlay({
streamId,
onClose,
}: TextComposeOverlayProps) {
const [content, setContent] = useState("");
const [sending, setSending] = useState(false);
export function TextComposeStep({
textContent,
onTextChange,
onAdvance,
onCancel,
}: TextComposeStepProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const addParticleToStream = useAppStore((s) => s.addParticleToStream);
useEffect(() => {
textareaRef.current?.focus();
}, []);
const handleSend = useCallback(async () => {
const trimmed = content.trim();
if (!trimmed || sending) return;
setSending(true);
try {
const particle = await apiClient.createStreamParticle(streamId, {
type: "text",
data: { content: trimmed },
});
addParticleToStream(streamId, particle);
onClose();
} catch {
setSending(false);
}
}, [content, sending, streamId, addParticleToStream, onClose]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
onClose();
onCancel();
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSend();
if (textContent.trim()) onAdvance();
}
},
[onClose, handleSend],
[onCancel, onAdvance, textContent],
);
const style = getTextStyle(content.length);
const style = getTextStyle(textContent.length);
return (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/90">
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
value={textContent}
onChange={(e) => onTextChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
disabled={sending}
className={cn(
"w-full max-w-2xl resize-none border-none bg-transparent p-8 text-center text-white placeholder-white/40 outline-none",
style.size,
@@ -86,9 +66,9 @@ export function TextComposeOverlay({
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Cmd+Enter
+Enter
</kbd>{" "}
send
next
</span>
</div>
</div>
+124
View File
@@ -0,0 +1,124 @@
import { useCallback, useEffect, useRef } from "react";
import type { RecordingMode } from "@/hooks/use-recording-mode";
const VIDEO_PREFERRED_MIME = "video/webm;codecs=vp9,opus";
const VIDEO_FALLBACK_MIME = "video/webm";
const AUDIO_PREFERRED_MIME = "audio/webm;codecs=opus";
const AUDIO_FALLBACK_MIME = "audio/webm";
function getMediaMime(mode: "video" | "audio"): string {
if (mode === "audio") {
return MediaRecorder.isTypeSupported(AUDIO_PREFERRED_MIME)
? AUDIO_PREFERRED_MIME
: AUDIO_FALLBACK_MIME;
}
return MediaRecorder.isTypeSupported(VIDEO_PREFERRED_MIME)
? VIDEO_PREFERRED_MIME
: VIDEO_FALLBACK_MIME;
}
interface UseRecorderOptions {
mode: RecordingMode;
onStreamReady: (stream: MediaStream) => void;
onStreamCleanup: () => void;
onFinish: (blob: Blob, durationMs: number, mimeType: string) => void;
onError: (message: string) => void;
}
/**
* Manages MediaRecorder lifecycle. Pure media utility — knows nothing
* about application state. The consumer provides callbacks for all outputs.
*/
export function useRecorder({
mode,
onStreamReady,
onStreamCleanup,
onFinish,
onError,
}: UseRecorderOptions) {
const recorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const chunksRef = useRef<Blob[]>([]);
const startTimeRef = useRef<number>(0);
// Refs to avoid stale closures in MediaRecorder event handlers
const onStreamCleanupRef = useRef(onStreamCleanup);
const onFinishRef = useRef(onFinish);
const onErrorRef = useRef(onError);
useEffect(() => {
onStreamCleanupRef.current = onStreamCleanup;
onFinishRef.current = onFinish;
onErrorRef.current = onError;
});
const stopTracks = useCallback(() => {
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
recorderRef.current = null;
chunksRef.current = [];
onStreamCleanupRef.current();
}, []);
const startRecording = useCallback(async () => {
try {
const constraints =
mode === "video" ? { video: true, audio: true } : { audio: true };
const mediaStream =
await navigator.mediaDevices.getUserMedia(constraints);
streamRef.current = mediaStream;
onStreamReady(mediaStream);
chunksRef.current = [];
startTimeRef.current = Date.now();
const mime = getMediaMime(mode);
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) {
onFinishRef.current(blob, durationMs, mime);
}
};
recorder.start();
} catch (err) {
stopTracks();
onErrorRef.current(
err instanceof Error ? err.message : "Failed to start recording",
);
}
}, [mode, onStreamReady, stopTracks]);
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();
}, [stopTracks]);
useEffect(() => {
return () => stopTracks();
}, [stopTracks]);
return { startRecording, stopRecording, cancelRecording };
}
+137
View File
@@ -0,0 +1,137 @@
import { WindowControls } from "@/components/window-controls";
import { Button } from "@/components/ui/button";
import { Outlet, useLocation, useNavigate, useParams } from "react-router-dom";
import { Home, Settings } from "lucide-react";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { useNetworks } from "@/hooks/use-networks";
import { particlePath } from "@/lib/particle-path";
import { useParticle } from "@/hooks/use-particle";
import type { Particle } from "@/api/types";
function getParticleDisplayName(particle: Particle): string {
switch (particle.type) {
case "stream":
case "folder":
return particle.properties.name;
case "quest":
return particle.properties.title;
case "paper":
return particle.properties.title;
case "file":
return particle.properties.filename;
case "text":
return particle.properties.content.slice(0, 30);
case "media":
return particle.type;
}
}
function NetworkBreadcrumbContent({ networkId }: { networkId: string }) {
const { data: networks } = useNetworks();
const network = networks?.find((n) => n.id === networkId);
const name = network?.name ?? networkId;
const initials = name.slice(0, 2).toUpperCase();
return (
<span className="flex items-center gap-1.5">
<Avatar size="sm">
<AvatarFallback className="bg-primary/10 text-primary text-[10px] font-medium">
{initials}
</AvatarFallback>
</Avatar>
{name}
</span>
);
}
function TopBar() {
const navigate = useNavigate();
const { networkId, "*": rest } = useParams();
const segments = [networkId, ...rest?.split("/") ?? []].filter(Boolean);
const path = rest ? particlePath(networkId!, rest.split("/").filter(Boolean)) : undefined;
const { data: particle } = useParticle(path);
return (
<div className="drag-region flex items-center gap-3 border-b px-3 py-1">
<WindowControls />
<Breadcrumb className="no-drag">
<BreadcrumbList>
<BreadcrumbItem className="text-xs">
{segments.length === 0 ? (
<BreadcrumbPage className="flex items-center gap-1">
<Home className="size-3.5" />
</BreadcrumbPage>
) : (
<BreadcrumbLink
className="flex cursor-pointer items-center gap-1"
onClick={() => navigate("/")}
>
<Home className="size-3.5" />
</BreadcrumbLink>
)}
</BreadcrumbItem>
{networkId && (
<span className="contents">
<BreadcrumbSeparator />
<BreadcrumbItem className="text-xs">
{segments.length === 1 ? (
<BreadcrumbPage>
<NetworkBreadcrumbContent networkId={networkId} />
</BreadcrumbPage>
) : (
<BreadcrumbLink
className="cursor-pointer"
onClick={() => navigate(`/${networkId}`)}
>
<NetworkBreadcrumbContent networkId={networkId} />
</BreadcrumbLink>
)}
</BreadcrumbItem>
</span>
)}
{particle && (
<span key={path} className="contents">
<BreadcrumbSeparator />
<BreadcrumbItem className="text-xs">
<BreadcrumbPage>{getParticleDisplayName(particle)}</BreadcrumbPage>
</BreadcrumbItem>
</span>
)}
</BreadcrumbList>
</Breadcrumb>
<div className="flex-1" />
<Button
variant="ghost"
size="sm"
className="no-drag text-muted-foreground"
onClick={() => navigate("/settings")}
>
<Settings className="size-3.5" />
</Button>
</div>
);
}
export default function Layout() {
return (
<div className="flex h-screen flex-col">
<TopBar />
<Outlet />
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { useParams } from "react-router-dom";
import { particlePath } from "@/lib/particle-path";
import { ParticleListView } from "@/features/particles/particle-list-view";
import ControlsIndicator from "@/features/compose/controls-indicator";
import { ComposeOverlay } from "./compose/compose-overlay";
/**
* Route-level component for /:networkId (index).
* Shows root-level particles for the selected network.
*/
export default function NetworkRoot() {
const { networkId } = useParams();
const path = particlePath(networkId!, []);
return (
<div className="flex flex-col h-full relative">
<ParticleListView path={path} />
<ComposeOverlay networkId={networkId!} />
<ControlsIndicator type={"new"} />
</div>
);
}
+1 -1
View File
@@ -42,7 +42,7 @@ function NetworkRow({
);
}
export function NetworkSelector() {
export default function NetworkSelector() {
const navigate = useNavigate();
const { data, isPending, error } = useNetworks();
+9 -6
View File
@@ -1,20 +1,23 @@
import { Particle } from "@/api/types";
import { useParticleChildren } from "@/hooks/use-particle-children";
import { useLiveParticleChildren } from "@/hooks/use-particle";
import { parseParticlePath, type ParticlePath } from "@/lib/particle-path";
import { ComposeOverlay } from "@/features/compose/compose-overlay";
interface FolderViewProps {
folderParticle: Particle;
networkId: string;
particleSegments: string[];
path: ParticlePath;
}
export function FolderView({ networkId, particleSegments, folderParticle }: FolderViewProps) {
const { children, error, isLoading } = useParticleChildren(networkId, particleSegments);
export function FolderView({ path, folderParticle }: FolderViewProps) {
const { children, error, isLoading } = useLiveParticleChildren(path);
const { networkId } = parseParticlePath(path);
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">
Folder view {networkId}/{particleSegments.join("/")}
Folder view {folderParticle.id}
</p>
<ComposeOverlay networkId={networkId} />
</div>
);
}
+244 -30
View File
@@ -1,43 +1,257 @@
import { useParticleChildren } from "@/hooks/use-particle-children";
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import {
Radio,
MessageSquare,
Video,
Mic,
Image,
FileText,
CircleCheck,
StickyNote,
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useLiveParticleChildren, useLiveLatestChild } from "@/hooks/use-particle";
import { useAuthStore } from "@/stores/auth-store";
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";
import { Progress } from "@/components/ui/progress";
import { Small } from "@/components/ui/typography";
import type { Particle, StreamProperties } from "@/api/types";
function getParticleTypeIcon(particle: Particle): LucideIcon {
switch (particle.type) {
case "text":
return MessageSquare;
case "media": {
const mime = particle.properties.mime_type;
if (mime.startsWith("video/")) return Video;
if (mime.startsWith("audio/")) return Mic;
if (mime.startsWith("image/")) return Image;
return Video;
}
case "file":
return FileText;
case "quest":
return CircleCheck;
case "paper":
return StickyNote;
default:
return Radio;
}
}
function getMessagePreview(particle: Particle): string {
switch (particle.type) {
case "text":
return particle.properties.content;
case "media": {
const mime = particle.properties.mime_type;
if (mime.startsWith("video/")) return "Video clip";
if (mime.startsWith("audio/")) return "Voice note";
if (mime.startsWith("image/")) return "Photo";
return "Media";
}
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 streamPath = particlePath(networkId, [particle.id]);
const { latestChild } = useLiveLatestChild(streamPath);
const user = useAuthStore((s) => s.user);
const userId = user?.id ?? "";
const userEmail = user?.email ?? "";
const isDM =
particle.visible_to.length === 2 &&
particle.visible_to.every((v) => v.startsWith("human:"));
const initials = useMemo(() => {
if (isDM) {
const otherEntry = particle.visible_to.find(
(v) => v !== `human:${userEmail}`,
);
if (otherEntry) {
const otherEmail = otherEntry.replace("human:", "");
return getInitials(otherEmail);
}
}
return particle.properties.name.slice(0, 2).toUpperCase();
}, [isDM, particle.visible_to, particle.properties.name, userEmail]);
const isUnseen = useMemo(() => {
if (!latestChild) return false;
const latestChildTimestamp = latestChild.created_at.getTime();
const userPlaybackPosition =
particle.playback_markers?.[userId]?.getTime() ?? 0;
return latestChildTimestamp > userPlaybackPosition;
}, [latestChild, particle.playback_markers, userId]);
const senderPrefix = useMemo(() => {
if (!latestChild) return null;
const isCurrentUser = latestChild.created_by_email === userEmail;
if (isDM) {
return isCurrentUser ? "You: " : null;
}
// Group stream
if (isCurrentUser) return "You: ";
const emailPrefix = latestChild.created_by_email.split("@")[0];
const capitalized =
emailPrefix.charAt(0).toUpperCase() + emailPrefix.slice(1);
return `${capitalized}: `;
}, [latestChild, userEmail, isDM]);
const subtitle = latestChild
? getMessagePreview(latestChild)
: particle.properties.status;
const TypeIcon = latestChild ? getParticleTypeIcon(latestChild) : Radio;
return (
<button
type="button"
onClick={onClick}
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-accent"
>
<Avatar
className={cn(isUnseen && "ring-2 ring-primary")}
>
<AvatarFallback className="bg-primary/10 text-primary font-medium">
{initials}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p
className={cn(
"truncate text-sm",
isUnseen
? "font-semibold text-foreground"
: "font-medium text-muted-foreground",
)}
>
{particle.properties.name}
</p>
{latestChild && (
<Small
className={cn(
"shrink-0",
isUnseen ? "text-primary" : "text-muted-foreground",
)}
>
{formatDistanceToNow(latestChild.created_at.toISOString())}
</Small>
)}
</div>
<div className="flex items-center gap-1">
<TypeIcon
className={cn(
"size-3.5 shrink-0",
isUnseen ? "text-foreground" : "text-muted-foreground",
)}
/>
<Small
className={cn(
"truncate",
isUnseen
? "text-foreground font-medium"
: "text-muted-foreground font-normal",
)}
>
{senderPrefix && (
<span className="text-muted-foreground">{senderPrefix}</span>
)}
{subtitle}
</Small>
</div>
</div>
{isUnseen && (
<span className="size-2 shrink-0 rounded-full bg-primary" />
)}
</button>
);
}
// Generates the scopes for filtering particles to those that the user has access to
function useVisibilityScopes(
userEmail?: string,
networkId?: string,
) {
return useMemo(() => {
let scopes: string[] = [];
if (userEmail) {
scopes.push(`human:${userEmail}`);
}
if (networkId) {
scopes.push(`network:${networkId}`);
}
return scopes;
}, [userEmail, networkId]);
}
interface ParticleListViewProps {
networkId: string;
particleSegments: string[];
path: ParticlePath;
}
/**
* Grid/list of child particles for a container (folder, stream root, or network root).
* List of stream particles for a container (network root, folder, etc.).
*/
export function ParticleListView({ networkId, particleSegments }: ParticleListViewProps) {
const { children, isLoading } = useParticleChildren(networkId, particleSegments);
export function ParticleListView({ path }: ParticleListViewProps) {
const { networkId } = parseParticlePath(path);
const user = useAuthStore((s) => s.user);
const visibilityScopes = useVisibilityScopes(user?.email, networkId);
const { children, isLoading } = useLiveParticleChildren(path, "last_child_created_at", "desc", visibilityScopes);
const navigate = useNavigate();
const streams = useMemo(
() => children.filter((c) => c.type === "stream"),
[children],
);
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">Loading particles...</p>
</div>
);
}
if (children.length === 0) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">No particles yet</p>
</div>
);
return <Progress />;
}
return (
<div className="grid grid-cols-2 gap-3 p-4">
{children.map((child) => (
<div
key={child.id}
className="rounded-lg border p-3 text-sm"
>
<p className="font-medium">{child.id}</p>
<p className="text-muted-foreground text-xs">{child.type}</p>
</div>
))}
</div>
<ScrollArea className="h-full">
<div className="py-1">
{streams.map((stream, index) => (
<div key={stream.id}>
<StreamRow
particle={stream}
networkId={networkId}
onClick={() => navigate(`/${networkId}/${stream.id}`)}
/>
{index < streams.length - 1 && <Separator className="mx-4" />}
</div>
))}
</div>
</ScrollArea>
);
}
@@ -1,6 +1,5 @@
import { useEffect, useState } from "react";
import type { StreamParticle } from "@/api/types";
import { getParticleData } from "@/api/types";
import type { Particle } from "@/api/types";
import { apiClient } from "@/api/client";
import { Skeleton } from "@/components/ui/skeleton";
import {
@@ -11,21 +10,8 @@ import {
FileIcon,
FolderIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
interface ParticlePreviewProps {
particle: StreamParticle;
}
/** Dynamic text sizing for card previews — inspired by TextParticleView. */
function getPreviewTextStyle(length: number) {
if (length < 30) return "text-xl font-semibold";
if (length < 80) return "text-lg font-medium";
if (length < 200) return "text-base font-normal";
return "text-sm font-normal";
}
export function ParticlePreview({ particle }: ParticlePreviewProps) {
export function ParticlePreview({ particle }: { particle: Particle }) {
switch (particle.type) {
case "text":
return <TextPreview particle={particle} />;
@@ -44,27 +30,24 @@ export function ParticlePreview({ particle }: ParticlePreviewProps) {
}
}
function TextPreview({ particle }: { particle: StreamParticle }) {
const data = getParticleData(particle, "text");
// only show x first chars for preview, to avoid overflow and also to determine text size
const truncated = data.content.length > 30 ? data.content.slice(0, 30) + "..." : data.content;
function TextPreview({ particle }: { particle: Extract<Particle, { type: "text" }> }) {
const truncated =
particle.properties.content.length > 30
? particle.properties.content.slice(0, 30) + "..."
: particle.properties.content;
return (
<div className="flex h-full w-full items-center justify-center p-4">
<p
className={cn(
"line-clamp-4 text-center leading-relaxed text-4xl",
)}
>
<p className="line-clamp-4 text-center text-4xl leading-relaxed">
{truncated}
</p>
</div>
);
}
function MediaPreview({ particle }: { particle: StreamParticle }) {
const data = getParticleData(particle, "media");
const isVideo = data.mime_type.startsWith("video");
const durationSec = Math.round(data.duration_ms / 1000);
function MediaPreview({ particle }: { particle: Extract<Particle, { type: "media" }> }) {
const { mime_type, duration_ms } = particle.properties;
const isVideo = mime_type.startsWith("video");
const durationSec = Math.round(duration_ms / 1000);
const durationLabel = `${Math.floor(durationSec / 60)}:${String(durationSec % 60).padStart(2, "0")}`;
if (isVideo) {
@@ -132,54 +115,51 @@ function VideoThumbnail({
);
}
function QuestPreview({ particle }: { particle: StreamParticle }) {
const data = getParticleData(particle, "quest");
function QuestPreview({ particle }: { particle: Extract<Particle, { type: "quest" }> }) {
const { title, status } = particle.properties;
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-amber-500/10 p-4">
<ScrollText className="h-6 w-6 text-amber-600/70 dark:text-amber-400/70" />
<p className="line-clamp-2 text-center text-sm font-medium">
{data.title}
{title}
</p>
{data.status && (
{status && (
<span className="text-muted-foreground text-[10px] uppercase tracking-wide">
{data.status}
{status}
</span>
)}
</div>
);
}
function PaperPreview({ particle }: { particle: StreamParticle }) {
const data = getParticleData(particle, "paper");
function PaperPreview({ particle }: { particle: Extract<Particle, { type: "paper" }> }) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-blue-500/10 p-4">
<BookOpen className="h-6 w-6 text-blue-600/70 dark:text-blue-400/70" />
<p className="line-clamp-2 text-center text-sm font-medium">
{data.title}
{particle.properties.title}
</p>
</div>
);
}
function FilePreview({ particle }: { particle: StreamParticle }) {
const data = getParticleData(particle, "file");
function FilePreview({ particle }: { particle: Extract<Particle, { type: "file" }> }) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-emerald-500/10 p-4">
<FileIcon className="h-6 w-6 text-emerald-600/70 dark:text-emerald-400/70" />
<p className="text-muted-foreground line-clamp-1 text-center text-xs">
{data.filename}
{particle.properties.filename}
</p>
</div>
);
}
function FolderPreview({ particle }: { particle: StreamParticle }) {
const data = getParticleData(particle, "folder");
function FolderPreview({ particle }: { particle: Extract<Particle, { type: "folder" }> }) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-orange-500/10 p-4">
<FolderIcon className="h-6 w-6 text-orange-600/70 dark:text-orange-400/70" />
<p className="text-muted-foreground line-clamp-1 text-center text-xs">
{data.name}
{particle.properties.name}
</p>
</div>
);
@@ -1,20 +1,22 @@
import { useParticle } from "@/hooks/use-particle";
import { StreamView } from "./stream-view";
import { FolderView } from "./folder-view";
import { ParticleListView } from "./particle-list-view";
import { useParams } from "react-router-dom";
import { useLiveParticle } from "@/hooks/use-particle";
import { particlePath } from "@/lib/particle-path";
import { isContainerType } from "@/api/types";
interface ParticleViewResolverProps {
networkId: string;
particleSegments: string[];
}
import { StreamView } from "@/features/particles/stream-view";
import { FolderView } from "@/features/particles/folder-view";
import { ParticleListView } from "@/features/particles/particle-list-view";
/**
* Resolves a particle by its path segments and renders the appropriate view
* based on particle type (e.g. stream would show clips in story mode, folder would list files, etc.)
* Route-level component for /:networkId/*.
* Reads params from the router, resolves the particle, and renders
* the appropriate view based on particle type.
*/
export function ParticleViewResolver({ networkId, particleSegments }: ParticleViewResolverProps) {
const { particle, isLoading, error } = useParticle(networkId, particleSegments);
export default function ParticleViewResolver() {
const { networkId, "*": rest } = useParams();
const segments = (rest ?? "").split("/").filter(Boolean);
const path = particlePath(networkId!, segments); // path of current container particle
const { particle, isLoading, error } = useLiveParticle(path);
if (isLoading) {
return (
@@ -32,12 +34,11 @@ export function ParticleViewResolver({ networkId, particleSegments }: ParticleVi
);
}
// While the hook is stubbed, particle will be null — show a placeholder
if (!particle) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">
Particle: {particleSegments.join(" / ")}
Particle: {segments.join(" / ")}
</p>
</div>
);
@@ -45,15 +46,13 @@ export function ParticleViewResolver({ networkId, particleSegments }: ParticleVi
switch (particle.type) {
case "stream":
return <StreamView streamParticle={particle} networkId={networkId} particleSegments={particleSegments} />;
return <StreamView streamParticle={particle} path={path} />;
case "folder":
return <FolderView folderParticle={particle} networkId={networkId} particleSegments={particleSegments} />;
return <FolderView folderParticle={particle} path={path} />;
default:
// For container types we haven't built a view for, fall back to list
if (isContainerType(particle.type)) {
return <ParticleListView networkId={networkId} particleSegments={particleSegments} />;
return <ParticleListView path={path} />;
}
// Leaf particle — placeholder
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">
+421 -25
View File
@@ -1,36 +1,432 @@
import { Particle } from "@/api/types";
import { useParticleChildren } from "@/hooks/use-particle-children";
import { useState, useEffect, useEffectEvent, useCallback, useReducer, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { useAuthStore } from "@/stores/auth-store";
import type { Particle } from "@/api/types";
import { useLiveParticleChildren } from "@/hooks/use-particle";
import { parseParticlePath, toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path";
import { ComposeOverlay } from "@/features/compose/compose-overlay";
import { PlaybackPageIndicator } from "@/features/playback/playback-page-indicator";
import { MediaParticleView } from "@/features/playback/media-particle-view";
import { TextParticleView } from "@/features/playback/text-particle-view";
import { FallbackParticleView } from "@/features/playback/fallback-particle-view";
import { Avatar, AvatarFallback, AvatarGroup, AvatarGroupCount } from "@/components/ui/avatar";
import ControlsIndicator from "@/features/compose/controls-indicator";
import { updateStreamPlaybackMarker } from "@/lib/firestore-particles";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useNetwork } from "@/hooks/use-networks";
interface StreamViewProps {
streamParticle: Particle;
networkId: string;
particleSegments: string[];
// --- Playback reducer ---
type PlaybackStatus = "idle" | "playing" | "ended";
interface PlaybackState {
currentIndex: number;
status: PlaybackStatus;
paused: boolean;
}
export function StreamView({ networkId, particleSegments, streamParticle }: StreamViewProps) {
const { children, error, isLoading } = useParticleChildren(networkId, particleSegments);
type PlaybackAction =
| { type: "INIT"; particleCount: number, initialIndex?: number }
| { type: "NEXT"; particleCount: number }
| { type: "PREV" }
| { type: "GO_TO"; index: number; particleCount: number }
| { type: "PAUSE" }
| { type: "RESUME" }
| { type: "SYNC_PARTICLES"; particleCount: number };
function playbackReducer(
state: PlaybackState,
action: PlaybackAction,
): PlaybackState {
switch (action.type) {
case "INIT":
return {
currentIndex: action.initialIndex ?? 0,
status: action.particleCount > 0 ? "playing" : "idle",
paused: false,
};
case "NEXT":
if (state.currentIndex < action.particleCount - 1) {
return { ...state, currentIndex: state.currentIndex + 1, paused: false };
}
return { ...state, status: "ended", paused: false };
case "PREV":
if (state.currentIndex > 0) {
return {
...state,
currentIndex: state.currentIndex - 1,
status: "playing",
paused: false,
};
}
return state;
case "GO_TO":
if (action.index >= 0 && action.index < action.particleCount) {
return {
...state,
currentIndex: action.index,
status: "playing",
paused: false,
};
}
return state;
case "PAUSE":
return { ...state, paused: true };
case "RESUME":
return { ...state, paused: false };
case "SYNC_PARTICLES":
// Clamp index if particles were removed; don't reset position
if (action.particleCount === 0) {
return { currentIndex: 0, status: "idle", paused: state.paused };
}
if (state.status === "ended" && state.currentIndex < action.particleCount - 1) {
// New particle appended — resume and advance to it
return { ...state, currentIndex: state.currentIndex + 1, status: "playing", paused: false };
}
if (state.currentIndex >= action.particleCount) {
return { ...state, currentIndex: action.particleCount - 1 };
}
return state;
}
}
const initialState: PlaybackState = {
currentIndex: 0,
status: "idle",
paused: false,
};
// --- Exit countdown hook ---
const EXIT_DELAY_MS = 5000;
const EXIT_TICK_MS = 100;
function useExitCountdown(
status: PlaybackStatus,
composeActive: boolean,
onExit: () => void,
) {
const [remainingMs, setRemainingMs] = useState<number | null>(null);
const handleExit = useEffectEvent(() => {
onExit();
});
// Start/cancel countdown based on playback status
useEffect(() => {
if (status === "ended") {
setRemainingMs(EXIT_DELAY_MS);
} else {
setRemainingMs(null);
}
}, [status]);
// Tick the countdown down (pauses when compose is active)
useEffect(() => {
if (remainingMs === null || remainingMs <= 0 || composeActive) return;
const interval = setInterval(() => {
setRemainingMs((prev) => {
if (prev === null) return null;
const next = prev - EXIT_TICK_MS;
return next <= 0 ? 0 : next;
});
}, EXIT_TICK_MS);
return () => clearInterval(interval);
}, [remainingMs !== null && remainingMs > 0, composeActive]);
// Navigate once countdown hits zero
useEffect(() => {
if (remainingMs !== null && remainingMs <= 0) {
handleExit();
}
}, [remainingMs]);
return remainingMs;
}
// --- StreamView ---
interface StreamViewProps {
streamParticle: Particle & { type: "stream" };
path: ParticlePath;
}
export function StreamView({ path, streamParticle }: StreamViewProps) {
const { networkId } = parseParticlePath(path);
const navigate = useNavigate();
const { children } = useLiveParticleChildren(path, "created_at", "asc");
const [state, dispatch] = useReducer(playbackReducer, initialState);
const [composeActive, setComposeActive] = useState(false);
const [progress, setProgress] = useState(0);
const hasInitializedRef = useRef<string | null>(null);
const handleExitNavigate = useCallback(() => {
navigate(`/${networkId}`);
}, [navigate, networkId]);
const exitRemainingMs = useExitCountdown(
state.status,
composeActive,
handleExitNavigate,
);
const userId = useAuthStore((s) => s.user?.id);
// Reset progress when particle changes
useEffect(() => {
setProgress(0);
}, [state.currentIndex]);
// Init playback once per stream entry, only after children have loaded
useEffect(() => {
if (children.length === 0) return;
if (hasInitializedRef.current === streamParticle.id) return;
hasInitializedRef.current = streamParticle.id;
const playbackPosition = streamParticle.playback_markers?.[userId ?? ""];
let initialIndex = 0;
if (playbackPosition) {
const foundIndex = children.findIndex(
(c) => c.created_at.getTime() === playbackPosition.getTime(),
);
if (foundIndex !== -1) {
initialIndex = foundIndex;
}
}
dispatch({ type: "INIT", particleCount: children.length, initialIndex });
}, [streamParticle.id, userId, children]);
// Sync on subsequent changes (new particle appended, removed, etc.)
useEffect(() => {
if (hasInitializedRef.current !== streamParticle.id) return;
dispatch({ type: "SYNC_PARTICLES", particleCount: children.length });
}, [children.length, streamParticle.id]);
// Pause/resume playback when compose overlay opens/closes
useEffect(() => {
if (composeActive) dispatch({ type: "PAUSE" });
else dispatch({ type: "RESUME" });
}, [composeActive]);
const next = useCallback(() => {
dispatch({ type: "NEXT", particleCount: children.length });
}, [children.length]);
const prev = useCallback(() => {
dispatch({ type: "PREV" });
}, []);
const goTo = useCallback(
(index: number) => {
dispatch({ type: "GO_TO", index, particleCount: children.length });
},
[children.length],
);
// Click-to-navigate: left 30% = prev, right 70% = next
const handlePlaybackClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
if (x < 0.3) prev();
else if (x > 0.7) next();
},
[prev, next],
);
// Playback keyboard: arrows, escape
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (composeActive) return;
const target = e.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
) {
return;
}
switch (e.key) {
case "ArrowRight":
case "ArrowDown":
e.preventDefault();
next();
break;
case "ArrowLeft":
case "ArrowUp":
e.preventDefault();
prev();
break;
case "Escape":
e.preventDefault();
navigate(`/${networkId}`);
break;
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
},
[composeActive, next, prev, navigate, networkId],
);
const currentParticle = children[state.currentIndex] ?? null;
useEffect(() => {
if (!userId || !currentParticle) return;
const streamDocPath = toFirestoreDocPath(path);
updateStreamPlaybackMarker(streamDocPath, userId, currentParticle.created_at);
}, [currentParticle?.id, path])
// Author info from current particle
const authorEmail = currentParticle?.created_by_email ?? "";
const authorInitials = authorEmail.split("@")[0]?.slice(0, 2).toUpperCase() ?? "";
if (children.length === 0) {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 bg-black text-white">
<p className="text-muted-foreground text-sm">
No particles in this stream yet
</p>
<ControlsIndicator type="reply" />
<ComposeOverlay
networkId={networkId!}
targetPath={path}
onActiveChange={setComposeActive}
/>
</div>
);
}
// Render particle content inline (replaces ParticleRenderer)
function renderParticle(particle: Particle) {
switch (particle.type) {
case "media":
return (
<MediaParticleView
key={particle.id}
particle={particle}
paused={state.paused}
onEnded={next}
onProgress={setProgress}
/>
);
case "text":
return (
<TextParticleView
key={particle.id}
particle={particle}
paused={state.paused}
onEnded={next}
onProgress={setProgress}
/>
);
default:
return <FallbackParticleView particle={particle} />;
}
}
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">
Stream view {networkId}/{particleSegments.join("/")}
</p>
<div className="relative flex h-full flex-col bg-black text-white">
{/* Progress indicator */}
<div className="z-10 absolute left-0 right-0">
<PlaybackPageIndicator
total={children.length}
current={state.currentIndex}
progress={progress}
onGoTo={goTo}
/>
</div>
{isLoading && <p className="text-muted-foreground text-sm">Loading stream data...</p>}
{error && <p className="text-destructive text-sm">Failed to load stream data</p>}
{!isLoading && !error && (
<div className="mt-4">
<p className="text-sm font-medium">Stream Children:</p>
<ul className="list-disc list-inside">
{children.map((child) => (
<li key={child.id} className="text-sm">
{child.id} ({child.type})
</li>
))}
</ul>
{/* Author overlay */}
{currentParticle && (
<div className="absolute top-5 left-1/2 transform -translate-x-1/2 z-10 flex items-center justify-center gap-2 bg-black/30 backdrop-blur-sm p-1 pr-2 rounded-full">
<Avatar size="sm">
<AvatarFallback className="bg-white/20 text-[10px] font-medium text-white">
{authorInitials}
</AvatarFallback>
</Avatar>
<span className="text-xs text-white/70">
{authorEmail.split("@")[0]}
</span>
</div>
)}
{/* Main playback area */}
<div className="flex-1 overflow-hidden">
{currentParticle && (
<div
className="relative flex h-full w-full cursor-pointer items-center justify-center"
onClick={handlePlaybackClick}
>
{renderParticle(currentParticle)}
</div>
)}
</div>
<ComposeOverlay
networkId={networkId!}
targetPath={path}
onActiveChange={setComposeActive}
/>
{/* Bottom overlay: stream info + reply */}
<div className="absolute right-0 left-0 bottom-0 z-10">
<ControlsIndicator type={"reply"}>
<div className="flex items-center gap-1 text-xs text-white/70">
Seen by
<SeenIndicator stream={streamParticle} currentParticle={currentParticle} networkId={networkId} />
{/* Exit countdown */}
{exitRemainingMs !== null && (
<span>
Closing in {Math.ceil(exitRemainingMs / 1000)}s
</span>
)}
</div>
</ControlsIndicator>
</div>
</div>
);
}
// Shows a list of avatars of users who have seen the current particle, based on playback markers in the stream particle.
const SeenIndicator = ({ stream, currentParticle, networkId }: { stream: Particle & { type: "stream" }, currentParticle: Particle, networkId: string }) => {
const network = useNetwork(networkId);
const playbackMarkers = stream.playback_markers ?? {};
const seenUserIds = Object.entries(playbackMarkers)
.filter(([_, timestamp]) => timestamp.getTime() >= currentParticle.created_at.getTime())
.map(([userId, _]) => userId);
const seenUserEmails = seenUserIds
.map((userId) => network?.humans?.find((h) => h.id === userId)?.email)
.filter((email): email is string => !!email);
if (seenUserIds.length === 0) return null;
return (
<AvatarGroup>
{seenUserEmails.map((email) => (
<Tooltip key={email}>
<TooltipTrigger asChild>
<Avatar size="sm">
<AvatarFallback>
{email.split("@")[0].slice(0, 2)}
</AvatarFallback>
</Avatar>
</TooltipTrigger>
<TooltipContent>
<p>Seen by {email.split("@")[0]}</p>
</TooltipContent>
</Tooltip>
))}
</AvatarGroup>
);
}
-84
View File
@@ -1,84 +0,0 @@
import { Heart } from "lucide-react";
import { useCallback } from "react";
import type { AckInfo } from "@/api/types";
import { apiClient } from "@/api/client";
import { useAuthStore } from "@/stores/auth-store";
import { useAppStore } from "@/stores/app-store";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface AckButtonProps {
particleId: string;
acks: AckInfo[];
}
function getInitials(email: string): string {
const prefix = email.split("@")[0];
const parts = prefix.split(/[._-]/);
if (parts.length >= 2) {
return (parts[0][0] + parts[1][0]).toUpperCase();
}
return prefix.slice(0, 2).toUpperCase();
}
export function AckButton({ particleId, acks }: AckButtonProps) {
const currentEmail = useAuthStore((s) => s.user?.email);
const ackParticle = useAppStore((s) => s.ackParticle);
const hasAcked = acks.some((a) => a.email === currentEmail);
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (hasAcked || !currentEmail) return;
ackParticle(particleId, currentEmail);
apiClient.ackParticle(particleId).catch(() => {});
},
[hasAcked, currentEmail, particleId, ackParticle],
);
const displayedAcks = acks.slice(0, 3);
return (
<div className="flex flex-col items-center gap-1.5">
<button
type="button"
onClick={handleClick}
className={cn(
"flex h-10 w-10 flex-col items-center justify-center rounded-full bg-black/30 backdrop-blur-sm transition-colors",
hasAcked
? "text-red-500"
: "text-white hover:bg-black/40",
)}
>
<Heart
className="h-4 w-4"
fill={hasAcked ? "currentColor" : "none"}
/>
<span className="mt-0.5 text-[10px] font-medium leading-none">
{acks.length}
</span>
</button>
{displayedAcks.length > 0 && (
<div className="flex flex-col items-center gap-1">
{displayedAcks.map((ack) => (
<Tooltip key={ack.email}>
<TooltipTrigger asChild>
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-white/20 text-[10px] font-medium text-white backdrop-blur-sm">
{getInitials(ack.email)}
</div>
</TooltipTrigger>
<TooltipContent side="left">
<p>{ack.email}</p>
</TooltipContent>
</Tooltip>
))}
</div>
)}
</div>
);
}
@@ -1,5 +1,4 @@
import type { StreamParticle } from "@/api/types";
import { getParticleData } from "@/api/types";
import type { Particle } from "@/api/types";
import {
Card,
CardContent,
@@ -16,7 +15,7 @@ const TYPE_META: Record<string, { icon: typeof FileIcon; label: string }> = {
};
interface FallbackParticleViewProps {
particle: StreamParticle;
particle: Particle;
}
export function FallbackParticleView({ particle }: FallbackParticleViewProps) {
@@ -28,13 +27,13 @@ export function FallbackParticleView({ particle }: FallbackParticleViewProps) {
const title = (() => {
switch (particle.type) {
case "quest":
return getParticleData(particle, "quest").title;
return particle.properties.title;
case "paper":
return getParticleData(particle, "paper").title;
return particle.properties.title;
case "file":
return getParticleData(particle, "file").filename;
return particle.properties.filename;
case "folder":
return getParticleData(particle, "folder").name;
return particle.properties.name;
default:
return null;
}
@@ -1,86 +1,37 @@
import { useEffect, useRef, useState } from "react";
import type { MediaParticleData, StreamParticle } from "@/api/types";
import { apiClient } from "@/api/client";
import { usePlaybackStore } from "@/stores/playback-store";
import type { Particle } from "@/api/types";
import { useDownloadUrl } from "@/hooks/use-download-url";
import { Skeleton } from "@/components/ui/skeleton";
import { AudioLevelBars } from "@/components/audio/audio-level-bars";
import { useAudioSource } from "@/components/audio/use-audio-source";
type MediaParticle = Extract<Particle, { type: "media" }>;
interface MediaParticleViewProps {
particle: StreamParticle;
}
function formatTime(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
function DurationPill({
currentTimeMs,
totalDurationMs,
}: {
currentTimeMs: number;
totalDurationMs: number;
}) {
return (
<div className="absolute top-3 right-3 rounded-full bg-white/10 px-2.5 py-1 backdrop-blur-sm">
<span className="font-mono text-xs text-white/80">
{formatTime(currentTimeMs)} / {formatTime(totalDurationMs)}
</span>
</div>
);
particle: MediaParticle;
paused: boolean;
onEnded: () => void;
onProgress?: (ratio: number) => void;
}
export function MediaParticleView({
particle,
paused,
onEnded,
onProgress,
}: MediaParticleViewProps) {
const cachedUrl = usePlaybackStore(
(s) => s.downloadUrlCache[particle.id],
);
const cacheDownloadUrl = usePlaybackStore((s) => s.cacheDownloadUrl);
const next = usePlaybackStore((s) => s.next);
const paused = usePlaybackStore((s) => s.paused);
const [error, setError] = useState<string | null>(null);
const { data: url, error } = useDownloadUrl(particle.properties.object_id);
const videoRef = useRef<HTMLVideoElement>(null);
const audioRef = useRef<HTMLAudioElement>(null);
const [currentTimeMs, setCurrentTimeMs] = useState(0);
const isAudio = particle.properties.mime_type?.startsWith("audio/");
const data = particle.data as MediaParticleData;
const isAudio = data.mime_type?.startsWith("audio/");
// WORKAROUND: useAudioSource needs an audio element, but audioRef is only set after mount
const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null);
const audioSource = useAudioSource(audioEl);
useEffect(() => {
if (cachedUrl) {
return;
}
let cancelled = false;
apiClient
.getParticleDownloadUrl(particle.id)
.then((downloadUrl) => {
if (cancelled) return;
cacheDownloadUrl(particle.id, downloadUrl);
})
.catch(() => {
if (!cancelled) setError("Failed to load media");
});
return () => {
cancelled = true;
};
}, [particle.id, cacheDownloadUrl]);
// Handle pause/resume
useEffect(() => {
var el: HTMLVideoElement | HTMLAudioElement | null = null;
if (isAudio) {
el = audioRef.current;
} else {
el = videoRef.current;
}
const el = isAudio ? audioRef.current : videoRef.current;
if (!el) return;
if (paused) {
@@ -90,17 +41,17 @@ export function MediaParticleView({
console.warn("Playback failed", { particleId: particle.id });
});
}
}, [paused]);
}, [paused, isAudio, particle.id]);
if (error) {
return (
<div className="text-muted-foreground flex items-center justify-center text-sm">
{error}
Failed to load media
</div>
);
}
if (!cachedUrl) {
if (!url) {
return <Skeleton className="h-full w-full rounded-none" />;
}
@@ -108,20 +59,25 @@ export function MediaParticleView({
return (
<div className="relative flex h-full w-full items-center justify-center bg-black/90">
<audio
ref={audioRef}
ref={(el) => {
audioRef.current = el;
setAudioEl(el);
}}
crossOrigin="anonymous"
src={cachedUrl}
src={url}
autoPlay
onEnded={next}
onEnded={onEnded}
onTimeUpdate={(e) => {
setCurrentTimeMs(e.currentTarget.currentTime * 1000);
const { currentTime, duration } = e.currentTarget;
if (duration > 0) onProgress?.(currentTime / duration);
}}
/>
<DurationPill
currentTimeMs={currentTimeMs}
totalDurationMs={data.duration_ms}
/>
{audioSource && (
<div className="z-10 absolute bottom-15">
<AudioLevelBars sourceNode={audioSource.sourceNode} />
</div>
)}
</div>
);
}
@@ -130,19 +86,16 @@ export function MediaParticleView({
<div className="relative h-full w-full">
<video
ref={videoRef}
src={cachedUrl}
src={url}
autoPlay
playsInline
onEnded={next}
onEnded={onEnded}
onTimeUpdate={(e) => {
setCurrentTimeMs(e.currentTarget.currentTime * 1000);
const { currentTime, duration } = e.currentTarget;
if (duration > 0) onProgress?.(currentTime / duration);
}}
className="h-full w-full object-cover"
/>
<DurationPill
currentTimeMs={currentTimeMs}
totalDurationMs={data.duration_ms}
/>
</div>
);
}
@@ -1,62 +0,0 @@
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";
import { AckButton } from "./ack-button";
interface ParticleRendererProps {
particle: StreamParticle;
}
export function ParticleRenderer({
particle,
}: ParticleRendererProps) {
const next = usePlaybackStore((s) => s.next);
const prev = usePlaybackStore((s) => s.prev);
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);
}
}, [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) prev();
else if (x > 0.7) next();
};
return (
<div
className="relative flex h-full w-full cursor-pointer items-center justify-center"
onClick={handleClick}
>
<ParticleContent particle={particle} />
<div className="absolute right-4 bottom-16">
<AckButton particleId={particle.id} acks={particle.acks} />
</div>
</div>
);
}
function ParticleContent({ particle }: { particle: StreamParticle }) {
switch (particle.type) {
case "media":
{/* NOTE: it's more robust to re-mount the MediaParticleView when the particle changes, to ensure playback state is well-behaved */ }
return <MediaParticleView key={particle.id} particle={particle} />;
case "text":
return <TextParticleView particle={particle} />;
default:
return <FallbackParticleView particle={particle} />;
}
}
@@ -3,12 +3,14 @@ import { cn } from "@/lib/utils";
interface PlaybackPageIndicatorProps {
total: number;
current: number;
progress: number;
onGoTo: (index: number) => void;
}
export function PlaybackPageIndicator({
total,
current,
progress,
onGoTo,
}: PlaybackPageIndicatorProps) {
if (total === 0) return null;
@@ -24,14 +26,29 @@ export function PlaybackPageIndicator({
}}
className="group relative h-3 flex-1"
>
{/* Track */}
{/* Dim track */}
<div
className={cn(
"absolute inset-x-0 top-1 h-1 rounded-full transition-all",
i <= current ? "bg-white/90" : "bg-white/30",
"absolute inset-x-0 top-1 h-1 rounded-full bg-white/30",
"group-hover:h-1.5 group-hover:top-0.5",
)}
/>
{/* Fill */}
<div
className={cn(
"absolute left-0 top-1 h-1 rounded-full bg-white/90",
"group-hover:h-1.5 group-hover:top-0.5",
)}
style={{
width:
i < current
? "100%"
: i === current
? `${progress * 100}%`
: "0%",
transition: i === current ? "width 300ms linear" : "none",
}}
/>
</button>
))}
</div>
@@ -1,8 +1,25 @@
import type { StreamParticle, TextParticleData } from "@/api/types";
import { useEffect, useRef } from "react";
import type { Particle } from "@/api/types";
import { cn } from "@/lib/utils";
type TextParticle = Extract<Particle, { type: "text" }>;
interface TextParticleViewProps {
particle: StreamParticle;
particle: TextParticle;
paused: boolean;
onEnded: () => void;
onProgress?: (ratio: number) => void;
}
const WORDS_PER_MINUTE = 200;
const MIN_DURATION_S = 3;
const MAX_DURATION_S = 15;
const TICK_MS = 100;
function computeReadDuration(text: string): number {
const wordCount = text.trim().split(/\s+/).length;
const seconds = (wordCount / WORDS_PER_MINUTE) * 60;
return Math.min(Math.max(seconds, MIN_DURATION_S), MAX_DURATION_S);
}
function getTextStyle(length: number) {
@@ -12,9 +29,37 @@ function getTextStyle(length: number) {
return { size: "text-lg", weight: "font-normal" };
}
export function TextParticleView({ particle }: TextParticleViewProps) {
const data = particle.data as TextParticleData;
const style = getTextStyle(data.content.length);
export function TextParticleView({
particle,
paused,
onEnded,
onProgress,
}: TextParticleViewProps) {
const style = getTextStyle(particle.properties.content.length);
const durationS = computeReadDuration(particle.properties.content);
const elapsedRef = useRef(0);
// Reset elapsed when particle changes
useEffect(() => {
elapsedRef.current = 0;
}, [particle.id]);
useEffect(() => {
if (paused) return;
const interval = setInterval(() => {
elapsedRef.current += TICK_MS / 1000;
const ratio = Math.min(elapsedRef.current / durationS, 1);
onProgress?.(ratio);
if (ratio >= 1) {
clearInterval(interval);
onEnded();
}
}, TICK_MS);
return () => clearInterval(interval);
}, [paused, durationS, onEnded, onProgress, particle.id]);
return (
<div className="flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 p-8">
@@ -25,7 +70,7 @@ export function TextParticleView({ particle }: TextParticleViewProps) {
style.weight,
)}
>
{data.content}
{particle.properties.content}
</p>
</div>
);
-53
View File
@@ -1,53 +0,0 @@
import { Video, Mic } from "lucide-react";
import { useRecordingStore } from "@/stores/recording-store";
import { cn } from "@/lib/utils";
export function ReplyIndicator() {
const recordingMode = useRecordingStore((s) => s.recordingMode);
const setRecordingMode = useRecordingStore((s) => s.setRecordingMode);
return (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() =>
setRecordingMode(recordingMode === "video" ? "audio" : "video")
}
className={cn(
"rounded p-1 transition-colors hover:bg-white/20",
"text-white/60 hover:text-white/90",
)}
title={
recordingMode === "video"
? "Switch to audio-only"
: "Switch to video"
}
>
{recordingMode === "video" ? (
<Video className="h-3.5 w-3.5" />
) : (
<Mic className="h-3.5 w-3.5" />
)}
</button>
<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 · Press{" "}
<kbd
className={cn(
"bg-muted rounded px-1.5 py-0.5 font-mono text-xs",
)}
>
T
</kbd>{" "}
to text
</div>
</div>
);
}
-191
View File
@@ -1,191 +0,0 @@
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 VIDEO_PREFERRED_MIME = "video/webm;codecs=vp9,opus";
const VIDEO_FALLBACK_MIME = "video/webm";
const AUDIO_PREFERRED_MIME = "audio/webm;codecs=opus";
const AUDIO_FALLBACK_MIME = "audio/webm";
function getMediaMime(mode: "video" | "audio"): string {
if (mode === "audio") {
if (MediaRecorder.isTypeSupported(AUDIO_PREFERRED_MIME))
return AUDIO_PREFERRED_MIME;
return AUDIO_FALLBACK_MIME;
}
if (MediaRecorder.isTypeSupported(VIDEO_PREFERRED_MIME))
return VIDEO_PREFERRED_MIME;
return VIDEO_FALLBACK_MIME;
}
export function useRecorder(
streamId: string | null,
networkId: string | null,
) {
const recorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const chunksRef = useRef<Blob[]>([]);
const startTimeRef = useRef<number>(0);
const mimeRef = useRef<string>("");
const recordingMode = useRecordingStore((s) => s.recordingMode);
const setStatus = useRecordingStore((s) => s.setStatus);
const setError = useRecordingStore((s) => s.setError);
const setMediaStream = useRecordingStore((s) => s.setMediaStream);
const setReviewBlob = useRecordingStore((s) => s.setReviewBlob);
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 = [];
setMediaStream(null);
}, [setMediaStream]);
const confirmSend = useCallback(async () => {
if (!streamId || !networkId) return;
const { reviewBlob, reviewDurationMs } = useRecordingStore.getState();
if (!reviewBlob) return;
setStatus("uploading");
try {
const mimeType = reviewBlob.type || VIDEO_FALLBACK_MIME;
const fileName = `recording-${Date.now()}.webm`;
const { object_id, upload_url } = await apiClient.prepareUpload({
network_id: networkId,
name: fileName,
content_type: mimeType,
content_length: reviewBlob.size,
});
await fetch(upload_url, {
method: "PUT",
headers: { "Content-Type": mimeType },
body: reviewBlob,
});
await apiClient.confirmUpload(object_id);
const particle = await apiClient.createStreamParticle(streamId, {
type: "media",
data: {
object_id,
duration_ms: reviewDurationMs,
mime_type: mimeType,
},
});
addParticleToStream(streamId, particle);
const playbackState = usePlaybackStore.getState();
if (playbackState.streamId === streamId) {
usePlaybackStore.setState({
particles: [...playbackState.particles, particle],
});
}
resetRecording();
} catch (err) {
setError(err instanceof Error ? err.message : "Upload failed");
}
}, [streamId, networkId, setStatus, setError, resetRecording, addParticleToStream]);
const startRecording = useCallback(async () => {
const currentStatus = useRecordingStore.getState().status;
if (currentStatus !== "idle") return;
try {
const constraints =
recordingMode === "video"
? { video: true, audio: true }
: { audio: true };
setStatus("recording");
const mediaStream =
await navigator.mediaDevices.getUserMedia(constraints);
streamRef.current = mediaStream;
setMediaStream(mediaStream);
chunksRef.current = [];
startTimeRef.current = Date.now();
const mime = getMediaMime(recordingMode);
mimeRef.current = mime;
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) {
setReviewBlob(blob, durationMs);
} else {
resetRecording();
}
};
recorder.start();
} catch (err) {
stopTracks();
setError(
err instanceof Error ? err.message : "Failed to start recording",
);
}
}, [
recordingMode,
setStatus,
setError,
setMediaStream,
setReviewBlob,
stopTracks,
resetRecording,
]);
const stopRecording = useCallback(() => {
if (recorderRef.current?.state === "recording") {
recorderRef.current.stop();
}
}, []);
const cancelRecording = useCallback(() => {
const currentStatus = useRecordingStore.getState().status;
if (currentStatus === "reviewing") {
resetRecording();
return;
}
if (recorderRef.current) {
recorderRef.current.ondataavailable = null;
recorderRef.current.onstop = null;
if (recorderRef.current.state === "recording") {
recorderRef.current.stop();
}
}
stopTracks();
resetRecording();
}, [stopTracks, resetRecording]);
// Cleanup on unmount
useEffect(() => {
return () => {
stopTracks();
};
}, [stopTracks]);
return { startRecording, stopRecording, cancelRecording, confirmSend };
}
+139
View File
@@ -0,0 +1,139 @@
import { useNavigate } from "react-router-dom";
import { ChevronRight, LogOut, User, Info, Shield } from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Separator } from "@/components/ui/separator";
import { WindowControls } from "@/components/window-controls";
import { Button } from "@/components/ui/button";
import { Muted } from "@/components/ui/typography";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useAuthStore } from "@/stores/auth-store";
import { ArrowLeft } from "lucide-react";
interface SettingsRowProps {
icon: React.ReactNode;
label: string;
detail?: string;
onClick?: () => void;
destructive?: boolean;
}
function SettingsRow({
icon,
label,
detail,
onClick,
destructive,
}: SettingsRowProps) {
return (
<button
type="button"
onClick={onClick}
className={`flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-accent ${destructive ? "text-destructive" : ""}`}
>
<span className="text-muted-foreground flex size-5 items-center justify-center">
{icon}
</span>
<span className="min-w-0 flex-1 text-sm font-medium">{label}</span>
{detail && <Muted className="text-xs">{detail}</Muted>}
{onClick && !destructive && (
<ChevronRight className="text-muted-foreground size-4" />
)}
</button>
);
}
function SettingsGroup({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<div>
<p className="text-muted-foreground px-4 pb-1 pt-4 text-xs font-medium uppercase tracking-wider">
{title}
</p>
<div>{children}</div>
</div>
);
}
export default function SettingsPage() {
const navigate = useNavigate();
const user = useAuthStore((s) => s.user);
const signOut = useAuthStore((s) => s.signOut);
const initials = user?.email_prefix?.slice(0, 2).toUpperCase() ?? "?";
return (
<div className="flex h-screen flex-col">
<div className="drag-region flex items-center gap-3 border-b px-3 py-1">
<WindowControls />
<Button
variant="ghost"
size="sm"
className="no-drag text-muted-foreground"
onClick={() => navigate(-1)}
>
<ArrowLeft className="size-3.5" />
</Button>
<span className="text-sm font-medium">Settings</span>
<div className="flex-1" />
</div>
<ScrollArea className="flex-1">
{/* Profile header */}
<div className="flex items-center gap-3 px-4 py-5">
<Avatar size="lg">
<AvatarFallback className="bg-primary/10 text-primary font-medium">
{initials}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{user?.email_prefix}
</p>
<Muted className="text-xs">{user?.email}</Muted>
</div>
</div>
<Separator />
<SettingsGroup title="Account">
<SettingsRow
icon={<User className="size-4" />}
label="Profile"
detail={user?.email_prefix}
/>
<Separator className="mx-4" />
<SettingsRow
icon={<Shield className="size-4" />}
label="Privacy"
/>
</SettingsGroup>
<Separator className="mt-4" />
<SettingsGroup title="About">
<SettingsRow
icon={<Info className="size-4" />}
label="Version"
detail="1.0.0"
/>
</SettingsGroup>
<Separator className="mt-4" />
<div className="py-4">
<SettingsRow
icon={<LogOut className="size-4" />}
label="Sign out"
onClick={signOut}
destructive
/>
</div>
</ScrollArea>
</div>
);
}
@@ -1,112 +0,0 @@
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>
);
}