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" | "submitting"; interface ComposeOverlayProps { networkId: string; // Optional target path for reply mode. If not provided, compose creates a new stream. targetPath?: ParticlePath; onActiveChange?: (active: boolean) => void; } const HOLD_THRESHOLD_MS = 250; /** * 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("idle"); const [error, setError] = useState(null); const [textContent, setTextContent] = useState(""); const [mediaStream, setMediaStream] = useState(null); const [reviewBlob, setReviewBlob] = useState(null); const [reviewDurationMs, setReviewDurationMs] = useState(0); const [reviewMimeType, setReviewMimeType] = useState(null); const recordingMode = useMediaSettingsStore((s) => s.recordingMode); const userEmail = useAuthStore((s) => s.user?.email); const createParticle = useCreateParticle(); const createStream = useCreateStreamParticle(); // Refs for synchronous reads in keyboard handlers const stepRef = useRef(step); const recordStartRef = useRef(0); const setStepSync = useCallback((next: ComposeStep) => { stepRef.current = next; setStep(next); }, []); // Notify parent when active state changes useEffect(() => { onActiveChange?.(step !== "idle"); }, [step, onActiveChange]); const cancel = useCallback(() => { setStepSync("idle"); setError(null); setTextContent(""); setMediaStream(null); setReviewBlob(null); setReviewDurationMs(0); setReviewMimeType(null); }, [setStepSync]); const { startRecording, stopRecording, cancelRecording } = useRecorder({ mode: recordingMode, onStreamReady: (stream) => setMediaStream(stream), onStreamCleanup: () => setMediaStream(null), onFinish: (blob, durationMs, mimeType) => { setStepSync("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 || stepRef.current === "submitting") return; setStepSync("submitting"); await createChildParticle(targetPath); cancel(); }); // New stream mode: create stream + first child const handleStreamSubmit = useCallback( async (streamName: string, visibleTo: string[]) => { if (!userEmail || stepRef.current === "submitting") return; setStepSync("submitting"); 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") { if (e.key === "Escape") { e.preventDefault(); cancel(); } 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(); recordStartRef.current = Date.now(); setStepSync("recording"); startRecording(); } else if (e.key === "t" || e.key === "T") { e.preventDefault(); setStepSync("typing"); } break; } case "recording": { if (e.key === "`" && !e.repeat) { // Second tap stops recording (toggle mode) e.preventDefault(); stopRecording(); } else 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 { setStepSync("configuring"); } } break; } } }; const handleKeyUp = (e: KeyboardEvent) => { if (stepRef.current === "recording" && e.key === "`") { e.preventDefault(); // Only stop on release if held long enough (hold-to-record mode). // Quick taps are handled by the second keydown (toggle mode). if (Date.now() - recordStartRef.current >= HOLD_THRESHOLD_MS) { stopRecording(); } } }; window.addEventListener("keydown", handleKeyDown); window.addEventListener("keyup", handleKeyUp); return () => { window.removeEventListener("keydown", handleKeyDown); window.removeEventListener("keyup", handleKeyUp); }; }, [targetPath, startRecording, stopRecording, cancelRecording, cancel, setStepSync]); // --- Render --- if (step === "idle") return null; const handleTextAdvance = targetPath ? onSubmitReply : () => setStepSync("configuring"); return ( <> {(step === "recording" || step === "reviewing") && ( )} {step === "typing" && ( )} {!targetPath && step === "configuring" && ( )} {step === "submitting" && (
Sending...
)} ); }