import { useState, useCallback, useRef } from 'react'; import { Alert, Platform, ActionSheetIOS, Linking } from 'react-native'; import * as ImagePicker from 'expo-image-picker'; import * as FileSystem from 'expo-file-system/legacy'; import { uploadMedia } from '../lib/mediaApi'; export interface UseAvatarPickerOptions { /** User ID for storage path. Required for upload. */ userId?: string; /** If true, automatically uploads after selection. Default: false */ autoUpload?: boolean; /** Called when an image is selected (before upload) */ onSelect?: (uri: string) => void; /** Called after successful upload */ onUploadComplete?: (publicUrl: string) => void; /** Called on upload error */ onUploadError?: (error: Error) => void; } export interface UseAvatarPickerReturn { /** Local URI of selected image (before upload) */ avatarUri: string | null; /** Set avatar URI directly */ setAvatarUri: (uri: string | null) => void; /** Whether an upload is in progress */ isUploading: boolean; /** Show the picker action sheet/dialog */ showPicker: () => void; /** Upload the currently selected image to storage */ uploadAvatar: () => Promise; /** Clear the selected avatar */ clearAvatar: () => void; } /** * Hook for avatar image picking and uploading. * Handles cross-platform image selection and authenticated API upload. */ export function useAvatarPicker(options?: UseAvatarPickerOptions): UseAvatarPickerReturn { const [avatarUri, setAvatarUri] = useState(null); const [isUploading, setIsUploading] = useState(false); // Use ref for options to avoid stale closures in callbacks const optionsRef = useRef(options); optionsRef.current = options; // Internal upload function that takes a URI parameter const uploadAvatarWithUri = useCallback(async (uri: string): Promise => { const currentOptions = optionsRef.current; if (!uri || !currentOptions?.userId) { return null; } setIsUploading(true); try { let ext = 'jpg'; if (Platform.OS === 'web') { const response = await fetch(uri); const blob = await response.blob(); if (blob.type === 'image/png') ext = 'png'; else if (blob.type === 'image/webp') ext = 'webp'; } else { const uriExt = uri.split('.').pop()?.toLowerCase(); if (uriExt && ['jpg', 'jpeg', 'png', 'webp'].includes(uriExt)) { ext = uriExt === 'jpeg' ? 'jpg' : uriExt; } } const contentType = `image/${ext === 'jpg' ? 'jpeg' : ext}`; const result = await uploadMedia(uri, { kind: 'avatar', mimeType: contentType }); currentOptions?.onUploadComplete?.(result.reference); return result.reference; } catch (error) { console.error('Error uploading avatar:', error); currentOptions?.onUploadError?.(error as Error); return null; } finally { setIsUploading(false); } }, []); const pickImage = useCallback(async (source: 'camera' | 'library') => { const currentOptions = optionsRef.current; try { let result: ImagePicker.ImagePickerResult; if (source === 'camera') { const existingPermission = await ImagePicker.getCameraPermissionsAsync(); if (existingPermission.status !== 'granted' && !existingPermission.canAskAgain) { Alert.alert( 'Camera Access', 'To take a photo, allow camera access in Settings.', [ { text: 'Not Now', style: 'cancel' }, { text: 'Open Settings', onPress: () => Linking.openSettings() }, ] ); return; } const { status } = await ImagePicker.requestCameraPermissionsAsync(); if (status !== 'granted') { return; } result = await ImagePicker.launchCameraAsync({ mediaTypes: ['images'], allowsEditing: true, aspect: [1, 1], quality: 0.8, exif: false, }); } else { // Android's system photo picker grants the selected file directly, so // selecting an avatar does not need broad media-library access. if (Platform.OS !== 'android') { const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (status !== 'granted') { Alert.alert('Permission Denied', 'Photo library access is required to choose a photo.'); return; } } result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], allowsEditing: true, aspect: [1, 1], quality: 0.8, exif: false, }); } if (!result.canceled && result.assets[0]) { const uri = result.assets[0].uri; // Validate image is accessible on native platforms if (Platform.OS !== 'web') { try { const info = await FileSystem.getInfoAsync(uri); if (!info.exists) { Alert.alert('Error', 'Selected image could not be accessed.'); return; } // Warn if file is very large (> 10MB) if (info.size && info.size > 10 * 1024 * 1024) { Alert.alert('Large Image', 'This image is quite large and may take longer to upload.'); } } catch (e) { console.warn('Could not validate image:', e); } } setAvatarUri(uri); currentOptions?.onSelect?.(uri); // Auto-upload if enabled if (currentOptions?.autoUpload) { await uploadAvatarWithUri(uri); } } } catch (error) { console.error('Error picking avatar:', error); Alert.alert('Error', 'Failed to pick image. Please try again.'); } }, [uploadAvatarWithUri]); const showPicker = useCallback(() => { if (Platform.OS === 'ios') { ActionSheetIOS.showActionSheetWithOptions( { options: ['Cancel', 'Take Photo', 'Choose from Library'], cancelButtonIndex: 0, }, (buttonIndex) => { if (buttonIndex === 1) { pickImage('camera'); } else if (buttonIndex === 2) { pickImage('library'); } } ); } else { Alert.alert( 'Add Profile Photo', 'Choose an option', [ { text: 'Cancel', style: 'cancel' }, { text: 'Take Photo', onPress: () => pickImage('camera') }, { text: 'Choose from Library', onPress: () => pickImage('library') }, ] ); } }, [pickImage]); // Public upload function uses stored avatarUri const uploadAvatar = useCallback(async (): Promise => { if (!avatarUri) return null; return uploadAvatarWithUri(avatarUri); }, [avatarUri, uploadAvatarWithUri]); const clearAvatar = useCallback(() => { setAvatarUri(null); }, []); return { avatarUri, setAvatarUri, isUploading, showPicker, uploadAvatar, clearAvatar, }; }