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}
/>
)}
</>
);
}