import { useCallback, useEffect, useRef, useState } from "react"; import { Pressable, Text, View } from "react-native"; import { Mic, Type as TypeIcon, Video as VideoIcon } from "lucide-react-native"; import * as Haptics from "expo-haptics"; import { setAudioModeAsync } from "expo-audio"; import { useCameraPermissions, useMicrophonePermissions, } from "expo-camera"; import { Gesture, GestureDetector, } from "react-native-gesture-handler"; import Animated, { Easing, runOnJS, useAnimatedStyle, useSharedValue, withTiming, } from "react-native-reanimated"; import { toast } from "sonner-native"; import { cn } from "@/lib/utils"; import { useEvent } from "@/hooks/use-event"; import { logError } from "@/lib/errors"; import { useSuspendPlayback } from "@/hooks/use-suspend-playback"; import { useAuthStore } from "@/stores/auth-store"; import { createTextParticle, uploadMediaParticle, } from "@/lib/upload"; import type { ParticlePath } from "@/lib/particle-path"; import { useStreamComposingBroadcast, type ComposingMode, } from "@/features/stream-view/stream-presence-context"; import { TextComposeModal } from "./TextComposeModal"; import { VideoRecordingOverlay } from "./VideoRecordingOverlay"; import { AudioRecordingOverlay } from "./AudioRecordingOverlay"; import { ReviewSheet } from "./ReviewSheet"; type RecordingMode = "video" | "audio"; type PendingAction = "commit" | "cancel" | null; type ComposeUiState = | { kind: "idle" } | { kind: "warming-up"; mode: RecordingMode } | { kind: "recording"; mode: RecordingMode } | { kind: "review"; mode: RecordingMode; uri: string; durationMs: number; } | { kind: "uploading" }; const CANCEL_THRESHOLD_PX = 120; const SHORT_RELEASE_MS = 350; interface SubmitMediaParams { fileUri: string; mimeType: string; durationMs: number; source: "camera" | "screen"; } interface ComposeDockProps { networkId: string; /** Where new particles are created. Defaults to the current stream. */ targetPath: ParticlePath; /** * When true, the dock won't broadcast composing state — used by the * new-stream screen where there's no presence channel to broadcast to. */ silentPresence?: boolean; /** * Optional: replace the default media upload logic. NewStreamScreen uses * this to create the stream + first particle in one shot. The dock still * owns the review-sheet / haptic / state-machine flow; only the final * persistence step is swapped. */ submitMedia?: (params: SubmitMediaParams) => Promise; /** Optional: replace the default text-particle creation. */ submitText?: (content: string) => Promise; } /** * Bottom dock + recording orchestrator. The hold-to-record gesture lives on * the FAB and drives every state transition. `cancelProgress` is a worklet * shared value the overlays read for live cancel-pill animations. Playback * is suspended via the pause-store while the dock is anything but idle. */ export function ComposeDock({ networkId, targetPath, silentPresence = false, submitMedia, submitText: submitTextOverride, }: ComposeDockProps) { const userId = useAuthStore((s) => s.user?.id); const [mode, setMode] = useState("video"); const [ui, setUi] = useState({ kind: "idle" }); const [textOpen, setTextOpen] = useState(false); // Both overlays read this prop to know how to finalize. It's set on release // and cleared as the overlay unmounts. const [pendingAction, setPendingAction] = useState(null); const [camPerm, requestCamPerm] = useCameraPermissions(); const [micPerm, requestMicPerm] = useMicrophonePermissions(); // Suspend underlying playback whenever the dock isn't idle. Mirrors how // desktop's compose-overlay flips the playback-pause-store. const isComposing = ui.kind !== "idle" || textOpen; useSuspendPlayback(isComposing, "compose"); // Broadcast composing state over Pusher so other viewers see the dot. // When silentPresence is set we skip — the dock renders outside a stream // (e.g. NewStreamScreen) where no presence channel exists yet. useComposingBroadcast({ ui, textOpen, silent: silentPresence }); // Worklet-side flag the overlays read for live cancel UI. const cancelProgress = useSharedValue(0); // Press start time, kept on a ref so worklets can read it via runOnJS. const pressStartedAtRef = useRef(null); const ensurePermissions = useCallback( async (forVideo: boolean): Promise => { if (forVideo) { const cam = camPerm?.granted ? camPerm : await requestCamPerm(); if (!cam.granted) { toast.error("Camera permission is required to record video."); return false; } } const mic = micPerm?.granted ? micPerm : await requestMicPerm(); if (!mic.granted) { toast.error("Microphone permission is required to record."); return false; } return true; }, [camPerm, micPerm, requestCamPerm, requestMicPerm], ); // ----- Lifecycle handlers ----- const beginRecording = useEvent(async () => { if (ui.kind !== "idle") return; pressStartedAtRef.current = Date.now(); const ok = await ensurePermissions(mode === "video"); if (!ok) { pressStartedAtRef.current = null; return; } // iOS requires the audio session to allow recording before either the // audio recorder or the camera's audio track will work. try { await setAudioModeAsync({ allowsRecording: true, playsInSilentMode: true, }); } catch (err) { logError(err, { scope: "compose.audio.setMode.record" }); } void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); setUi({ kind: "warming-up", mode }); }); // Restore the audio session to playback-only when we leave recording, // otherwise iOS keeps routing playback through the earpiece. const restoreAudioMode = useCallback(() => { setAudioModeAsync({ allowsRecording: false, playsInSilentMode: true, }).catch((err) => logError(err, { scope: "compose.audio.setMode.restore" }), ); }, []); const handleRecordingStarted = useCallback(() => { setUi((prev) => prev.kind === "warming-up" ? { kind: "recording", mode: prev.mode } : prev, ); }, []); const handleRecordingComplete = useCallback( ({ uri, durationMs }: { uri: string; durationMs: number }) => { void Haptics.selectionAsync(); setUi((prev) => { const m = "mode" in prev ? prev.mode : mode; return { kind: "review", mode: m, uri, durationMs }; }); setPendingAction(null); pressStartedAtRef.current = null; restoreAudioMode(); }, [mode, restoreAudioMode], ); const handleRecordingCancel = useCallback(() => { setUi({ kind: "idle" }); setPendingAction(null); pressStartedAtRef.current = null; cancelProgress.value = 0; restoreAudioMode(); }, [cancelProgress, restoreAudioMode]); const finalizeFromGesture = useEvent(({ cancelled }: { cancelled: boolean }) => { const heldMs = pressStartedAtRef.current ? Date.now() - pressStartedAtRef.current : 0; // Recording never warmed up to "recording" — drop back to idle without // shipping anything (permission denied, or mount race). if (ui.kind === "warming-up") { setUi({ kind: "idle" }); pressStartedAtRef.current = null; cancelProgress.value = 0; restoreAudioMode(); return; } if (ui.kind !== "recording") return; const wantCancel = cancelled || heldMs < SHORT_RELEASE_MS; if (wantCancel) { void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); } setPendingAction(wantCancel ? "cancel" : "commit"); cancelProgress.value = 0; }); // ----- Gesture: hold-to-record on FAB ----- const pan = Gesture.Pan() .minDistance(0) .activateAfterLongPress(120) .onStart(() => { "worklet"; runOnJS(beginRecording)(); }) .onUpdate((e) => { "worklet"; const upPx = Math.max(0, -e.translationY); cancelProgress.value = Math.min(upPx / CANCEL_THRESHOLD_PX, 1); }) .onEnd((e) => { "worklet"; const cancelled = e.translationY < -CANCEL_THRESHOLD_PX; runOnJS(finalizeFromGesture)({ cancelled }); }) .onFinalize(() => { "worklet"; cancelProgress.value = 0; }); // ----- Submit handlers ----- const sendReview = useEvent(async () => { if (ui.kind !== "review" || !userId) return; const captured = ui; setUi({ kind: "uploading" }); try { const mimeType = captured.mode === "audio" ? "audio/mp4" : "video/mp4"; if (submitMedia) { await submitMedia({ fileUri: captured.uri, mimeType, durationMs: captured.durationMs, source: "camera", }); } else { await uploadMediaParticle({ networkId, targetPath, fileUri: captured.uri, mimeType, durationMs: captured.durationMs, source: "camera", createdByHumanId: userId, }); } void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); setUi({ kind: "idle" }); } catch (err) { void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); // Restore the review sheet so the user can retry without losing their // take. The sheet's own toast already surfaced the error message. setUi(captured); throw err; } }); const retake = useCallback(() => setUi({ kind: "idle" }), []); const cancelReview = useCallback(() => setUi({ kind: "idle" }), []); const submitText = useEvent(async (content: string) => { if (!userId) throw new Error("Not signed in."); if (submitTextOverride) { await submitTextOverride(content); } else { await createTextParticle({ networkId, targetPath, content, createdByHumanId: userId, }); } void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); }); // ----- FAB animations ----- const fabScale = useSharedValue(1); useEffect(() => { const target = ui.kind === "recording" ? 1.18 : 1; fabScale.value = withTiming(target, { duration: 220, easing: Easing.out(Easing.cubic), }); }, [ui.kind, fabScale]); const fabStyle = useAnimatedStyle(() => ({ transform: [{ scale: fabScale.value }], })); const ringStyle = useAnimatedStyle(() => { const opacity = ui.kind === "recording" ? 1 : 0; const armed = cancelProgress.value > 0.5; return { opacity, borderColor: armed ? "rgba(239, 68, 68, 0.95)" : "rgba(255,255,255,0.95)", }; }); // ----- Render ----- const dockHidden = ui.kind === "review" || ui.kind === "uploading"; return ( <> {!dockHidden ? ( setMode((m) => (m === "video" ? "audio" : "video")) } disabled={ui.kind !== "idle"} accessibilityLabel={`Switch to ${ mode === "video" ? "audio" : "video" } mode`} className={cn( "h-11 w-11 items-center justify-center rounded-full bg-white/15", ui.kind !== "idle" && "opacity-40", )} > {mode === "video" ? ( ) : ( )} {ui.kind === "recording" ? "Release to send · slide up to cancel" : "Hold to record"} setTextOpen(true)} disabled={ui.kind !== "idle"} accessibilityLabel="Compose text" className={cn( "h-11 w-11 items-center justify-center rounded-full bg-white/15", ui.kind !== "idle" && "opacity-40", )} > ) : null} {ui.kind === "warming-up" || ui.kind === "recording" ? ( ui.mode === "video" ? ( ) : ( ) ) : null} setTextOpen(false)} onSubmit={submitText} /> ); } /** * Reads the dock UI state and tells the StreamPresenceContext when to start * or stop broadcasting composing_start/composing_stop. The hook intentionally * uses optional context access so the dock can also be hosted on the * new-stream screen, where there's no provider above us. */ function useComposingBroadcast({ ui, textOpen, silent, }: { ui: ComposeUiState; textOpen: boolean; silent: boolean; }) { // Optional consumer — `useStreamComposingBroadcast` throws when used // outside its provider, so we trap that here. Cheap because the throw // happens once per mount. let broadcast: ReturnType | null; try { broadcast = useStreamComposingBroadcast(); } catch { broadcast = null; } const mode: ComposingMode | null = ui.kind === "recording" || ui.kind === "warming-up" ? "recording" : textOpen ? "typing" : null; useEffect(() => { if (silent || !broadcast) return; if (mode) { broadcast.startComposing(mode); return () => broadcast?.stopComposing(); } }, [mode, silent, broadcast]); }