diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/assets/icons/camera/flash-off.svg | 1 | ||||
-rw-r--r-- | src/assets/icons/camera/flash-on.svg | 1 | ||||
-rw-r--r-- | src/assets/icons/camera/flip.svg | 1 | ||||
-rw-r--r-- | src/assets/icons/camera/save.svg | 1 | ||||
-rw-r--r-- | src/components/camera/FlashButton.tsx | 42 | ||||
-rw-r--r-- | src/components/camera/FlipButton.tsx | 29 | ||||
-rw-r--r-- | src/components/camera/GalleryIcon.tsx | 39 | ||||
-rw-r--r-- | src/components/camera/SaveButton.tsx | 26 | ||||
-rw-r--r-- | src/components/camera/index.ts | 4 | ||||
-rw-r--r-- | src/components/camera/styles.tsx | 53 | ||||
-rw-r--r-- | src/components/comments/ZoomInCropper.tsx | 4 | ||||
-rw-r--r-- | src/components/index.ts | 9 | ||||
-rw-r--r-- | src/components/moments/Moment.tsx | 116 | ||||
-rw-r--r-- | src/routes/main/MainStackNavigator.tsx | 9 | ||||
-rw-r--r-- | src/routes/main/MainStackScreen.tsx | 22 | ||||
-rw-r--r-- | src/screens/moments/CameraScreen.tsx | 228 | ||||
-rw-r--r-- | src/screens/moments/index.ts | 1 | ||||
-rw-r--r-- | src/screens/profile/CaptionScreen.tsx | 26 | ||||
-rw-r--r-- | src/services/MomentService.ts | 20 | ||||
-rw-r--r-- | src/utils/camera.ts | 98 |
20 files changed, 576 insertions, 154 deletions
diff --git a/src/assets/icons/camera/flash-off.svg b/src/assets/icons/camera/flash-off.svg new file mode 100644 index 00000000..fb04efd2 --- /dev/null +++ b/src/assets/icons/camera/flash-off.svg @@ -0,0 +1 @@ +<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 720"><defs><style>.cls-1{fill:#fff;}</style></defs><path class="cls-1" d="M361.84,13.57,128.3,360c-3.94,2.81-6.8,11.94-7.74,16.16-4.5,29.23,19.23,41.69,31.66,44.27H289.39l-26,253c-2.11,10.3-1.13,33.3,19.69,42.86s38.69-1.18,45-7.73l260.27-373.1,9.85-16.16c8.44-32.61-14.78-46.38-27.44-49.19H400.53L427.26,38.16c1.13-17.42-11.26-29.28-17.59-33C386.6-7.23,368.17,5.61,361.84,13.57Z"/></svg>
\ No newline at end of file diff --git a/src/assets/icons/camera/flash-on.svg b/src/assets/icons/camera/flash-on.svg new file mode 100644 index 00000000..b4608b75 --- /dev/null +++ b/src/assets/icons/camera/flash-on.svg @@ -0,0 +1 @@ +<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 720"><defs><style>.cls-1{fill:#fff;}</style></defs><path class="cls-1" d="M413.14,36.09,387.86,255.37h5.7L251.35,397.57H153c-11.76-2.43-34.2-14.21-29.95-41.86.89-4,3.6-12.62,7.32-15.28L351.28,12.83c6-7.53,23.41-19.67,45.23-8C402.5,8.4,414.2,19.61,413.14,36.09Z"/><path class="cls-1" d="M594.91,341l-9.32,15.28L339.45,709.1c-6,6.2-22.89,16.35-42.58,7.31s-20.62-30.78-18.63-40.53L297,493.2,495.79,294.46H569C580.94,297.12,602.89,310.14,594.91,341Z"/><path class="cls-1" d="M634.48,114.82a29,29,0,0,1-8.5,20.53L486.15,275.18,287.4,473.92,135.09,626.24A29,29,0,1,1,94,585.18l167.8-167.8L404,275.18,584.92,94.29a29,29,0,0,1,49.56,20.53Z"/></svg>
\ No newline at end of file diff --git a/src/assets/icons/camera/flip.svg b/src/assets/icons/camera/flip.svg new file mode 100644 index 00000000..e2ef1a0c --- /dev/null +++ b/src/assets/icons/camera/flip.svg @@ -0,0 +1 @@ +<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 720"><defs><style>.cls-1{fill:none;stroke:#fff;stroke-linecap:round;stroke-linejoin:round;stroke-width:49.69px;}</style></defs><path class="cls-1" d="M691.29,360c0,164.67-133.49,298.16-298.16,298.16-132.89,0-245.48-86.95-284-207.06m0-182.2c38.5-120.11,151.09-207.06,284-207.06A297.74,297.74,0,0,1,633.31,183.3"/><path class="cls-1" d="M28.71,525.64l74.54-99.38L227.48,476"/><path class="cls-1" d="M525.64,208.11l124.16-4.06L680.34,73.78"/></svg>
\ No newline at end of file diff --git a/src/assets/icons/camera/save.svg b/src/assets/icons/camera/save.svg new file mode 100644 index 00000000..6a28fb55 --- /dev/null +++ b/src/assets/icons/camera/save.svg @@ -0,0 +1 @@ +<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 720"><defs><style>.cls-1{fill:none;stroke:#fff;stroke-linecap:round;stroke-linejoin:round;stroke-width:49.92px;}</style></defs><path class="cls-1" d="M359.15,42v526.9"/><path class="cls-1" d="M192.76,424.7,359.85,577.23,539.41,424.7"/><path class="cls-1" d="M26.37,458V618.53c.63,20.44,15.92,61.3,72.08,61.3h527c21.53-1.29,65-15.37,66.46-61.3V458"/></svg>
\ No newline at end of file diff --git a/src/components/camera/FlashButton.tsx b/src/components/camera/FlashButton.tsx new file mode 100644 index 00000000..06a4e44e --- /dev/null +++ b/src/components/camera/FlashButton.tsx @@ -0,0 +1,42 @@ +import React, {Dispatch, SetStateAction} from 'react'; +import {Text, TouchableOpacity} from 'react-native'; +import {FlashMode} from 'react-native-camera'; +import FlashOffIcon from '../../assets/icons/camera/flash-off.svg'; +import FlashOnIcon from '../../assets/icons/camera/flash-on.svg'; +import {styles} from './styles'; + +interface FlashButtonProps { + flashMode: keyof FlashMode; + setFlashMode: Dispatch<SetStateAction<keyof FlashMode>>; +} + +/* + * Toggles between flash on/off modes + */ +export const FlashButton: React.FC<FlashButtonProps> = ({ + flashMode, + setFlashMode, +}) => ( + <TouchableOpacity + onPress={() => setFlashMode(flashMode === 'on' ? 'off' : 'on')} + style={styles.flashButtonContainer}> + {flashMode === 'on' ? ( + <FlashOnIcon + height={30} + width={20} + color={'white'} + style={styles.flashIcon} + /> + ) : ( + <FlashOffIcon + height={30} + width={20} + color={'white'} + style={styles.flashIcon} + /> + )} + <Text style={styles.saveButtonLabel}>Flash</Text> + </TouchableOpacity> +); + +export default FlashButton; diff --git a/src/components/camera/FlipButton.tsx b/src/components/camera/FlipButton.tsx new file mode 100644 index 00000000..c6f710a9 --- /dev/null +++ b/src/components/camera/FlipButton.tsx @@ -0,0 +1,29 @@ +import React, {Dispatch, SetStateAction} from 'react'; +import {Text, TouchableOpacity} from 'react-native'; +import {CameraType} from 'react-native-camera'; +import FlipIcon from '../../assets/icons/camera/flip.svg'; +import {styles} from './styles'; + +interface FlipButtonProps { + setCameraType: Dispatch<SetStateAction<keyof CameraType>>; + cameraType: keyof CameraType; +} + +/* + * Toggles between back camera and front camera + * Appears only when user has not taken a picture yet + * Once user takes a picture, this button disappears to reveal the save button + */ +export const FlipButton: React.FC<FlipButtonProps> = ({ + setCameraType, + cameraType, +}) => ( + <TouchableOpacity + onPress={() => setCameraType(cameraType === 'front' ? 'back' : 'front')} + style={styles.saveButton}> + <FlipIcon width={40} height={40} /> + <Text style={styles.saveButtonLabel}>Flip</Text> + </TouchableOpacity> +); + +export default FlipButton; diff --git a/src/components/camera/GalleryIcon.tsx b/src/components/camera/GalleryIcon.tsx new file mode 100644 index 00000000..8d396550 --- /dev/null +++ b/src/components/camera/GalleryIcon.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import {Image, Text, TouchableOpacity, View} from 'react-native'; +import {navigateToImagePicker} from '../../utils/camera'; +import {Image as ImageType} from 'react-native-image-crop-picker'; +import {styles} from './styles'; + +interface GalleryIconProps { + mostRecentPhotoUri: string; + callback: (pic: ImageType) => void; +} + +/* + * Displays the most recent photo in the user's gallery + * On click, navigates to the image picker + */ +export const GalleryIcon: React.FC<GalleryIconProps> = ({ + mostRecentPhotoUri, + callback, +}) => { + return ( + <TouchableOpacity + onPress={() => navigateToImagePicker(callback)} + style={styles.saveButton}> + {mostRecentPhotoUri !== '' ? ( + <Image + source={{uri: mostRecentPhotoUri}} + width={40} + height={40} + style={styles.galleryIcon} + /> + ) : ( + <View style={styles.galleryIconEmpty} /> + )} + <Text style={styles.saveButtonLabel}>Gallery</Text> + </TouchableOpacity> + ); +}; + +export default GalleryIcon; diff --git a/src/components/camera/SaveButton.tsx b/src/components/camera/SaveButton.tsx new file mode 100644 index 00000000..0e220497 --- /dev/null +++ b/src/components/camera/SaveButton.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import {Text, TouchableOpacity} from 'react-native'; +import SaveIcon from '../../assets/icons/camera/save.svg'; +import {saveImageToGallery} from '../../utils/camera'; +import {styles} from './styles'; + +interface SaveButtonProps { + capturedImageURI: string; +} + +/* + * Appears when a picture has been taken, + * On click, saves the captured image to "Recents" album on device gallery + */ +export const SaveButton: React.FC<SaveButtonProps> = ({capturedImageURI}) => ( + <TouchableOpacity + onPress={() => { + saveImageToGallery(capturedImageURI); + }} + style={styles.saveButton}> + <SaveIcon width={40} height={40} /> + <Text style={styles.saveButtonLabel}>Save</Text> + </TouchableOpacity> +); + +export default SaveButton; diff --git a/src/components/camera/index.ts b/src/components/camera/index.ts new file mode 100644 index 00000000..d33d1e4a --- /dev/null +++ b/src/components/camera/index.ts @@ -0,0 +1,4 @@ +export {default as GalleryIcon} from './GalleryIcon'; +export {default as FlashButton} from './FlashButton'; +export {default as FlipButton} from './FlipButton'; +export {default as SaveButton} from './SaveButton'; diff --git a/src/components/camera/styles.tsx b/src/components/camera/styles.tsx new file mode 100644 index 00000000..33b47cc4 --- /dev/null +++ b/src/components/camera/styles.tsx @@ -0,0 +1,53 @@ +import {StyleSheet} from 'react-native'; +import {normalize, SCREEN_WIDTH} from '../../utils/layouts'; + +export const styles = StyleSheet.create({ + saveButton: { + zIndex: 1, + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + width: (SCREEN_WIDTH - 100) / 2, + }, + saveButtonLabel: { + color: 'white', + fontWeight: '700', + fontSize: normalize(12), + lineHeight: normalize(14.32), + marginTop: 5, + zIndex: 999, + }, + flashButtonContainer: { + position: 'absolute', + backgroundColor: '#808080', + opacity: 0.25, + zIndex: 1, + top: normalize(50), + right: 0, + marginRight: normalize(18), + height: 86, + width: 49, + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + borderRadius: 30, + }, + galleryIcon: { + borderWidth: 2, + borderColor: 'white', + borderRadius: 5, + width: 40, + height: 40, + }, + galleryIconEmpty: { + borderWidth: 2, + borderColor: 'white', + borderRadius: 5, + width: 40, + height: 40, + backgroundColor: 'grey', + }, + flashIcon: { + zIndex: 2, + }, +}); diff --git a/src/components/comments/ZoomInCropper.tsx b/src/components/comments/ZoomInCropper.tsx index bca4e599..7fa88f6e 100644 --- a/src/components/comments/ZoomInCropper.tsx +++ b/src/components/comments/ZoomInCropper.tsx @@ -1,7 +1,7 @@ import {RouteProp} from '@react-navigation/core'; import {useFocusEffect} from '@react-navigation/native'; import {StackNavigationProp} from '@react-navigation/stack'; -import {default as React, useCallback, useEffect, useState} from 'react'; +import React, {useCallback, useEffect, useState} from 'react'; import {Image, StyleSheet, TouchableOpacity} from 'react-native'; import {normalize} from 'react-native-elements'; import ImageZoom, {IOnMove} from 'react-native-image-pan-zoom'; @@ -34,7 +34,6 @@ export const ZoomInCropper: React.FC<ZoomInCropperProps> = ({ const [y0, setY0] = useState<number>(); const [y1, setY1] = useState<number>(); - // Removes bottom navigation bar on current screen and add it back when navigating away useFocusEffect( useCallback(() => { navigation.dangerouslyGetParent()?.setOptions({ @@ -80,7 +79,6 @@ export const ZoomInCropper: React.FC<ZoomInCropperProps> = ({ screenType, title: title, media: { - filename: media.filename, uri: croppedURL, isVideo: false, }, diff --git a/src/components/index.ts b/src/components/index.ts index 47dc583b..c2f50118 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,9 +1,10 @@ +export * from './camera'; +export * from './comments'; export * from './common'; +export * from './messages'; +export * from './moments'; export * from './onboarding'; export * from './profile'; export * from './search'; -export * from './taggs'; -export * from './comments'; -export * from './moments'; export * from './suggestedPeople'; -export * from './messages'; +export * from './taggs'; diff --git a/src/components/moments/Moment.tsx b/src/components/moments/Moment.tsx index ec9129c5..108ea100 100644 --- a/src/components/moments/Moment.tsx +++ b/src/components/moments/Moment.tsx @@ -11,9 +11,9 @@ import BigPlusIcon from '../../assets/icons/plus-icon-white.svg'; import PlusIcon from '../../assets/icons/plus-icon.svg'; import UpIcon from '../../assets/icons/up_icon.svg'; import {TAGG_LIGHT_BLUE} from '../../constants'; -import {ERROR_UPLOAD} from '../../constants/strings'; import {MomentType, ScreenType} from '../../types'; import {normalize, SCREEN_WIDTH} from '../../utils'; +import {navigateToVideoPicker} from '../../utils/camera'; import MomentTile from './MomentTile'; interface MomentProps { @@ -43,115 +43,25 @@ const Moment: React.FC<MomentProps> = ({ }) => { const navigation = useNavigation(); + // TODO: remove this later, tmp solution for handling videos const navigateToCaptionScreenForVideo = (uri: string) => { - const randHash = Math.random().toString(36).substring(7); navigation.navigate('CaptionScreen', { screenType, title, media: { - filename: `poc_${randHash}.mov`, uri, isVideo: true, }, }); }; - /** - * This function opens the ImagePicker, only lets you select video files, - * formats the file extension, then makes a call to the server to get the presigned URL, - * after which it makes a POST request to the returned URL to upload the file directly to S3. - * params: none - * @returns: none - */ - const navigateToVideoPicker = () => { - ImagePicker.openPicker({ - mediaType: 'video', - }) - .then(async (vid) => { - console.log(vid); - if (vid.path) { - navigateToCaptionScreenForVideo(vid.path); - } - }) - .catch((err) => { - if (err.code && err.code !== 'E_PICKER_CANCELLED') { - Alert.alert(ERROR_UPLOAD); - } - }); - }; - const navigateToImagePicker = () => { - ImagePicker.openPicker({ - smartAlbums: [ - 'Favorites', - 'RecentlyAdded', - 'SelfPortraits', - 'Screenshots', - 'UserLibrary', - ], - mediaType: 'any', - }) - .then((picture) => { - if ( - picture.path && - picture.filename && - (picture.filename.endsWith('gif') || picture.filename.endsWith('GIF')) - ) { - showGIFFailureAlert(picture); - } else if (picture.path && picture.filename) { - navigation.navigate('ZoomInCropper', { - screenType, - title, - media: { - filename: picture.filename, - uri: picture.path, - isVideo: false, - }, - }); - } - }) - .catch((err) => { - if (err.code && err.code !== 'E_PICKER_CANCELLED') { - Alert.alert(ERROR_UPLOAD); - } - }); + const navigateToCameraScreen = () => { + navigation.navigate('CameraScreen', { + title, + screenType, + }); }; - /* Handles GIF files */ - const showGIFFailureAlert = (picture) => - Alert.alert( - 'Warning', - 'The app currently cannot handle GIFs, and will only save a static image.', - [ - { - text: 'Cancel', - onPress: () => {}, - style: 'cancel', - }, - { - text: 'Post', - onPress: () => { - navigation.navigate('ZoomInCropper', { - screenType, - title, - media: { - filename: picture.filename, - uri: picture.path, - isVideo: false, - }, - }); - }, - style: 'default', - }, - ], - { - cancelable: true, - onDismiss: () => - Alert.alert( - 'This alert was dismissed by tapping outside of the alert dialog.', - ), - }, - ); - return ( <View style={[styles.container, externalStyles?.container]}> <View style={[styles.header, externalStyles?.header]}> @@ -192,7 +102,10 @@ const Moment: React.FC<MomentProps> = ({ Alert.alert('Video Upload', 'pick one', [ { text: 'gallery', - onPress: navigateToVideoPicker, + onPress: () => + navigateToVideoPicker((vid) => + navigateToCaptionScreenForVideo(vid.path), + ), }, { text: 'camera (simulator will not work)', @@ -216,7 +129,7 @@ const Moment: React.FC<MomentProps> = ({ <PlusIcon width={23} height={23} - onPress={() => navigateToImagePicker()} + onPress={navigateToCameraScreen} color={TAGG_LIGHT_BLUE} style={styles.horizontalMargin} /> @@ -246,7 +159,7 @@ const Moment: React.FC<MomentProps> = ({ /> ))} {(images === undefined || images.length === 0) && !userXId && ( - <TouchableOpacity onPress={() => navigateToImagePicker()}> + <TouchableOpacity onPress={navigateToCameraScreen}> <LinearGradient colors={['rgba(105, 141, 211, 1)', 'rgba(105, 141, 211, 0.3)']}> <View style={styles.defaultImage}> @@ -282,9 +195,6 @@ const styles = StyleSheet.create({ color: TAGG_LIGHT_BLUE, maxWidth: '70%', }, - flexer: { - flex: 1, - }, scrollContainer: { height: SCREEN_WIDTH / 3.25, backgroundColor: '#eee', diff --git a/src/routes/main/MainStackNavigator.tsx b/src/routes/main/MainStackNavigator.tsx index c518d75e..a5d73988 100644 --- a/src/routes/main/MainStackNavigator.tsx +++ b/src/routes/main/MainStackNavigator.tsx @@ -37,14 +37,13 @@ export type MainStackParams = { }; CaptionScreen: { title?: string; - media?: {filename: string; uri: string; isVideo: boolean}; + media?: {uri: string; isVideo: boolean}; screenType: ScreenType; selectedTags?: MomentTagType[]; moment?: MomentType; }; TagFriendsScreen: { media: { - filename: string; uri: string; isVideo: boolean; }; @@ -111,10 +110,14 @@ export type MainStackParams = { Chat: undefined; NewChatModal: undefined; ZoomInCropper: { - media: {filename: string; uri: string; isVideo: boolean}; + media: {uri: string; isVideo: boolean}; screenType: ScreenType; title: string; }; + CameraScreen: { + title: string; + screenType: ScreenType; + }; }; export const MainStack = createStackNavigator<MainStackParams>(); diff --git a/src/routes/main/MainStackScreen.tsx b/src/routes/main/MainStackScreen.tsx index 9e3747f9..65a695f5 100644 --- a/src/routes/main/MainStackScreen.tsx +++ b/src/routes/main/MainStackScreen.tsx @@ -34,6 +34,7 @@ import { SuggestedPeopleWelcomeScreen, TagSelectionScreen, TagFriendsScreen, + CameraScreen, } from '../../screens'; import MutualBadgeHolders from '../../screens/suggestedPeople/MutualBadgeHolders'; import {ScreenType} from '../../types'; @@ -331,6 +332,15 @@ const MainStackScreen: React.FC<MainStackProps> = ({route}) => { name="ZoomInCropper" component={ZoomInCropper} options={{ + ...modalStyle, + gestureEnabled: false, + }} + /> + <MainStack.Screen + name="CameraScreen" + component={CameraScreen} + options={{ + ...modalStyle, gestureEnabled: false, }} /> @@ -399,18 +409,6 @@ const styles = StyleSheet.create({ letterSpacing: normalize(1.3), fontWeight: '700', }, - whiteHeaderTitle: { - fontSize: normalize(16), - letterSpacing: normalize(1.3), - fontWeight: '700', - color: 'white', - }, - blackHeaderTitle: { - fontSize: normalize(16), - letterSpacing: normalize(1.3), - fontWeight: '700', - color: 'black', - }, }); export default MainStackScreen; diff --git a/src/screens/moments/CameraScreen.tsx b/src/screens/moments/CameraScreen.tsx new file mode 100644 index 00000000..d9278876 --- /dev/null +++ b/src/screens/moments/CameraScreen.tsx @@ -0,0 +1,228 @@ +import CameraRoll from '@react-native-community/cameraroll'; +import {useBottomTabBarHeight} from '@react-navigation/bottom-tabs'; +import {RouteProp} from '@react-navigation/core'; +import {useFocusEffect} from '@react-navigation/native'; +import {StackNavigationProp} from '@react-navigation/stack'; +import React, {createRef, useCallback, useEffect, useState} from 'react'; +import {StyleSheet, TouchableOpacity, View} from 'react-native'; +import {CameraType, FlashMode, RNCamera} from 'react-native-camera'; +import CloseIcon from '../../assets/ionicons/close-outline.svg'; +import { + FlashButton, + FlipButton, + GalleryIcon, + SaveButton, + TaggSquareButton, +} from '../../components'; +import {MainStackParams} from '../../routes'; +import {HeaderHeight, normalize, SCREEN_WIDTH} from '../../utils'; +import {showGIFFailureAlert, takePicture} from '../../utils/camera'; + +type CameraScreenRouteProps = RouteProp<MainStackParams, 'CameraScreen'>; +export type CameraScreenNavigationProps = StackNavigationProp< + MainStackParams, + 'CameraScreen' +>; +interface CameraScreenProps { + route: CameraScreenRouteProps; + navigation: CameraScreenNavigationProps; +} +const CameraScreen: React.FC<CameraScreenProps> = ({route, navigation}) => { + const {title, screenType} = route.params; + const cameraRef = createRef<RNCamera>(); + const tabBarHeight = useBottomTabBarHeight(); + const [cameraType, setCameraType] = useState<keyof CameraType>('front'); + const [flashMode, setFlashMode] = useState<keyof FlashMode>('off'); + const [capturedImage, setCapturedImage] = useState<string>(''); + const [mostRecentPhoto, setMostRecentPhoto] = useState<string>(''); + const [showSaveButton, setShowSaveButton] = useState<boolean>(false); + + useFocusEffect( + useCallback(() => { + navigation.dangerouslyGetParent()?.setOptions({ + tabBarVisible: false, + }); + return () => { + navigation.dangerouslyGetParent()?.setOptions({ + tabBarVisible: true, + }); + }; + }, [navigation]), + ); + + /* + * Chooses the last picture from gallery to display as the gallery button icon + */ + useEffect(() => { + CameraRoll.getPhotos({first: 1}) + .then((lastPhoto) => { + if (lastPhoto.edges.length > 0) { + const image = lastPhoto.edges[0].node.image; + setMostRecentPhoto(image.uri); + } + }) + .catch((_err) => + console.log('Unable to fetch preview photo for gallery'), + ); + }, [capturedImage]); + + const navigateToCropper = (uri: string) => { + navigation.navigate('ZoomInCropper', { + screenType, + title, + media: { + uri, + isVideo: false, // TODO: only support image for now + }, + }); + }; + + const navigateToCaptionScreen = () => { + navigation.navigate('CaptionScreen', { + screenType, + title, + media: { + uri: capturedImage, + isVideo: false, // TODO: only support image for now + }, + }); + }; + + /* + * If picture is not taken yet, exists from camera screen to profile view + * If picture is taken, exists from captured image's preview to camera + * */ + const handleClose = () => { + if (showSaveButton) { + cameraRef.current?.resumePreview(); + setShowSaveButton(false); + setCapturedImage(''); + } else { + navigation.goBack(); + } + }; + + return ( + <View style={styles.container}> + <TouchableOpacity style={styles.closeButton} onPress={handleClose}> + <CloseIcon height={25} width={25} color={'white'} /> + </TouchableOpacity> + <FlashButton flashMode={flashMode} setFlashMode={setFlashMode} /> + <RNCamera + ref={cameraRef} + style={styles.camera} + type={cameraType} + flashMode={flashMode} + /> + <View style={[styles.bottomContainer, {bottom: tabBarHeight}]}> + {showSaveButton ? ( + <SaveButton capturedImageURI={capturedImage} /> + ) : ( + <FlipButton cameraType={cameraType} setCameraType={setCameraType} /> + )} + <TouchableOpacity + onPress={() => + takePicture(cameraRef, (pic) => { + setShowSaveButton(true); + setCapturedImage(pic.uri); + }) + } + style={styles.captureButtonContainer}> + <View style={styles.captureButton} /> + </TouchableOpacity> + <View style={styles.bottomRightContainer}> + {capturedImage ? ( + <TaggSquareButton + onPress={navigateToCaptionScreen} + title={'Next'} + buttonStyle={'large'} + buttonColor={'blue'} + labelColor={'white'} + style={styles.nextButton} + labelStyle={styles.nextButtonLabel} + /> + ) : ( + <GalleryIcon + mostRecentPhotoUri={mostRecentPhoto} + callback={(pic) => { + const filename = pic.filename; + if ( + filename && + (filename.endsWith('gif') || filename.endsWith('GIF')) + ) { + showGIFFailureAlert(() => navigateToCropper(pic.path)); + } else { + navigateToCropper(pic.path); + } + }} + /> + )} + </View> + </View> + </View> + ); +}; + +const styles = StyleSheet.create({ + camera: { + flex: 1, + justifyContent: 'space-between', + }, + container: { + flex: 1, + flexDirection: 'column', + backgroundColor: 'black', + }, + captureButtonContainer: { + alignSelf: 'center', + backgroundColor: 'transparent', + borderRadius: 100, + borderWidth: 4, + borderColor: '#fff', + width: 93, + height: 93, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + captureButton: { + backgroundColor: '#fff', + width: 68, + height: 68, + borderRadius: 74, + }, + closeButton: { + position: 'absolute', + top: 0, + paddingTop: HeaderHeight, + zIndex: 1, + marginLeft: '5%', + }, + bottomContainer: { + position: 'absolute', + width: SCREEN_WIDTH, + flexDirection: 'row', + justifyContent: 'center', + }, + bottomRightContainer: { + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + width: (SCREEN_WIDTH - 100) / 2, + }, + nextButton: { + zIndex: 1, + width: normalize(100), + height: normalize(37), + borderRadius: 10, + }, + nextButtonLabel: { + fontWeight: '700', + fontSize: normalize(15), + lineHeight: normalize(17.8), + letterSpacing: normalize(1.3), + textAlign: 'center', + }, +}); + +export default CameraScreen; diff --git a/src/screens/moments/index.ts b/src/screens/moments/index.ts index aac2ddeb..07d55192 100644 --- a/src/screens/moments/index.ts +++ b/src/screens/moments/index.ts @@ -1,2 +1,3 @@ export {default as TagSelectionScreen} from './TagSelectionScreen'; export {default as TagFriendsScreen} from './TagFriendsScreen'; +export {default as CameraScreen} from './CameraScreen'; diff --git a/src/screens/profile/CaptionScreen.tsx b/src/screens/profile/CaptionScreen.tsx index 364b81a3..05db8ed7 100644 --- a/src/screens/profile/CaptionScreen.tsx +++ b/src/screens/profile/CaptionScreen.tsx @@ -69,7 +69,6 @@ const CaptionScreen: React.FC<CaptionScreenProps> = ({route, navigation}) => { selectedTags ? selectedTags : [], ); const [taggedList, setTaggedList] = useState<string>(''); - const mediaFilename = moment ? undefined : route.params.media!.filename; const mediaUri = moment ? moment.moment_url : route.params.media!.uri; // TODO: change this once moment refactor is done const isMediaAVideo = moment @@ -138,7 +137,7 @@ const CaptionScreen: React.FC<CaptionScreenProps> = ({route, navigation}) => { const handleShare = async () => { setLoading(true); - if (moment || !mediaFilename || !title) { + if (moment || !title) { handleFailed(); return; } @@ -146,22 +145,20 @@ const CaptionScreen: React.FC<CaptionScreenProps> = ({route, navigation}) => { let momentId; // separate upload logic for image/video if (isMediaAVideo) { - const presignedURL = await handlePresignedURL(mediaFilename, title); - if (!presignedURL) { + const presignedURLResponse = await handlePresignedURL(title); + if (!presignedURLResponse) { handleFailed(); return; } - momentId = presignedURL.moment_id; - // TODO: assume success for now - await handleVideoUpload(mediaFilename, mediaUri, presignedURL); + momentId = presignedURLResponse.moment_id; + const fileHash = presignedURLResponse.response_url.fields.key; + if (fileHash !== null && fileHash !== '' && fileHash !== undefined) { + await handleVideoUpload(mediaUri, presignedURLResponse); + } else { + handleFailed(); + } } else { - const momentResponse = await postMoment( - mediaFilename, - mediaUri, - caption, - title, - userId, - ); + const momentResponse = await postMoment(mediaUri, caption, title, userId); if (!momentResponse) { handleFailed(); return; @@ -252,7 +249,6 @@ const CaptionScreen: React.FC<CaptionScreenProps> = ({route, navigation}) => { onPress={() => navigation.navigate('TagFriendsScreen', { media: { - filename: mediaFilename ?? '', uri: mediaUri, isVideo: isMediaAVideo, }, diff --git a/src/services/MomentService.ts b/src/services/MomentService.ts index b274ef04..60e6be3f 100644 --- a/src/services/MomentService.ts +++ b/src/services/MomentService.ts @@ -16,7 +16,6 @@ import {MomentPostType, MomentTagType, PresignedURLResponse} from '../types'; import {checkImageUploadStatus} from '../utils'; export const postMoment = async ( - fileName: string, uri: string, caption: string, category: string, @@ -25,13 +24,9 @@ export const postMoment = async ( try { const request = new FormData(); - //Manipulating filename to end with .jpg instead of .heic - if (fileName.endsWith('.heic') || fileName.endsWith('.HEIC')) { - fileName = fileName.split('.')[0] + '.jpg'; - } request.append('image', { uri: uri, - name: fileName, + name: 'moment.jpg', // we don't care about filename, anything works type: 'image/jpg', }); request.append('moment', category); @@ -219,14 +214,13 @@ export const deleteMomentTag = async (moment_tag_id: string) => { * This function makes a request to the server in order to provide the client with a presigned URL. * This is called first, in order for the client to directly upload a file to S3 * @param value: string | undefined - * @param filename: string | undefined * @returns a PresignedURLResponse object */ -export const handlePresignedURL = async ( - filename: string | undefined, - momentCategory: string, -) => { +export const handlePresignedURL = async (momentCategory: string) => { try { + // TODO: just a random filename for video poc, we should not need to once complete + const randHash = Math.random().toString(36).substring(7); + const filename = `pc_${randHash}.mov`; const token = await AsyncStorage.getItem('token'); const response = await fetch(PRESIGNED_URL_ENDPOINT, { method: 'POST', @@ -260,13 +254,11 @@ export const handlePresignedURL = async ( /** * This util function takes in the file object and the PresignedURLResponse object, creates form data from the latter, * and makes a post request to the presigned URL, sending the file object inside of the form data. - * @param filename: the filename * @param filePath: the path to the file, including filename * @param urlObj PresignedURLResponse | undefined * @returns responseURL or boolean */ export const handleVideoUpload = async ( - filename: string, filePath: string, urlObj: PresignedURLResponse | undefined, ) => { @@ -297,7 +289,7 @@ export const handleVideoUpload = async ( uri: filePath, // other types such as 'quicktime' 'image' etc exist, and we can programmatically type this, but for now sticking with simple 'video' type: 'video', - name: filename, + name: urlObj.response_url.fields.key, }); const response = await fetch(urlObj.response_url.url, { method: 'POST', diff --git a/src/utils/camera.ts b/src/utils/camera.ts new file mode 100644 index 00000000..3937129a --- /dev/null +++ b/src/utils/camera.ts @@ -0,0 +1,98 @@ +import CameraRoll from '@react-native-community/cameraroll'; +import {RefObject} from 'react'; +import {Alert} from 'react-native'; +import { + RNCamera, + TakePictureOptions, + TakePictureResponse, +} from 'react-native-camera'; +import ImagePicker, {Image, Video} from 'react-native-image-crop-picker'; +import {ERROR_UPLOAD} from '../constants/strings'; + +/* + * Captures a photo and pauses to show the preview of the picture taken + */ +export const takePicture = ( + cameraRef: RefObject<RNCamera>, + callback: (pic: TakePictureResponse) => void, +) => { + if (cameraRef !== null) { + cameraRef.current?.pausePreview(); + const options: TakePictureOptions = { + forceUpOrientation: true, + orientation: 'portrait', + writeExif: false, + }; + cameraRef.current?.takePictureAsync(options).then((pic) => { + callback(pic); + }); + } +}; + +export const saveImageToGallery = (capturedImageURI: string) => { + CameraRoll.save(capturedImageURI, {album: 'Recents', type: 'photo'}) + .then((_res) => Alert.alert('Saved to device!')) + .catch((_err) => Alert.alert('Failed to save to device!')); +}; + +export const navigateToImagePicker = (callback: (pic: Image) => void) => { + ImagePicker.openPicker({ + smartAlbums: [ + 'Favorites', + 'RecentlyAdded', + 'SelfPortraits', + 'Screenshots', + 'UserLibrary', + ], + mediaType: 'photo', + }) + .then((pic) => { + callback(pic); + }) + .catch((err) => { + if (err.code && err.code !== 'E_PICKER_CANCELLED') { + Alert.alert(ERROR_UPLOAD); + } + }); +}; + +export const navigateToVideoPicker = (callback: (vid: Video) => void) => { + ImagePicker.openPicker({ + mediaType: 'video', + }) + .then(async (vid) => { + if (vid.path) { + callback(vid); + } + }) + .catch((err) => { + if (err.code && err.code !== 'E_PICKER_CANCELLED') { + Alert.alert(ERROR_UPLOAD); + } + }); +}; + +export const showGIFFailureAlert = (onSuccess: () => void) => + Alert.alert( + 'Warning', + 'The app currently cannot handle GIFs, and will only save a static image.', + [ + { + text: 'Cancel', + onPress: () => {}, + style: 'cancel', + }, + { + text: 'Post', + onPress: onSuccess, + style: 'default', + }, + ], + { + cancelable: true, + onDismiss: () => + Alert.alert( + 'This alert was dismissed by tapping outside of the alert dialog.', + ), + }, + ); |