diff options
author | Ivan Chen <ivan@tagg.id> | 2021-05-21 21:47:43 -0400 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-05-21 21:47:43 -0400 |
commit | 5afdf9208fd3d7498a2595797e6c9fb5f567fc61 (patch) | |
tree | b76d16e06c0fb5f89a3da9ffa44eddec71f9d52c /src | |
parent | 83802c3a18b1a1406cb4f1336b91e477161e7340 (diff) | |
parent | dcbe315638f6c5edc98415d6cec2a016bfc601bf (diff) |
Merge pull request #436 from shravyaramesh/tma853-tag-selection-screen
[TMA-853] Tag selection screen
Diffstat (limited to 'src')
29 files changed, 887 insertions, 154 deletions
diff --git a/src/assets/icons/tagging/tag-icon.png b/src/assets/icons/tagging/tag-icon.png Binary files differnew file mode 100644 index 00000000..5dfa9099 --- /dev/null +++ b/src/assets/icons/tagging/tag-icon.png diff --git a/src/assets/icons/tagging/white-plus-icon.png b/src/assets/icons/tagging/white-plus-icon.png Binary files differnew file mode 100644 index 00000000..258166a7 --- /dev/null +++ b/src/assets/icons/tagging/white-plus-icon.png diff --git a/src/assets/icons/tagging/x-icon.png b/src/assets/icons/tagging/x-icon.png Binary files differnew file mode 100644 index 00000000..5f2b244c --- /dev/null +++ b/src/assets/icons/tagging/x-icon.png diff --git a/src/components/common/MomentTags.tsx b/src/components/common/MomentTags.tsx index fb9ef5be..04b0558b 100644 --- a/src/components/common/MomentTags.tsx +++ b/src/components/common/MomentTags.tsx @@ -30,7 +30,6 @@ const MomentTags: React.FC<MomentTagsProps> = ({ if (!tags) { return null; } - return editing && deleteFromList ? ( <> {tags.map((tag) => ( diff --git a/src/components/common/TaggRadioButton.tsx b/src/components/common/TaggRadioButton.tsx new file mode 100644 index 00000000..3cc2780c --- /dev/null +++ b/src/components/common/TaggRadioButton.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { + GestureResponderEvent, + StyleSheet, + TouchableOpacity, + View, +} from 'react-native'; +import {RADIO_BUTTON_GREY, TAGG_LIGHT_BLUE_2} from '../../constants/constants'; + +interface TaggRadioButtonProps { + pressed: boolean; + onPress: (event: GestureResponderEvent) => void; +} +const TaggRadioButton: React.FC<TaggRadioButtonProps> = ({ + pressed, + onPress, +}) => { + const activeOuterStyle = { + borderColor: pressed ? TAGG_LIGHT_BLUE_2 : RADIO_BUTTON_GREY, + }; + + const activeInnerStyle = { + backgroundColor: pressed ? TAGG_LIGHT_BLUE_2 : 'white', + }; + return ( + <TouchableOpacity + style={[styles.outer, activeOuterStyle]} + onPress={onPress}> + {pressed && <View style={[styles.inner, activeInnerStyle]} />} + </TouchableOpacity> + ); +}; + +const styles = StyleSheet.create({ + outer: { + width: 20, + height: 20, + borderWidth: 1.5, + borderRadius: 20, + + backgroundColor: 'white', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + inner: { + width: 14, + height: 14, + borderRadius: 8, + }, +}); + +export default TaggRadioButton; diff --git a/src/components/common/TaggUserSelectionCell.tsx b/src/components/common/TaggUserSelectionCell.tsx new file mode 100644 index 00000000..2ea1e4ce --- /dev/null +++ b/src/components/common/TaggUserSelectionCell.tsx @@ -0,0 +1,73 @@ +import React, {useEffect, useState} from 'react'; +import {StyleSheet, View} from 'react-native'; +import {ProfilePreview} from '..'; +import {ProfilePreviewType, ScreenType} from '../../types'; +import {SCREEN_WIDTH} from '../../utils'; +import TaggRadioButton from './TaggRadioButton'; + +interface TaggUserSelectionCellProps { + item: ProfilePreviewType; + selectedUsers: ProfilePreviewType[]; + setSelectedUsers: Function; +} +const TaggUserSelectionCell: React.FC<TaggUserSelectionCellProps> = ({ + item, + selectedUsers, + setSelectedUsers, +}) => { + const [pressed, setPressed] = useState<boolean>(false); + + /* + * To update state of radio button on initial render and subsequent re-renders + */ + useEffect(() => { + const updatePressed = () => { + const userSelected = selectedUsers.findIndex( + (selectedUser) => item.id === selectedUser.id, + ); + setPressed(userSelected !== -1); + }; + updatePressed(); + }); + + /* + * Handles on press on radio button + * Adds/removes user from selected list of users + */ + const handlePress = () => { + // Add to selected list of users + if (pressed === false) { + setSelectedUsers([...selectedUsers, item]); + } + // Remove item from selected list of users + else { + const filteredSelection = selectedUsers.filter( + (user) => user.id !== item.id, + ); + setSelectedUsers(filteredSelection); + } + }; + return ( + <View style={styles.container}> + <View style={{width: SCREEN_WIDTH * 0.8}}> + <ProfilePreview + profilePreview={item} + previewType={'Search'} + screenType={ScreenType.Profile} + /> + </View> + <TaggRadioButton pressed={pressed} onPress={handlePress} /> + </View> + ); +}; + +const styles = StyleSheet.create({ + container: { + marginHorizontal: '3%', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, +}); + +export default TaggUserSelectionCell; diff --git a/src/components/common/index.ts b/src/components/common/index.ts index 692c9f8a..4f5c0232 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -27,4 +27,5 @@ export {default as Avatar} from './Avatar'; export {default as TaggTypeahead} from './TaggTypeahead'; export {default as TaggUserRowCell} from './TaggUserRowCell'; export {default as LikeButton} from './LikeButton'; +export {default as TaggUserSelectionCell} from './TaggUserSelectionCell'; export {default as MomentTags} from './MomentTags'; diff --git a/src/components/moments/TagFriendsFoooter.tsx b/src/components/moments/TagFriendsFoooter.tsx new file mode 100644 index 00000000..6b8fc62a --- /dev/null +++ b/src/components/moments/TagFriendsFoooter.tsx @@ -0,0 +1,132 @@ +import {useNavigation} from '@react-navigation/native'; +import React, {Dispatch, SetStateAction} from 'react'; +import {Image, StyleSheet, Text, TouchableOpacity, View} from 'react-native'; +import {ProfilePreview} from '..'; +import {ProfilePreviewType, ScreenType} from '../../types'; +import {normalize} from '../../utils/layouts'; + +interface TagFriendsFooterProps { + taggedUsers: ProfilePreviewType[]; + setTaggedUsers: Dispatch<SetStateAction<ProfilePreviewType[]>>; +} +const TagFriendsFooter: React.FC<TagFriendsFooterProps> = ({ + taggedUsers, + setTaggedUsers, +}) => { + const navigation = useNavigation(); + + const handleRemoveTag = (user: ProfilePreviewType) => { + const filteredSelection = taggedUsers.filter((item) => user.id !== item.id); + setTaggedUsers(filteredSelection); + }; + + const TaggMoreButton = () => ( + <TouchableOpacity + onPress={() => + navigation.navigate('TagSelectionScreen', { + selectedUsers: taggedUsers, + }) + } + style={{ + flexDirection: 'column', + alignItems: 'center', + }}> + <Image + source={require('../../assets/icons/tagging/white-plus-icon.png')} + style={{width: 38, height: 38, top: -2}} + /> + <Text style={styles.taggMoreLabel}>{'Tagg More'}</Text> + </TouchableOpacity> + ); + + const TaggedUser = (user: ProfilePreviewType) => ( + <View style={{flexDirection: 'row-reverse'}} key={user.id}> + <TouchableOpacity + style={styles.closeIconContainer} + onPress={() => handleRemoveTag(user)}> + <Image + source={require('../../assets/icons/tagging/x-icon.png')} + style={{ + width: 20, + height: 20, + }} + /> + </TouchableOpacity> + <ProfilePreview + profilePreview={user} + previewType={'Tag Selection'} + screenType={ScreenType.Profile} + /> + </View> + ); + + /* + * Title/Button depending on the number of users inside taggedUsers list + * If taggUsers is empty, title acts as a button + * Else, gets disabled and TaggMore button appears + */ + const TagFriendsTitle = () => ( + <TouchableOpacity + style={{ + flexDirection: 'row', + }} + disabled={taggedUsers.length !== 0} + onPress={() => + navigation.navigate('TagSelectionScreen', { + selectedUsers: taggedUsers, + }) + }> + <Image + source={require('../../assets/icons/tagging/tag-icon.png')} + style={styles.tagIcon} + /> + <Text style={styles.tagFriendsTitle}>Tag Friends</Text> + </TouchableOpacity> + ); + + return ( + <> + <TagFriendsTitle /> + <View style={styles.tagFriendsContainer}> + {taggedUsers.map((user) => ( + <TaggedUser {...user} /> + ))} + {taggedUsers.length !== 0 && <TaggMoreButton />} + </View> + </> + ); +}; + +const styles = StyleSheet.create({ + tagIcon: {width: 20, height: 20, marginRight: '3%'}, + tagFriendsTitle: { + color: 'white', + fontSize: normalize(12), + lineHeight: normalize(16.71), + letterSpacing: normalize(0.3), + fontWeight: '600', + }, + tagFriendsContainer: { + flexDirection: 'row', + marginTop: '3%', + flexWrap: 'wrap', + justifyContent: 'flex-start', + }, + taggMoreLabel: { + fontWeight: '500', + fontSize: normalize(9), + lineHeight: normalize(10), + letterSpacing: normalize(0.2), + color: 'white', + textAlign: 'center', + marginVertical: '5%', + }, + closeIconContainer: { + width: 20, + height: 20, + right: -20, + zIndex: 1, + }, +}); + +export default TagFriendsFooter; diff --git a/src/components/moments/index.ts b/src/components/moments/index.ts index 89fd689c..6af29bc5 100644 --- a/src/components/moments/index.ts +++ b/src/components/moments/index.ts @@ -3,3 +3,4 @@ export {default as CaptionScreenHeader} from './CaptionScreenHeader'; export {default as MomentPostHeader} from './MomentPostHeader'; export {default as MomentPostContent} from './MomentPostContent'; export {default as Moment} from './Moment'; +export {default as TagFriendsFooter} from './TagFriendsFoooter'; diff --git a/src/components/profile/ProfilePreview.tsx b/src/components/profile/ProfilePreview.tsx index 66d68d8f..88c075e2 100644 --- a/src/components/profile/ProfilePreview.tsx +++ b/src/components/profile/ProfilePreview.tsx @@ -148,6 +148,14 @@ const ProfilePreview: React.FC<ProfilePreviewProps> = ({ usernameStyle = styles.discoverUsersUsername; nameStyle = styles.discoverUsersName; break; + case 'Tag Selection': + containerStyle = styles.tagSelectionContainer; + avatarStyle = styles.tagSelectionAvatar; + nameContainerStyle = styles.tagSelectionNameContainer; + usernameToDisplay = '@' + username; + usernameStyle = styles.tagSelectionUsername; + nameStyle = styles.tagSelectionName; + break; case 'Comment': containerStyle = styles.commentContainer; avatarStyle = styles.commentAvatar; @@ -195,10 +203,9 @@ const ProfilePreview: React.FC<ProfilePreviewProps> = ({ <Text style={nameStyle}>{first_name.concat(' ', last_name)}</Text> </> )} - {previewType === 'Comment' && ( - <Text style={usernameStyle}>{usernameToDisplay}</Text> - )} - {previewType === 'Discover Users' && ( + {(previewType === 'Discover Users' || + previewType === 'Tag Selection' || + previewType === 'Comment') && ( <> <Text style={usernameStyle}>{usernameToDisplay}</Text> </> @@ -368,6 +375,35 @@ const styles = StyleSheet.create({ marginRight: 15, borderRadius: 50, }, + tagSelectionContainer: { + width: 60, + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'flex-start', + margin: '1%', + }, + tagSelectionAvatar: { + width: 34, + height: 34, + borderRadius: 20, + }, + tagSelectionNameContainer: { + width: '100%', + marginVertical: '10%', + }, + tagSelectionUsername: { + fontWeight: '500', + fontSize: normalize(9), + lineHeight: normalize(10), + letterSpacing: normalize(0.2), + color: 'white', + textAlign: 'center', + }, + tagSelectionName: { + fontWeight: '500', + fontSize: 8, + color: 'white', + }, }); export default ProfilePreview; diff --git a/src/components/search/SearchBar.tsx b/src/components/search/SearchBar.tsx index 25ea3b59..a17d0695 100644 --- a/src/components/search/SearchBar.tsx +++ b/src/components/search/SearchBar.tsx @@ -21,10 +21,10 @@ import {getSearchSuggestions, normalize} from '../../utils'; const AnimatedIcon = Animated.createAnimatedComponent(Icon); interface SearchBarProps extends TextInputProps { - onCancel: () => void; - animationProgress: Animated.SharedValue<number>; - searching: boolean; - onLayout: (e: LayoutChangeEvent) => void; + onCancel?: () => void; + animationProgress?: Animated.SharedValue<number>; + searching?: boolean; + onLayout?: (e: LayoutChangeEvent) => void; } const SearchBar: React.FC<SearchBarProps> = ({ onFocus, @@ -113,8 +113,8 @@ const SearchBar: React.FC<SearchBarProps> = ({ * On-search marginRight style ("cancel" button slides and fades in). */ const animatedStyles = useAnimatedStyle<ViewStyle>(() => ({ - marginRight: animationProgress.value * 58, - opacity: animationProgress.value, + marginRight: (animationProgress ? animationProgress.value : 0) * 58, + opacity: animationProgress ? animationProgress.value : 0, })); return ( @@ -136,11 +136,13 @@ const SearchBar: React.FC<SearchBarProps> = ({ {...{placeholder, value, onChangeText, onFocus, onBlur}} /> </Animated.View> - <Animated.View style={animatedStyles}> - <TouchableOpacity style={styles.cancelButton} onPress={onCancel}> - <Text style={styles.cancelText}>Cancel</Text> - </TouchableOpacity> - </Animated.View> + {onCancel && ( + <Animated.View style={animatedStyles}> + <TouchableOpacity style={styles.cancelButton} onPress={onCancel}> + <Text style={styles.cancelText}>Cancel</Text> + </TouchableOpacity> + </Animated.View> + )} </View> ); }; diff --git a/src/constants/api.ts b/src/constants/api.ts index d52fc203..f02ee407 100644 --- a/src/constants/api.ts +++ b/src/constants/api.ts @@ -32,6 +32,7 @@ export const SEARCH_ENDPOINT_MESSAGES: string = API_URL + 'search/messages/'; export const SEARCH_ENDPOINT_SUGGESTED: string = API_URL + 'search/suggested/'; export const MOMENTS_ENDPOINT: string = API_URL + 'moments/'; export const MOMENT_TAGS_ENDPOINT: string = API_URL + 'moments/tags/'; +export const MOMENTTAG_ENDPOINT: string = API_URL + 'moment-tag/'; export const MOMENT_THUMBNAIL_ENDPOINT: string = API_URL + 'moment-thumbnail/'; export const VERIFY_INVITATION_CODE_ENDPOUNT: string = API_URL + 'verify-code/'; export const COMMENTS_ENDPOINT: string = API_URL + 'comments/'; diff --git a/src/constants/constants.ts b/src/constants/constants.ts index f533563d..99d3901b 100644 --- a/src/constants/constants.ts +++ b/src/constants/constants.ts @@ -65,6 +65,7 @@ export const TAGG_DARK_BLUE = '#4E699C'; export const TAGG_LIGHT_BLUE: string = '#698DD3'; export const TAGG_LIGHT_BLUE_2: string = '#6EE7E7'; export const TAGG_LIGHT_PURPLE = '#F4DDFF'; +export const RADIO_BUTTON_GREY: string = '#BEBEBE'; export const TAGGS_GRADIENT = { start: '#9F00FF', diff --git a/src/constants/index.ts b/src/constants/index.ts index a9cfe947..96ed3fb0 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -1,3 +1,4 @@ export * from './api'; export * from './constants'; export * from './regex'; +export * from './badges'; diff --git a/src/routes/main/MainStackNavigator.tsx b/src/routes/main/MainStackNavigator.tsx index 8f2192f1..aeead38d 100644 --- a/src/routes/main/MainStackNavigator.tsx +++ b/src/routes/main/MainStackNavigator.tsx @@ -6,6 +6,7 @@ import {Image} from 'react-native-image-crop-picker'; import { CommentBaseType, MomentType, + ProfilePreviewType, ScreenType, SearchCategoryType, } from '../../types'; @@ -39,6 +40,7 @@ export type MainStackParams = { title: string; image: Image; screenType: ScreenType; + selectedUsers?: ProfilePreviewType[]; }; IndividualMoment: { moment: MomentType; @@ -97,6 +99,14 @@ export type MainStackParams = { ChatList: undefined; Chat: undefined; NewChatModal: undefined; + TagSelectionScreen: { + selectedUsers: ProfilePreviewType[]; + }; + TagFriendsScreen: { + image: Image; + screenType: ScreenType; + selectedUsers?: ProfilePreviewType[]; + }; }; export const MainStack = createStackNavigator<MainStackParams>(); diff --git a/src/routes/main/MainStackScreen.tsx b/src/routes/main/MainStackScreen.tsx index d76f9137..f6a012d6 100644 --- a/src/routes/main/MainStackScreen.tsx +++ b/src/routes/main/MainStackScreen.tsx @@ -32,6 +32,8 @@ import { SuggestedPeopleScreen, SuggestedPeopleUploadPictureScreen, SuggestedPeopleWelcomeScreen, + TagSelectionScreen, + TagFriendsScreen, } from '../../screens'; import MutualBadgeHolders from '../../screens/suggestedPeople/MutualBadgeHolders'; import {ScreenType} from '../../types'; @@ -310,6 +312,20 @@ const MainStackScreen: React.FC<MainStackProps> = ({route}) => { component={NewChatModal} options={{headerShown: false, ...newChatModalStyle}} /> + <MainStack.Screen + name="TagSelectionScreen" + component={TagSelectionScreen} + options={{ + ...headerBarOptions('black', ''), + }} + /> + <MainStack.Screen + name="TagFriendsScreen" + component={TagFriendsScreen} + options={{ + gestureEnabled: false, + }} + /> </MainStack.Navigator> ); }; diff --git a/src/screens/chat/ChatScreen.tsx b/src/screens/chat/ChatScreen.tsx index 17618867..8991d65b 100644 --- a/src/screens/chat/ChatScreen.tsx +++ b/src/screens/chat/ChatScreen.tsx @@ -21,6 +21,7 @@ import { MessageFooter, TypingIndicator, } from '../../components'; +import {TAGG_LIGHT_BLUE_2} from '../../constants/constants'; import {MainStackParams} from '../../routes'; import {ScreenType} from '../../types'; import {HeaderHeight, normalize, SCREEN_WIDTH} from '../../utils'; @@ -38,7 +39,7 @@ const ChatScreen: React.FC<ChatScreenProps> = ({navigation}) => { const insets = useSafeAreaInsets(); const chatTheme: DeepPartial<Theme> = { colors: { - accent_blue: '#6EE7E7', + accent_blue: TAGG_LIGHT_BLUE_2, }, messageList: { container: { diff --git a/src/screens/chat/ChatSearchBar.tsx b/src/screens/chat/ChatSearchBar.tsx index 91018d4c..1c91f493 100644 --- a/src/screens/chat/ChatSearchBar.tsx +++ b/src/screens/chat/ChatSearchBar.tsx @@ -17,6 +17,7 @@ interface SearchBarProps extends TextInputProps { onCancel: () => void; searching: boolean; placeholder: string; + label?: string; } const ChatSearchBar: React.FC<SearchBarProps> = ({ onFocus, @@ -26,6 +27,7 @@ const ChatSearchBar: React.FC<SearchBarProps> = ({ onCancel, onLayout, placeholder, + label, }) => { const handleSubmit = ( e: NativeSyntheticEvent<TextInputSubmitEditingEventData>, @@ -34,14 +36,18 @@ const ChatSearchBar: React.FC<SearchBarProps> = ({ Keyboard.dismiss(); }; + const extraLabelStyle = {paddingLeft: label ? 0 : 10}; + return ( <View style={styles.container} onLayout={onLayout}> <Animated.View style={styles.inputContainer}> - <Animated.View style={styles.searchTextContainer}> - <Text style={styles.searchTextStyes}>To:</Text> - </Animated.View> + {label && ( + <Animated.View style={styles.searchTextContainer}> + <Text style={styles.searchTextStyes}>{label}</Text> + </Animated.View> + )} <TextInput - style={styles.input} + style={[extraLabelStyle, styles.input]} placeholderTextColor={'#828282'} onSubmitEditing={handleSubmit} clearButtonMode="always" diff --git a/src/screens/chat/NewChatModal.tsx b/src/screens/chat/NewChatModal.tsx index 9872dd6f..e57e7f7a 100644 --- a/src/screens/chat/NewChatModal.tsx +++ b/src/screens/chat/NewChatModal.tsx @@ -98,6 +98,7 @@ const NewChatModal: React.FC<NewChatModalProps> = ({ value={query} searching={searching} placeholder={''} + label={'To:'} /> {results.length > 0 && ( <View style={styles.headerContainerStyles}> diff --git a/src/screens/index.ts b/src/screens/index.ts index 44ae4b52..0c7d911f 100644 --- a/src/screens/index.ts +++ b/src/screens/index.ts @@ -6,3 +6,4 @@ export * from './suggestedPeople'; export * from './suggestedPeopleOnboarding'; export * from './badge'; export * from './chat'; +export * from './moments'; diff --git a/src/screens/moments/TagFriendsScreen.tsx b/src/screens/moments/TagFriendsScreen.tsx new file mode 100644 index 00000000..e6a9f5fb --- /dev/null +++ b/src/screens/moments/TagFriendsScreen.tsx @@ -0,0 +1,155 @@ +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 {Button} from 'react-native-elements'; +import {MainStackParams} from 'src/routes'; +import { + CaptionScreenHeader, + MomentTags, + SearchBackground, +} from '../../components'; +import {TagFriendsFooter} from '../../components/moments'; +import {TAGG_LIGHT_BLUE_2} from '../../constants'; +import {ProfilePreviewType} from '../../types'; +import {SCREEN_WIDTH, StatusBarHeight} from '../../utils'; + +type TagFriendsScreenRouteProps = RouteProp< + MainStackParams, + 'TagFriendsScreen' +>; +interface TagFriendsScreenProps { + route: TagFriendsScreenRouteProps; +} +const TagFriendsScreen: React.FC<TagFriendsScreenProps> = ({route}) => { + const {image, selectedUsers} = route.params; + const navigation = useNavigation(); + const imageRef = useRef(null); + const [taggedUsers, setTaggedUsers] = useState<ProfilePreviewType[]>([]); + + /* + * Update list of tagged users from route params + */ + useEffect(() => { + if (selectedUsers !== undefined) { + setTaggedUsers(selectedUsers); + } + }, [selectedUsers]); + + /* + * Navigate back to Tag Users Screen, send selected users + */ + const handleDone = () => { + navigation.navigate('CaptionScreen', { + ...route.params, + selectedUsers: taggedUsers, + }); + }; + + 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> + <CaptionScreenHeader + style={styles.header} + title={'Tap on photo to Tag friends!'} + /> + <Image + ref={imageRef} + style={styles.image} + source={{uri: image.path}} + resizeMode={'cover'} + /> + {taggedUsers.length !== 0 && ( + <MomentTags + editing={true} + tags={taggedUsers.map((user) => ({ + id: '', + x: 0, + y: 0, + user, + }))} + imageRef={imageRef} + deleteFromList={(user) => + setTaggedUsers(taggedUsers.filter((u) => u.id !== user.id)) + } + /> + )} + <View style={{marginHorizontal: '5%', marginTop: '3%'}}> + <TagFriendsFooter + taggedUsers={taggedUsers} + setTaggedUsers={setTaggedUsers} + /> + </View> + </View> + </KeyboardAvoidingView> + </TouchableWithoutFeedback> + </SearchBackground> + ); +}; + +const styles = StyleSheet.create({ + contentContainer: { + paddingTop: StatusBarHeight, + justifyContent: 'flex-end', + }, + buttonsContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + marginLeft: '5%', + marginRight: '5%', + }, + button: { + backgroundColor: 'transparent', + }, + shareButtonTitle: { + fontWeight: 'bold', + color: TAGG_LIGHT_BLUE_2, + }, + header: { + marginVertical: 20, + }, + image: { + position: 'relative', + width: SCREEN_WIDTH, + aspectRatio: 1, + marginBottom: '3%', + }, + text: { + position: 'relative', + backgroundColor: 'white', + width: '100%', + paddingHorizontal: '2%', + paddingVertical: '1%', + height: 60, + }, + flex: { + flex: 1, + }, +}); + +export default TagFriendsScreen; diff --git a/src/screens/moments/TagSelectionScreen.tsx b/src/screens/moments/TagSelectionScreen.tsx new file mode 100644 index 00000000..a698a07b --- /dev/null +++ b/src/screens/moments/TagSelectionScreen.tsx @@ -0,0 +1,168 @@ +import {useBottomTabBarHeight} from '@react-navigation/bottom-tabs'; +import {useNavigation} from '@react-navigation/core'; +import {RouteProp} from '@react-navigation/native'; +import React, {useEffect, useState} from 'react'; +import {StyleSheet, Text, TouchableOpacity, View} from 'react-native'; +import {FlatList} from 'react-native-gesture-handler'; +import BackIcon from '../../assets/icons/back-arrow.svg'; +import {SearchBar, TaggUserSelectionCell} from '../../components'; +import {SEARCH_ENDPOINT_MESSAGES} from '../../constants'; +import {MainStackParams} from '../../routes'; +import {loadSearchResults} from '../../services'; +import {ProfilePreviewType} from '../../types'; +import { + isIPhoneX, + loadTaggUserSuggestions, + normalize, + SCREEN_HEIGHT, + SCREEN_WIDTH, + StatusBarHeight, +} from '../../utils'; + +type TagSelectionScreenRouteProps = RouteProp< + MainStackParams, + 'TagSelectionScreen' +>; +interface TagSelectionScreenProps { + route: TagSelectionScreenRouteProps; +} + +const TagSelectionScreen: React.FC<TagSelectionScreenProps> = ({route}) => { + const navigation = useNavigation(); + const [users, setUsers] = useState<ProfilePreviewType[]>([]); + const [selectedUsers, setSelectedUsers] = useState<ProfilePreviewType[]>( + route.params.selectedUsers, + ); + const [searching, setSearching] = useState(false); + const [query, setQuery] = useState<string>(''); + const [label, setLabel] = useState<string>('Recent'); + const paddingBottom = useBottomTabBarHeight(); + + /* + * Add back button, Send selected users to CaptionScreen + */ + useEffect(() => { + navigation.setOptions({ + headerLeft: () => ( + <TouchableOpacity + onPress={() => { + navigation.navigate('TagFriendsScreen', { + ...route.params, + selectedUsers: selectedUsers, + }); + }}> + <BackIcon + height={normalize(18)} + width={normalize(18)} + color={'black'} + style={styles.backButton} + /> + </TouchableOpacity> + ), + }); + }); + + /* + * Load the initial list users from search/suggested endpoint + * that the loggedInUser might want to select + */ + const loadUsers = async () => { + const data: ProfilePreviewType[] = await loadTaggUserSuggestions(); + const filteredData: ProfilePreviewType[] = data.filter((user) => { + const index = selectedUsers.findIndex((s) => s.id === user.id); + return index === -1; + }); + setUsers([...filteredData, ...selectedUsers]); + }; + + /* + * Load list of users based on search query + */ + const getQuerySuggested = async () => { + if (query.length > 0) { + const searchResults = await loadSearchResults( + `${SEARCH_ENDPOINT_MESSAGES}?query=${query}`, + ); + setUsers(searchResults?.users); + } else { + setUsers([]); + } + }; + + /* + * Make calls to appropriate functions to load user lists for selection + */ + useEffect(() => { + if (query.length === 0) { + setLabel('Recent'); + loadUsers(); + } + + if (!searching) { + return; + } + + if (query.length < 3) { + return; + } + setLabel(''); + getQuerySuggested(); + }, [query]); + + return ( + <View style={styles.container}> + <View style={styles.searchBarContainer}> + <SearchBar + onChangeText={setQuery} + onFocus={() => { + setSearching(true); + }} + value={query} + /> + </View> + {label !== '' && <Text style={styles.title}>{label}</Text>} + {users && ( + <FlatList + data={users} + contentContainerStyle={{paddingBottom: paddingBottom}} + keyExtractor={(item) => item.id} + renderItem={(item) => ( + <TaggUserSelectionCell + key={item.item.id} + item={item.item} + selectedUsers={selectedUsers} + setSelectedUsers={setSelectedUsers} + /> + )} + /> + )} + </View> + ); +}; + +const styles = StyleSheet.create({ + container: { + paddingTop: StatusBarHeight, + backgroundColor: 'white', + height: SCREEN_HEIGHT, + }, + backButton: { + marginLeft: 30, + marginTop: 20, + }, + searchBarContainer: { + width: SCREEN_WIDTH * 0.9, + alignSelf: 'flex-end', + marginTop: isIPhoneX() ? 15 : 12, + marginBottom: '3%', + }, + title: { + fontWeight: '700', + fontSize: normalize(17), + lineHeight: normalize(20.29), + marginHorizontal: '7%', + marginBottom: '2%', + }, +}); + +export default TagSelectionScreen; diff --git a/src/screens/moments/index.ts b/src/screens/moments/index.ts new file mode 100644 index 00000000..aac2ddeb --- /dev/null +++ b/src/screens/moments/index.ts @@ -0,0 +1,2 @@ +export {default as TagSelectionScreen} from './TagSelectionScreen'; +export {default as TagFriendsScreen} from './TagFriendsScreen'; diff --git a/src/screens/onboarding/BasicInfoOnboarding.tsx b/src/screens/onboarding/BasicInfoOnboarding.tsx index 3058a04e..e5e6f59b 100644 --- a/src/screens/onboarding/BasicInfoOnboarding.tsx +++ b/src/screens/onboarding/BasicInfoOnboarding.tsx @@ -27,6 +27,7 @@ import { nameRegex, passwordRegex, phoneRegex, + TAGG_LIGHT_BLUE_2, usernameRegex, } from '../../constants'; import { @@ -70,9 +71,8 @@ const BasicInfoOnboarding: React.FC<BasicInfoOnboardingProps> = ({route}) => { const [invalidWithError, setInvalidWithError] = useState( 'Please enter a valid ', ); - const [autoCapitalize, setAutoCap] = useState< - 'none' | 'sentences' | 'words' | 'characters' | undefined - >('none'); + const [autoCapitalize, setAutoCap] = + useState<'none' | 'sentences' | 'words' | 'characters' | undefined>('none'); const [fadeValue, setFadeValue] = useState<Animated.Value<number>>( new Animated.Value(0), ); @@ -565,7 +565,7 @@ const styles = StyleSheet.create({ alignItems: 'center', }, arrow: { - color: '#6EE7E7', + color: TAGG_LIGHT_BLUE_2, }, showPassContainer: { marginVertical: '1%', diff --git a/src/screens/profile/CaptionScreen.tsx b/src/screens/profile/CaptionScreen.tsx index 56fe672e..188ccc1f 100644 --- a/src/screens/profile/CaptionScreen.tsx +++ b/src/screens/profile/CaptionScreen.tsx @@ -1,6 +1,6 @@ import {RouteProp} from '@react-navigation/native'; import {StackNavigationProp} from '@react-navigation/stack'; -import React, {Fragment, useRef, useState} from 'react'; +import React, {Fragment, useEffect, useState} from 'react'; import { Alert, Image, @@ -8,24 +8,28 @@ import { KeyboardAvoidingView, Platform, StyleSheet, + Text, + TouchableOpacity, TouchableWithoutFeedback, View, } from 'react-native'; import {MentionInput} from 'react-native-controlled-mentions'; -import {Button} from 'react-native-elements'; +import {Button, normalize} from 'react-native-elements'; import {useDispatch, useSelector} from 'react-redux'; -import {MomentTags, SearchBackground} from '../../components'; +import FrontArrow from '../../assets/icons/front-arrow.svg'; +import {SearchBackground} from '../../components'; import {CaptionScreenHeader} from '../../components/'; import TaggLoadingIndicator from '../../components/common/TaggLoadingIndicator'; import {TAGG_LIGHT_BLUE_2} from '../../constants'; import {ERROR_UPLOAD, SUCCESS_PIC_UPLOAD} from '../../constants/strings'; import {MainStackParams} from '../../routes'; -import {postMoment} from '../../services'; +import {postMoment, postMomentTags} from '../../services'; import { loadUserMoments, updateProfileCompletionStage, } from '../../store/actions'; import {RootState} from '../../store/rootReducer'; +import {ProfilePreviewType} from '../../types'; import {SCREEN_WIDTH, StatusBarHeight} from '../../utils'; import {mentionPartTypes} from '../../utils/comments'; @@ -43,41 +47,34 @@ interface CaptionScreenProps { } const CaptionScreen: React.FC<CaptionScreenProps> = ({route, navigation}) => { - const {title, image, screenType} = route.params; + const {title, image, screenType, selectedUsers} = route.params; const { user: {userId}, } = useSelector((state: RootState) => state.user); const dispatch = useDispatch(); const [caption, setCaption] = useState(''); const [loading, setLoading] = useState(false); - const imageRef = useRef(null); + const [taggedUsers, setTaggedUsers] = useState<ProfilePreviewType[]>([]); + const [taggedList, setTaggedList] = useState<string>(''); - const [taggList, setTaggList] = useState([ - { - first_name: 'Ivan', - id: 'cee45bf8-7f3d-43c8-99bb-ec04908efe58', - last_name: 'Chen', - thumbnail_url: - 'https://tagg-dev.s3.us-east-2.amazonaws.com/thumbnails/smallProfilePicture/spp-cee45bf8-7f3d-43c8-99bb-ec04908efe58-thumbnail.jpg', - username: 'ivan.tagg', - }, - { - first_name: 'Ankit', - id: '3bcf6947-bee6-46b0-ad02-6f4d25eaeac3', - last_name: 'Thanekar', - thumbnail_url: - 'https://tagg-dev.s3.us-east-2.amazonaws.com/thumbnails/smallProfilePicture/spp-3bcf6947-bee6-46b0-ad02-6f4d25eaeac3-thumbnail.jpg', - username: 'ankit.thanekar', - }, - { - first_name: 'Ankit', - id: '3bcf6947-bee6-46b0-ad02-6f4d25eaeac3', - last_name: 'Thanekar', - thumbnail_url: - 'https://tagg-dev.s3.us-east-2.amazonaws.com/thumbnails/smallProfilePicture/spp-3bcf6947-bee6-46b0-ad02-6f4d25eaeac3-thumbnail.jpg', - username: 'ankit.thanekar', - }, - ]); + useEffect(() => { + setTaggedUsers(selectedUsers ? selectedUsers : []); + }, [route.params]); + + useEffect(() => { + const getTaggedUsersListString = () => { + let listString = ''; + for (let i = 0; i < taggedUsers.length; i++) { + if (listString.length < 21) { + listString = listString.concat(`@${taggedUsers[i].username} `); + } else { + break; + } + } + setTaggedList(listString); + }; + getTaggedUsersListString(); + }, [taggedUsers]); const navigateToProfile = () => { //Since the logged In User is navigating to own profile, useXId is not required @@ -88,27 +85,51 @@ const CaptionScreen: React.FC<CaptionScreenProps> = ({route, navigation}) => { }; const handleShare = async () => { + const handleFailed = () => { + setLoading(false); + setTimeout(() => { + Alert.alert(ERROR_UPLOAD); + }, 500); + }; + const handleSuccess = () => { + setLoading(false); + navigateToProfile(); + setTimeout(() => { + Alert.alert(SUCCESS_PIC_UPLOAD); + }, 500); + }; setLoading(true); if (!image.filename) { return; } - postMoment(image.filename, image.path, caption, title, userId).then( - (data) => { - setLoading(false); - if (data) { - dispatch(loadUserMoments(userId)); - dispatch(updateProfileCompletionStage(data)); - navigateToProfile(); - setTimeout(() => { - Alert.alert(SUCCESS_PIC_UPLOAD); - }, 500); - } else { - setTimeout(() => { - Alert.alert(ERROR_UPLOAD); - }, 500); - } - }, + const momentResponse = await postMoment( + image.filename, + image.path, + caption, + title, + userId, + ); + if (!momentResponse) { + handleFailed(); + return; + } + const momentTagResponse = await postMomentTags( + momentResponse.moment_id, + taggedUsers.map((u, index) => ({ + x: index * 50 - 150, + y: index * 50 - 150, + user_id: u.id, + })), ); + if (!momentTagResponse) { + handleFailed(); + return; + } + dispatch(loadUserMoments(userId)); + dispatch( + updateProfileCompletionStage(momentResponse.profile_completion_stage), + ); + handleSuccess(); }; return ( @@ -135,7 +156,6 @@ const CaptionScreen: React.FC<CaptionScreenProps> = ({route, navigation}) => { <CaptionScreenHeader style={styles.header} {...{title: title}} /> {/* this is the image we want to center our tags' initial location within */} <Image - ref={imageRef} style={styles.image} source={{uri: image.path}} resizeMode={'cover'} @@ -148,19 +168,41 @@ const CaptionScreen: React.FC<CaptionScreenProps> = ({route, navigation}) => { onChange={setCaption} partTypes={mentionPartTypes('blue')} /> - <MomentTags - editing={true} - tags={taggList.map((user) => ({ - id: '', - x: 0, - y: 0, - user, - }))} - imageRef={imageRef} - deleteFromList={(user) => - setTaggList(taggList.filter((u) => u.id !== user.id)) + <TouchableOpacity + onPress={() => + navigation.navigate('TagFriendsScreen', { + image: image, + screenType: screenType, + selectedUsers: taggedUsers, + }) } - /> + style={{ + marginHorizontal: '5%', + marginTop: '3%', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }}> + <Image + source={require('../../assets/icons/tagging/tag-icon.png')} + style={{width: 20, height: 20}} + /> + <Text style={styles.tagFriendsTitle}>Tag Friends</Text> + <Text + numberOfLines={1} + style={{ + color: 'white', + width: 150, + fontSize: normalize(10), + lineHeight: normalize(11), + letterSpacing: normalize(0.3), + textAlign: 'right', + }}> + {taggedList} + {taggedList.length > 21 ? '. . .' : ''} + </Text> + <FrontArrow width={12} height={12} color={'white'} /> + </TouchableOpacity> </View> </KeyboardAvoidingView> </TouchableWithoutFeedback> @@ -205,6 +247,17 @@ const styles = StyleSheet.create({ flex: { flex: 1, }, + tagFriendsTitle: { + color: 'white', + fontSize: normalize(12), + lineHeight: normalize(16.71), + letterSpacing: normalize(0.3), + fontWeight: '600', + }, + tagFriendsContainer: { + flexDirection: 'row', + marginTop: '3%', + }, }); export default CaptionScreen; diff --git a/src/screens/profile/InviteFriendsScreen.tsx b/src/screens/profile/InviteFriendsScreen.tsx index 4f6319f7..89f2e62f 100644 --- a/src/screens/profile/InviteFriendsScreen.tsx +++ b/src/screens/profile/InviteFriendsScreen.tsx @@ -9,14 +9,12 @@ import { StatusBar, StyleSheet, Text, - TextInput, TouchableWithoutFeedback, View, } from 'react-native'; import {checkPermission} from 'react-native-contacts'; -import Animated from 'react-native-reanimated'; -import Icon from 'react-native-vector-icons/Feather'; -import {TabsGradient} from '../../components'; +import {TAGG_LIGHT_BLUE} from '../../constants'; +import {SearchBar, TabsGradient} from '../../components'; import {InviteFriendTile} from '../../components/friends'; import {headerBarOptions} from '../../routes'; import { @@ -33,7 +31,6 @@ import { SCREEN_WIDTH, StatusBarHeight, } from '../../utils'; -const AnimatedIcon = Animated.createAnimatedComponent(Icon); export type InviteContactType = { firstName: string; @@ -193,32 +190,13 @@ const InviteFriendsScreen: React.FC = () => { </Text> </View> <View style={styles.container}> - <Animated.View style={styles.inputContainer}> - <AnimatedIcon - name="search" - color={'#7E7E7E'} - size={16} - style={styles.searchIcon} - /> - <TextInput - style={[styles.input]} - placeholderTextColor={'#828282'} - clearButtonMode="while-editing" - autoCapitalize="none" - autoCorrect={false} - onChangeText={(text) => { - setQuery(text.toLowerCase()); - }} - onBlur={() => { - Keyboard.dismiss(); - }} - onEndEditing={() => { - Keyboard.dismiss(); - }} - value={query} - placeholder={'Search'} - /> - </Animated.View> + <SearchBar + onChangeText={setQuery} + onBlur={() => { + Keyboard.dismiss(); + }} + value={query} + /> </View> <View style={[ @@ -278,43 +256,44 @@ const styles = StyleSheet.create({ marginBottom: '5%', }, container: { + width: '100%', + height: normalize(42), + marginBottom: '3%', + }, + ppContainer: { alignSelf: 'center', flexDirection: 'row', justifyContent: 'space-between', width: '100%', height: normalize(42), alignItems: 'center', - marginBottom: '3%', + marginBottom: '5%', marginHorizontal: 10, }, - inputContainer: { - flexGrow: 1, - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 8, - marginHorizontal: '3%', - borderRadius: 20, - backgroundColor: '#F0F0F0', - height: 34, - }, - searchIcon: { - marginRight: '5%', - }, - input: { - flex: 1, - fontSize: normalize(16), - color: '#000', - letterSpacing: normalize(0.5), - }, - cancelButton: { + friend: { + alignSelf: 'center', height: '100%', - position: 'absolute', + }, + addFriendButton: { + alignSelf: 'center', justifyContent: 'center', - paddingHorizontal: 8, + alignItems: 'center', + width: 82, + height: 25, + borderColor: TAGG_LIGHT_BLUE, + borderWidth: 2, + borderRadius: 2, + padding: 0, + backgroundColor: TAGG_LIGHT_BLUE, }, - cancelText: { - color: '#818181', - fontWeight: '500', + addFriendButtonTitle: { + color: 'white', + padding: 0, + fontSize: normalize(11), + fontWeight: '700', + lineHeight: normalize(13.13), + letterSpacing: normalize(0.6), + paddingHorizontal: '3.8%', }, }); diff --git a/src/services/MomentService.ts b/src/services/MomentService.ts index a26a1abb..46b55066 100644 --- a/src/services/MomentService.ts +++ b/src/services/MomentService.ts @@ -2,24 +2,19 @@ import AsyncStorage from '@react-native-community/async-storage'; import RNFetchBlob from 'rn-fetch-blob'; import { MOMENTS_ENDPOINT, + MOMENTTAG_ENDPOINT, MOMENT_TAGS_ENDPOINT, MOMENT_THUMBNAIL_ENDPOINT, } from '../constants'; import {MomentTagType, MomentType} from '../types'; import {checkImageUploadStatus} from '../utils'; -export const postMoment: ( +export const postMoment = async ( fileName: string, uri: string, caption: string, category: string, userId: string, -) => Promise<number | undefined> = async ( - fileName, - uri, - caption, - category, - userId, ) => { try { const request = new FormData(); @@ -45,9 +40,13 @@ export const postMoment: ( body: request, }); let statusCode = response.status; - let data = await response.json(); + let data: { + moments: any; + moment_id: string; + profile_completion_stage: number; + } = await response.json(); if (statusCode === 200 && checkImageUploadStatus(data.moments)) { - return data.profile_completion_stage; + return data; } } catch (err) { console.log(err); @@ -142,3 +141,33 @@ export const loadMomentTags = async (moment_id: string) => { return []; } }; + +export const postMomentTags = async ( + moment_id: string, + tags: { + x: number; + y: number; + user_id: string; + }[], +) => { + try { + const token = await AsyncStorage.getItem('token'); + const form = new FormData(); + form.append('moment_id', moment_id); + form.append('tags', JSON.stringify(tags)); + const response = await fetch( + MOMENTTAG_ENDPOINT + `?moment_id=${moment_id}`, + { + method: 'POST', + headers: { + Authorization: 'Token ' + token, + }, + body: form, + }, + ); + return response.status === 201 || response.status === 200; + } catch (error) { + console.error(error); + return false; + } +}; diff --git a/src/types/types.ts b/src/types/types.ts index 1b4b7ecf..e957483b 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -150,7 +150,8 @@ export type PreviewType = | 'Discover Users' | 'Friend' | 'Suggested People Drawer' - | 'Suggested People Screen'; + | 'Suggested People Screen' + | 'Tag Selection'; export enum ScreenType { Profile, diff --git a/src/utils/search.ts b/src/utils/search.ts index 26f40b1b..789acbc3 100644 --- a/src/utils/search.ts +++ b/src/utils/search.ts @@ -1,6 +1,7 @@ import AsyncStorage from '@react-native-community/async-storage'; +import {loadSearchResults} from '../services'; -import {BADGE_DATA} from '../constants/badges'; +import {BADGE_DATA, SEARCH_ENDPOINT_SUGGESTED} from '../constants'; import { ProfilePreviewType, CategoryPreviewType, @@ -138,3 +139,13 @@ export const getRecentlySearchedCategories = async (): Promise< } return []; }; + +/* + * Retrieves and returns a list of suggested tagg users + */ +export const loadTaggUserSuggestions = async (): Promise< + ProfilePreviewType[] +> => { + const searchResults = await loadSearchResults(`${SEARCH_ENDPOINT_SUGGESTED}`); + return searchResults?.users; +}; |