/** * AttachmentMenu — bottom-sheet с вариантами прикрепления. * * Выводится при нажатии на `+` в composer'е. Опции: * - 📷 Photo / video из галереи (expo-image-picker) * - 📸 Take photo (камера) * - 📎 File (expo-document-picker) * - 🎙️ Voice message — stub (запись через expo-av потребует * permissions runtime + recording UI; сейчас добавляет мок- * голосовое с duration 4s) * * Всё визуально — тёмный overlay + sheet снизу. Закрытие по tap'у на * overlay или на Cancel. */ import React from 'react'; import { View, Text, Pressable, Alert, Modal } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import * as ImagePicker from 'expo-image-picker'; import * as DocumentPicker from 'expo-document-picker'; import type { Attachment } from '@/lib/types'; export interface AttachmentMenuProps { visible: boolean; onClose: () => void; /** Вызывается когда attachment готов для отправки. */ onPick: (att: Attachment) => void; } export function AttachmentMenu({ visible, onClose, onPick }: AttachmentMenuProps) { const insets = useSafeAreaInsets(); const pickImageOrVideo = async () => { try { const perm = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (!perm.granted) { Alert.alert('Permission needed', 'Grant photos access to attach media.'); return; } const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.All, quality: 0.85, allowsEditing: false, }); if (result.canceled) return; const asset = result.assets[0]; onPick({ kind: asset.type === 'video' ? 'video' : 'image', uri: asset.uri, mime: asset.mimeType, width: asset.width, height: asset.height, duration: asset.duration ? Math.round(asset.duration / 1000) : undefined, }); onClose(); } catch (e: any) { Alert.alert('Pick failed', e?.message ?? 'Unknown error'); } }; const takePhoto = async () => { try { const perm = await ImagePicker.requestCameraPermissionsAsync(); if (!perm.granted) { Alert.alert('Permission needed', 'Grant camera access to take a photo.'); return; } const result = await ImagePicker.launchCameraAsync({ quality: 0.85 }); if (result.canceled) return; const asset = result.assets[0]; onPick({ kind: asset.type === 'video' ? 'video' : 'image', uri: asset.uri, mime: asset.mimeType, width: asset.width, height: asset.height, }); onClose(); } catch (e: any) { Alert.alert('Camera failed', e?.message ?? 'Unknown error'); } }; const pickFile = async () => { try { const res = await DocumentPicker.getDocumentAsync({ type: '*/*', copyToCacheDirectory: true, }); if (res.canceled) return; const asset = res.assets[0]; onPick({ kind: 'file', uri: asset.uri, name: asset.name, mime: asset.mimeType ?? undefined, size: asset.size, }); onClose(); } catch (e: any) { Alert.alert('File pick failed', e?.message ?? 'Unknown error'); } }; // Voice recorder больше не stub — см. inline-кнопку 🎤 в composer'е, // которая разворачивает VoiceRecorder (expo-av Audio.Recording). Опция // Voice в этом меню убрана, т.к. дублировала бы UX. return ( {}} style={{ backgroundColor: '#111111', borderTopLeftRadius: 20, borderTopRightRadius: 20, paddingTop: 8, paddingBottom: Math.max(insets.bottom, 12) + 10, paddingHorizontal: 10, borderTopWidth: 1, borderColor: '#1f1f1f', }} > {/* Drag handle */} Attach ); } function Row({ icon, label, onPress, }: { icon: React.ComponentProps['name']; label: string; onPress: () => void; }) { return ( ({ flexDirection: 'row', alignItems: 'center', gap: 14, paddingHorizontal: 14, paddingVertical: 14, borderRadius: 14, backgroundColor: pressed ? '#1a1a1a' : 'transparent', })} > {label} ); }