import { useCallback, useEffect, useState } from 'react'; import { Pressable, Text, View } from 'react-native'; import { ListTodo, Mic, Type as TypeIcon, Video as VideoIcon, } from 'lucide-react-native'; import * as Haptics from 'expo-haptics'; import { useCameraPermissions, useMicrophonePermissions } from 'expo-camera'; import { toast } from 'sonner-native'; import { cn } from '@/lib/utils'; import { useEvent } from '@/hooks/use-event'; import { usePlaybackPauseStore } from '@/stores/playback-pause-store'; import { useAuthStore } from '@/stores/auth-store'; import { createTaskParticle, createTextParticle, uploadMediaParticle, } from '@/lib/upload'; import type { ParticlePath } from '@/lib/particle-path'; import { useStreamComposingBroadcastOptional, type ComposingMode, } from '@/features/stream-view/stream-presence-context'; import { TextComposeModal } from './TextComposeModal'; import { TaskComposeSheet } from './TaskComposeSheet'; import { VideoRecordingOverlay } from './VideoRecordingOverlay'; import { AudioRecordingOverlay } from './AudioRecordingOverlay'; import { ReviewSheet } from './ReviewSheet'; type RecordingMode = 'video' | 'audio'; type ComposeUiState = | { kind: 'idle' } | { kind: 'recording'; mode: RecordingMode } | { kind: 'review'; mode: RecordingMode; uri: string; durationMs: number; } | { kind: 'uploading'; mode: RecordingMode; uri: string; durationMs: number; }; interface SubmitMediaParams { fileUri: string; mimeType: string; durationMs: number; source: 'camera' | 'screen'; } interface ComposeDockProps { networkId: string; targetPath: ParticlePath; silentPresence?: boolean; /** * Whether to show the task compose button. Disabled in the new-stream flow, * where a stream's first particle must be text or media (a task can't open a * stream — it's added once the stream exists). */ allowTask?: boolean; submitMedia?: (params: SubmitMediaParams) => Promise; submitText?: (content: string) => Promise; /** * Called with the new particle's id right after it's created on the default * send path. Not fired when `submitMedia`/`submitText` overrides are supplied, * since those own the created particle themselves. Lets the stream follow a * just-sent particle when the user was at the end. */ onParticleCreated?: (particleId: string) => void; } export function ComposeDock({ networkId, targetPath, silentPresence = false, allowTask = true, submitMedia, submitText: submitTextOverride, onParticleCreated, }: ComposeDockProps) { const userId = useAuthStore((s) => s.user?.id); const [mode, setMode] = useState('video'); const [ui, setUi] = useState({ kind: 'idle' }); const [textOpen, setTextOpen] = useState(false); const [taskOpen, setTaskOpen] = useState(false); const [camPerm, requestCamPerm] = useCameraPermissions(); const [micPerm, requestMicPerm] = useMicrophonePermissions(); // Tell StreamView to fully unmount its expo-video player while we record. // That player otherwise holds the iOS AVAudioSession and crashes the camera. const setComposing = usePlaybackPauseStore((s) => s.setComposing); const isComposing = ui.kind !== 'idle' || textOpen || taskOpen; useEffect(() => { setComposing(isComposing); return () => setComposing(false); }, [isComposing, setComposing]); useComposingBroadcast({ ui, textOpen, taskOpen, silent: silentPresence }); 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], ); const startRecording = useEvent(async () => { if (ui.kind !== 'idle') return; const ok = await ensurePermissions(mode === 'video'); if (!ok) return; void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); setUi({ kind: 'recording', mode }); }); 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 }; }); }, [mode], ); const handleRecordingCancel = useCallback(() => { setUi({ kind: 'idle' }); }, []); const sendReview = useEvent(async () => { if (ui.kind !== 'review' || !userId) return; const captured = ui; setUi({ kind: 'uploading', mode: captured.mode, uri: captured.uri, durationMs: captured.durationMs, }); try { const mimeType = captured.mode === 'audio' ? 'audio/mp4' : 'video/mp4'; if (submitMedia) { await submitMedia({ fileUri: captured.uri, mimeType, durationMs: captured.durationMs, source: 'camera', }); } else { const particleId = await uploadMediaParticle({ networkId, targetPath, fileUri: captured.uri, mimeType, durationMs: captured.durationMs, source: 'camera', createdByHumanId: userId, }); onParticleCreated?.(particleId); } void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); setUi({ kind: 'idle' }); } catch (err) { void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); 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 { const particleId = await createTextParticle({ networkId, targetPath, content, createdByHumanId: userId, }); onParticleCreated?.(particleId); } void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); }); const submitTask = useEvent( async ({ title, notes }: { title: string; notes?: string }) => { if (!userId) throw new Error('Not signed in.'); const particleId = await createTaskParticle({ targetPath, title, notes, createdByHumanId: userId, }); onParticleCreated?.(particleId); void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); }, ); const dockHidden = ui.kind === 'review' || ui.kind === 'uploading' || ui.kind === 'recording'; return ( <> {!dockHidden ? ( {/* Left and right clusters flex equally so the record button stays centered regardless of how many side controls are present. */} 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' ? ( ) : ( )} Tap to record {allowTask ? ( setTaskOpen(true)} disabled={ui.kind !== 'idle'} accessibilityLabel="Create task" className={cn( 'h-11 w-11 items-center justify-center rounded-full bg-white/15', ui.kind !== 'idle' && 'opacity-40', )} > ) : null} 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 === 'recording' ? ( ui.mode === 'video' ? ( ) : ( ) ) : null} setTextOpen(false)} onSubmit={submitText} /> setTaskOpen(false)} onSubmit={submitTask} /> ); } function useComposingBroadcast({ ui, textOpen, taskOpen, silent, }: { ui: ComposeUiState; textOpen: boolean; taskOpen: boolean; silent: boolean; }) { // null when the dock is rendered outside a stream (no presence provider). const broadcast = useStreamComposingBroadcastOptional(); const mode: ComposingMode | null = ui.kind === 'recording' ? 'recording' : textOpen || taskOpen ? 'typing' : null; useEffect(() => { if (silent || !broadcast) return; if (mode) { broadcast.startComposing(mode); return () => broadcast?.stopComposing(); } }, [mode, silent, broadcast]); }