Files
Sauci/apps/mobile/src/hooks/useAvatarPicker.ts
T
jjbrunton 8658fc38fe fix(android): use system photo picker
Block broad image and video read permissions in Expo config and the checked-in Android manifest. Keep picker access scoped to user-selected media and gallery saves write-only. Add regression coverage and document the release-manifest invariant.
2026-08-30 17:15:57 +01:00

203 lines
7.7 KiB
TypeScript

import { useState, useCallback, useRef } from 'react';
import { Alert, Platform, ActionSheetIOS } 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<string | null>;
/** 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<string | null>(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<string | null> => {
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 { status } = await ImagePicker.requestCameraPermissionsAsync();
if (status !== 'granted') {
Alert.alert('Permission Denied', 'Camera access is required to take a photo.');
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<string | null> => {
if (!avatarUri) return null;
return uploadAvatarWithUri(avatarUri);
}, [avatarUri, uploadAvatarWithUri]);
const clearAvatar = useCallback(() => {
setAvatarUri(null);
}, []);
return {
avatarUri,
setAvatarUri,
isUploading,
showPicker,
uploadAvatar,
clearAvatar,
};
}