diff options
Diffstat (limited to 'src')
27 files changed, 703 insertions, 389 deletions
diff --git a/src/components/camera/GalleryIcon.tsx b/src/components/camera/GalleryIcon.tsx index c49ace7d..8d396550 100644 --- a/src/components/camera/GalleryIcon.tsx +++ b/src/components/camera/GalleryIcon.tsx @@ -1,14 +1,12 @@ -import {useNavigation} from '@react-navigation/native'; import React from 'react'; import {Image, Text, TouchableOpacity, View} from 'react-native'; -import {ScreenType} from '../../types'; import {navigateToImagePicker} from '../../utils/camera'; +import {Image as ImageType} from 'react-native-image-crop-picker'; import {styles} from './styles'; interface GalleryIconProps { - screenType: ScreenType; - title: string; mostRecentPhotoUri: string; + callback: (pic: ImageType) => void; } /* @@ -16,14 +14,12 @@ interface GalleryIconProps { * On click, navigates to the image picker */ export const GalleryIcon: React.FC<GalleryIconProps> = ({ - screenType, - title, mostRecentPhotoUri, + callback, }) => { - const navigation = useNavigation(); return ( <TouchableOpacity - onPress={() => navigateToImagePicker(navigation, screenType, title)} + onPress={() => navigateToImagePicker(callback)} style={styles.saveButton}> {mostRecentPhotoUri !== '' ? ( <Image diff --git a/src/components/camera/SaveButton.tsx b/src/components/camera/SaveButton.tsx index 840cc804..0e220497 100644 --- a/src/components/camera/SaveButton.tsx +++ b/src/components/camera/SaveButton.tsx @@ -1,7 +1,7 @@ import React from 'react'; import {Text, TouchableOpacity} from 'react-native'; import SaveIcon from '../../assets/icons/camera/save.svg'; -import {downloadImage} from '../../utils/camera'; +import {saveImageToGallery} from '../../utils/camera'; import {styles} from './styles'; interface SaveButtonProps { @@ -15,7 +15,7 @@ interface SaveButtonProps { export const SaveButton: React.FC<SaveButtonProps> = ({capturedImageURI}) => ( <TouchableOpacity onPress={() => { - downloadImage(capturedImageURI); + saveImageToGallery(capturedImageURI); }} style={styles.saveButton}> <SaveIcon width={40} height={40} /> diff --git a/src/components/comments/AddComment.tsx b/src/components/comments/AddComment.tsx index 8a4ec082..33707d94 100644 --- a/src/components/comments/AddComment.tsx +++ b/src/components/comments/AddComment.tsx @@ -8,12 +8,11 @@ import { View, } from 'react-native'; import {useDispatch} from 'react-redux'; -import {TAGG_LIGHT_BLUE} from '../../constants'; import {CommentContext} from '../../screens/profile/MomentCommentsScreen'; import {postComment} from '../../services'; import {updateReplyPosted} from '../../store/actions'; import {CommentThreadType, CommentType} from '../../types'; -import {SCREEN_HEIGHT, SCREEN_WIDTH, normalize} from '../../utils'; +import {normalize, SCREEN_HEIGHT, SCREEN_WIDTH} from '../../utils'; import {mentionPartTypes} from '../../utils/comments'; import {CommentTextField} from './CommentTextField'; import MentionInputControlled from './MentionInputControlled'; @@ -174,26 +173,6 @@ const styles = StyleSheet.create({ flex: 1, maxHeight: 100, }, - avatar: { - height: 35, - width: 35, - borderRadius: 30, - marginRight: 10, - marginLeft: '3%', - marginVertical: '2%', - alignSelf: 'flex-end', - }, - submitButton: { - height: 35, - width: 35, - backgroundColor: TAGG_LIGHT_BLUE, - borderRadius: 999, - justifyContent: 'center', - alignItems: 'center', - marginRight: '3%', - marginVertical: '2%', - alignSelf: 'flex-end', - }, whiteBackround: { backgroundColor: '#fff', }, diff --git a/src/components/comments/CommentTextField.tsx b/src/components/comments/CommentTextField.tsx index 6e92329c..6d86eb3f 100644 --- a/src/components/comments/CommentTextField.tsx +++ b/src/components/comments/CommentTextField.tsx @@ -1,8 +1,8 @@ import React, {FC, ReactFragment} from 'react'; import { NativeSyntheticEvent, - StyleSheet, StyleProp, + StyleSheet, Text, TextInput, TextInputSelectionChangeEventData, @@ -10,22 +10,21 @@ import { View, ViewStyle, } from 'react-native'; -import {useSelector} from 'react-redux'; -import {TAGG_LIGHT_BLUE} from '../../constants'; -import {RootState} from '../../store/rootReducer'; import { + MentionPartType, Part, PartType, - MentionPartType, } from 'react-native-controlled-mentions/dist/types'; import { defaultMentionTextStyle, isMentionPartType, } from 'react-native-controlled-mentions/dist/utils'; -import {Avatar} from '../common'; -import {normalize} from '../../utils'; - +import {useSelector} from 'react-redux'; import UpArrowIcon from '../../assets/icons/up_arrow.svg'; +import {TAGG_LIGHT_BLUE} from '../../constants'; +import {RootState} from '../../store/rootReducer'; +import {normalize} from '../../utils'; +import {Avatar} from '../common'; type CommentTextFieldProps = { containerStyle: StyleProp<ViewStyle>; @@ -40,8 +39,6 @@ type CommentTextFieldProps = { ) => null; parts: Part[]; addComment: () => any; - theme?: 'dark' | 'white'; - keyboardVisible?: boolean; comment?: string; }; @@ -56,8 +53,6 @@ const CommentTextField: FC<CommentTextFieldProps> = ({ handleSelectionChange, parts, addComment, - theme = 'white', - keyboardVisible = true, comment = '', ...textInputProps }) => { @@ -99,20 +94,18 @@ const CommentTextField: FC<CommentTextFieldProps> = ({ )} </Text> </TextInput> - {(theme === 'white' || (theme === 'dark' && keyboardVisible)) && ( - <View style={styles.submitButton}> - <TouchableOpacity - style={ - comment === '' - ? [styles.submitButton, styles.greyButton] - : styles.submitButton - } - disabled={comment === ''} - onPress={addComment}> - <UpArrowIcon width={35} height={35} color={'white'} /> - </TouchableOpacity> - </View> - )} + <View style={styles.submitButton}> + <TouchableOpacity + style={ + comment === '' + ? [styles.submitButton, styles.greyButton] + : styles.submitButton + } + disabled={comment === ''} + onPress={addComment}> + <UpArrowIcon width={35} height={35} color={'white'} /> + </TouchableOpacity> + </View> </View> {validateInput(keyboardText) && diff --git a/src/components/comments/CommentsCount.tsx b/src/components/comments/CommentsCount.tsx index 90514193..d4a93bdd 100644 --- a/src/components/comments/CommentsCount.tsx +++ b/src/components/comments/CommentsCount.tsx @@ -3,27 +3,32 @@ import React from 'react'; import {StyleSheet, Text} from 'react-native'; import {TouchableOpacity} from 'react-native-gesture-handler'; import CommentsIcon from '../../assets/icons/moment-comment-icon.svg'; -import {MomentPostType, ScreenType} from '../../types'; +import {ScreenType} from '../../types'; import {normalize} from '../../utils'; interface CommentsCountProps { - moment: MomentPostType; + momentId: string; + count: number; screenType: ScreenType; } -const CommentsCount: React.FC<CommentsCountProps> = ({moment, screenType}) => { +const CommentsCount: React.FC<CommentsCountProps> = ({ + momentId, + count, + screenType, +}) => { const navigation = useNavigation(); return ( <TouchableOpacity style={styles.countContainer} onPress={() => navigation.navigate('MomentCommentsScreen', { - moment_id: moment.moment_id, + moment_id: momentId, screenType, }) }> <CommentsIcon width={25} height={25} /> - <Text style={styles.count}>{moment.comments_count}</Text> + <Text style={styles.count}>{count}</Text> </TouchableOpacity> ); }; diff --git a/src/components/comments/ZoomInCropper.tsx b/src/components/comments/ZoomInCropper.tsx index 94e772b6..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'; @@ -25,7 +25,7 @@ export const ZoomInCropper: React.FC<ZoomInCropperProps> = ({ route, navigation, }) => { - const {screenType, title, image} = route.params; + const {screenType, title, media} = route.params; const [aspectRatio, setAspectRatio] = useState<number>(1); // Stores the coordinates of the cropped image @@ -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({ @@ -50,9 +49,9 @@ export const ZoomInCropper: React.FC<ZoomInCropperProps> = ({ // Setting original aspect ratio of image useEffect(() => { - if (image.sourceURL) { + if (media.uri) { Image.getSize( - image.sourceURL, + media.uri, (w, h) => { setAspectRatio(w / h); }, @@ -67,10 +66,9 @@ export const ZoomInCropper: React.FC<ZoomInCropperProps> = ({ x0 !== undefined && x1 !== undefined && y0 !== undefined && - y1 !== undefined && - image.sourceURL + y1 !== undefined ) { - PhotoManipulator.crop(image.sourceURL, { + PhotoManipulator.crop(media.uri, { x: x0, y: y1, width: Math.abs(x0 - x1), @@ -80,7 +78,10 @@ export const ZoomInCropper: React.FC<ZoomInCropperProps> = ({ navigation.navigate('CaptionScreen', { screenType, title: title, - image: {filename: croppedURL, path: croppedURL}, + media: { + uri: croppedURL, + isVideo: false, + }, }); }) .catch((err) => console.log('err: ', err)); @@ -88,13 +89,12 @@ export const ZoomInCropper: React.FC<ZoomInCropperProps> = ({ x0 === undefined && x1 === undefined && y0 === undefined && - y1 === undefined && - image.sourceURL + y1 === undefined ) { navigation.navigate('CaptionScreen', { screenType, title: title, - image: {filename: image.sourceURL, path: image.sourceURL}, + media, }); } }; @@ -104,7 +104,7 @@ export const ZoomInCropper: React.FC<ZoomInCropperProps> = ({ */ const onMove = (position: IOnMove) => { Image.getSize( - image.path, + media.uri, (w, h) => { const x = position.positionX; const y = position.positionY; @@ -154,7 +154,7 @@ export const ZoomInCropper: React.FC<ZoomInCropperProps> = ({ <Image style={{width: SCREEN_WIDTH, height: SCREEN_WIDTH / aspectRatio}} source={{ - uri: image.sourceURL, + uri: media.uri, }} /> </ImageZoom> diff --git a/src/components/common/MomentTags.tsx b/src/components/common/MomentTags.tsx index 4afacddb..d8a70353 100644 --- a/src/components/common/MomentTags.tsx +++ b/src/components/common/MomentTags.tsx @@ -1,4 +1,5 @@ -import React, {createRef, MutableRefObject, useEffect, useState} from 'react'; +import React, {createRef, RefObject, useEffect, useState} from 'react'; +import {Image, View} from 'react-native'; import {MomentTagType, ProfilePreviewType} from '../../types'; import TaggDraggable from '../taggs/TaggDraggable'; import Draggable from './Draggable'; @@ -7,7 +8,7 @@ interface MomentTagsProps { editing: boolean; tags: MomentTagType[]; setTags: (tag: MomentTagType[]) => void; - imageRef: MutableRefObject<null>; + imageRef: RefObject<Image>; deleteFromList?: (user: ProfilePreviewType) => void; } @@ -21,14 +22,9 @@ const MomentTags: React.FC<MomentTagsProps> = ({ const [offset, setOffset] = useState([0, 0]); const [imageDimensions, setImageDimensions] = useState([0, 0]); const [maxZIndex, setMaxZIndex] = useState(1); - const [draggableRefs, setDraggableRefs] = useState< - React.MutableRefObject<null>[] - >([]); + const [draggableRefs, setDraggableRefs] = useState<RefObject<View>[]>([]); - const updateTagPosition = ( - ref: React.MutableRefObject<null>, - userId: string, - ) => { + const updateTagPosition = (ref: RefObject<Image>, userId: string) => { if (ref !== null && ref.current !== null) { ref.current.measure( ( diff --git a/src/components/moments/Moment.tsx b/src/components/moments/Moment.tsx index 9449271b..1e1cadce 100644 --- a/src/components/moments/Moment.tsx +++ b/src/components/moments/Moment.tsx @@ -1,6 +1,6 @@ import {useNavigation} from '@react-navigation/native'; import React from 'react'; -import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'; +import {Alert, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'; import {Text} from 'react-native-animatable'; import {ScrollView, TouchableOpacity} from 'react-native-gesture-handler'; import LinearGradient from 'react-native-linear-gradient'; @@ -12,6 +12,8 @@ import UpIcon from '../../assets/icons/up_icon.svg'; import {TAGG_LIGHT_BLUE} from '../../constants'; import {MomentType, ScreenType} from '../../types'; import {normalize, SCREEN_WIDTH} from '../../utils'; +import {navigateToVideoPicker} from '../../utils/camera'; +import ImagePicker from 'react-native-image-crop-picker'; import MomentTile from './MomentTile'; interface MomentProps { @@ -41,6 +43,17 @@ const Moment: React.FC<MomentProps> = ({ }) => { const navigation = useNavigation(); + const navigateToCaptionScreenForVideo = (uri: string) => { + navigation.navigate('CaptionScreen', { + screenType, + title, + media: { + uri, + isVideo: true, + }, + }); + }; + const navigateToCameraScreen = () => { navigation.navigate('CameraScreen', { title, @@ -84,7 +97,37 @@ const Moment: React.FC<MomentProps> = ({ <PlusIcon width={23} height={23} - onPress={() => navigateToCameraScreen()} + onPress={() => + Alert.alert('Video Upload', 'pick one', [ + { + text: 'gallery', + onPress: () => + navigateToVideoPicker((vid) => + navigateToCaptionScreenForVideo(vid.path), + ), + }, + { + text: 'camera (simulator will not work)', + onPress: () => + ImagePicker.openCamera({ + mediaType: 'video', + }) + .then((vid) => { + if (vid.path) { + navigateToCaptionScreenForVideo(vid.path); + } + }) + .catch((err) => console.error(err)), + }, + ]) + } + color={'black'} + style={styles.horizontalMargin} + /> + <PlusIcon + width={23} + height={23} + onPress={navigateToCameraScreen} color={TAGG_LIGHT_BLUE} style={styles.horizontalMargin} /> @@ -114,7 +157,7 @@ const Moment: React.FC<MomentProps> = ({ /> ))} {(images === undefined || images.length === 0) && !userXId && ( - <TouchableOpacity onPress={() => navigateToCameraScreen()}> + <TouchableOpacity onPress={navigateToCameraScreen}> <LinearGradient colors={['rgba(105, 141, 211, 1)', 'rgba(105, 141, 211, 0.3)']}> <View style={styles.defaultImage}> @@ -150,9 +193,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/components/moments/MomentPost.tsx b/src/components/moments/MomentPost.tsx index 6eccf5ab..319542f9 100644 --- a/src/components/moments/MomentPost.tsx +++ b/src/components/moments/MomentPost.tsx @@ -1,5 +1,5 @@ import {useNavigation} from '@react-navigation/native'; -import React, {useContext, useEffect, useRef, useState} from 'react'; +import React, {useContext, useEffect, useMemo, useRef, useState} from 'react'; import { Image, KeyboardAvoidingView, @@ -12,6 +12,7 @@ import { View, } from 'react-native'; import Animated, {EasingNode} from 'react-native-reanimated'; +import Video from 'react-native-video'; import {useDispatch, useSelector, useStore} from 'react-redux'; import {headerBarOptions} from '../../routes'; import {MomentContext} from '../../screens/profile/IndividualMoment'; @@ -71,7 +72,16 @@ const MomentPost: React.FC<MomentPostProps> = ({ const [momentTagId, setMomentTagId] = useState<string>(''); const imageRef = useRef(null); - const {keyboardVisible} = useContext(MomentContext); + const videoRef = useRef<Video>(null); + const {keyboardVisible, currentVisibleMomentId} = useContext(MomentContext); + const isVideo = !( + moment.moment_url.endsWith('jpg') || + moment.moment_url.endsWith('JPG') || + moment.moment_url.endsWith('PNG') || + moment.moment_url.endsWith('png') || + moment.moment_url.endsWith('GIF') || + moment.moment_url.endsWith('gif') + ); /* * Load tags on initial render to pass tags data to moment header and content @@ -126,13 +136,15 @@ const MomentPost: React.FC<MomentPostProps> = ({ * determine if image must be displayed in full screen or not */ useEffect(() => { - Image.getSize( - moment.moment_url, - (w, h) => { - setAspectRatio(w / h); - }, - (err) => console.log(err), - ); + if (!isVideo) { + Image.getSize( + moment.moment_url, + (w, h) => { + setAspectRatio(w / h); + }, + (err) => console.log(err), + ); + } }, []); /* @@ -155,22 +167,31 @@ const MomentPost: React.FC<MomentPostProps> = ({ } }, [keyboardVisible, hideText]); - const MomentPosterPreview = () => ( - <View style={styles.momentPosterContainer}> - <TouchableOpacity - onPress={() => - navigateToProfile(state, dispatch, navigation, screenType, user) - } - style={styles.header}> - <TaggAvatar - style={styles.avatar} - userXId={userXId} - screenType={screenType} - editable={false} - /> - <Text style={styles.headerText}>{user.username}</Text> - </TouchableOpacity> - </View> + useEffect(() => { + if (moment.moment_id !== currentVisibleMomentId) { + videoRef.current?.seek(0); + } + }, [currentVisibleMomentId]); + + const momentPosterPreview = useMemo( + () => ( + <View style={styles.momentPosterContainer}> + <TouchableOpacity + onPress={() => + navigateToProfile(state, dispatch, navigation, screenType, user) + } + style={styles.header}> + <TaggAvatar + style={styles.avatar} + userXId={userXId} + screenType={screenType} + editable={false} + /> + <Text style={styles.headerText}>{user.username}</Text> + </TouchableOpacity> + </View> + ), + [user.username], ); return ( @@ -178,17 +199,44 @@ const MomentPost: React.FC<MomentPostProps> = ({ <StatusBar barStyle={'light-content'} /> <View style={styles.mainContainer}> <View style={styles.imageContainer}> - <Image - source={{uri: moment.moment_url}} - style={[ - styles.image, - { - height: SCREEN_WIDTH / aspectRatio, - }, - ]} - resizeMode={'contain'} - ref={imageRef} - /> + {isVideo ? ( + <View + ref={imageRef} + style={[ + styles.media, + { + height: SCREEN_WIDTH / aspectRatio, + }, + ]}> + <Video + ref={videoRef} + source={{ + uri: moment.moment_url, + }} + volume={1} + style={[ + styles.media, + { + height: SCREEN_WIDTH / aspectRatio, + }, + ]} + repeat={true} + resizeMode={'contain'} + onLoad={(response) => { + const {width, height} = response.naturalSize; + setAspectRatio(width / height); + }} + paused={moment.moment_id !== currentVisibleMomentId} + /> + </View> + ) : ( + <Image + source={{uri: moment.moment_url}} + style={styles.media} + resizeMode={'contain'} + ref={imageRef} + /> + )} </View> {visible && ( <Animated.View style={[styles.tagsContainer, {opacity: fadeValue}]}> @@ -233,9 +281,13 @@ const MomentPost: React.FC<MomentPostProps> = ({ /> )} <View style={styles.commentsCountContainer}> - <CommentsCount moment={moment} screenType={screenType} /> + <CommentsCount + momentId={moment.moment_id} + count={commentCount} + screenType={screenType} + /> </View> - <MomentPosterPreview /> + {momentPosterPreview} {!hideText && ( <> {moment.caption !== '' && @@ -281,8 +333,9 @@ const MomentPost: React.FC<MomentPostProps> = ({ }; const styles = StyleSheet.create({ - image: { + media: { zIndex: 0, + flex: 1, }, imageContainer: { height: SCREEN_HEIGHT, @@ -340,6 +393,7 @@ const styles = StyleSheet.create({ }, commentsCountContainer: { position: 'absolute', + zIndex: 3, right: '2%', bottom: SCREEN_HEIGHT * 0.12, }, diff --git a/src/components/moments/legacy/MomentPostContent.tsx b/src/components/moments/legacy/MomentPostContent.tsx index 6388be27..0e6e5eed 100644 --- a/src/components/moments/legacy/MomentPostContent.tsx +++ b/src/components/moments/legacy/MomentPostContent.tsx @@ -3,6 +3,7 @@ import React, {useContext, useEffect, useRef, useState} from 'react'; import {Image, StyleSheet, Text, View, ViewProps} from 'react-native'; import {TouchableWithoutFeedback} from 'react-native-gesture-handler'; import Animated, {EasingNode} from 'react-native-reanimated'; +import Video from 'react-native-video'; import {useDispatch, useStore} from 'react-redux'; import {MomentContext} from '../../../screens/profile/IndividualMoment'; import {RootState} from '../../../store/rootReducer'; @@ -32,14 +33,12 @@ interface MomentPostContentProps extends ViewProps { screenType: ScreenType; moment: MomentPostType; momentTags: MomentTagType[]; - index: number; } const MomentPostContent: React.FC<MomentPostContentProps> = ({ screenType, moment, style, momentTags, - index, }) => { const [tags, setTags] = useState<MomentTagType[]>(momentTags); const state: RootState = useStore().getState(); @@ -55,8 +54,14 @@ const MomentPostContent: React.FC<MomentPostContentProps> = ({ ); const [commentPreview, setCommentPreview] = useState<MomentCommentPreviewType | null>(moment.comment_preview); - const {keyboardVisible, scrollTo} = useContext(MomentContext); + const {keyboardVisible} = useContext(MomentContext); const [hideText, setHideText] = useState(false); + const isVideo = !( + moment.moment_url.endsWith('jpg') || + moment.moment_url.endsWith('JPG') || + moment.moment_url.endsWith('PNG') || + moment.moment_url.endsWith('png') + ); useEffect(() => { setTags(momentTags); @@ -78,7 +83,6 @@ const MomentPostContent: React.FC<MomentPostContentProps> = ({ setHideText(false); } }, [keyboardVisible, hideText]); - return ( <View style={[styles.container, style]}> <TouchableWithoutFeedback @@ -86,12 +90,34 @@ const MomentPostContent: React.FC<MomentPostContentProps> = ({ setVisible(!visible); setFadeValue(new Animated.Value(0)); }}> - <Image - ref={imageRef} - style={styles.image} - source={{uri: moment.moment_url}} - resizeMode={'cover'} - /> + {isVideo ? ( + <View ref={imageRef}> + <Video + // ref={imageRef} + source={{ + uri: moment.moment_url, + }} + // HLS m3u8 version + // source={{ + // uri: 'https://multiplatform-f.akamaihd.net/i/multi/will/bunny/big_buck_bunny_,640x360_400,640x360_700,640x360_1000,950x540_1500,.f4v.csmil/master.m3u8', + // }} + // mp4 version + // source={{ + // uri: 'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4', + // }} + volume={1} + style={styles.image} + repeat={true} + /> + </View> + ) : ( + <Image + ref={imageRef} + style={styles.image} + source={{uri: moment.moment_url}} + resizeMode={'cover'} + /> + )} {tags.length > 0 && ( <Image source={require('../../assets/icons/tag_indicate.png')} @@ -115,7 +141,7 @@ const MomentPostContent: React.FC<MomentPostContentProps> = ({ renderTextWithMentions({ value: moment.caption, styles: styles.captionText, - partTypes: mentionPartTypes('white'), + partTypes: mentionPartTypes('white', 'caption'), onPress: (user: UserType) => navigateToProfile( state, @@ -145,7 +171,6 @@ const MomentPostContent: React.FC<MomentPostContentProps> = ({ }} onFocus={() => { setHideText(true); - scrollTo(index); }} isKeyboardAvoiding={false} theme={'dark'} diff --git a/src/components/profile/MomentMoreInfoDrawer.tsx b/src/components/profile/MomentMoreInfoDrawer.tsx index dc4ebe32..910aa095 100644 --- a/src/components/profile/MomentMoreInfoDrawer.tsx +++ b/src/components/profile/MomentMoreInfoDrawer.tsx @@ -31,6 +31,13 @@ interface MomentMoreInfoDrawerProps extends ViewProps { tags: MomentTagType[]; } +type DrawerButtonType = [ + string, + (event: GestureResponderEvent) => void, + JSX.Element?, + TextStyle?, +][]; + const MomentMoreInfoDrawer: React.FC<MomentMoreInfoDrawerProps> = (props) => { const { setIsOpen, @@ -45,9 +52,7 @@ const MomentMoreInfoDrawer: React.FC<MomentMoreInfoDrawerProps> = (props) => { const navigation = useNavigation(); - const [drawerButtons, setDrawerButtons] = useState< - [string, (event: GestureResponderEvent) => void, JSX.Element?, TextStyle?][] - >([]); + const [drawerButtons, setDrawerButtons] = useState<DrawerButtonType>([]); const handleDeleteMoment = async () => { setIsOpen(false); diff --git a/src/components/profile/ProfileBody.tsx b/src/components/profile/ProfileBody.tsx index c0ee508a..cc001516 100644 --- a/src/components/profile/ProfileBody.tsx +++ b/src/components/profile/ProfileBody.tsx @@ -80,7 +80,6 @@ const ProfileBody: React.FC<ProfileBodyProps> = ({ ); }}>{`${website}`}</Text> )} - {userXId && isBlocked && ( <View style={styles.toggleButtonContainer}> <ToggleButton diff --git a/src/components/taggs/TaggDraggable.tsx b/src/components/taggs/TaggDraggable.tsx index d458fab6..ea19591d 100644 --- a/src/components/taggs/TaggDraggable.tsx +++ b/src/components/taggs/TaggDraggable.tsx @@ -1,5 +1,5 @@ import {useNavigation} from '@react-navigation/native'; -import React from 'react'; +import React, {RefObject} from 'react'; import { Image, StyleSheet, @@ -17,7 +17,7 @@ import {normalize} from '../../utils'; import {navigateToProfile} from '../../utils/users'; interface TaggDraggableProps extends ViewProps { - draggableRef: React.MutableRefObject<null>; + draggableRef: RefObject<View>; taggedUser: ProfilePreviewType; editingView: boolean; deleteFromList: () => void; diff --git a/src/constants/api.ts b/src/constants/api.ts index b55489d9..6dab1153 100644 --- a/src/constants/api.ts +++ b/src/constants/api.ts @@ -38,6 +38,7 @@ export const VERIFY_INVITATION_CODE_ENDPOUNT: string = API_URL + 'verify-code/'; export const COMMENTS_ENDPOINT: string = API_URL + 'comments/'; export const COMMENT_REACTIONS_ENDPOINT: string = API_URL + 'reaction-comment/'; export const COMMENT_REACTIONS_REPLY_ENDPOINT: string = API_URL + 'reaction-reply/'; +export const PRESIGNED_URL_ENDPOINT: string = API_URL + 'presigned-url/'; export const FRIENDS_ENDPOINT: string = API_URL + 'friends/'; export const ALL_USERS_ENDPOINT: string = API_URL + 'users/'; export const REPORT_ISSUE_ENDPOINT: string = API_URL + 'report/'; diff --git a/src/constants/regex.ts b/src/constants/regex.ts index f934185d..61523203 100644 --- a/src/constants/regex.ts +++ b/src/constants/regex.ts @@ -36,7 +36,7 @@ export const nameRegex: RegExp = /^[A-Za-z'\-,. ]{2,20}$/; * - match alphanumerics, and special characters used in URLs */ export const websiteRegex: RegExp = - /^$|^(https?:\/\/)?(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,50}\.[a-zA-Z0-9()]{2,6}\b([-a-zA-Z0-9()@:%_+.~#?&\/=]{0,35})$/; + /^$|^(https?:\/\/)?(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,50}\.[a-zA-Z0-9()]{2,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]{0,35})$/; /** * The website regex has the following constraints diff --git a/src/constants/strings.ts b/src/constants/strings.ts index a1064f49..112bc546 100644 --- a/src/constants/strings.ts +++ b/src/constants/strings.ts @@ -77,7 +77,7 @@ You've been tagged by ${invitee}. Follow the instructions below to skip the line Sign up and use this code to get in: ${inviteCode}\n ${APP_STORE_LINK}`; export const SUCCESS_LAST_CONTACT_INVITE = 'Done! That was your last invite, hope you used it wisely!'; export const SUCCESS_LINK = (str: string) => `Successfully linked ${str} 🎉`; -export const SUCCESS_PIC_UPLOAD = 'Beautiful, the picture was uploaded successfully!'; +export const SUCCESS_PIC_UPLOAD = 'Beautiful, the Moment was uploaded successfully!'; export const SUCCESS_PWD_RESET = 'Your password was reset successfully!'; export const SUCCESS_VERIFICATION_CODE_SENT = 'New verification code sent! Check your phone messages for your code'; export const UP_TO_DATE = 'Up-to-Date!'; diff --git a/src/routes/main/MainStackNavigator.tsx b/src/routes/main/MainStackNavigator.tsx index ef3fc7fd..a5d73988 100644 --- a/src/routes/main/MainStackNavigator.tsx +++ b/src/routes/main/MainStackNavigator.tsx @@ -2,7 +2,6 @@ * Note the name userXId here, it refers to the id of the user being visited */ import {createStackNavigator} from '@react-navigation/stack'; -import {Image} from 'react-native-image-crop-picker'; import { CommentBaseType, MomentTagType, @@ -38,16 +37,16 @@ export type MainStackParams = { }; CaptionScreen: { title?: string; - image?: { - filename: string; - path: string; - }; + media?: {uri: string; isVideo: boolean}; screenType: ScreenType; selectedTags?: MomentTagType[]; moment?: MomentType; }; TagFriendsScreen: { - imagePath: string; + media: { + uri: string; + isVideo: boolean; + }; selectedTags?: MomentTagType[]; }; TagSelectionScreen: { @@ -111,7 +110,7 @@ export type MainStackParams = { Chat: undefined; NewChatModal: undefined; ZoomInCropper: { - image: Image; + media: {uri: string; isVideo: boolean}; screenType: ScreenType; title: string; }; diff --git a/src/routes/main/MainStackScreen.tsx b/src/routes/main/MainStackScreen.tsx index f6adeab1..43760b60 100644 --- a/src/routes/main/MainStackScreen.tsx +++ b/src/routes/main/MainStackScreen.tsx @@ -332,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, }} /> @@ -407,18 +416,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/chat/ChatListScreen.tsx b/src/screens/chat/ChatListScreen.tsx index 1df5c2da..0f5d8073 100644 --- a/src/screens/chat/ChatListScreen.tsx +++ b/src/screens/chat/ChatListScreen.tsx @@ -6,10 +6,10 @@ import {useStore} from 'react-redux'; import {ChannelList, Chat} from 'stream-chat-react-native'; import {ChatContext} from '../../App'; import {TabsGradient} from '../../components'; +import EmptyContentView from '../../components/common/EmptyContentView'; import {ChannelPreview, MessagesHeader} from '../../components/messages'; import {MainStackParams} from '../../routes'; import {RootState} from '../../store/rootReducer'; -import EmptyContentView from '../../components/common/EmptyContentView'; import { LocalAttachmentType, LocalChannelType, diff --git a/src/screens/moments/CameraScreen.tsx b/src/screens/moments/CameraScreen.tsx index c6ed1116..37b37264 100644 --- a/src/screens/moments/CameraScreen.tsx +++ b/src/screens/moments/CameraScreen.tsx @@ -1,6 +1,7 @@ import CameraRoll from '@react-native-community/cameraroll'; import {useBottomTabBarHeight} from '@react-navigation/bottom-tabs'; -import {RouteProp, useFocusEffect} from '@react-navigation/core'; +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'; @@ -15,7 +16,7 @@ import { } from '../../components'; import {MainStackParams} from '../../routes'; import {HeaderHeight, normalize, SCREEN_WIDTH} from '../../utils'; -import {takePicture} from '../../utils/camera'; +import {showGIFFailureAlert, takePicture} from '../../utils/camera'; type CameraScreenRouteProps = RouteProp<MainStackParams, 'CameraScreen'>; export type CameraScreenNavigationProps = StackNavigationProp< @@ -36,9 +37,6 @@ const CameraScreen: React.FC<CameraScreenProps> = ({route, navigation}) => { const [mostRecentPhoto, setMostRecentPhoto] = useState<string>(''); const [showSaveButton, setShowSaveButton] = useState<boolean>(false); - /* - * Removes bottom navigation bar on current screen and add it back when navigating away - */ useFocusEffect( useCallback(() => { navigation.dangerouslyGetParent()?.setOptions({ @@ -68,16 +66,24 @@ const CameraScreen: React.FC<CameraScreenProps> = ({route, navigation}) => { ); }, [capturedImage]); - /* - * Appears once a picture has been captured to navigate to the caption screen - */ - const handleNext = () => { + const navigateToCropper = (uri: string) => { + navigation.navigate('ZoomInCropper', { + screenType, + title, + media: { + uri, + isVideo: false, + }, + }); + }; + + const navigateToCaptionScreen = () => { navigation.navigate('CaptionScreen', { screenType, title, - image: { - filename: capturedImage, - path: capturedImage, + media: { + uri: capturedImage, + isVideo: false, }, }); }; @@ -116,7 +122,10 @@ const CameraScreen: React.FC<CameraScreenProps> = ({route, navigation}) => { )} <TouchableOpacity onPress={() => - takePicture(cameraRef, setShowSaveButton, setCapturedImage) + takePicture(cameraRef, (pic) => { + setShowSaveButton(true); + setCapturedImage(pic.uri); + }) } style={styles.captureButtonContainer}> <View style={styles.captureButton} /> @@ -124,7 +133,7 @@ const CameraScreen: React.FC<CameraScreenProps> = ({route, navigation}) => { <View style={styles.bottomRightContainer}> {capturedImage ? ( <TaggSquareButton - onPress={handleNext} + onPress={navigateToCaptionScreen} title={'Next'} buttonStyle={'large'} buttonColor={'blue'} @@ -135,8 +144,17 @@ const CameraScreen: React.FC<CameraScreenProps> = ({route, navigation}) => { ) : ( <GalleryIcon mostRecentPhotoUri={mostRecentPhoto} - screenType={screenType} - title={title} + callback={(pic) => { + const filename = pic.filename; + if ( + filename && + (filename.endsWith('gif') || filename.endsWith('GIF')) + ) { + showGIFFailureAlert(() => navigateToCropper(pic.path)); + } else { + navigateToCropper(pic.path); + } + }} /> )} </View> @@ -155,11 +173,6 @@ const styles = StyleSheet.create({ flexDirection: 'column', backgroundColor: 'black', }, - preview: { - flex: 1, - justifyContent: 'flex-end', - alignItems: 'center', - }, captureButtonContainer: { alignSelf: 'center', backgroundColor: 'transparent', diff --git a/src/screens/moments/TagFriendsScreen.tsx b/src/screens/moments/TagFriendsScreen.tsx index 15926b5a..201caf49 100644 --- a/src/screens/moments/TagFriendsScreen.tsx +++ b/src/screens/moments/TagFriendsScreen.tsx @@ -1,16 +1,9 @@ import {RouteProp} from '@react-navigation/core'; import {useNavigation} from '@react-navigation/native'; import React, {useEffect, useRef, useState} from 'react'; -import { - Image, - Keyboard, - KeyboardAvoidingView, - Platform, - StyleSheet, - TouchableWithoutFeedback, - View, -} from 'react-native'; +import {Image, StyleSheet, TouchableWithoutFeedback, View} from 'react-native'; import {Button} from 'react-native-elements'; +import Video from 'react-native-video'; import {MainStackParams} from 'src/routes'; import { CaptionScreenHeader, @@ -30,7 +23,7 @@ interface TagFriendsScreenProps { route: TagFriendsScreenRouteProps; } const TagFriendsScreen: React.FC<TagFriendsScreenProps> = ({route}) => { - const {imagePath, selectedTags} = route.params; + const {media, selectedTags} = route.params; const navigation = useNavigation(); const imageRef = useRef(null); const [tags, setTags] = useState<MomentTagType[]>([]); @@ -54,26 +47,30 @@ const TagFriendsScreen: React.FC<TagFriendsScreenProps> = ({route}) => { }); }; + const setMediaDimensions = (width: number, height: number) => { + const imageAspectRatio = width / height; + + // aspectRatio: >= 1 [Landscape] [1:1] + if (imageAspectRatio >= 1) { + setImageWidth(SCREEN_WIDTH); + setImageHeight(SCREEN_WIDTH / imageAspectRatio); + } + // aspectRatio: < 1 [Portrait] + if (imageAspectRatio < 1) { + setImageHeight(SCREEN_WIDTH); + setImageWidth(SCREEN_WIDTH * imageAspectRatio); + } + }; + /* - * Calculating image width and height with respect to it's enclosing view's dimensions + * Calculating image width and height with respect to it's enclosing view's dimensions. Only works for images. */ useEffect(() => { - if (imageRef && imageRef.current) { + if (imageRef && imageRef.current && !media.isVideo) { Image.getSize( - imagePath, + media.uri, (w, h) => { - const imageAspectRatio = w / h; - - // aspectRatio: >= 1 [Landscape] [1:1] - if (imageAspectRatio >= 1) { - setImageWidth(SCREEN_WIDTH); - setImageHeight(SCREEN_WIDTH / imageAspectRatio); - } - // aspectRatio: < 1 [Portrait] - else if (imageAspectRatio < 1) { - setImageHeight(SCREEN_WIDTH); - setImageWidth(SCREEN_WIDTH * imageAspectRatio); - } + setMediaDimensions(w, h); }, (err) => console.log(err), ); @@ -82,65 +79,85 @@ const TagFriendsScreen: React.FC<TagFriendsScreenProps> = ({route}) => { return ( <SearchBackground> - <TouchableWithoutFeedback onPress={Keyboard.dismiss}> - <KeyboardAvoidingView - behavior={Platform.OS === 'ios' ? 'padding' : 'height'} - style={styles.flex}> - <View style={styles.contentContainer}> - <View style={styles.buttonsContainer}> - <Button - title="Cancel" - buttonStyle={styles.button} - onPress={() => navigation.goBack()} - /> - <Button - title="Done" - titleStyle={styles.shareButtonTitle} - buttonStyle={styles.button} - onPress={handleDone} + <View style={styles.contentContainer}> + <View style={styles.buttonsContainer}> + <Button + title="Cancel" + buttonStyle={styles.button} + onPress={() => navigation.goBack()} + /> + <Button + title="Done" + titleStyle={styles.shareButtonTitle} + buttonStyle={styles.button} + onPress={handleDone} + /> + </View> + <CaptionScreenHeader + style={styles.header} + title={'Tap on photo to tag friends!'} + /> + <TouchableWithoutFeedback + onPress={() => + navigation.navigate('TagSelectionScreen', { + selectedTags: tags, + }) + }> + {media.isVideo ? ( + <View + style={{ + width: imageWidth, + height: imageHeight, + marginVertical: (SCREEN_WIDTH - imageHeight) / 2, + marginHorizontal: (SCREEN_WIDTH - imageWidth) / 2, + }} + ref={imageRef}> + <Video + style={{ + width: imageWidth, + height: imageHeight, + }} + source={{uri: media.uri}} + repeat={true} + onLoad={(response) => { + const {width, height, orientation} = response.naturalSize; + // portrait will flip width and height + if (orientation === 'portrait') { + setMediaDimensions(height, width); + } else { + setMediaDimensions(width, height); + } + }} /> </View> - <CaptionScreenHeader - style={styles.header} - title={'Tap on photo to tag friends!'} + ) : ( + <Image + ref={imageRef} + style={{ + width: imageWidth, + height: imageHeight, + marginVertical: (SCREEN_WIDTH - imageHeight) / 2, + marginHorizontal: (SCREEN_WIDTH - imageWidth) / 2, + }} + source={{uri: media.uri}} /> - <TouchableWithoutFeedback - onPress={() => - navigation.navigate('TagSelectionScreen', { - selectedTags: tags, - }) - }> - <Image - ref={imageRef} - style={[ - { - width: imageWidth, - height: imageHeight, - marginVertical: (SCREEN_WIDTH - imageHeight) / 2, - marginHorizontal: (SCREEN_WIDTH - imageWidth) / 2, - }, - styles.image, - ]} - source={{uri: imagePath}} - /> - </TouchableWithoutFeedback> - {tags.length !== 0 && ( - <MomentTags - tags={tags} - setTags={setTags} - editing={true} - imageRef={imageRef} - deleteFromList={(user) => - setTags(tags.filter((tag) => tag.user.id !== user.id)) - } - /> - )} - <View style={styles.footerContainer}> - <TagFriendsFooter tags={tags} setTags={setTags} /> - </View> - </View> - </KeyboardAvoidingView> - </TouchableWithoutFeedback> + )} + </TouchableWithoutFeedback> + {tags.length !== 0 && ( + <MomentTags + tags={tags} + setTags={setTags} + editing={true} + imageRef={imageRef} + deleteFromList={(user) => + setTags(tags.filter((tag) => tag.user.id !== user.id)) + } + /> + )} + <View style={styles.footerContainer}> + <TagFriendsFooter tags={tags} setTags={setTags} /> + </View> + </View> </SearchBackground> ); }; @@ -148,7 +165,6 @@ const TagFriendsScreen: React.FC<TagFriendsScreenProps> = ({route}) => { const styles = StyleSheet.create({ contentContainer: { paddingTop: StatusBarHeight, - justifyContent: 'flex-end', }, buttonsContainer: { flexDirection: 'row', @@ -166,19 +182,10 @@ const styles = StyleSheet.create({ header: { marginVertical: 20, }, - image: {zIndex: 0, justifyContent: 'center', alignSelf: 'center'}, - text: { - position: 'relative', - backgroundColor: 'white', - width: '100%', - paddingHorizontal: '2%', - paddingVertical: '1%', - height: 60, - }, - flex: { - flex: 1, + footerContainer: { + marginHorizontal: '5%', + marginTop: '3%', }, - footerContainer: {marginHorizontal: '5%', marginTop: '3%'}, }); export default TagFriendsScreen; diff --git a/src/screens/profile/CaptionScreen.tsx b/src/screens/profile/CaptionScreen.tsx index 75533a9b..05db8ed7 100644 --- a/src/screens/profile/CaptionScreen.tsx +++ b/src/screens/profile/CaptionScreen.tsx @@ -15,6 +15,7 @@ import { } from 'react-native'; import {MentionInputControlled} from '../../components'; import {Button, normalize} from 'react-native-elements'; +import Video from 'react-native-video'; import {useDispatch, useSelector} from 'react-redux'; import FrontArrow from '../../assets/icons/front-arrow.svg'; import {SearchBackground} from '../../components'; @@ -27,7 +28,13 @@ import { SUCCESS_PIC_UPLOAD, } from '../../constants/strings'; import {MainStackParams} from '../../routes'; -import {patchMoment, postMoment, postMomentTags} from '../../services'; +import { + handlePresignedURL, + handleVideoUpload, + patchMoment, + postMoment, + postMomentTags, +} from '../../services'; import { loadUserMoments, updateProfileCompletionStage, @@ -51,7 +58,7 @@ interface CaptionScreenProps { } const CaptionScreen: React.FC<CaptionScreenProps> = ({route, navigation}) => { - const {title, image, screenType, selectedTags, moment} = route.params; + const {title, screenType, selectedTags, moment} = route.params; const { user: {userId}, } = useSelector((state: RootState) => state.user); @@ -62,6 +69,16 @@ const CaptionScreen: React.FC<CaptionScreenProps> = ({route, navigation}) => { selectedTags ? selectedTags : [], ); const [taggedList, setTaggedList] = useState<string>(''); + const mediaUri = moment ? moment.moment_url : route.params.media!.uri; + // TODO: change this once moment refactor is done + const isMediaAVideo = moment + ? !( + moment.moment_url.endsWith('.jpg') || + moment.moment_url.endsWith('.JPG') || + moment.moment_url.endsWith('.png') || + moment.moment_url.endsWith('.PNG') + ) + : route.params.media?.isVideo ?? false; useEffect(() => { setTags(selectedTags ? selectedTags : []); @@ -120,36 +137,50 @@ const CaptionScreen: React.FC<CaptionScreenProps> = ({route, navigation}) => { const handleShare = async () => { setLoading(true); - if (!image?.filename || !title) { - return; - } - const momentResponse = await postMoment( - image.filename, - image.path, - caption, - title, - userId, - ); - if (!momentResponse) { + if (moment || !title) { handleFailed(); return; } - const momentTagResponse = await postMomentTags( - momentResponse.moment_id, - formattedTags(), - ); - if (!momentTagResponse) { - handleFailed(); - return; + let profileCompletionStage; + let momentId; + // separate upload logic for image/video + if (isMediaAVideo) { + const presignedURLResponse = await handlePresignedURL(title); + if (!presignedURLResponse) { + handleFailed(); + return; + } + 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(mediaUri, caption, title, userId); + if (!momentResponse) { + handleFailed(); + return; + } + profileCompletionStage = momentResponse.profile_completion_stage; + momentId = momentResponse.moment_id; + } + if (momentId) { + const momentTagResponse = await postMomentTags(momentId, formattedTags()); + if (!momentTagResponse) { + handleFailed(); + return; + } } dispatch(loadUserMoments(userId)); - dispatch( - updateProfileCompletionStage(momentResponse.profile_completion_stage), - ); + if (profileCompletionStage) { + dispatch(updateProfileCompletionStage(profileCompletionStage)); + } handleSuccess(); }; - const handleDone = async () => { + const handleDoneEditing = async () => { setLoading(true); if (moment?.moment_id) { const success = await patchMoment( @@ -186,19 +217,26 @@ const CaptionScreen: React.FC<CaptionScreenProps> = ({route, navigation}) => { title={moment ? 'Done' : 'Share'} titleStyle={styles.shareButtonTitle} buttonStyle={styles.button} - onPress={moment ? handleDone : handleShare} + onPress={moment ? handleDoneEditing : handleShare} /> </View> <CaptionScreenHeader style={styles.header} - {...{title: moment ? moment.moment_category : title}} - /> - {/* this is the image we want to center our tags' initial location within */} - <Image - style={styles.image} - source={{uri: moment ? moment.moment_url : image?.path}} - resizeMode={'contain'} + {...{title: moment ? moment.moment_category : title ?? ''}} /> + {isMediaAVideo ? ( + <Video + style={styles.media} + source={{uri: mediaUri}} + repeat={true} + /> + ) : ( + <Image + style={styles.media} + source={{uri: mediaUri}} + resizeMode={'contain'} + /> + )} <MentionInputControlled containerStyle={styles.text} placeholder="Write something....." @@ -210,11 +248,10 @@ const CaptionScreen: React.FC<CaptionScreenProps> = ({route, navigation}) => { <TouchableOpacity onPress={() => navigation.navigate('TagFriendsScreen', { - imagePath: moment - ? moment.moment_url - : image - ? image.path - : '', + media: { + uri: mediaUri, + isVideo: isMediaAVideo, + }, selectedTags: tags, }) } @@ -257,7 +294,7 @@ const styles = StyleSheet.create({ header: { marginVertical: 20, }, - image: { + media: { position: 'relative', width: SCREEN_WIDTH, aspectRatio: 1, diff --git a/src/screens/profile/IndividualMoment.tsx b/src/screens/profile/IndividualMoment.tsx index ca31ad5b..7d231312 100644 --- a/src/screens/profile/IndividualMoment.tsx +++ b/src/screens/profile/IndividualMoment.tsx @@ -1,22 +1,17 @@ import {RouteProp} from '@react-navigation/native'; import {StackNavigationProp} from '@react-navigation/stack'; import React, {useEffect, useRef, useState} from 'react'; -import {FlatList, Keyboard} from 'react-native'; +import {FlatList, Keyboard, ViewToken} from 'react-native'; import {useSelector} from 'react-redux'; import {MomentPost, TabsGradient} from '../../components'; -import {AVATAR_DIM} from '../../constants'; import {MainStackParams} from '../../routes'; import {RootState} from '../../store/rootreducer'; import {MomentPostType} from '../../types'; -import {isIPhoneX} from '../../utils'; - -/** - * Individual moment view opened when user clicks on a moment tile - */ +import {SCREEN_HEIGHT} from '../../utils'; type MomentContextType = { keyboardVisible: boolean; - scrollTo: (index: number) => void; + currentVisibleMomentId: string | undefined; }; export const MomentContext = React.createContext({} as MomentContextType); @@ -48,6 +43,26 @@ const IndividualMoment: React.FC<IndividualMomentProps> = ({route}) => { ); const initialIndex = momentData.findIndex((m) => m.moment_id === moment_id); const [keyboardVisible, setKeyboardVisible] = useState(false); + const [currentVisibleMomentId, setCurrentVisibleMomentId] = useState< + string | undefined + >(); + const [viewableItems, setViewableItems] = useState<ViewToken[]>([]); + + // https://stackoverflow.com/a/57502343 + const viewabilityConfigCallback = useRef( + (info: {viewableItems: ViewToken[]}) => { + setViewableItems(info.viewableItems); + }, + ); + + useEffect(() => { + if (viewableItems.length > 0) { + const index = viewableItems[0].index; + if (index !== null && momentData.length > 0) { + setCurrentVisibleMomentId(momentData[index].moment_id); + } + } + }, [viewableItems]); useEffect(() => { const showKeyboard = () => setKeyboardVisible(true); @@ -60,20 +75,11 @@ const IndividualMoment: React.FC<IndividualMomentProps> = ({route}) => { }; }, []); - const scrollTo = (index: number) => { - // TODO: make this dynamic - const offset = isIPhoneX() ? -(AVATAR_DIM + 100) : -(AVATAR_DIM + 160); - scrollRef.current?.scrollToIndex({ - index: index, - viewOffset: offset, - }); - }; - return ( <MomentContext.Provider value={{ keyboardVisible, - scrollTo, + currentVisibleMomentId, }}> <FlatList ref={scrollRef} @@ -89,13 +95,12 @@ const IndividualMoment: React.FC<IndividualMomentProps> = ({route}) => { keyExtractor={(item, _) => item.moment_id} showsVerticalScrollIndicator={false} initialScrollIndex={initialIndex} - onScrollToIndexFailed={(info) => { - setTimeout(() => { - scrollRef.current?.scrollToIndex({ - index: info.index, - }); - }, 500); - }} + onViewableItemsChanged={viewabilityConfigCallback.current} + getItemLayout={(data, index) => ({ + length: SCREEN_HEIGHT, + offset: SCREEN_HEIGHT * index, + index, + })} pagingEnabled /> <TabsGradient /> diff --git a/src/screens/suggestedPeople/SuggestedPeopleScreen.tsx b/src/screens/suggestedPeople/SuggestedPeopleScreen.tsx index 39d98bcc..6156e950 100644 --- a/src/screens/suggestedPeople/SuggestedPeopleScreen.tsx +++ b/src/screens/suggestedPeople/SuggestedPeopleScreen.tsx @@ -44,6 +44,7 @@ const SuggestedPeopleScreen: React.FC = () => { const [hideStatusBar, setHideStatusBar] = useState(false); // boolean for showing/hiding loading indicator const [loading, setLoading] = useState(true); + const [viewableItems, setViewableItems] = useState<ViewToken[]>([]); // set loading to false once there are people to display useEffect(() => { @@ -59,6 +60,20 @@ const SuggestedPeopleScreen: React.FC = () => { const stausBarRef = useRef(hideStatusBar); + // https://stackoverflow.com/a/57502343 + const viewabilityConfigCallback = useRef( + (info: {viewableItems: ViewToken[]}) => { + setViewableItems(info.viewableItems); + }, + ); + + useEffect(() => { + if (viewableItems.length > 0) { + setHideStatusBar(viewableItems[0].index !== 0); + stausBarRef.current = viewableItems[0].index !== 0; + } + }, [viewableItems]); + useEffect(() => { const handlePageChange = async () => { const checkAsync = await AsyncStorage.getItem( @@ -208,14 +223,6 @@ const SuggestedPeopleScreen: React.FC = () => { updateDisplayedUser(user, 'no_record', ''); }; - const onViewableItemsChanged = useCallback( - ({viewableItems}: {viewableItems: ViewToken[]}) => { - setHideStatusBar(viewableItems[0].index !== 0); - stausBarRef.current = viewableItems[0].index !== 0; - }, - [], - ); - useFocusEffect(() => { if (suggested_people_linked === 0) { navigation.navigate('SPWelcomeScreen'); @@ -244,7 +251,7 @@ const SuggestedPeopleScreen: React.FC = () => { }} keyExtractor={(_, index) => index.toString()} showsVerticalScrollIndicator={false} - onViewableItemsChanged={onViewableItemsChanged} + onViewableItemsChanged={viewabilityConfigCallback.current} onEndReached={() => setPage(page + 1)} onEndReachedThreshold={3} refreshControl={ diff --git a/src/services/MomentService.ts b/src/services/MomentService.ts index b837585a..0c93876a 100644 --- a/src/services/MomentService.ts +++ b/src/services/MomentService.ts @@ -5,12 +5,17 @@ import { MOMENTTAG_ENDPOINT, MOMENT_TAGS_ENDPOINT, MOMENT_THUMBNAIL_ENDPOINT, + PRESIGNED_URL_ENDPOINT, + TAGG_CUSTOMER_SUPPORT, } from '../constants'; -import {MomentPostType, MomentTagType} from '../types'; +import { + ERROR_SOMETHING_WENT_WRONG, + ERROR_SOMETHING_WENT_WRONG_REFRESH, +} from '../constants/strings'; +import {MomentPostType, MomentTagType, PresignedURLResponse} from '../types'; import {checkImageUploadStatus} from '../utils'; export const postMoment = async ( - fileName: string, uri: string, caption: string, category: string, @@ -18,13 +23,10 @@ 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', type: 'image/jpg', }); request.append('moment', category); @@ -208,3 +210,108 @@ export const deleteMomentTag = async (moment_tag_id: string) => { return false; } }; +/** + * 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 + * @returns a PresignedURLResponse object + */ +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', + headers: { + Authorization: 'Token ' + token, + }, + body: JSON.stringify({ + filename, + category: momentCategory, + }), + }); + const status = response.status; + let data: PresignedURLResponse = await response.json(); + if (status === 200) { + return data; + } else { + if (status === 404) { + console.log( + `Please make sure that the email / username entered is registered with us. You may contact our customer support at ${TAGG_CUSTOMER_SUPPORT}`, + ); + } else { + console.log(ERROR_SOMETHING_WENT_WRONG_REFRESH); + } + console.log(response); + } + } catch (error) { + console.log(error); + console.log(ERROR_SOMETHING_WENT_WRONG); + } +}; +/** + * 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 filePath: the path to the file, including filename + * @param urlObj PresignedURLResponse | undefined + * @returns responseURL or boolean + */ +export const handleVideoUpload = async ( + filePath: string, + urlObj: PresignedURLResponse | undefined, +) => { + try { + if (urlObj === null || urlObj === undefined) { + console.log('Invalid urlObj'); + return false; + } + const form = new FormData(); + form.append('key', urlObj.response_url.fields.key); + form.append( + 'x-amz-algorithm', + urlObj.response_url.fields['x-amz-algorithm'], + ); + form.append( + 'x-amz-credential', + urlObj.response_url.fields['x-amz-credential'], + ); + form.append('x-amz-date', urlObj.response_url.fields['x-amz-date']); + form.append('policy', urlObj.response_url.fields.policy); + form.append( + 'x-amz-signature', + urlObj.response_url.fields['x-amz-signature'], + ); + form.append('file', { + 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: urlObj.response_url.fields.key, + }); + const response = await fetch(urlObj.response_url.url, { + method: 'POST', + headers: { + 'Content-Type': 'multipart/form-data', + }, + body: form, + }); + const status = response.status; + if (status === 200 || status === 204) { + console.log('complete'); + return true; + } else { + if (status === 404) { + console.log( + `Please make sure that the email / username entered is registered with us. You may contact our customer support at ${TAGG_CUSTOMER_SUPPORT}`, + ); + } else { + console.log(ERROR_SOMETHING_WENT_WRONG_REFRESH); + } + } + } catch (error) { + console.log(error); + console.log(ERROR_SOMETHING_WENT_WRONG); + } + return false; +}; diff --git a/src/types/types.ts b/src/types/types.ts index 171a9ff3..416d9146 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -365,3 +365,19 @@ export type ReactionType = { id: string; type: ReactionOptionsType; }; +// used to handle direct S3 uploads by packaging presigned_url info into one object +export type PresignedURLResponse = { + response_msg: string; + response_url: { + url: string; + fields: { + key: string | undefined; + 'x-amz-algorithm': string; + 'x-amz-credential': string; + 'x-amz-date': string; + policy: string; + 'x-amz-signature': string; + }; + }; + moment_id: string; +}; diff --git a/src/utils/camera.ts b/src/utils/camera.ts index 73461ad7..3937129a 100644 --- a/src/utils/camera.ts +++ b/src/utils/camera.ts @@ -1,43 +1,41 @@ import CameraRoll from '@react-native-community/cameraroll'; -import {Dispatch, RefObject, SetStateAction} from 'react'; +import {RefObject} from 'react'; import {Alert} from 'react-native'; -import {RNCamera} from 'react-native-camera'; -import ImagePicker from 'react-native-image-crop-picker'; -import {ScreenType} from 'src/types'; +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 shoe the preview of the picture taken + * Captures a photo and pauses to show the preview of the picture taken */ export const takePicture = ( cameraRef: RefObject<RNCamera>, - setShowSaveButton: Dispatch<SetStateAction<boolean>>, - setCapturedImage: Dispatch<SetStateAction<string>>, + callback: (pic: TakePictureResponse) => void, ) => { if (cameraRef !== null) { cameraRef.current?.pausePreview(); - const options = { + const options: TakePictureOptions = { forceUpOrientation: true, + orientation: 'portrait', writeExif: false, }; - cameraRef.current?.takePictureAsync(options).then((response) => { - setShowSaveButton(true); - setCapturedImage(response.uri); + cameraRef.current?.takePictureAsync(options).then((pic) => { + callback(pic); }); } }; -export const downloadImage = (capturedImageURI: string) => { +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 = ( - navigation: any, - screenType: ScreenType, - title: string, -) => { +export const navigateToImagePicker = (callback: (pic: Image) => void) => { ImagePicker.openPicker({ smartAlbums: [ 'Favorites', @@ -48,13 +46,23 @@ export const navigateToImagePicker = ( ], mediaType: 'photo', }) - .then((picture) => { - if ('path' in picture) { - navigation.navigate('ZoomInCropper', { - screenType, - title, - image: picture, - }); + .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) => { @@ -63,3 +71,28 @@ export const navigateToImagePicker = ( } }); }; + +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.', + ), + }, + ); |