Media Handling
Media Handling
Section titled “Media Handling”Summary
Section titled “Summary”The Media Handling feature enables agents to send and receive various types of media attachments including images, videos, audio files, and documents in conversations.
User Story: As a support agent, I want to send and receive media files so that I can provide visual assistance and receive customer uploads.
Acceptance Criteria:
- ✅ Capture photos via camera
- ✅ Select images from gallery
- ✅ Select videos from gallery
- ✅ Pick documents and files
- ✅ Record voice messages
- ✅ Upload files with progress indicator
- ✅ Display image thumbnails in messages
- ✅ Full-screen image viewer
- ✅ Video playback
- ✅ Audio playback
- ✅ File download and sharing
Inputs:
- Camera capture
- Gallery selection
- File picker selection
- Audio recording
Outputs:
- Uploaded file URL
- Thumbnail URL
- File metadata (type, size, name)
Image Upload Flow
Section titled “Image Upload Flow”User Taps Attachment Button ↓Show Action Sheet: - Take Photo - Choose from Library ↓User Selects Option ↓Request Camera/Gallery Permission ↓├─ Permission Granted│ ↓│ Open Camera/Gallery│ ↓│ User Selects/Captures Image│ ↓│ Validate File Size (max 15MB)│ ↓│ Generate Thumbnail│ ↓│ Show Upload Preview│ ↓│ User Confirms│ ↓│ Start Upload│ ↓│ Show Progress (0-100%)│ ↓│ Receive File URL from Server│ ↓│ Send Message with Attachment│└─ Permission Denied ↓ Show Settings PromptVideo Upload Flow
Section titled “Video Upload Flow”User Selects Video from Gallery ↓Validate File Size (max 50MB) ↓Compress Video if Needed ↓Generate Thumbnail Frame ↓Show Upload Preview ↓Start Upload with Progress ↓Receive Video URL ↓Send Message with Video AttachmentVoice Message Flow
Section titled “Voice Message Flow”User Taps and Holds Microphone Button ↓Request Microphone Permission ↓Start Audio Recording ↓Show Recording UI: - Timer - Waveform Visualization - Cancel/Send Actions ↓User Releases Button ↓Stop Recording ↓Process Audio File (AAC/M4A) ↓Show Playback Preview ↓User Confirms Send ↓Upload Audio File ↓Send Message with Audio AttachmentFile Download Flow
Section titled “File Download Flow”User Taps File Attachment ↓Check if File Already Downloaded ↓├─ Cached│ ↓│ Open File Viewer│└─ Not Cached ↓ Show Download Progress ↓ Download File ↓ Save to Cache ↓ Open File ViewerEdge Cases:
- File size exceeds limit
- Unsupported file type
- Network interruption during upload
- Insufficient storage space
- Permission denied
- Corrupt media file
API/Contracts
Section titled “API/Contracts”Upload Attachment
Section titled “Upload Attachment”Request:
POST /api/v1/accounts/{accountId}/conversations/{conversationId}/messages
Content-Type: multipart/form-data
Body:{ message: { content: string, private: boolean }, attachments: File[]}Response:
{ id: number; content: string; attachments: [ { id: number; file_type: 'image' | 'video' | 'audio' | 'file'; data_url: string; thumb_url?: string; file_size: number; extension: string; } ];}Supported File Types
Section titled “Supported File Types”const SUPPORTED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];const SUPPORTED_VIDEO_TYPES = ['video/mp4', 'video/quicktime', 'video/x-msvideo'];const SUPPORTED_AUDIO_TYPES = ['audio/mpeg', 'audio/mp4', 'audio/wav', 'audio/aac'];const SUPPORTED_DOCUMENT_TYPES = [ 'application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'text/plain',];
const FILE_SIZE_LIMITS = { image: 15 * 1024 * 1024, // 15MB video: 50 * 1024 * 1024, // 50MB audio: 10 * 1024 * 1024, // 10MB file: 20 * 1024 * 1024, // 20MB};Implementation Notes
Section titled “Implementation Notes”Image Picker Integration
Section titled “Image Picker Integration”import { launchCamera, launchImageLibrary } from 'react-native-image-picker';
export const pickImage = async () => { const result = await launchImageLibrary({ mediaType: 'photo', quality: 0.8, maxWidth: 1920, maxHeight: 1920, });
if (result.assets && result.assets[0]) { return result.assets[0]; }
return null;};
export const capturePhoto = async () => { const result = await launchCamera({ mediaType: 'photo', quality: 0.8, maxWidth: 1920, maxHeight: 1920, saveToPhotos: true, });
if (result.assets && result.assets[0]) { return result.assets[0]; }
return null;};File Validation
Section titled “File Validation”export const validateFile = (file: File): ValidationResult => { const { type, size } = file; const fileType = getFileType(type); const maxSize = FILE_SIZE_LIMITS[fileType];
if (size > maxSize) { return { valid: false, error: I18n.t('ERRORS.FILE_TOO_LARGE', { max: formatFileSize(maxSize), }), }; }
if (!isSupportedType(type)) { return { valid: false, error: I18n.t('ERRORS.UNSUPPORTED_FILE_TYPE'), }; }
return { valid: true };};Upload with Progress
Section titled “Upload with Progress”export const uploadAttachment = async ( file: File, conversationId: number, onProgress?: (progress: number) => void) => { const formData = new FormData(); formData.append('message[attachments][]', { uri: file.uri, name: file.fileName || 'attachment', type: file.type, });
return apiService.post( `conversations/${conversationId}/messages`, formData, { headers: { 'Content-Type': 'multipart/form-data' }, onUploadProgress: (progressEvent) => { const progress = (progressEvent.loaded / progressEvent.total!) * 100; onProgress?.(Math.round(progress)); }, } );};Voice Recording
Section titled “Voice Recording”import AudioRecorderPlayer from 'react-native-audio-recorder-player';
const audioRecorderPlayer = new AudioRecorderPlayer();
export class VoiceRecorder { private path: string = '';
async startRecording() { const path = `${RNFS.CachesDirectoryPath}/voice-${Date.now()}.m4a`; this.path = path;
await audioRecorderPlayer.startRecorder(path, { AudioEncoderAndroid: 'aac', AudioSourceAndroid: 'mic', AVEncoderAudioQualityKeyIOS: 'high', AVFormatIDKeyIOS: 'mpeg4', }); }
async stopRecording() { const result = await audioRecorderPlayer.stopRecorder(); return { uri: result, duration: await this.getDuration(result), }; }
async getDuration(uri: string) { const info = await audioRecorderPlayer.setSubscriptionDuration(uri); return info.duration; }}Error Handling and Retries
Section titled “Error Handling and Retries”Upload Retry Logic
Section titled “Upload Retry Logic”const uploadWithRetry = async ( file: File, conversationId: number, maxRetries = 3) => { let lastError: Error;
for (let attempt = 1; attempt <= maxRetries; attempt++) { try { return await uploadAttachment(file, conversationId); } catch (error) { lastError = error;
if (attempt < maxRetries) { await delay(1000 * attempt); // Exponential backoff } } }
throw lastError!;};Permission Handling
Section titled “Permission Handling”import { request, PERMISSIONS, RESULTS } from 'react-native-permissions';
export const requestCameraPermission = async () => { const permission = Platform.select({ ios: PERMISSIONS.IOS.CAMERA, android: PERMISSIONS.ANDROID.CAMERA, });
const result = await request(permission);
if (result === RESULTS.DENIED || result === RESULTS.BLOCKED) { Alert.alert( I18n.t('PERMISSIONS.CAMERA_DENIED_TITLE'), I18n.t('PERMISSIONS.CAMERA_DENIED_MESSAGE'), [ { text: I18n.t('CANCEL'), style: 'cancel' }, { text: I18n.t('OPEN_SETTINGS'), onPress: () => Linking.openSettings() }, ] ); return false; }
return result === RESULTS.GRANTED;};Telemetry
Section titled “Telemetry”Tracked Events
Section titled “Tracked Events”// Attachment uploadedAnalyticsHelper.track('attachment_uploaded', { file_type: fileType, file_size: fileSize, upload_duration_ms: uploadDuration, conversation_id: conversationId,});
// Voice message recordedAnalyticsHelper.track('voice_message_recorded', { duration_seconds: duration, file_size: fileSize,});
// Media viewedAnalyticsHelper.track('media_viewed', { media_type: mediaType, source: 'message',});Example
Section titled “Example”Attachment Button Component
Section titled “Attachment Button Component”import { ActionSheet } from '@/components-next';
export const AttachmentButton = ({ conversationId }: { conversationId: number }) => { const [uploading, setUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState(0);
const handleAttachment = async (type: 'camera' | 'gallery' | 'file') => { let file;
switch (type) { case 'camera': const hasPermission = await requestCameraPermission(); if (!hasPermission) return; file = await capturePhoto(); break; case 'gallery': file = await pickImage(); break; case 'file': file = await pickDocument(); break; }
if (!file) return;
const validation = validateFile(file); if (!validation.valid) { showToast({ message: validation.error }); return; }
setUploading(true); try { await uploadAttachment(file, conversationId, setUploadProgress); showToast({ message: I18n.t('ATTACHMENT_SENT') }); } catch (error) { showToast({ message: I18n.t('ERRORS.UPLOAD_FAILED') }); } finally { setUploading(false); setUploadProgress(0); } };
return ( <> <TouchableOpacity onPress={() => setShowActionSheet(true)}> <AttachmentIcon /> </TouchableOpacity>
<ActionSheet visible={showActionSheet} onClose={() => setShowActionSheet(false)} actions={[ { label: I18n.t('TAKE_PHOTO'), onPress: () => handleAttachment('camera'), }, { label: I18n.t('CHOOSE_FROM_LIBRARY'), onPress: () => handleAttachment('gallery'), }, { label: I18n.t('CHOOSE_FILE'), onPress: () => handleAttachment('file'), }, ]} />
{uploading && ( <UploadProgress progress={uploadProgress} /> )} </> );};Further Reading
Section titled “Further Reading”- Messaging - Message composition
- Push Notifications - Notification handling