Skip to content

Media Handling

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)
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 Prompt
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 Attachment
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 Attachment
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 Viewer

Edge Cases:

  • File size exceeds limit
  • Unsupported file type
  • Network interruption during upload
  • Insufficient storage space
  • Permission denied
  • Corrupt media file

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;
}
];
}
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
};
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;
};
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 };
};
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));
},
}
);
};
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;
}
}
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!;
};
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;
};
// Attachment uploaded
AnalyticsHelper.track('attachment_uploaded', {
file_type: fileType,
file_size: fileSize,
upload_duration_ms: uploadDuration,
conversation_id: conversationId,
});
// Voice message recorded
AnalyticsHelper.track('voice_message_recorded', {
duration_seconds: duration,
file_size: fileSize,
});
// Media viewed
AnalyticsHelper.track('media_viewed', {
media_type: mediaType,
source: 'message',
});
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} />
)}
</>
);
};