/** * Composer — плавающий блок ввода сообщения, прибит к низу. * * Композиция: * 1. Опциональный баннер (edit / reply) сверху. * 2. Опциональная pending-attachment preview. * 3. Либо: * - обычный input-bubble с `[+] [textarea] [↑/🎤/⭕]` * - inline VoiceRecorder когда идёт запись голосового * * Send-action зависит от состояния: * - есть текст/attachment → ↑ (send) * - пусто → показываем две иконки: 🎤 (start voice) + ⭕ (open video circle) * * API: * mode, onCancelMode * text, onChangeText * onSend, sending * onAttach — tap на + (AttachmentMenu) * attachment, onClearAttach * onFinishVoice — готовая voice-attachment (из VoiceRecorder) * onStartVideoCircle — tap на ⭕, родитель открывает VideoCircleRecorder * placeholder */ import React, { useRef, useState } from 'react'; import { View, Text, TextInput, Pressable, ActivityIndicator, Image } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import type { Attachment } from '@/lib/types'; import { VoiceRecorder } from '@/components/chat/VoiceRecorder'; export type ComposerMode = | { kind: 'new' } | { kind: 'edit'; text: string } | { kind: 'reply'; msgId: string; author: string; preview: string }; export interface ComposerProps { mode: ComposerMode; onCancelMode?: () => void; text: string; onChangeText: (t: string) => void; onSend: () => void; sending?: boolean; onAttach?: () => void; attachment?: Attachment | null; onClearAttach?: () => void; /** Voice recording завершена и отправляем сразу (мгновенный flow). */ onFinishVoice?: (att: Attachment) => void; /** Tap на "⭕" — родитель открывает VideoCircleRecorder. */ onStartVideoCircle?: () => void; placeholder?: string; } const INPUT_MIN_HEIGHT = 24; const INPUT_MAX_HEIGHT = 72; export function Composer(props: ComposerProps) { const { mode, onCancelMode, text, onChangeText, onSend, sending, onAttach, attachment, onClearAttach, onFinishVoice, onStartVideoCircle, placeholder, } = props; const inputRef = useRef(null); const [recordingVoice, setRecordingVoice] = useState(false); const hasContent = !!text.trim() || !!attachment; const canSend = hasContent && !sending; const inEdit = mode.kind === 'edit'; const inReply = mode.kind === 'reply'; const focusInput = () => inputRef.current?.focus(); return ( {/* ── Banner: edit / reply ── */} {(inEdit || inReply) && !recordingVoice && ( {inEdit && ( Edit message )} {inReply && ( <> Reply to {(mode as { author: string }).author} {(mode as { preview: string }).preview} )} ({ opacity: pressed ? 0.5 : 1 })} > )} {/* ── Pending attachment preview ── */} {attachment && !recordingVoice && ( )} {/* ── Voice recording (inline) ИЛИ обычный input ── */} {recordingVoice ? ( { setRecordingVoice(false); onFinishVoice?.(att); }} onCancel={() => setRecordingVoice(false)} /> ) : ( {/* + attach — всегда, кроме edit */} {onAttach && !inEdit && ( { e.stopPropagation?.(); onAttach(); }} hitSlop={6} style={({ pressed }) => ({ width: 32, height: 32, borderRadius: 16, alignItems: 'center', justifyContent: 'center', opacity: pressed ? 0.6 : 1, })} > )} {/* Правая часть: send ИЛИ [mic + video-circle] */} {canSend ? ( { e.stopPropagation?.(); onSend(); }} style={({ pressed }) => ({ width: 32, height: 32, borderRadius: 16, backgroundColor: pressed ? '#1a8cd8' : '#1d9bf0', alignItems: 'center', justifyContent: 'center', })} > {sending ? ( ) : ( )} ) : !inEdit && (onFinishVoice || onStartVideoCircle) ? ( {onStartVideoCircle && ( { e.stopPropagation?.(); onStartVideoCircle(); }} hitSlop={6} style={({ pressed }) => ({ width: 32, height: 32, borderRadius: 16, alignItems: 'center', justifyContent: 'center', opacity: pressed ? 0.6 : 1, })} > )} {onFinishVoice && ( { e.stopPropagation?.(); setRecordingVoice(true); }} hitSlop={6} style={({ pressed }) => ({ width: 32, height: 32, borderRadius: 16, alignItems: 'center', justifyContent: 'center', opacity: pressed ? 0.6 : 1, })} > )} ) : null} )} ); } // ─── Attachment chip — preview текущего pending attachment'а ──────── function AttachmentChip({ attachment, onClear, }: { attachment: Attachment; onClear?: () => void; }) { const icon: React.ComponentProps['name'] = attachment.kind === 'image' ? 'image-outline' : attachment.kind === 'video' ? 'videocam-outline' : attachment.kind === 'voice' ? 'mic-outline' : 'document-outline'; return ( {attachment.kind === 'image' || attachment.kind === 'video' ? ( ) : ( )} {attachment.name ?? attachmentLabel(attachment)} {attachment.kind.toUpperCase()} {attachment.circle ? ' · circle' : ''} {attachment.size ? ` · ${(attachment.size / 1024).toFixed(0)} KB` : ''} {attachment.duration ? ` · ${attachment.duration}s` : ''} ({ opacity: pressed ? 0.5 : 1, padding: 4 })} > ); } function attachmentLabel(a: Attachment): string { switch (a.kind) { case 'image': return 'Photo'; case 'video': return a.circle ? 'Video message' : 'Video'; case 'voice': return 'Voice message'; case 'file': return 'File'; } }