diff --git a/native/avatars/avatar-hooks.js b/native/avatars/avatar-hooks.js --- a/native/avatars/avatar-hooks.js +++ b/native/avatars/avatar-hooks.js @@ -576,6 +576,126 @@ ); } +function useSelectFromCameraAndUpdateThreadAvatar(threadID: string): { + +updateThreadAvatar: (selection: PhotoCapture) => Promise, + +isCameraAvatarUpdateLoading: boolean, +} { + const dispatchActionPromise = useDispatchActionPromise(); + const changeThreadSettingsCall = useServerCall(changeThreadSettings); + + const processSelectedMedia = useProcessSelectedMedia(); + const uploadProcessedMedia = useUploadProcessedMedia(); + + const [processingOrUploadInProgress, setProcessingOrUploadInProgress] = + React.useState(false); + + const updateThreadAvatarLoadingStatus: LoadingStatus = useSelector( + threadAvatarLoadingStatusSelector, + ); + + const inProgress = React.useMemo( + () => + processingOrUploadInProgress || + updateThreadAvatarLoadingStatus === 'loading', + [processingOrUploadInProgress, updateThreadAvatarLoadingStatus], + ); + + const selectFromCameraAndUpdateThreadAvatar = React.useCallback( + async (selection: PhotoCapture) => { + if (!selection) { + Alert.alert( + 'Media selection failed', + 'Unable to select media from Media Library.', + ); + return; + } + + setProcessingOrUploadInProgress(true); + let processedMedia; + try { + processedMedia = await processSelectedMedia(selection); + } catch (e) { + Alert.alert( + 'Media processing failed', + 'Unable to process selected media.', + ); + setProcessingOrUploadInProgress(false); + return; + } + + if (!processedMedia || !processedMedia.success) { + Alert.alert( + 'Media processing failed', + 'Unable to process selected media.', + ); + setProcessingOrUploadInProgress(false); + return; + } + + let uploadedMedia: ?UploadMultimediaResult; + try { + uploadedMedia = await uploadProcessedMedia(processedMedia); + } catch { + Alert.alert( + 'Media upload failed', + 'Unable to upload selected media. Please try again.', + ); + setProcessingOrUploadInProgress(false); + return; + } + + if (!uploadedMedia) { + Alert.alert( + 'Media upload failed', + 'Unable to upload selected media. Please try again.', + ); + setProcessingOrUploadInProgress(false); + return; + } + + const imageAvatarUpdateRequest: ImageAvatarDBContent = { + type: 'image', + uploadID: uploadedMedia.id, + }; + + const updateThreadRequest: UpdateThreadRequest = { + threadID, + changes: { + avatar: imageAvatarUpdateRequest, + }, + }; + + dispatchActionPromise( + changeThreadSettingsActionTypes, + (async () => { + setProcessingOrUploadInProgress(false); + try { + return await changeThreadSettingsCall(updateThreadRequest); + } catch { + Alert.alert('Avatar update failed', 'Unable to update avatar.'); + } + })(), + { customKeyName: `${changeThreadSettingsActionTypes.started}:avatar` }, + ); + }, + [ + changeThreadSettingsCall, + dispatchActionPromise, + processSelectedMedia, + threadID, + uploadProcessedMedia, + ], + ); + + return React.useMemo( + () => ({ + updateThreadAvatar: selectFromCameraAndUpdateThreadAvatar, + isCameraAvatarUpdateLoading: inProgress, + }), + [selectFromCameraAndUpdateThreadAvatar, inProgress], + ); +} + type ShowAvatarActionSheetOptions = { +id: 'emoji' | 'image' | 'camera' | 'ens' | 'cancel' | 'remove', +onPress?: () => mixed, @@ -709,4 +829,5 @@ useRemoveThreadAvatar, useENSUserAvatar, useSelectFromCameraAndUpdateUserAvatar, + useSelectFromCameraAndUpdateThreadAvatar, }; diff --git a/native/avatars/edit-thread-avatar.react.js b/native/avatars/edit-thread-avatar.react.js --- a/native/avatars/edit-thread-avatar.react.js +++ b/native/avatars/edit-thread-avatar.react.js @@ -1,5 +1,6 @@ // @flow +import { useNavigation } from '@react-navigation/native'; import * as React from 'react'; import { ActivityIndicator, TouchableOpacity, View } from 'react-native'; @@ -8,10 +9,12 @@ import { useRemoveThreadAvatar, useSelectFromGalleryAndUpdateThreadAvatar, + useSelectFromCameraAndUpdateThreadAvatar, useShowAvatarActionSheet, } from './avatar-hooks.js'; import EditAvatarBadge from './edit-avatar-badge.react.js'; import ThreadAvatar from './thread-avatar.react.js'; +import { ThreadAvatarCameraModalRouteName } from '../navigation/route-names.js'; import { useStyles } from '../themes/colors.js'; type Props = { @@ -29,16 +32,30 @@ const [removeThreadAvatar, isRemoveAvatarUpdateLoading] = useRemoveThreadAvatar(threadInfo.id); + const { isCameraAvatarUpdateLoading } = + useSelectFromCameraAndUpdateThreadAvatar(threadInfo.id); + const navigation = useNavigation(); + const navigateToCamera = React.useCallback(() => { + navigation.navigate<'ThreadAvatarCameraModal'>({ + name: ThreadAvatarCameraModalRouteName, + params: { threadID: threadInfo.id }, + }); + }, [navigation, threadInfo.id]); + const isAvatarUpdateInProgress = - isGalleryAvatarUpdateLoading || isRemoveAvatarUpdateLoading; + isGalleryAvatarUpdateLoading || + isCameraAvatarUpdateLoading || + isRemoveAvatarUpdateLoading; const actionSheetConfig = React.useMemo( () => [ { id: 'emoji', onPress: onPressEmojiAvatarFlow }, { id: 'image', onPress: selectFromGalleryAndUpdateThreadAvatar }, + { id: 'camera', onPress: navigateToCamera }, { id: 'remove', onPress: removeThreadAvatar }, ], [ + navigateToCamera, onPressEmojiAvatarFlow, removeThreadAvatar, selectFromGalleryAndUpdateThreadAvatar, diff --git a/native/media/thread-avatar-camera-modal.react.js b/native/media/thread-avatar-camera-modal.react.js new file mode 100644 --- /dev/null +++ b/native/media/thread-avatar-camera-modal.react.js @@ -0,0 +1,38 @@ +// @flow + +import * as React from 'react'; + +import type { PhotoCapture } from 'lib/types/media-types.js'; + +import { useSelectFromCameraAndUpdateThreadAvatar } from '../avatars/avatar-hooks.js'; +import CameraModal from '../media/camera-modal.react.js'; +import type { AppNavigationProp } from '../navigation/app-navigator.react.js'; +import type { NavigationRoute } from '../navigation/route-names.js'; + +export type ThreadAvatarCameraModalParams = { + +threadID: string, +}; + +type Props = { + +navigation: AppNavigationProp<'ThreadAvatarCameraModal'>, + +route: NavigationRoute<'ThreadAvatarCameraModal'>, +}; + +function ThreadAvatarCameraModal(props: Props): React.Node { + const { navigation, route } = props; + const { threadID } = route.params; + + const { updateThreadAvatar } = + useSelectFromCameraAndUpdateThreadAvatar(threadID); + + const sendPhoto = React.useCallback( + (capture: PhotoCapture) => { + updateThreadAvatar(capture); + }, + [updateThreadAvatar], + ); + + return ; +} + +export default ThreadAvatarCameraModal; diff --git a/native/navigation/app-navigator.react.js b/native/navigation/app-navigator.react.js --- a/native/navigation/app-navigator.react.js +++ b/native/navigation/app-navigator.react.js @@ -15,6 +15,7 @@ import type { RootNavigationProp } from './root-navigator.react.js'; import { UserAvatarCameraModalRouteName, + ThreadAvatarCameraModalRouteName, ImageModalRouteName, MultimediaMessageTooltipModalRouteName, ActionResultModalRouteName, @@ -35,6 +36,7 @@ import KeyboardStateContainer from '../keyboard/keyboard-state-container.react.js'; import ChatCameraModal from '../media/chat-camera-modal.react.js'; import ImageModal from '../media/image-modal.react.js'; +import ThreadAvatarCameraModal from '../media/thread-avatar-camera-modal.react.js'; import UserAvatarCameraModal from '../media/user-avatar-camera-modal.react.js'; import VideoPlaybackModal from '../media/video-playback-modal.react.js'; import RelationshipListItemTooltipModal from '../profile/relationship-list-item-tooltip-modal.react.js'; @@ -141,6 +143,10 @@ name={UserAvatarCameraModalRouteName} component={UserAvatarCameraModal} /> +