- {isUploading ? (
-
-
- Sending...
-
- ) : isRecording && !isLoading ? (
+ {isRecording && !isLoading ? (
) : isReviewing ? (
@@ -185,9 +164,9 @@ export function RecordingOverlay({ onClose }: RecordingOverlayProps) {
) : null}
- {/* Center: audio level bars (recording with active stream) */}
+ {/* Bottom center: audio level bars (recording with active stream) */}
{isRecording && recordingAudioSource && (
-
+
)}
@@ -217,7 +196,7 @@ export function RecordingOverlay({ onClose }: RecordingOverlayProps) {
Enter
{" "}
- to send
+ next
@@ -229,9 +208,9 @@ export function RecordingOverlay({ onClose }: RecordingOverlayProps) {
)}
{/* Error state */}
- {status === "error" && (
+ {error && (
- {useRecordingStore.getState().error ?? "Recording failed"}
+ {error}
)}
diff --git a/js/src/features/send/text-compose-overlay.tsx b/js/src/features/compose/text-compose-step.tsx
similarity index 56%
rename from js/src/features/send/text-compose-overlay.tsx
rename to js/src/features/compose/text-compose-step.tsx
index 73365bf..c3fcfe8 100644
--- a/js/src/features/send/text-compose-overlay.tsx
+++ b/js/src/features/compose/text-compose-step.tsx
@@ -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
(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 (
diff --git a/js/src/features/compose/use-compose-keyboard.ts b/js/src/features/compose/use-compose-keyboard.ts
new file mode 100644
index 0000000..b86a0cd
--- /dev/null
+++ b/js/src/features/compose/use-compose-keyboard.ts
@@ -0,0 +1,96 @@
+import { useEffect, useCallback } from "react";
+import { useComposeStore } from "@/stores/compose-store";
+import { useRecorder } from "@/features/compose/use-recorder";
+import { useParams } from "react-router-dom";
+
+/**
+ * Global keyboard handler for the compose flow.
+ *
+ * Handles keys for idle, recording, and reviewing steps.
+ * The typing and configuring steps handle their own keyboard
+ * events via focused elements — this hook ignores those steps.
+ */
+export function useComposeKeyboard() {
+ const networkId = useParams()["networkId"];
+
+ const { startRecording, stopRecording, cancelRecording } = useRecorder();
+
+ const handleKeyDown = useCallback(
+ (e: KeyboardEvent) => {
+ const { step } = useComposeStore.getState();
+
+ // Typing and configuring steps own their focused keyboard events
+ if (step === "typing" || step === "configuring") return;
+
+ // Don't intercept if the user is typing in an unrelated input
+ const target = e.target as HTMLElement;
+ if (
+ target.tagName === "INPUT" ||
+ target.tagName === "TEXTAREA" ||
+ target.isContentEditable
+ ) {
+ return;
+ }
+
+ switch (step) {
+ case "idle": {
+ if (!networkId) return;
+ if (e.key === "`" && !e.repeat) {
+ e.preventDefault();
+ useComposeStore.getState().startRecording(networkId);
+ startRecording();
+ } else if (e.key === "t" || e.key === "T") {
+ e.preventDefault();
+ useComposeStore.getState().startTyping(networkId);
+ }
+ break;
+ }
+
+ case "recording": {
+ if (e.key === "q" || e.key === "Q" || e.key === "Escape") {
+ e.preventDefault();
+ cancelRecording();
+ useComposeStore.getState().cancel();
+ }
+ break;
+ }
+
+ case "reviewing": {
+ if (e.key === "q" || e.key === "Q" || e.key === "Escape") {
+ e.preventDefault();
+ cancelRecording();
+ useComposeStore.getState().cancel();
+ } else if (e.key === "Enter") {
+ e.preventDefault();
+ useComposeStore.getState().advanceToConfigure();
+ }
+ break;
+ }
+ }
+ },
+ [networkId, startRecording, cancelRecording],
+ );
+
+ const handleKeyUp = useCallback(
+ (e: KeyboardEvent) => {
+ const { step } = useComposeStore.getState();
+
+ if (step === "recording" && e.key === "`") {
+ e.preventDefault();
+ stopRecording();
+ // finishRecording is called by the MediaRecorder onstop handler
+ // once the blob is ready — no need to call it here.
+ }
+ },
+ [stopRecording],
+ );
+
+ useEffect(() => {
+ window.addEventListener("keydown", handleKeyDown);
+ window.addEventListener("keyup", handleKeyUp);
+ return () => {
+ window.removeEventListener("keydown", handleKeyDown);
+ window.removeEventListener("keyup", handleKeyUp);
+ };
+ }, [handleKeyDown, handleKeyUp]);
+}
diff --git a/js/src/features/compose/use-recorder.ts b/js/src/features/compose/use-recorder.ts
new file mode 100644
index 0000000..a1a697f
--- /dev/null
+++ b/js/src/features/compose/use-recorder.ts
@@ -0,0 +1,109 @@
+import { useCallback, useEffect, useRef } from "react";
+import { useComposeStore } from "@/stores/compose-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") {
+ return MediaRecorder.isTypeSupported(AUDIO_PREFERRED_MIME)
+ ? AUDIO_PREFERRED_MIME
+ : AUDIO_FALLBACK_MIME;
+ }
+ return MediaRecorder.isTypeSupported(VIDEO_PREFERRED_MIME)
+ ? VIDEO_PREFERRED_MIME
+ : VIDEO_FALLBACK_MIME;
+}
+
+/**
+ * Manages MediaRecorder lifecycle and writes results to compose-store.
+ *
+ * Does NOT handle uploads or particle creation — that responsibility
+ * belongs to the configure step after the user finalizes stream metadata.
+ */
+export function useRecorder() {
+ const recorderRef = useRef