diff options
Diffstat (limited to 'src')
68 files changed, 1903 insertions, 664 deletions
diff --git a/src/App.tsx b/src/App.tsx index ea3617dc..b8d64461 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,18 +1,48 @@ import {NavigationContainer} from '@react-navigation/native'; -import React from 'react'; +import React, {useState} from 'react'; import {Provider} from 'react-redux'; +import {StreamChat} from 'stream-chat'; +import {OverlayProvider} from 'stream-chat-react-native'; +import {STREAM_CHAT_API} from './constants'; import {navigationRef} from './RootNavigation'; import Routes from './routes'; import store from './store/configureStore'; +import { + ChannelGroupedType, + ChatContextType, + LocalAttachmentType, + LocalChannelType, + LocalCommandType, + LocalEventType, + LocalMessageType, + LocalResponseType, + LocalUserType, +} from './types'; + +export const ChatContext = React.createContext({} as ChatContextType); const App = () => { + const [channel, setChannel] = useState<ChannelGroupedType>(); + const chatClient = StreamChat.getInstance< + LocalAttachmentType, + LocalChannelType, + LocalCommandType, + LocalEventType, + LocalMessageType, + LocalResponseType, + LocalUserType + >(STREAM_CHAT_API); return ( /** * This is the provider from the redux store, it acts as the root provider for our application */ <Provider store={store}> <NavigationContainer ref={navigationRef}> - <Routes /> + <ChatContext.Provider value={{channel, setChannel, chatClient}}> + <OverlayProvider> + <Routes /> + </OverlayProvider> + </ChatContext.Provider> </NavigationContainer> </Provider> ); diff --git a/src/assets/icons/compose.svg b/src/assets/icons/compose.svg new file mode 100644 index 00000000..062e08cf --- /dev/null +++ b/src/assets/icons/compose.svg @@ -0,0 +1 @@ +<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 720"><defs><style>.cls-1,.cls-2,.cls-3{fill:none;}.cls-1,.cls-2{stroke:#828282;stroke-linecap:round;stroke-width:48px;}.cls-1,.cls-3{stroke-miterlimit:10;}.cls-2{stroke-linejoin:round;}.cls-3{stroke:#5c5b5e;stroke-width:0.91px;}</style></defs><path class="cls-1" d="M607.16,358.14V584.69c0,41.63-38,75.36-85,75.36H122.42c-46.91,0-84.93-33.73-84.93-75.36V168c0-41.62,38-75.4,84.93-75.4H412.23"/><path class="cls-2" d="M672.13,165.09l-289,290.8-52.62,19.66-81.11,30.28,27.77-83.35,17.06-51.26a2,2,0,0,1,.28-.32L585.59,78a60.89,60.89,0,0,1,86.54,0A61.87,61.87,0,0,1,672.13,165.09Z"/><line class="cls-3" x1="280.02" y1="283.62" x2="279.34" y2="283.87"/></svg>
\ No newline at end of file diff --git a/src/assets/images/cover-placeholder@2x.png b/src/assets/images/cover-placeholder@2x.png Binary files differindex 402ac1fe..70294346 100644 --- a/src/assets/images/cover-placeholder@2x.png +++ b/src/assets/images/cover-placeholder@2x.png diff --git a/src/assets/images/cover-placeholder@3x.png b/src/assets/images/cover-placeholder@3x.png Binary files differindex be87023d..66fa3ce1 100644 --- a/src/assets/images/cover-placeholder@3x.png +++ b/src/assets/images/cover-placeholder@3x.png diff --git a/src/assets/navigationIcons/chat-clicked.png b/src/assets/navigationIcons/chat-clicked.png Binary files differnew file mode 100644 index 00000000..f62b4cf5 --- /dev/null +++ b/src/assets/navigationIcons/chat-clicked.png diff --git a/src/assets/navigationIcons/chat-clicked@2x.png b/src/assets/navigationIcons/chat-clicked@2x.png Binary files differnew file mode 100644 index 00000000..4ce0f46a --- /dev/null +++ b/src/assets/navigationIcons/chat-clicked@2x.png diff --git a/src/assets/navigationIcons/chat-clicked@3x.png b/src/assets/navigationIcons/chat-clicked@3x.png Binary files differnew file mode 100644 index 00000000..bd3a1352 --- /dev/null +++ b/src/assets/navigationIcons/chat-clicked@3x.png diff --git a/src/assets/navigationIcons/chat-notifications.png b/src/assets/navigationIcons/chat-notifications.png Binary files differnew file mode 100644 index 00000000..cffb5751 --- /dev/null +++ b/src/assets/navigationIcons/chat-notifications.png diff --git a/src/assets/navigationIcons/chat-notifications@2x.png b/src/assets/navigationIcons/chat-notifications@2x.png Binary files differnew file mode 100644 index 00000000..22ae62db --- /dev/null +++ b/src/assets/navigationIcons/chat-notifications@2x.png diff --git a/src/assets/navigationIcons/chat-notifications@3x.png b/src/assets/navigationIcons/chat-notifications@3x.png Binary files differnew file mode 100644 index 00000000..98b1073d --- /dev/null +++ b/src/assets/navigationIcons/chat-notifications@3x.png diff --git a/src/assets/navigationIcons/chat.png b/src/assets/navigationIcons/chat.png Binary files differnew file mode 100644 index 00000000..cffb5751 --- /dev/null +++ b/src/assets/navigationIcons/chat.png diff --git a/src/assets/navigationIcons/chat@2x.png b/src/assets/navigationIcons/chat@2x.png Binary files differnew file mode 100644 index 00000000..22ae62db --- /dev/null +++ b/src/assets/navigationIcons/chat@2x.png diff --git a/src/assets/navigationIcons/chat@3x.png b/src/assets/navigationIcons/chat@3x.png Binary files differnew file mode 100644 index 00000000..98b1073d --- /dev/null +++ b/src/assets/navigationIcons/chat@3x.png diff --git a/src/components/common/BottomDrawer.tsx b/src/components/common/BottomDrawer.tsx index bef9434a..988c1e79 100644 --- a/src/components/common/BottomDrawer.tsx +++ b/src/components/common/BottomDrawer.tsx @@ -6,7 +6,7 @@ import { View, ViewProps, } from 'react-native'; -import Animated from 'react-native-reanimated'; +import Animated, {interpolateColors} from 'react-native-reanimated'; import BottomSheet from 'reanimated-bottom-sheet'; import {SCREEN_HEIGHT, SCREEN_WIDTH} from '../../utils'; @@ -50,6 +50,10 @@ const BottomDrawer: React.FC<BottomDrawerProps> = (props) => { ); }; + const backgroundColor = interpolateColors(bgAlpha, { + inputRange: [0, 1], + outputColorRange: ['rgba(0,0,0,0.3)', 'rgba(0,0,0,0)'], + }); return ( <Modal transparent @@ -75,17 +79,7 @@ const BottomDrawer: React.FC<BottomDrawerProps> = (props) => { onPress={() => { setIsOpen(false); }}> - <Animated.View - style={[ - styles.backgroundView, - { - backgroundColor: Animated.interpolateColors(bgAlpha, { - inputRange: [0, 1], - outputColorRange: ['rgba(0,0,0,0.3)', 'rgba(0,0,0,0)'], - }), - }, - ]} - /> + <Animated.View style={[styles.backgroundView, {backgroundColor}]} /> </TouchableWithoutFeedback> </Modal> ); diff --git a/src/components/common/GradientBorderButton.tsx b/src/components/common/GradientBorderButton.tsx index 32ac5c52..a5dbde9d 100644 --- a/src/components/common/GradientBorderButton.tsx +++ b/src/components/common/GradientBorderButton.tsx @@ -42,7 +42,7 @@ const GradientBorderButton: React.FC<GradientBorderButtonProps> = ({ }; const styles = StyleSheet.create({ container: { - marginVertical: 15, + marginVertical: 10, }, gradientContainer: { width: SCREEN_WIDTH / 2 - 40, diff --git a/src/components/common/NavigationIcon.tsx b/src/components/common/NavigationIcon.tsx index 1a9934f2..5128f3da 100644 --- a/src/components/common/NavigationIcon.tsx +++ b/src/components/common/NavigationIcon.tsx @@ -14,7 +14,8 @@ interface NavigationIconProps extends TouchableOpacityProps { | 'Upload' | 'Notifications' | 'Profile' - | 'SuggestedPeople'; + | 'SuggestedPeople' + | 'Chat'; disabled?: boolean; newIcon?: boolean; } @@ -44,6 +45,13 @@ const NavigationIcon = (props: NavigationIconProps) => { : require('../../assets/navigationIcons/notifications.png') : require('../../assets/navigationIcons/notifications-clicked.png'); break; + case 'Chat': + imgSrc = props.disabled + ? props.newIcon + ? require('../../assets/navigationIcons/chat-notifications.png') + : require('../../assets/navigationIcons/chat.png') + : require('../../assets/navigationIcons/chat-clicked.png'); + break; case 'Profile': imgSrc = props.disabled ? require('../../assets/navigationIcons/profile.png') diff --git a/src/components/common/TabsGradient.tsx b/src/components/common/TabsGradient.tsx index a95e8bc3..07c55042 100644 --- a/src/components/common/TabsGradient.tsx +++ b/src/components/common/TabsGradient.tsx @@ -14,7 +14,7 @@ const TabsGradient: React.FC = () => { }; const styles = StyleSheet.create({ gradient: { - position: 'absolute', + ...StyleSheet.absoluteFillObject, top: (SCREEN_HEIGHT / 10) * 9, height: SCREEN_HEIGHT / 10, width: SCREEN_WIDTH, diff --git a/src/components/common/TaggPrompt.tsx b/src/components/common/TaggPrompt.tsx index 721b1eb8..5e125d00 100644 --- a/src/components/common/TaggPrompt.tsx +++ b/src/components/common/TaggPrompt.tsx @@ -68,7 +68,7 @@ const styles = StyleSheet.create({ justifyContent: 'center', alignItems: 'center', backgroundColor: 'white', - height: isIPhoneX() ? SCREEN_HEIGHT / 6 : SCREEN_HEIGHT / 5, + height: SCREEN_HEIGHT / 4, }, closeButton: { position: 'relative', diff --git a/src/components/index.ts b/src/components/index.ts index d5649323..47dc583b 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -6,3 +6,4 @@ export * from './taggs'; export * from './comments'; export * from './moments'; export * from './suggestedPeople'; +export * from './messages'; diff --git a/src/components/messages/ChannelPreview.tsx b/src/components/messages/ChannelPreview.tsx new file mode 100644 index 00000000..312f879a --- /dev/null +++ b/src/components/messages/ChannelPreview.tsx @@ -0,0 +1,131 @@ +import {useNavigation} from '@react-navigation/core'; +import React, {useContext} from 'react'; +import {Image, StyleSheet, Text, View} from 'react-native'; +import {TouchableOpacity} from 'react-native-gesture-handler'; +import {useStore} from 'react-redux'; +import {usernameRegex} from 'src/constants'; +import {ChannelPreviewMessengerProps} from 'stream-chat-react-native'; +import {ChatContext} from '../../App'; +import { + LocalAttachmentType, + LocalChannelType, + LocalCommandType, + LocalEventType, + LocalMessageType, + LocalReactionType, + LocalUserType, +} from '../../types'; +import {normalize, SCREEN_HEIGHT, SCREEN_WIDTH} from '../../utils'; +import {getMember, isOnline} from '../../utils/messages'; + +const ChannelPreview: React.FC<ChannelPreviewMessengerProps< + LocalAttachmentType, + LocalChannelType, + LocalCommandType, + LocalEventType, + LocalMessageType, + LocalReactionType, + LocalUserType +>> = (props) => { + const {setChannel} = useContext(ChatContext); + const state = useStore().getState(); + const navigation = useNavigation(); + const {channel} = props; + const member = getMember(channel, state); + const online = isOnline(member?.user?.last_active); + const unread = channel.state.unreadCount > 0; + + return ( + <TouchableOpacity + style={styles.container} + onPress={() => { + setChannel(channel); + navigation.navigate('Chat'); + }}> + <View> + <Image + style={styles.avatar} + source={ + member + ? {uri: member.user?.thumbnail_url} + : require('../../assets/images/avatar-placeholder.png') + } + /> + {online && <View style={styles.online} />} + </View> + <View style={styles.content}> + <Text + style={[styles.name, unread ? styles.unread : {}]} + numberOfLines={1}> + {member?.user?.first_name} {member?.user?.last_name} + </Text> + <Text + style={[styles.lastMessage, unread ? styles.unread : {}]} + numberOfLines={1}> + {channel.state.messages.length > 0 + ? channel.state.messages[channel.state.messages.length - 1].text + : ''} + </Text> + </View> + {unread && <View style={styles.purpleDot} />} + </TouchableOpacity> + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + flexDirection: 'row', + height: Math.round(SCREEN_HEIGHT / 9), + width: Math.round(SCREEN_WIDTH * 0.85), + alignSelf: 'center', + alignItems: 'center', + }, + avatar: { + width: normalize(60), + height: normalize(60), + borderRadius: normalize(62) / 2, + }, + online: { + position: 'absolute', + backgroundColor: '#6EE7E7', + width: normalize(18), + height: normalize(18), + borderRadius: normalize(18) / 2, + borderColor: 'white', + borderWidth: 2, + bottom: 0, + right: 0, + }, + content: { + flex: 1, + height: '60%', + flexDirection: 'column', + marginLeft: '5%', + }, + name: { + fontWeight: '500', + fontSize: normalize(14), + lineHeight: normalize(17), + }, + lastMessage: { + color: '#828282', + fontWeight: '500', + fontSize: normalize(12), + lineHeight: normalize(14), + paddingTop: '5%', + }, + unread: { + fontWeight: '700', + color: 'black', + }, + purpleDot: { + backgroundColor: '#8F01FF', + width: normalize(10), + height: normalize(10), + borderRadius: normalize(10) / 2, + marginLeft: '5%', + }, +}); + +export default ChannelPreview; diff --git a/src/components/messages/ChatHeader.tsx b/src/components/messages/ChatHeader.tsx new file mode 100644 index 00000000..2bc096ec --- /dev/null +++ b/src/components/messages/ChatHeader.tsx @@ -0,0 +1,84 @@ +import React, {useContext} from 'react'; +import {Image, StyleSheet, View} from 'react-native'; +import {Text} from 'react-native-animatable'; +import {useStore} from 'react-redux'; +import {ChatContext} from '../../App'; +import {ChatHeaderHeight, normalize, StatusBarHeight} from '../../utils'; +import {formatLastSeenText, getMember, isOnline} from '../../utils/messages'; + +type ChatHeaderProps = {}; + +const ChatHeader: React.FC<ChatHeaderProps> = () => { + const {channel} = useContext(ChatContext); + const state = useStore().getState(); + const member = getMember(channel, state); + const online = isOnline(member?.user?.last_active); + const lastSeen = formatLastSeenText(member?.user?.last_active); + + return ( + <View style={styles.container}> + <View> + <Image + style={styles.avatar} + source={ + member + ? {uri: member.user?.thumbnail_url} + : require('../../assets/images/avatar-placeholder.png') + } + /> + {online && <View style={styles.online} />} + </View> + <View style={styles.content}> + <Text style={styles.name} numberOfLines={1}> + {member?.user?.first_name} {member?.user?.last_name} + </Text> + <Text style={styles.lastSeen}>{lastSeen}</Text> + </View> + </View> + ); +}; + +const styles = StyleSheet.create({ + container: { + height: ChatHeaderHeight - StatusBarHeight, + flexDirection: 'row', + alignItems: 'center', + paddingLeft: '15%', + }, + avatar: { + width: normalize(40), + height: normalize(40), + borderRadius: normalize(40) / 2, + }, + online: { + position: 'absolute', + backgroundColor: '#6EE7E7', + width: normalize(16), + height: normalize(16), + borderRadius: normalize(16) / 2, + borderColor: 'white', + borderWidth: 3, + top: 0, + right: 0, + }, + content: { + flex: 1, + height: '80%', + justifyContent: 'space-between', + flexDirection: 'column', + marginLeft: '5%', + }, + name: { + fontWeight: '700', + fontSize: normalize(15), + lineHeight: normalize(18), + }, + lastSeen: { + color: '#828282', + fontWeight: '500', + fontSize: normalize(12), + lineHeight: normalize(14), + }, +}); + +export default ChatHeader; diff --git a/src/components/messages/MessagesHeader.tsx b/src/components/messages/MessagesHeader.tsx new file mode 100644 index 00000000..660da97d --- /dev/null +++ b/src/components/messages/MessagesHeader.tsx @@ -0,0 +1,59 @@ +import React, {Fragment, useContext} from 'react'; +import {StyleSheet, View} from 'react-native'; +import {Text} from 'react-native-animatable'; +import {TouchableOpacity} from 'react-native-gesture-handler'; +import {normalize} from '../../utils'; +import ComposeIcon from '../../assets/icons/compose.svg'; +import {ChatContext} from '../../App'; + +type MessagesHeaderProps = { + createChannel: () => void; +}; + +const MessagesHeader: React.FC<MessagesHeaderProps> = ({createChannel}) => { + const {chatClient} = useContext(ChatContext); + const unread = chatClient.user?.total_unread_count as number; + return ( + <View style={styles.header}> + <Text style={styles.headerText}>Messages</Text> + {unread && unread !== 0 ? ( + <Text style={styles.unreadText}> + {unread > 99 ? '99+' : unread} unread + </Text> + ) : ( + <Fragment /> + )} + <View style={styles.flex} /> + <TouchableOpacity style={styles.compose} onPress={createChannel}> + <ComposeIcon width={normalize(20)} height={normalize(20)} /> + </TouchableOpacity> + </View> + ); +}; + +const styles = StyleSheet.create({ + flex: { + flex: 1, + }, + header: { + marginHorizontal: '8%', + marginTop: '5%', + alignItems: 'center', + flexDirection: 'row', + }, + headerText: { + fontWeight: '700', + fontSize: normalize(18), + lineHeight: normalize(21), + }, + unreadText: { + color: '#8F01FF', + marginLeft: 10, + fontWeight: '700', + lineHeight: normalize(17), + fontSize: normalize(14), + }, + compose: {}, +}); + +export default MessagesHeader; diff --git a/src/components/messages/index.ts b/src/components/messages/index.ts new file mode 100644 index 00000000..e194093c --- /dev/null +++ b/src/components/messages/index.ts @@ -0,0 +1,2 @@ +export {default as MessagesHeader} from './MessagesHeader'; +export {default as ChannelPreview} from './ChannelPreview'; diff --git a/src/components/profile/Content.tsx b/src/components/profile/Content.tsx index 9c33eabc..05098d14 100644 --- a/src/components/profile/Content.tsx +++ b/src/components/profile/Content.tsx @@ -1,14 +1,10 @@ import React, {useCallback, useEffect, useRef, useState} from 'react'; -import { - LayoutChangeEvent, - NativeScrollEvent, - NativeSyntheticEvent, - RefreshControl, - StyleSheet, -} from 'react-native'; -import Animated from 'react-native-reanimated'; +import {LayoutChangeEvent, RefreshControl, StyleSheet} from 'react-native'; +import Animated, { + useSharedValue, + useAnimatedScrollHandler, +} from 'react-native-reanimated'; import {useDispatch, useSelector, useStore} from 'react-redux'; -import {COVER_HEIGHT} from '../../constants'; import { blockUnblockUser, loadFriendsData, @@ -20,12 +16,11 @@ import { NO_USER, } from '../../store/initialStates'; import {RootState} from '../../store/rootreducer'; -import {ContentProps} from '../../types'; +import {ScreenType} from '../../types'; import { canViewProfile, fetchUserX, getUserAsProfilePreviewType, - SCREEN_HEIGHT, userLogin, } from '../../utils'; import TaggsBar from '../taggs/TaggsBar'; @@ -35,8 +30,13 @@ import ProfileBody from './ProfileBody'; import ProfileCutout from './ProfileCutout'; import ProfileHeader from './ProfileHeader'; import PublicProfile from './PublicProfile'; +import {useScrollToTop} from '@react-navigation/native'; -const Content: React.FC<ContentProps> = ({y, userXId, screenType}) => { +interface ContentProps { + userXId: string | undefined; + screenType: ScreenType; +} +const Content: React.FC<ContentProps> = ({userXId, screenType}) => { const dispatch = useDispatch(); const { user = NO_USER, @@ -60,12 +60,14 @@ const Content: React.FC<ContentProps> = ({y, userXId, screenType}) => { * If scrolling is enabled. Set to false before scrolling up for the tutorial. */ const [scrollEnabled, setScrollEnabled] = useState<boolean>(true); + const y = useSharedValue<number>(0); /** * States */ const [isBlocked, setIsBlocked] = useState<boolean>(false); const [profileBodyHeight, setProfileBodyHeight] = useState(0); + const [socialsBarHeight, setSocialsBarHeight] = useState(0); const [shouldBounce, setShouldBounce] = useState<boolean>(true); const [refreshing, setRefreshing] = useState<boolean>(false); @@ -88,6 +90,11 @@ const Content: React.FC<ContentProps> = ({y, userXId, screenType}) => { setProfileBodyHeight(height); }; + const onSocialsBarLayout = (e: LayoutChangeEvent) => { + const {height} = e.nativeEvent.layout; + setSocialsBarHeight(height); + }; + useEffect(() => { const isActuallyBlocked = blockedUsers.some( (cur_user) => user.username === cur_user.username, @@ -103,45 +110,32 @@ const Content: React.FC<ContentProps> = ({y, userXId, screenType}) => { * updateUserXFriends updates friends list for the user. */ const handleBlockUnblock = async (callback?: () => void) => { - await dispatch( + dispatch( blockUnblockUser( loggedInUser, getUserAsProfilePreviewType(user, profile), isBlocked, ), ); - await dispatch(loadFriendsData(loggedInUser.userId)); - await dispatch(updateUserXFriends(user.userId, state)); + dispatch(loadFriendsData(loggedInUser.userId)); + dispatch(updateUserXFriends(user.userId, state)); if (callback) { callback(); } }; - const handleScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => { - /** - * Set the new y position - */ - const newY = e.nativeEvent.contentOffset.y; - y.setValue(newY); + const scrollHandler = useAnimatedScrollHandler((event) => { + y.value = event.contentOffset.y; + }); - /** - * Do not allow overflow of scroll on bottom of the screen - * SCREEN_HEIGHT - COVER_HEIGHT = Height of the scroll view - */ - if (newY >= SCREEN_HEIGHT - COVER_HEIGHT) { - setShouldBounce(false); - } else if (newY === 0) { - setShouldBounce(true); - } - }; + useScrollToTop(scrollViewRef); return ( <Animated.ScrollView ref={scrollViewRef} contentContainerStyle={styles.contentContainer} style={styles.container} - onScroll={(e) => handleScroll(e)} - bounces={shouldBounce} + onScroll={scrollHandler} showsVerticalScrollIndicator={false} scrollEventThrottle={1} stickyHeaderIndices={[4]} @@ -165,7 +159,7 @@ const Content: React.FC<ContentProps> = ({y, userXId, screenType}) => { /> <TaggsBar {...{y, profileBodyHeight, userXId, screenType}} - whiteRing={undefined} + onLayout={onSocialsBarLayout} /> {canViewProfile(state, userXId, screenType) ? ( <PublicProfile @@ -175,6 +169,7 @@ const Content: React.FC<ContentProps> = ({y, userXId, screenType}) => { screenType, setScrollEnabled, profileBodyHeight, + socialsBarHeight, scrollViewRef, }} /> diff --git a/src/components/profile/Cover.tsx b/src/components/profile/Cover.tsx index ee804ff3..27777b64 100644 --- a/src/components/profile/Cover.tsx +++ b/src/components/profile/Cover.tsx @@ -27,7 +27,7 @@ const Cover: React.FC<CoverProps> = ({userXId, screenType}) => { const styles = StyleSheet.create({ container: { - position: 'absolute', + ...StyleSheet.absoluteFillObject, }, image: { width: IMAGE_WIDTH, diff --git a/src/components/profile/Friends.tsx b/src/components/profile/Friends.tsx index 44f6bb48..c1dca755 100644 --- a/src/components/profile/Friends.tsx +++ b/src/components/profile/Friends.tsx @@ -27,7 +27,7 @@ interface FriendsProps { const Friends: React.FC<FriendsProps> = ({result, screenType, userId}) => { const state: RootState = useStore().getState(); const dispatch = useDispatch(); - const {user: loggedInUser = NO_USER} = state; + const {user: loggedInUser = NO_USER} = state.user; const navigation = useNavigation(); const [usersFromContacts, setUsersFromContacts] = useState< ProfilePreviewType[] @@ -39,7 +39,7 @@ const Friends: React.FC<FriendsProps> = ({result, screenType, userId}) => { const permission = await checkPermission(); if (permission === 'authorized') { let response = await usersFromContactsService(contacts); - await setUsersFromContacts(response.existing_tagg_users); + setUsersFromContacts(response.existing_tagg_users); } else { console.log('Authorize access to contacts'); } @@ -84,10 +84,10 @@ const Friends: React.FC<FriendsProps> = ({result, screenType, userId}) => { return ( <> - {loggedInUser.userId === userId && ( + {loggedInUser.userId === userId && usersFromContacts.length !== 0 && ( <View style={styles.subheader}> <View style={styles.addFriendHeaderContainer}> - <Text style={[styles.subheaderText]}>Contacts on tagg</Text> + <Text style={[styles.subheaderText]}>Contacts on Tagg</Text> </View> <UsersFromContacts /> </View> diff --git a/src/components/profile/PublicProfile.tsx b/src/components/profile/PublicProfile.tsx index 88e0ecd1..1c49bff5 100644 --- a/src/components/profile/PublicProfile.tsx +++ b/src/components/profile/PublicProfile.tsx @@ -35,6 +35,7 @@ const PublicProfile: React.FC<ContentProps> = ({ screenType, setScrollEnabled, profileBodyHeight, + socialsBarHeight, scrollViewRef, }) => { const dispatch = useDispatch(); @@ -99,11 +100,12 @@ const PublicProfile: React.FC<ContentProps> = ({ scrollViewRef.current ) { setScrollEnabled(false); - scrollViewRef.current.getNode().scrollTo({y: 0}); + scrollViewRef.current.scrollTo({y: 0}); navigation.navigate('MomentUploadPrompt', { screenType, momentCategory: momentCategories[0], profileBodyHeight, + socialsBarHeight, }); setIsStageOnePromptClosed(true); } @@ -133,6 +135,7 @@ const PublicProfile: React.FC<ContentProps> = ({ navigation, screenType, profileBodyHeight, + socialsBarHeight, scrollViewRef, ]), ); diff --git a/src/components/search/RecentSearches.tsx b/src/components/search/RecentSearches.tsx index 84d35cac..6cea9338 100644 --- a/src/components/search/RecentSearches.tsx +++ b/src/components/search/RecentSearches.tsx @@ -6,6 +6,7 @@ import { StyleSheet, TouchableOpacityProps, ScrollView, + Keyboard, } from 'react-native'; import { PreviewType, @@ -15,7 +16,7 @@ import { } from '../../types'; import {TAGG_LIGHT_BLUE} from '../../constants'; import SearchResults from './SearchResults'; -import {SCREEN_HEIGHT} from '../../utils'; +import {useBottomTabBarHeight} from '@react-navigation/bottom-tabs'; interface RecentSearchesProps extends TouchableOpacityProps { sectionTitle: PreviewType; @@ -25,35 +26,25 @@ interface RecentSearchesProps extends TouchableOpacityProps { } const RecentSearches: React.FC<RecentSearchesProps> = (props) => { - const {sectionTitle, recents, recentCategories, screenType} = props; + const {recents, recentCategories} = props; return ( - <ScrollView - style={styles.mainContainer} - contentContainerStyle={styles.contentContainer}> + <> <View style={styles.header}> - <Text style={styles.title}>{sectionTitle}</Text> + <Text style={styles.title}>Recent</Text> <TouchableOpacity {...props}> <Text style={styles.clear}>Clear all</Text> </TouchableOpacity> </View> - <SearchResults - results={recents} - categories={recentCategories} - previewType={sectionTitle} - screenType={screenType} - /> - </ScrollView> + <ScrollView + onScrollBeginDrag={Keyboard.dismiss} + contentContainerStyle={{paddingBottom: useBottomTabBarHeight()}}> + <SearchResults results={recents} categories={recentCategories} /> + </ScrollView> + </> ); }; const styles = StyleSheet.create({ - mainContainer: { - flex: 1, - }, - contentContainer: { - paddingBottom: SCREEN_HEIGHT * 0.1, - flex: 1, - }, header: { paddingHorizontal: 25, paddingVertical: 5, diff --git a/src/components/search/SearchBar.tsx b/src/components/search/SearchBar.tsx index 4824b56f..d441b07b 100644 --- a/src/components/search/SearchBar.tsx +++ b/src/components/search/SearchBar.tsx @@ -9,20 +9,23 @@ import { TextInputSubmitEditingEventData, TouchableOpacity, View, + ViewStyle, + LayoutChangeEvent, } from 'react-native'; import {normalize} from 'react-native-elements'; -import Animated, {interpolate} from 'react-native-reanimated'; +import Animated, {useAnimatedStyle} from 'react-native-reanimated'; import Icon from 'react-native-vector-icons/Feather'; import {useSelector} from 'react-redux'; import {RootState} from '../../store/rootReducer'; -import {getSearchSuggestions, SCREEN_HEIGHT} from '../../utils'; +import {getSearchSuggestions} from '../../utils'; const AnimatedIcon = Animated.createAnimatedComponent(Icon); interface SearchBarProps extends TextInputProps { onCancel: () => void; - top: Animated.Value<number>; + animationProgress: Animated.SharedValue<number>; searching: boolean; + onLayout: (e: LayoutChangeEvent) => void; } const SearchBar: React.FC<SearchBarProps> = ({ onFocus, @@ -31,7 +34,8 @@ const SearchBar: React.FC<SearchBarProps> = ({ value, onCancel, searching, - top, + animationProgress, + onLayout, }) => { const handleSubmit = ( e: NativeSyntheticEvent<TextInputSubmitEditingEventData>, @@ -107,19 +111,15 @@ const SearchBar: React.FC<SearchBarProps> = ({ }, [searching]); /* - * Animated nodes used in search bar activation animation. + * On-search marginRight style ("cancel" button slides and fades in). */ - const marginRight: Animated.Node<number> = interpolate(top, { - inputRange: [-SCREEN_HEIGHT, 0], - outputRange: [0, 58], - }); - const opacity: Animated.Node<number> = interpolate(top, { - inputRange: [-SCREEN_HEIGHT, 0], - outputRange: [0, 1], - }); + const animatedStyles = useAnimatedStyle<ViewStyle>(() => ({ + marginRight: animationProgress.value * 58, + opacity: animationProgress.value, + })); return ( - <View style={styles.container}> + <View style={styles.container} onLayout={onLayout}> <Animated.View style={styles.inputContainer}> <AnimatedIcon name="search" @@ -131,13 +131,13 @@ const SearchBar: React.FC<SearchBarProps> = ({ style={[styles.input]} placeholderTextColor={'#828282'} onSubmitEditing={handleSubmit} - clearButtonMode="while-editing" + clearButtonMode="always" autoCapitalize="none" autoCorrect={false} {...{placeholder, value, onChangeText, onFocus, onBlur}} /> </Animated.View> - <Animated.View style={{marginRight, opacity}}> + <Animated.View style={animatedStyles}> <TouchableOpacity style={styles.cancelButton} onPress={onCancel}> <Text style={styles.cancelText}>Cancel</Text> </TouchableOpacity> @@ -151,6 +151,7 @@ const styles = StyleSheet.create({ height: 40, paddingHorizontal: 20, flexDirection: 'row', + zIndex: 2, }, inputContainer: { flexGrow: 1, diff --git a/src/components/search/SearchCategories.tsx b/src/components/search/SearchCategories.tsx index c747b34f..3d142981 100644 --- a/src/components/search/SearchCategories.tsx +++ b/src/components/search/SearchCategories.tsx @@ -3,29 +3,40 @@ import React, {useEffect, useState} from 'react'; import {StyleSheet, View} from 'react-native'; import {getSuggestedSearchBubbleSuggestions} from '../../services/ExploreService'; import {SearchCategoryType} from '../../types'; -import {SCREEN_WIDTH} from '../../utils'; import GradientBorderButton from '../common/GradientBorderButton'; +import {useSelector} from 'react-redux'; +import {RootState} from 'src/store/rootReducer'; interface SearchCategoriesProps { darkStyle?: boolean; - defaultButtons?: SearchCategoryType[]; + useSuggestions: boolean; } const SearchCategories: React.FC<SearchCategoriesProps> = ({ darkStyle = false, - defaultButtons, + useSuggestions, }) => { const navigation = useNavigation(); - const mtSearchCategory: (key: number) => SearchCategoryType = (key) => ({ + const { + profile: {university = ''}, + } = useSelector((state: RootState) => state.user); + const defaultButtons: SearchCategoryType[] = [21, 22, 23, 24].map( + (year, index) => ({ + id: index * -1, + name: `${university.split(' ')[0]} '${year}`, + category: university, + }), + ); + const createloadingCategory: (key: number) => SearchCategoryType = (key) => ({ id: key, name: '...', category: '...', }); const [buttons, setButtons] = useState<SearchCategoryType[]>([ - mtSearchCategory(-1), - mtSearchCategory(-2), - mtSearchCategory(-3), - mtSearchCategory(-4), + createloadingCategory(1), + createloadingCategory(2), + createloadingCategory(3), + createloadingCategory(4), ]); useEffect(() => { @@ -36,7 +47,7 @@ const SearchCategories: React.FC<SearchCategoriesProps> = ({ setButtons(localButtons); } }; - if (!defaultButtons) { + if (useSuggestions) { loadButtons(); } else { setButtons(defaultButtons); @@ -45,33 +56,34 @@ const SearchCategories: React.FC<SearchCategoriesProps> = ({ return ( <View style={styles.container}> - {buttons.map((searchCategory) => ( - <GradientBorderButton - key={searchCategory.id} - text={searchCategory.name} - darkStyle={darkStyle} - onPress={() => { - if (searchCategory.name !== '...') { - navigation.push('DiscoverUsers', { - searchCategory, - }); - } - }} - /> - ))} + <View style={styles.categoryContainer}> + {buttons.map((searchCategory, index) => ( + <GradientBorderButton + key={index} + text={searchCategory.name} + darkStyle={darkStyle} + onPress={() => { + if (searchCategory.name !== '...') { + navigation.push('DiscoverUsers', { + searchCategory, + }); + } + }} + /> + ))} + </View> </View> ); }; const styles = StyleSheet.create({ container: { - zIndex: 0, - top: '3%', - alignSelf: 'center', + paddingVertical: 20, + }, + categoryContainer: { flexDirection: 'row', - width: SCREEN_WIDTH * 0.9, - flexWrap: 'wrap', justifyContent: 'space-evenly', + flexWrap: 'wrap', }, }); export default SearchCategories; diff --git a/src/components/search/SearchResultList.tsx b/src/components/search/SearchResultList.tsx index 687b2285..a32760e1 100644 --- a/src/components/search/SearchResultList.tsx +++ b/src/components/search/SearchResultList.tsx @@ -1,15 +1,23 @@ import React, {useEffect, useState} from 'react'; -import {SectionList, StyleSheet, Text, View} from 'react-native'; +import { + SectionList, + StyleSheet, + Text, + View, + Keyboard, + SectionListData, +} from 'react-native'; import {useSelector} from 'react-redux'; import {RootState} from '../../store/rootreducer'; import {NO_RESULTS_FOUND} from '../../constants/strings'; import {PreviewType, ScreenType} from '../../types'; -import {normalize, SCREEN_HEIGHT, SCREEN_WIDTH} from '../../utils'; +import {normalize, SCREEN_WIDTH} from '../../utils'; import SearchResultsCell from './SearchResultCell'; +import {useBottomTabBarHeight} from '@react-navigation/bottom-tabs'; interface SearchResultsProps { - results: Array<any> | undefined; - keyboardVisible: boolean; + // TODO: make sure results come in as same type, regardless of profile, category, badges + results: SectionListData<any>[]; previewType: PreviewType; screenType: ScreenType; } @@ -21,11 +29,8 @@ const sectionHeader: React.FC<Boolean> = (showBorder: Boolean) => { return null; }; -const SearchResultList: React.FC<SearchResultsProps> = ({ - results, - keyboardVisible, -}) => { - const [showEmptyView, setshowEmptyView] = useState(false); +const SearchResultList: React.FC<SearchResultsProps> = ({results}) => { + const [showEmptyView, setshowEmptyView] = useState<boolean>(false); const {user: loggedInUser} = useSelector((state: RootState) => state.user); useEffect(() => { @@ -38,57 +43,41 @@ const SearchResultList: React.FC<SearchResultsProps> = ({ } }, [results]); - return ( - <View style={styles.container}> - {showEmptyView && ( - <View style={styles.noResultsTextContainer}> - <Text style={styles.noResultsTextStyle}>{NO_RESULTS_FOUND}</Text> - </View> - )} - {!showEmptyView && ( - <SectionList - style={[ - {width: SCREEN_WIDTH}, - keyboardVisible ? styles.keyboardOpen : {}, - ]} - contentContainerStyle={styles.sectionListContentContainer} - sections={results} - keyExtractor={(item, index) => item.id + index} - renderItem={({section, item}) => { - return ( - <SearchResultsCell - profileData={item} - loggedInUser={loggedInUser} - /> - ); - }} - renderSectionHeader={({section: {data}}) => - sectionHeader(data.length !== 0) - } - /> - )} + return showEmptyView ? ( + <View style={styles.container} onTouchStart={Keyboard.dismiss}> + <Text style={styles.noResultsTextStyle}>{NO_RESULTS_FOUND}</Text> </View> + ) : ( + <SectionList + onScrollBeginDrag={Keyboard.dismiss} + contentContainerStyle={[{paddingBottom: useBottomTabBarHeight()}]} + sections={results} + keyExtractor={(item, index) => item.id + index} + renderItem={({item}) => { + return ( + <SearchResultsCell profileData={item} loggedInUser={loggedInUser} /> + ); + }} + renderSectionHeader={({section: {data}}) => + sectionHeader(data.length !== 0) + } + stickySectionHeadersEnabled={false} + /> ); }; const styles = StyleSheet.create({ container: { - height: SCREEN_HEIGHT, - paddingBottom: SCREEN_HEIGHT * 0.1, - }, - sectionListContentContainer: { - paddingBottom: SCREEN_HEIGHT * 0.15, - width: SCREEN_WIDTH, + flex: 1, + marginTop: 30, + alignItems: 'center', }, sectionHeaderStyle: { width: '100%', height: 0.5, - marginBottom: normalize(24), + marginVertical: 5, backgroundColor: '#C4C4C4', }, - keyboardOpen: { - marginBottom: SCREEN_HEIGHT * 0.35, - }, noResultsTextContainer: { justifyContent: 'center', flexDirection: 'row', diff --git a/src/components/search/SearchResults.tsx b/src/components/search/SearchResults.tsx index ef518d8b..a73d0b40 100644 --- a/src/components/search/SearchResults.tsx +++ b/src/components/search/SearchResults.tsx @@ -1,18 +1,10 @@ import React from 'react'; -import { - ProfilePreviewType, - PreviewType, - ScreenType, - CategoryPreviewType, -} from '../../types'; -import {View} from 'react-native'; +import {ProfilePreviewType, CategoryPreviewType} from '../../types'; import SearchResultsCell from './SearchResultCell'; import {useSelector} from 'react-redux'; import {RootState} from '../../store/rootReducer'; interface SearchResultsProps { results: ProfilePreviewType[]; - previewType: PreviewType; - screenType: ScreenType; categories: CategoryPreviewType[]; } const SearchResults: React.FC<SearchResultsProps> = ({results, categories}) => { @@ -22,7 +14,7 @@ const SearchResults: React.FC<SearchResultsProps> = ({results, categories}) => { */ const {user: loggedInUser} = useSelector((state: RootState) => state.user); return ( - <View> + <> {categories .slice(0) .reverse() @@ -43,7 +35,7 @@ const SearchResults: React.FC<SearchResultsProps> = ({results, categories}) => { {...{loggedInUser}} /> ))} - </View> + </> ); }; diff --git a/src/components/search/SearchResultsBackground.tsx b/src/components/search/SearchResultsBackground.tsx index 2833553d..e5236295 100644 --- a/src/components/search/SearchResultsBackground.tsx +++ b/src/components/search/SearchResultsBackground.tsx @@ -1,28 +1,55 @@ import React from 'react'; -import {StyleSheet} from 'react-native'; -import Animated, {interpolate} from 'react-native-reanimated'; -import {SCREEN_HEIGHT, SCREEN_WIDTH} from '../../utils'; +import {StyleSheet, ViewStyle} from 'react-native'; +import Animated, { + useAnimatedStyle, + useDerivedValue, + interpolate, + Extrapolate, +} from 'react-native-reanimated'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; interface SearchResultsBackgroundProps { - top: Animated.Value<number>; + animationProgress: Animated.SharedValue<number>; + searchBarHeight: number; + searching: boolean; } const SearchResultsBackground: React.FC<SearchResultsBackgroundProps> = ({ - top, + animationProgress, + searchBarHeight, + searching, children, }) => { - const opacityBackground: Animated.Node<number> = interpolate(top, { - inputRange: [-SCREEN_HEIGHT, 0], - outputRange: [0, 1], - }); - const opacityContent: Animated.Node<number> = interpolate(top, { - inputRange: [-SCREEN_HEIGHT / 40, 0], - outputRange: [0, 1], - }); + const {top: topInset} = useSafeAreaInsets(); + /* + * On-search container style (opacity fade-in). + */ + const backgroundAnimatedStyles = useAnimatedStyle<ViewStyle>(() => ({ + opacity: animationProgress.value, + })); + /* + * Derived animation value for contentAnimatedStyles. + */ + const contentAnimationProgress = useDerivedValue<number>(() => + interpolate(animationProgress.value, [0.9, 1], [0, 1], Extrapolate.CLAMP), + ); + /* + * On-search content style (delayed opacity fade-in). + */ + const contentAnimatedStyles = useAnimatedStyle<ViewStyle>(() => ({ + opacity: contentAnimationProgress.value, + })); return ( <Animated.View - style={[styles.container, {opacity: opacityBackground, top}]}> - <Animated.View - style={[styles.contentContainer, {opacity: opacityContent}]}> + style={[ + styles.container, + backgroundAnimatedStyles, + { + // absolute: inset + search screen paddingTop + searchBar + padding + paddingTop: topInset + 15 + searchBarHeight + 10, + }, + ]} + pointerEvents={searching ? 'auto' : 'none'}> + <Animated.View style={[styles.contentContainer, contentAnimatedStyles]}> {children} </Animated.View> </Animated.View> @@ -30,15 +57,11 @@ const SearchResultsBackground: React.FC<SearchResultsBackgroundProps> = ({ }; const styles = StyleSheet.create({ container: { - height: SCREEN_HEIGHT, - width: SCREEN_WIDTH, - position: 'absolute', + ...StyleSheet.absoluteFillObject, backgroundColor: 'white', }, contentContainer: { flex: 1, - paddingVertical: 10, - paddingBottom: SCREEN_HEIGHT / 15, }, }); export default SearchResultsBackground; diff --git a/src/components/suggestedPeople/SPTaggsBar.tsx b/src/components/suggestedPeople/SPTaggsBar.tsx new file mode 100644 index 00000000..adac6dcf --- /dev/null +++ b/src/components/suggestedPeople/SPTaggsBar.tsx @@ -0,0 +1,133 @@ +import React, {useEffect, useState} from 'react'; +import {StyleSheet} from 'react-native'; +import Animated from 'react-native-reanimated'; +import {useDispatch, useSelector, useStore} from 'react-redux'; +import {INTEGRATED_SOCIAL_LIST, SOCIAL_LIST} from '../../constants'; +import {getLinkedSocials} from '../../services'; +import {loadIndividualSocial, updateSocial} from '../../store/actions'; +import {RootState} from '../../store/rootReducer'; +import {ScreenType} from '../../types'; +import {canViewProfile} from '../../utils'; +import Tagg from '../taggs/Tagg'; + +const {View, ScrollView} = Animated; +interface TaggsBarProps { + userXId: string | undefined; + screenType: ScreenType; + linkedSocials?: string[]; +} +const TaggsBar: React.FC<TaggsBarProps> = ({ + userXId, + screenType, + linkedSocials, +}) => { + let [taggs, setTaggs] = useState<Object[]>([]); + let [taggsNeedUpdate, setTaggsNeedUpdate] = useState(true); + const {user} = useSelector((state: RootState) => + userXId ? state.userX[screenType][userXId] : state.user, + ); + const state: RootState = useStore().getState(); + const allowTaggsNavigation = canViewProfile(state, userXId, screenType); + + const dispatch = useDispatch(); + + /** + * Updates the individual social that needs update + * If username is empty, update nonintegrated socials like Snapchat and TikTok + * @param socialType Type of the social that needs update + */ + const handleSocialUpdate = (socialType: string, username: string) => { + if (username !== '') { + dispatch(updateSocial(socialType, username)); + } else { + dispatch(loadIndividualSocial(user.userId, socialType)); + } + }; + + /** + * This useEffect should be called evey time the user being viewed is changed OR + * And update is triggered manually + */ + useEffect(() => { + const loadData = async () => { + const socials: string[] = linkedSocials + ? linkedSocials + : await getLinkedSocials(user.userId); + const unlinkedSocials = SOCIAL_LIST.filter( + (s) => socials.indexOf(s) === -1, + ); + let new_taggs = []; + let i = 0; + for (let social of socials) { + new_taggs.push( + <Tagg + key={i} + social={social} + userXId={userXId} + user={user} + isLinked={true} + isIntegrated={INTEGRATED_SOCIAL_LIST.indexOf(social) !== -1} + setTaggsNeedUpdate={setTaggsNeedUpdate} + setSocialDataNeedUpdate={handleSocialUpdate} + whiteRing={true} + allowNavigation={allowTaggsNavigation} + />, + ); + i++; + } + if (!userXId) { + for (let social of unlinkedSocials) { + new_taggs.push( + <Tagg + key={i} + social={social} + isLinked={false} + isIntegrated={INTEGRATED_SOCIAL_LIST.indexOf(social) !== -1} + setTaggsNeedUpdate={setTaggsNeedUpdate} + setSocialDataNeedUpdate={handleSocialUpdate} + userXId={userXId} + user={user} + whiteRing={true} + allowNavigation={allowTaggsNavigation} + />, + ); + i++; + } + } + setTaggs(new_taggs); + setTaggsNeedUpdate(false); + }; + if (user.userId) { + loadData(); + } + }, [taggsNeedUpdate, user]); + + return taggs.length > 0 ? ( + <View style={styles.spContainer}> + <ScrollView + horizontal + showsHorizontalScrollIndicator={false} + contentContainerStyle={styles.contentContainer}> + {taggs} + </ScrollView> + </View> + ) : ( + <></> + ); +}; + +const styles = StyleSheet.create({ + spContainer: { + shadowColor: '#000', + shadowRadius: 10, + shadowOffset: {width: 0, height: 2}, + zIndex: 1, + marginBottom: 25, + }, + contentContainer: { + alignItems: 'center', + paddingBottom: 5, + }, +}); + +export default TaggsBar; diff --git a/src/components/suggestedPeople/index.ts b/src/components/suggestedPeople/index.ts index 515f6fb4..339c9ae0 100644 --- a/src/components/suggestedPeople/index.ts +++ b/src/components/suggestedPeople/index.ts @@ -1,2 +1,3 @@ export {default as MutualFriends} from './MutualFriends'; export {default as BadgesDropdown} from './BadgesDropdown'; +export {default as SPTaggsBar} from './SPTaggsBar'; diff --git a/src/components/taggs/TaggPostFooter.tsx b/src/components/taggs/TaggPostFooter.tsx index ae9d889d..750f1793 100644 --- a/src/components/taggs/TaggPostFooter.tsx +++ b/src/components/taggs/TaggPostFooter.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import {Linking, StyleSheet, View} from 'react-native'; +import {StyleSheet, View} from 'react-native'; import {Text} from 'react-native-animatable'; import {handleOpenSocialUrlOnBrowser} from '../../utils'; import {DateLabel} from '../common'; diff --git a/src/components/taggs/TaggsBar.tsx b/src/components/taggs/TaggsBar.tsx index 06acadc1..4d567b25 100644 --- a/src/components/taggs/TaggsBar.tsx +++ b/src/components/taggs/TaggsBar.tsx @@ -1,6 +1,11 @@ -import React, {Fragment, useEffect, useState} from 'react'; -import {StyleSheet} from 'react-native'; -import Animated from 'react-native-reanimated'; +import React, {useEffect, useState} from 'react'; +import {LayoutChangeEvent, StyleSheet} from 'react-native'; +import Animated, { + Extrapolate, + interpolate, + useAnimatedStyle, + useDerivedValue, +} from 'react-native-reanimated'; import {useSafeAreaInsets} from 'react-native-safe-area-context'; import {useDispatch, useSelector} from 'react-redux'; import { @@ -14,22 +19,22 @@ import {RootState} from '../../store/rootReducer'; import {ScreenType} from '../../types'; import Tagg from './Tagg'; -const {View, ScrollView, interpolate, Extrapolate} = Animated; +const {View, ScrollView} = Animated; interface TaggsBarProps { - y: Animated.Value<number>; + y: Animated.SharedValue<number>; profileBodyHeight: number; userXId: string | undefined; screenType: ScreenType; - whiteRing: boolean | undefined; linkedSocials?: string[]; + onLayout: (event: LayoutChangeEvent) => void; } const TaggsBar: React.FC<TaggsBarProps> = ({ y, profileBodyHeight, userXId, screenType, - whiteRing, linkedSocials, + onLayout, }) => { const dispatch = useDispatch(); let [taggs, setTaggs] = useState<Object[]>([]); @@ -37,7 +42,7 @@ const TaggsBar: React.FC<TaggsBarProps> = ({ const {user} = useSelector((state: RootState) => userXId ? state.userX[screenType][userXId] : state.user, ); - + const insetTop = useSafeAreaInsets().top; /** * Updates the individual social that needs update * If username is empty, update nonintegrated socials like Snapchat and TikTok @@ -77,12 +82,12 @@ const TaggsBar: React.FC<TaggsBarProps> = ({ isIntegrated={INTEGRATED_SOCIAL_LIST.indexOf(social) !== -1} setTaggsNeedUpdate={setTaggsNeedUpdate} setSocialDataNeedUpdate={handleSocialUpdate} - whiteRing={whiteRing ? whiteRing : undefined} + whiteRing={false} />, ); i++; } - if (!userXId && !whiteRing) { + if (!userXId) { for (let social of unlinkedSocials) { new_taggs.push( <Tagg @@ -95,7 +100,7 @@ const TaggsBar: React.FC<TaggsBarProps> = ({ userXId={userXId} screenType={screenType} user={user} - whiteRing={whiteRing ? whiteRing : undefined} + whiteRing={false} />, ); i++; @@ -108,64 +113,55 @@ const TaggsBar: React.FC<TaggsBarProps> = ({ loadData(); } }, [taggsNeedUpdate, user]); - - const shadowOpacity: Animated.Node<number> = interpolate(y, { - inputRange: [ - PROFILE_CUTOUT_BOTTOM_Y + profileBodyHeight, - PROFILE_CUTOUT_BOTTOM_Y + profileBodyHeight + 20, - ], - outputRange: [0, 0.2], - extrapolate: Extrapolate.CLAMP, - }); - const paddingTop: Animated.Node<number> = interpolate(y, { - inputRange: [ - PROFILE_CUTOUT_BOTTOM_Y + - profileBodyHeight - - (useSafeAreaInsets().top + 10), - PROFILE_CUTOUT_BOTTOM_Y + profileBodyHeight, - ], - outputRange: [10, useSafeAreaInsets().top], - extrapolate: Extrapolate.CLAMP, - }); + const paddingTopStylesProgress = useDerivedValue(() => + interpolate( + y.value, + [PROFILE_CUTOUT_BOTTOM_Y, PROFILE_CUTOUT_BOTTOM_Y + profileBodyHeight], + [0, 1], + Extrapolate.CLAMP, + ), + ); + const shadowOpacityStylesProgress = useDerivedValue(() => + interpolate( + y.value, + [ + PROFILE_CUTOUT_BOTTOM_Y + profileBodyHeight, + PROFILE_CUTOUT_BOTTOM_Y + profileBodyHeight + insetTop, + ], + [0, 1], + Extrapolate.CLAMP, + ), + ); + const animatedStyles = useAnimatedStyle(() => ({ + shadowOpacity: shadowOpacityStylesProgress.value / 5, + paddingTop: paddingTopStylesProgress.value * insetTop, + })); return taggs.length > 0 ? ( - <View - style={ - whiteRing - ? [styles.spContainer] - : [styles.container, {shadowOpacity, paddingTop}] - }> + <View style={[styles.container, animatedStyles]} onLayout={onLayout}> <ScrollView horizontal showsHorizontalScrollIndicator={false} - contentContainerStyle={styles.contentContainer}> + contentContainerStyle={[styles.contentContainer]}> {taggs} </ScrollView> </View> ) : ( - <Fragment /> + <></> ); }; const styles = StyleSheet.create({ - spContainer: { - shadowColor: '#000', - shadowRadius: 10, - shadowOffset: {width: 0, height: 2}, - zIndex: 1, - marginBottom: 25, - }, container: { backgroundColor: 'white', shadowColor: '#000', shadowRadius: 10, shadowOffset: {width: 0, height: 2}, zIndex: 1, - paddingBottom: 5, }, contentContainer: { alignItems: 'center', - paddingBottom: 5, + paddingBottom: 15, }, }); diff --git a/src/constants/api.ts b/src/constants/api.ts index 22890c33..43294386 100644 --- a/src/constants/api.ts +++ b/src/constants/api.ts @@ -1,6 +1,12 @@ /* eslint-disable */ -// const BASE_URL: string = 'http://3.22.188.127/'; // prod server -const BASE_URL: string = 'http://127.0.0.1:8000/'; // local server + +// Dev +const BASE_URL: string = 'http://127.0.0.1:8000/'; +export const STREAM_CHAT_API = 'g2hvnyqx9cmv'; + +// Prod +// const BASE_URL: string = 'http://app-prod.tagg.id/'; +// export const STREAM_CHAT_API = 'ur3kg5qz8x5v' const API_URL: string = BASE_URL + 'api/'; export const LOGIN_ENDPOINT: string = API_URL + 'login/'; @@ -18,7 +24,9 @@ export const PROFILE_PHOTO_THUMBNAIL_ENDPOINT: string = export const GET_IG_POSTS_ENDPOINT: string = API_URL + 'posts-ig/'; export const GET_FB_POSTS_ENDPOINT: string = API_URL + 'posts-fb/'; export const GET_TWITTER_POSTS_ENDPOINT: string = API_URL + 'posts-twitter/'; -export const SEARCH_ENDPOINT: string = API_URL + 'search/v2/'; +export const SEARCH_ENDPOINT: string = API_URL + 'search/'; +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_THUMBNAIL_ENDPOINT: string = API_URL + 'moment-thumbnail/'; export const VERIFY_INVITATION_CODE_ENDPOUNT: string = API_URL + 'verify-code/'; @@ -31,20 +39,24 @@ export const PASSWORD_RESET_ENDPOINT: string = API_URL + 'password-reset/'; export const MOMENT_CATEGORY_ENDPOINT: string = API_URL + 'moment-category/'; export const NOTIFICATIONS_ENDPOINT: string = API_URL + 'notifications/'; export const DISCOVER_ENDPOINT: string = API_URL + 'discover/'; -export const SEARCH_BUTTONS_ENDPOPINT: string = DISCOVER_ENDPOINT + 'search_buttons/'; +export const SEARCH_BUTTONS_ENDPOPINT: string = + DISCOVER_ENDPOINT + 'search_buttons/'; export const WAITLIST_USER_ENDPOINT: string = API_URL + 'waitlist-user/'; export const COMMENT_THREAD_ENDPOINT: string = API_URL + 'reply/'; export const USERS_FROM_CONTACTS_ENDPOINT: string = API_URL + 'user_contacts/find_friends/'; export const INVITE_FRIEND_ENDPOINT: string = -API_URL + 'user_contacts/invite_friend/'; + API_URL + 'user_contacts/invite_friend/'; // Suggested People export const SP_USERS_ENDPOINT: string = API_URL + 'suggested_people/'; -export const SP_UPDATE_PICTURE_ENDPOINT: string = SP_USERS_ENDPOINT + 'update_picture/'; -export const SP_MUTUAL_BADGE_HOLDERS_ENDPOINT: string = SP_USERS_ENDPOINT + 'get_mutual_badge_holders/'; +export const SP_UPDATE_PICTURE_ENDPOINT: string = + SP_USERS_ENDPOINT + 'update_picture/'; +export const SP_MUTUAL_BADGE_HOLDERS_ENDPOINT: string = + SP_USERS_ENDPOINT + 'get_mutual_badge_holders/'; export const ADD_BADGES_ENDPOINT: string = SP_USERS_ENDPOINT + 'add_badges/'; -export const UPDATE_BADGES_ENDPOINT: string = SP_USERS_ENDPOINT + 'update_badges/'; +export const UPDATE_BADGES_ENDPOINT: string = + SP_USERS_ENDPOINT + 'update_badges/'; // Register as FCM device export const FCM_ENDPOINT: string = API_URL + 'fcm/'; @@ -65,6 +77,7 @@ export const LINK_IG_OAUTH: string = `https://www.instagram.com/oauth/authorize/ export const LINK_FB_OAUTH: string = `https://www.facebook.com/v8.0/dialog/oauth?client_id=1308555659343609&redirect_uri=${DEEPLINK}&scope=user_posts,public_profile&response_type=code`; export const LINK_TWITTER_OAUTH: string = API_URL + 'link-twitter-request/'; -// Profile Links -export const COMMUNITY_GUIDELINES: string = 'https://www.tagg.id/community-guidelines'; +// Profile Links +export const COMMUNITY_GUIDELINES: string = + 'https://www.tagg.id/community-guidelines'; export const PRIVACY_POLICY: string = 'https://www.tagg.id/privacy-policy'; diff --git a/src/constants/strings.ts b/src/constants/strings.ts index 4f792dcc..300ceb90 100644 --- a/src/constants/strings.ts +++ b/src/constants/strings.ts @@ -17,6 +17,7 @@ export const ERROR_DUP_OLD_PWD = 'You may not use a previously used password'; export const ERROR_EMAIL_IN_USE = 'Email already in use, please try another one'; export const ERROR_FAILED_LOGIN_INFO = 'Login failed, please try re-entering your login information'; export const ERROR_FAILED_TO_COMMENT = 'Unable to post comment, refresh and try again!'; +export const ERROR_FAILED_TO_CREATE_CHANNEL = 'Failed to create a channel, Please try again!'; export const ERROR_FAILED_TO_DELETE_COMMENT = 'Unable to delete comment, refresh and try again!'; export const ERROR_INVALID_INVITATION_CODE = 'Invitation code invalid, try again or talk to the friend that sent it 😬'; export const ERROR_INVALID_LOGIN = 'Invalid login, Please login again'; diff --git a/src/routes/Routes.tsx b/src/routes/Routes.tsx index c7b9aeee..819ca785 100644 --- a/src/routes/Routes.tsx +++ b/src/routes/Routes.tsx @@ -50,7 +50,7 @@ const Routes: React.FC = () => { fcmService.setUpPushNotifications(); fcmService.sendFcmTokenToServer(); } - }); + }, []); useEffect(() => { const checkVersion = async () => { @@ -61,7 +61,7 @@ const Routes: React.FC = () => { } }; checkVersion(); - }); + }, []); return userId && !newVersionAvailable ? <NavigationBar /> : <Onboarding />; }; diff --git a/src/routes/main/MainStackNavigator.tsx b/src/routes/main/MainStackNavigator.tsx index 9b089634..64ad9198 100644 --- a/src/routes/main/MainStackNavigator.tsx +++ b/src/routes/main/MainStackNavigator.tsx @@ -73,6 +73,7 @@ export type MainStackParams = { screenType: ScreenType; momentCategory: string; profileBodyHeight: number; + socialsBarHeight: number; }; AnimatedTutorial: { screenType: ScreenType; @@ -92,6 +93,9 @@ export type MainStackParams = { screenType: ScreenType; }; SPWelcomeScreen: {}; + ChatList: undefined; + Chat: undefined; + NewChatModal: undefined; }; export const MainStack = createStackNavigator<MainStackParams>(); diff --git a/src/routes/main/MainStackScreen.tsx b/src/routes/main/MainStackScreen.tsx index d855f0df..37867151 100644 --- a/src/routes/main/MainStackScreen.tsx +++ b/src/routes/main/MainStackScreen.tsx @@ -1,7 +1,7 @@ import AsyncStorage from '@react-native-community/async-storage'; import {RouteProp} from '@react-navigation/native'; import {StackNavigationOptions} from '@react-navigation/stack'; -import React, {useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {StyleSheet, Text} from 'react-native'; import {normalize} from 'react-native-elements'; import BackIcon from '../../assets/icons/back-arrow.svg'; @@ -11,6 +11,8 @@ import { BadgeSelection, CaptionScreen, CategorySelection, + ChatListScreen, + ChatScreen, CreateCustomCategory, DiscoverUsers, EditProfile, @@ -19,20 +21,21 @@ import { InviteFriendsScreen, MomentCommentsScreen, MomentUploadPromptScreen, + NewChatModal, NotificationsScreen, - ProfileScreen, PrivacyScreen, + ProfileScreen, RequestContactsAccess, SearchScreen, + SettingsScreen, SocialMediaTaggs, SuggestedPeopleScreen, SuggestedPeopleUploadPictureScreen, SuggestedPeopleWelcomeScreen, - SettingsScreen, } from '../../screens'; import MutualBadgeHolders from '../../screens/suggestedPeople/MutualBadgeHolders'; import {ScreenType} from '../../types'; -import {AvatarHeaderHeight, SCREEN_WIDTH} from '../../utils'; +import {AvatarHeaderHeight, ChatHeaderHeight, SCREEN_WIDTH} from '../../utils'; import {MainStack, MainStackParams} from './MainStackNavigator'; /** @@ -50,7 +53,6 @@ type MainStackRouteProps = RouteProp<MainStackParams, 'Profile'>; interface MainStackProps { route: MainStackRouteProps; } - const MainStackScreen: React.FC<MainStackProps> = ({route}) => { const {screenType} = route.params; @@ -62,6 +64,10 @@ const MainStackScreen: React.FC<MainStackProps> = ({route}) => { 'true', ); + useEffect(() => { + loadResponseToAccessContacts(); + }, []); + const loadResponseToAccessContacts = () => { AsyncStorage.getItem('respondedToAccessContacts') .then((value) => { @@ -73,8 +79,6 @@ const MainStackScreen: React.FC<MainStackProps> = ({route}) => { }); }; - loadResponseToAccessContacts(); - const initialRouteName = (() => { switch (screenType) { case ScreenType.Profile: @@ -85,6 +89,8 @@ const MainStackScreen: React.FC<MainStackProps> = ({route}) => { return 'Notifications'; case ScreenType.SuggestedPeople: return 'SuggestedPeople'; + case ScreenType.Chat: + return 'ChatList'; } })(); @@ -102,200 +108,228 @@ const MainStackScreen: React.FC<MainStackProps> = ({route}) => { }), }; - return ( - <MainStack.Navigator - screenOptions={{ - headerShown: false, - gestureResponseDistance: {horizontal: SCREEN_WIDTH * 0.6}, - }} - mode="card" - initialRouteName={initialRouteName}> - <MainStack.Screen - name="Profile" - component={ProfileScreen} - initialParams={{screenType}} - options={{ - ...headerBarOptions('white', ''), + const newChatModalStyle: StackNavigationOptions = { + cardStyle: {backgroundColor: 'rgba(0, 0, 0, 0.5)'}, + cardOverlayEnabled: true, + animationEnabled: false, + }; + + const mainStackScreen = () => { + return ( + <MainStack.Navigator + screenOptions={{ + headerShown: false, + gestureResponseDistance: {horizontal: SCREEN_WIDTH * 0.6}, }} - /> - {isSuggestedPeopleTab && - (respondedToAccessContacts && respondedToAccessContacts === 'true' ? ( + mode="card" + initialRouteName={initialRouteName}> + <MainStack.Screen + name="Profile" + component={ProfileScreen} + initialParams={{screenType}} + options={{ + ...headerBarOptions('white', ''), + }} + /> + {isSuggestedPeopleTab && + (respondedToAccessContacts && respondedToAccessContacts === 'true' ? ( + <MainStack.Screen + name="SuggestedPeople" + component={SuggestedPeopleScreen} + initialParams={{screenType}} + /> + ) : ( + <MainStack.Screen + name="SuggestedPeople" + component={RequestContactsAccess} + initialParams={{screenType}} + /> + ))} + {isNotificationsTab && ( <MainStack.Screen - name="SuggestedPeople" - component={SuggestedPeopleScreen} + name="Notifications" + component={NotificationsScreen} initialParams={{screenType}} /> - ) : ( + )} + {isSearchTab && ( <MainStack.Screen - name="SuggestedPeople" - component={RequestContactsAccess} + name="Search" + component={SearchScreen} initialParams={{screenType}} /> - ))} - {isNotificationsTab && ( + )} + <MainStack.Screen + name="DiscoverUsers" + component={DiscoverUsers} + options={{ + ...headerBarOptions('white', 'Discover Users'), + }} + /> + <MainStack.Screen + name="SettingsScreen" + component={SettingsScreen} + options={{ + ...headerBarOptions('white', 'Settings and Privacy'), + }} + /> + <MainStack.Screen + name="PrivacyScreen" + component={PrivacyScreen} + options={{ + ...headerBarOptions('white', 'Privacy'), + }} + /> + <MainStack.Screen + name="AccountTypeScreen" + component={AccountType} + options={{ + ...headerBarOptions('white', 'Account Type'), + }} + /> <MainStack.Screen - name="Notifications" - component={NotificationsScreen} + name="AnimatedTutorial" + component={AnimatedTutorial} + options={{ + ...tutorialModalStyle, + }} initialParams={{screenType}} /> - )} - {isSearchTab && ( <MainStack.Screen - name="Search" - component={SearchScreen} + name="CaptionScreen" + component={CaptionScreen} + options={{ + ...modalStyle, + gestureEnabled: false, + }} + /> + <MainStack.Screen + name="SocialMediaTaggs" + component={SocialMediaTaggs} initialParams={{screenType}} + options={{ + ...headerBarOptions('white', ''), + headerStyle: {height: AvatarHeaderHeight}, + }} /> - )} - <MainStack.Screen - name="DiscoverUsers" - component={DiscoverUsers} - options={{ - ...headerBarOptions('white', 'Discover Users'), - }} - /> - <MainStack.Screen - name="SettingsScreen" - component={SettingsScreen} - options={{ - ...headerBarOptions('white', 'Settings and Privacy'), - }} - /> - <MainStack.Screen - name="PrivacyScreen" - component={PrivacyScreen} - options={{ - ...headerBarOptions('white', 'Privacy'), - }} - /> - <MainStack.Screen - name="AccountTypeScreen" - component={AccountType} - options={{ - ...headerBarOptions('white', 'Account Type'), - }} - /> - <MainStack.Screen - name="AnimatedTutorial" - component={AnimatedTutorial} - options={{ - ...tutorialModalStyle, - }} - initialParams={{screenType}} - /> - <MainStack.Screen - name="CaptionScreen" - component={CaptionScreen} - options={{ - ...modalStyle, - gestureEnabled: false, - }} - /> - <MainStack.Screen - name="SocialMediaTaggs" - component={SocialMediaTaggs} - initialParams={{screenType}} - options={{ - ...headerBarOptions('white', ''), - headerStyle: {height: AvatarHeaderHeight}, - }} - /> - <MainStack.Screen - name="CategorySelection" - component={CategorySelection} - options={{ - ...headerBarOptions('white', ''), - }} - /> - <MainStack.Screen - name="CreateCustomCategory" - component={CreateCustomCategory} - options={{ - ...headerBarOptions('white', ''), - }} - /> - <MainStack.Screen - name="IndividualMoment" - component={IndividualMoment} - initialParams={{screenType}} - options={{ - ...modalStyle, - gestureEnabled: false, - }} - /> - <MainStack.Screen - name="MomentCommentsScreen" - component={MomentCommentsScreen} - initialParams={{screenType}} - options={{ - ...headerBarOptions('black', 'Comments'), - }} - /> - <MainStack.Screen - name="MomentUploadPrompt" - component={MomentUploadPromptScreen} - initialParams={{screenType}} - options={{ - ...modalStyle, - }} - /> - <MainStack.Screen - name="FriendsListScreen" - component={FriendsListScreen} - initialParams={{screenType}} - options={{ - ...headerBarOptions('black', 'Friends'), - }} - /> - <MainStack.Screen - name="InviteFriendsScreen" - component={InviteFriendsScreen} - initialParams={{screenType}} - options={{ - ...headerBarOptions('black', 'Invites'), - }} - /> - <MainStack.Screen - name="RequestContactsAccess" - component={RequestContactsAccess} - initialParams={{screenType}} - /> - <MainStack.Screen - name="EditProfile" - component={EditProfile} - options={{ - ...headerBarOptions('white', 'Edit Profile'), - }} - /> - <MainStack.Screen - name="UpdateSPPicture" - component={SuggestedPeopleUploadPictureScreen} - initialParams={{editing: true}} - options={{ - ...headerBarOptions('white', ''), - }} - /> - <MainStack.Screen - name="BadgeSelection" - component={BadgeSelection} - initialParams={{editing: true}} - options={{ - ...headerBarOptions('white', ''), - }} - /> - <MainStack.Screen - name="MutualBadgeHolders" - component={MutualBadgeHolders} - options={{...modalStyle}} - /> - <MainStack.Screen - name="SPWelcomeScreen" - component={SuggestedPeopleWelcomeScreen} - options={{ - ...headerBarOptions('white', ''), - }} - /> - </MainStack.Navigator> - ); + <MainStack.Screen + name="CategorySelection" + component={CategorySelection} + options={{ + ...headerBarOptions('white', ''), + }} + /> + <MainStack.Screen + name="CreateCustomCategory" + component={CreateCustomCategory} + options={{ + ...headerBarOptions('white', ''), + }} + /> + <MainStack.Screen + name="IndividualMoment" + component={IndividualMoment} + initialParams={{screenType}} + options={{ + ...modalStyle, + gestureEnabled: false, + }} + /> + <MainStack.Screen + name="MomentCommentsScreen" + component={MomentCommentsScreen} + initialParams={{screenType}} + options={{ + ...headerBarOptions('black', 'Comments'), + }} + /> + <MainStack.Screen + name="MomentUploadPrompt" + component={MomentUploadPromptScreen} + initialParams={{screenType}} + options={{ + ...modalStyle, + }} + /> + <MainStack.Screen + name="FriendsListScreen" + component={FriendsListScreen} + initialParams={{screenType}} + options={{ + ...headerBarOptions('black', 'Friends'), + }} + /> + <MainStack.Screen + name="InviteFriendsScreen" + component={InviteFriendsScreen} + initialParams={{screenType}} + options={{ + ...headerBarOptions('black', 'Invites'), + }} + /> + <MainStack.Screen + name="RequestContactsAccess" + component={RequestContactsAccess} + initialParams={{screenType}} + /> + <MainStack.Screen + name="EditProfile" + component={EditProfile} + options={{ + ...headerBarOptions('white', 'Edit Profile'), + }} + /> + <MainStack.Screen + name="UpdateSPPicture" + component={SuggestedPeopleUploadPictureScreen} + initialParams={{editing: true}} + options={{ + ...headerBarOptions('white', ''), + }} + /> + <MainStack.Screen + name="BadgeSelection" + component={BadgeSelection} + initialParams={{editing: true}} + options={{ + ...headerBarOptions('white', ''), + }} + /> + <MainStack.Screen + name="MutualBadgeHolders" + component={MutualBadgeHolders} + options={{...modalStyle}} + /> + <MainStack.Screen + name="SPWelcomeScreen" + component={SuggestedPeopleWelcomeScreen} + options={{ + ...headerBarOptions('white', ''), + }} + /> + <MainStack.Screen + name="ChatList" + component={ChatListScreen} + options={{headerTitle: 'Chats'}} + /> + <MainStack.Screen + name="Chat" + component={ChatScreen} + options={{ + ...headerBarOptions('black', ''), + headerStyle: {height: ChatHeaderHeight}, + }} + /> + <MainStack.Screen + name="NewChatModal" + component={NewChatModal} + options={{headerShown: false, ...newChatModalStyle}} + /> + </MainStack.Navigator> + ); + }; + + return mainStackScreen(); }; export const headerBarOptions: ( diff --git a/src/routes/tabs/NavigationBar.tsx b/src/routes/tabs/NavigationBar.tsx index e9208525..9b8427e7 100644 --- a/src/routes/tabs/NavigationBar.tsx +++ b/src/routes/tabs/NavigationBar.tsx @@ -54,6 +54,8 @@ const NavigationBar: React.FC = () => { disabled={!focused} /> ); + case 'Chat': + return <NavigationIcon tab="Chat" disabled={!focused} />; case 'Profile': return <NavigationIcon tab="Profile" disabled={!focused} />; case 'SuggestedPeople': @@ -93,6 +95,11 @@ const NavigationBar: React.FC = () => { initialParams={{screenType: ScreenType.Notifications}} /> <Tabs.Screen + name="Chat" + component={MainStackScreen} + initialParams={{screenType: ScreenType.Chat}} + /> + <Tabs.Screen name="Profile" component={MainStackScreen} initialParams={{screenType: ScreenType.Profile}} diff --git a/src/screens/chat/ChatListScreen.tsx b/src/screens/chat/ChatListScreen.tsx new file mode 100644 index 00000000..daea9984 --- /dev/null +++ b/src/screens/chat/ChatListScreen.tsx @@ -0,0 +1,134 @@ +import AsyncStorage from '@react-native-community/async-storage'; +import {StackNavigationProp} from '@react-navigation/stack'; +import React, {useContext, useEffect, useMemo, useState} from 'react'; +import {SafeAreaView, StatusBar, StyleSheet, View} from 'react-native'; +import {useStore} from 'react-redux'; +import {ChannelList, Chat} from 'stream-chat-react-native'; +import {ChatContext} from '../../App'; +import {TabsGradient} from '../../components'; +import {ChannelPreview, MessagesHeader} from '../../components/messages'; +import {MainStackParams} from '../../routes'; +import {RootState} from '../../store/rootReducer'; +import { + LocalAttachmentType, + LocalChannelType, + LocalCommandType, + LocalEventType, + LocalMessageType, + LocalReactionType, + LocalUserType, +} from '../../types'; + +import NewChatModal from './NewChatModal'; +type ChatListScreenNavigationProp = StackNavigationProp< + MainStackParams, + 'ChatList' +>; +interface ChatListScreenProps { + navigation: ChatListScreenNavigationProp; +} +/* + * Screen that displays all of the user's active conversations. + */ +const ChatListScreen: React.FC<ChatListScreenProps> = ({navigation}) => { + const {chatClient, setChannel} = useContext(ChatContext); + const [modalVisible, setChatModalVisible] = useState(false); + + const [clientReady, setClientReady] = useState(false); + const state: RootState = useStore().getState(); + const loggedInUserId = state.user.user.userId; + + const memoizedFilters = useMemo( + () => ({ + members: {$in: [loggedInUserId]}, + type: 'messaging', + }), + [], + ); + + useEffect(() => { + const setupClient = async () => { + const chatToken = await AsyncStorage.getItem('chatToken'); + await chatClient.connectUser( + { + id: loggedInUserId, + }, + chatToken, + ); + return setClientReady(true); + }; + if (!clientReady) { + setupClient().catch((err) => { + console.error(err); + }); + } + }, []); + + return ( + <View style={styles.background}> + <SafeAreaView> + <StatusBar barStyle="dark-content" /> + <MessagesHeader + createChannel={() => { + setChatModalVisible(true); + }} + /> + {clientReady && ( + <Chat client={chatClient}> + <View style={styles.chatContainer}> + <ChannelList< + LocalAttachmentType, + LocalChannelType, + LocalCommandType, + LocalEventType, + LocalMessageType, + LocalReactionType, + LocalUserType + > + filters={memoizedFilters} + options={{ + presence: true, + state: true, + watch: true, + }} + sort={{last_message_at: -1}} + maxUnreadCount={99} + Preview={ChannelPreview} + /> + </View> + </Chat> + )} + <NewChatModal {...{modalVisible, setChatModalVisible}} /> + </SafeAreaView> + <TabsGradient /> + </View> + ); +}; + +const styles = StyleSheet.create({ + background: { + flex: 1, + backgroundColor: 'white', + }, + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + placeholder: { + fontSize: 14, + fontWeight: 'bold', + marginBottom: 10, + }, + button: { + backgroundColor: '#CCE4FC', + padding: 15, + borderRadius: 5, + }, + chatContainer: { + height: '100%', + marginTop: 10, + }, +}); + +export default ChatListScreen; diff --git a/src/screens/chat/ChatResultsCell.tsx b/src/screens/chat/ChatResultsCell.tsx new file mode 100644 index 00000000..d947c122 --- /dev/null +++ b/src/screens/chat/ChatResultsCell.tsx @@ -0,0 +1,117 @@ +import {useNavigation} from '@react-navigation/native'; +import React, {useContext, useEffect, useState} from 'react'; +import {Alert, Image, StyleSheet, Text, View} from 'react-native'; +import {TouchableOpacity} from 'react-native-gesture-handler'; +import {ChatContext} from '../../App'; +import {ERROR_FAILED_TO_CREATE_CHANNEL} from '../../constants/strings'; +import {loadImageFromURL} from '../../services'; +import {ProfilePreviewType, UserType} from '../../types'; +import {createChannel, normalize, SCREEN_WIDTH} from '../../utils'; +import {defaultUserProfile} from '../../utils/users'; + +interface ChatResults { + profileData: ProfilePreviewType; + loggedInUser: UserType; + setChatModalVisible: Function; +} + +const ChatResultsCell: React.FC<ChatResults> = ({ + profileData: {id, username, first_name, last_name, thumbnail_url}, + loggedInUser, + setChatModalVisible, +}) => { + const [avatar, setAvatar] = useState<string | undefined>(undefined); + const {chatClient, setChannel} = useContext(ChatContext); + + useEffect(() => { + (async () => { + if (thumbnail_url !== undefined) { + try { + const response = await loadImageFromURL(thumbnail_url); + if (response) { + setAvatar(response); + } + } catch (error) { + console.log('Error while downloading ', error); + throw error; + } + } + })(); + }, [thumbnail_url]); + + const navigation = useNavigation(); + const createChannelIfNotPresentAndNavigate = async () => { + try { + setChatModalVisible(false); + const channel = await createChannel(loggedInUser.userId, id, chatClient); + setChannel(channel); + setTimeout(() => { + navigation.navigate('Chat'); + }, 100); + } catch (error) { + Alert.alert(ERROR_FAILED_TO_CREATE_CHANNEL); + } + }; + + const userCell = () => { + return ( + <TouchableOpacity + onPress={createChannelIfNotPresentAndNavigate} + style={styles.cellContainer}> + <Image + defaultSource={defaultUserProfile()} + source={{uri: avatar}} + style={styles.imageContainer} + /> + <View style={[styles.initialTextContainer, styles.multiText]}> + <Text style={styles.initialTextStyle}>{`@${username}`}</Text> + <Text style={styles.secondaryTextStyle}> + {first_name + ' ' + last_name} + </Text> + </View> + </TouchableOpacity> + ); + }; + + return userCell(); +}; + +const styles = StyleSheet.create({ + cellContainer: { + flexDirection: 'row', + paddingHorizontal: 25, + paddingVertical: 15, + width: SCREEN_WIDTH, + }, + imageContainer: { + width: SCREEN_WIDTH * 0.112, + height: SCREEN_WIDTH * 0.112, + borderRadius: (SCREEN_WIDTH * 0.112) / 2, + }, + categoryBackground: { + backgroundColor: 'rgba(196, 196, 196, 0.45)', + justifyContent: 'center', + alignItems: 'center', + }, + categoryImage: { + width: '40%', + height: '40%', + }, + initialTextContainer: { + marginLeft: SCREEN_WIDTH * 0.08, + flexDirection: 'column', + justifyContent: 'center', + }, + initialTextStyle: { + fontWeight: '500', + fontSize: normalize(14), + }, + secondaryTextStyle: { + fontWeight: '500', + fontSize: normalize(12), + color: '#828282', + }, + multiText: {justifyContent: 'space-between'}, +}); + +export default ChatResultsCell; diff --git a/src/screens/chat/ChatResultsList.tsx b/src/screens/chat/ChatResultsList.tsx new file mode 100644 index 00000000..b9970772 --- /dev/null +++ b/src/screens/chat/ChatResultsList.tsx @@ -0,0 +1,102 @@ +import {useBottomTabBarHeight} from '@react-navigation/bottom-tabs'; +import React, {useEffect, useState} from 'react'; +import { + Keyboard, + SectionList, + SectionListData, + StyleSheet, + Text, + View, +} from 'react-native'; +import {useSelector} from 'react-redux'; +import {NO_RESULTS_FOUND} from '../../constants/strings'; +import {RootState} from '../../store/rootreducer'; +import {PreviewType, ScreenType} from '../../types'; +import {normalize, SCREEN_WIDTH} from '../../utils'; +import ChatResultsCell from './ChatResultsCell'; + +interface ChatResultsProps { + // TODO: make sure results come in as same type, regardless of profile, category, badges + results: SectionListData<any>[]; + previewType: PreviewType; + screenType: ScreenType; + setChatModalVisible: Function; +} + +const ChatResultsList: React.FC<ChatResultsProps> = ({ + results, + setChatModalVisible, +}) => { + const [showEmptyView, setshowEmptyView] = useState<boolean>(false); + const {user: loggedInUser} = useSelector((state: RootState) => state.user); + const tabbarHeight = useBottomTabBarHeight(); + + useEffect(() => { + if (results && results.length > 0) { + let showEmpty = true; + + results.forEach((e) => { + if (e.data.length > 0) { + showEmpty = false; + } + }); + setshowEmptyView(showEmpty); + } + }, [results]); + + return showEmptyView ? ( + <View style={styles.container} onTouchStart={Keyboard.dismiss}> + <Text style={styles.noResultsTextStyle}>{NO_RESULTS_FOUND}</Text> + </View> + ) : ( + <SectionList + onScrollBeginDrag={Keyboard.dismiss} + contentContainerStyle={[{paddingBottom: tabbarHeight}]} + sections={results} + keyExtractor={(item, index) => item.id + index} + renderItem={({item}) => ( + <ChatResultsCell + profileData={item} + setChatModalVisible={setChatModalVisible} + loggedInUser={loggedInUser} + /> + )} + stickySectionHeadersEnabled={false} + ListEmptyComponent={() => ( + <View style={styles.empty}> + <Text>Start a new chat by searching for someone</Text> + </View> + )} + /> + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + marginTop: 30, + alignItems: 'center', + }, + sectionHeaderStyle: { + width: '100%', + height: 0.5, + marginVertical: 5, + backgroundColor: '#C4C4C4', + }, + noResultsTextContainer: { + justifyContent: 'center', + flexDirection: 'row', + width: SCREEN_WIDTH, + }, + noResultsTextStyle: { + fontWeight: '500', + fontSize: normalize(14), + }, + empty: { + marginTop: 20, + justifyContent: 'center', + alignItems: 'center', + }, +}); + +export default ChatResultsList; diff --git a/src/screens/chat/ChatScreen.tsx b/src/screens/chat/ChatScreen.tsx new file mode 100644 index 00000000..59c53c99 --- /dev/null +++ b/src/screens/chat/ChatScreen.tsx @@ -0,0 +1,53 @@ +import {useBottomTabBarHeight} from '@react-navigation/bottom-tabs'; +import {StackNavigationProp} from '@react-navigation/stack'; +import React, {useContext} from 'react'; +import {StyleSheet} from 'react-native'; +import {SafeAreaView} from 'react-native-safe-area-context'; +import { + Channel, + Chat, + MessageInput, + MessageList, +} from 'stream-chat-react-native'; +import {ChatContext} from '../../App'; +import ChatHeader from '../../components/messages/ChatHeader'; +import {MainStackParams} from '../../routes'; +import {isIPhoneX} from '../../utils'; + +type ChatScreenNavigationProp = StackNavigationProp<MainStackParams, 'Chat'>; +interface ChatScreenProps { + navigation: ChatScreenNavigationProp; +} +/* + * Screen that displays all of the user's active conversations. + */ +const ChatScreen: React.FC<ChatScreenProps> = () => { + const {channel, chatClient} = useContext(ChatContext); + const tabbarHeight = useBottomTabBarHeight(); + + return ( + <SafeAreaView + style={[ + styles.container, + // unable to figure out the padding issue, a hacky solution + {paddingBottom: isIPhoneX() ? tabbarHeight + 20 : tabbarHeight + 50}, + ]}> + <ChatHeader /> + <Chat client={chatClient}> + <Channel channel={channel} keyboardVerticalOffset={0}> + <MessageList onThreadSelect={() => {}} /> + <MessageInput /> + </Channel> + </Chat> + </SafeAreaView> + ); +}; + +const styles = StyleSheet.create({ + container: { + backgroundColor: 'white', + flex: 1, + }, +}); + +export default ChatScreen; diff --git a/src/screens/chat/ChatSearchBar.tsx b/src/screens/chat/ChatSearchBar.tsx new file mode 100644 index 00000000..4916ec45 --- /dev/null +++ b/src/screens/chat/ChatSearchBar.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { + Keyboard, + NativeSyntheticEvent, + StyleSheet, + Text, + TextInput, + TextInputProps, + TextInputSubmitEditingEventData, + TouchableOpacity, + View, + ViewStyle, +} from 'react-native'; +import {normalize} from 'react-native-elements'; +import Animated, {useAnimatedStyle} from 'react-native-reanimated'; + +interface SearchBarProps extends TextInputProps { + onCancel: () => void; + animationProgress: Animated.SharedValue<number>; + searching: boolean; + placeholder: string; +} +const ChatSearchBar: React.FC<SearchBarProps> = ({ + onFocus, + onBlur, + onChangeText, + value, + onCancel, + searching, + animationProgress, + onLayout, + placeholder, +}) => { + const handleSubmit = ( + e: NativeSyntheticEvent<TextInputSubmitEditingEventData>, + ) => { + e.preventDefault(); + Keyboard.dismiss(); + }; + + /* + * On-search marginRight style ("cancel" button slides and fades in). + */ + const animatedStyles = useAnimatedStyle<ViewStyle>(() => ({ + marginRight: animationProgress.value * 58, + opacity: animationProgress.value, + })); + + 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> + <TextInput + style={[styles.input]} + placeholderTextColor={'#828282'} + onSubmitEditing={handleSubmit} + clearButtonMode="always" + autoCapitalize="none" + autoCorrect={false} + {...{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> + </View> + ); +}; + +const styles = StyleSheet.create({ + container: { + height: 40, + paddingHorizontal: 20, + flexDirection: 'row', + zIndex: 2, + }, + searchTextContainer: {marginHorizontal: 12}, + searchTextStyes: {fontWeight: 'bold', fontSize: 14, lineHeight: 17}, + inputContainer: { + flexGrow: 1, + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 8, + borderRadius: 20, + backgroundColor: '#F0F0F0', + }, + searchIcon: { + marginRight: 8, + }, + input: { + flex: 1, + fontSize: 16, + color: '#000', + letterSpacing: normalize(0.5), + }, + cancelButton: { + height: '100%', + position: 'absolute', + justifyContent: 'center', + paddingHorizontal: 8, + }, + cancelText: { + color: '#818181', + fontWeight: '500', + }, +}); + +export default ChatSearchBar; diff --git a/src/screens/chat/NewChatModal.tsx b/src/screens/chat/NewChatModal.tsx new file mode 100644 index 00000000..95e46ecd --- /dev/null +++ b/src/screens/chat/NewChatModal.tsx @@ -0,0 +1,161 @@ +import React, {useEffect, useState} from 'react'; +import { + Keyboard, + SectionListData, + StatusBar, + StyleSheet, + Text, + View, +} from 'react-native'; +import {useSharedValue} from 'react-native-reanimated'; +import {BottomDrawer} from '../../components'; +import { + SEARCH_ENDPOINT_MESSAGES, + SEARCH_ENDPOINT_SUGGESTED, +} from '../../constants'; +import {loadSearchResults} from '../../services'; +import {ScreenType} from '../../types'; +import {normalize} from '../../utils'; +import {ChatResultsList, ChatSearchBar} from './index'; +interface NewChatModalProps { + modalVisible: boolean; + setChatModalVisible: (open: boolean) => void; +} + +const NewChatModal: React.FC<NewChatModalProps> = ({ + modalVisible, + setChatModalVisible, +}) => { + const [searching, setSearching] = useState(false); + /* + * Animated value + */ + const animationProgress = useSharedValue<number>(0); + const [results, setResults] = useState<SectionListData<any>[]>([]); + const [query, setQuery] = useState<string>(''); + const handleFocus = () => { + setSearching(true); + }; + const handleBlur = () => { + Keyboard.dismiss(); + }; + const handleCancel = () => { + setSearching(false); + }; + + const getDefaultSuggested = async () => { + const searchResults = await loadSearchResults( + `${SEARCH_ENDPOINT_SUGGESTED}`, + ); + console.log(searchResults); + const sanitizedResult = [ + { + title: 'users', + data: searchResults?.users, + }, + ]; + console.log(searchResults, sanitizedResult); + setResults(sanitizedResult); + }; + + const getQuerySuggested = async () => { + const searchResults = await loadSearchResults( + `${SEARCH_ENDPOINT_MESSAGES}?query=${query}`, + ); + if (query.length > 2) { + const sanitizedResult = [ + { + title: 'users', + data: searchResults?.users, + }, + ]; + setResults(sanitizedResult); + } else { + setResults([]); + } + }; + + useEffect(() => { + if (query.length === 0) { + getDefaultSuggested(); + } + + if (!searching) { + return; + } + + if (query.length < 3) { + return; + } + getQuerySuggested(); + }, [query]); + + const _modalContent = () => { + return ( + <View style={styles.modalShadowContainer}> + <View style={styles.titleContainerStyles}> + <Text style={styles.titleTextStyles}>New Message</Text> + </View> + <ChatSearchBar + onCancel={handleCancel} + onChangeText={setQuery} + onBlur={handleBlur} + onFocus={handleFocus} + value={query} + {...{animationProgress, searching}} + placeholder={''} + /> + {results.length > 0 && ( + <View style={styles.headerContainerStyles}> + <Text style={styles.headerTextStyles}>Suggested</Text> + </View> + )} + <ChatResultsList + {...{results, setChatModalVisible}} + previewType={'Search'} + screenType={ScreenType.Search} + /> + </View> + ); + }; + + return ( + <View> + <StatusBar barStyle="dark-content" /> + <BottomDrawer + initialSnapPosition={'90%'} + isOpen={modalVisible} + setIsOpen={setChatModalVisible} + showHeader={false}> + {_modalContent()} + </BottomDrawer> + </View> + ); +}; + +const styles = StyleSheet.create({ + modalShadowContainer: { + height: '100%', + borderRadius: 9, + backgroundColor: 'white', + }, + titleContainerStyles: {marginVertical: 24}, + titleTextStyles: { + fontWeight: 'bold', + fontSize: normalize(18), + lineHeight: normalize(21), + textAlign: 'center', + }, + headerContainerStyles: { + marginTop: 26, + marginBottom: 10, + marginHorizontal: 28, + }, + headerTextStyles: { + fontWeight: 'bold', + fontSize: normalize(17), + lineHeight: normalize(20), + }, +}); + +export default NewChatModal; diff --git a/src/screens/chat/index.ts b/src/screens/chat/index.ts new file mode 100644 index 00000000..328eb8bf --- /dev/null +++ b/src/screens/chat/index.ts @@ -0,0 +1,6 @@ +export {default as ChatListScreen} from './ChatListScreen'; +export {default as ChatScreen} from './ChatScreen'; +export {default as NewChatModal} from './NewChatModal'; +export {default as ChatSearchBar} from './ChatSearchBar'; +export {default as ChatResultsList} from './ChatResultsList'; +export {default as ChatResultsCell} from './ChatResultsCell'; diff --git a/src/screens/index.ts b/src/screens/index.ts index 50ada3d1..44ae4b52 100644 --- a/src/screens/index.ts +++ b/src/screens/index.ts @@ -5,3 +5,4 @@ export * from './search'; export * from './suggestedPeople'; export * from './suggestedPeopleOnboarding'; export * from './badge'; +export * from './chat'; diff --git a/src/screens/main/NotificationsScreen.tsx b/src/screens/main/NotificationsScreen.tsx index 48e89f7a..71199c9b 100644 --- a/src/screens/main/NotificationsScreen.tsx +++ b/src/screens/main/NotificationsScreen.tsx @@ -330,7 +330,7 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'stretch', justifyContent: 'space-between', - width: SCREEN_WIDTH * 0.85, + width: SCREEN_WIDTH * 0.9, }, headerText: { fontWeight: '700', diff --git a/src/screens/onboarding/Login.tsx b/src/screens/onboarding/Login.tsx index 49ca5ff4..dd2bb2e4 100644 --- a/src/screens/onboarding/Login.tsx +++ b/src/screens/onboarding/Login.tsx @@ -160,6 +160,7 @@ const Login: React.FC<LoginProps> = ({navigation}: LoginProps) => { await AsyncStorage.setItem('token', data.token); await AsyncStorage.setItem('userId', data.UserID); await AsyncStorage.setItem('username', username); + await AsyncStorage.setItem('chatToken', data.chatToken); } if (statusCode === 200 && data.isOnboarded) { diff --git a/src/screens/profile/IndividualMoment.tsx b/src/screens/profile/IndividualMoment.tsx index 8c1dc327..871d62bf 100644 --- a/src/screens/profile/IndividualMoment.tsx +++ b/src/screens/profile/IndividualMoment.tsx @@ -27,7 +27,7 @@ interface IndividualMomentProps { navigation: IndividualMomentNavigationProp; } -const ITEM_HEIGHT = SCREEN_HEIGHT * (9 / 10); +const ITEM_HEIGHT = SCREEN_HEIGHT * 0.9; const IndividualMoment: React.FC<IndividualMomentProps> = ({ route, @@ -40,13 +40,13 @@ const IndividualMoment: React.FC<IndividualMomentProps> = ({ ); const { user: {username}, - } = userXId - ? useSelector((state: RootState) => state.userX[screenType][userXId]) - : useSelector((state: RootState) => state.user); + } = useSelector((state: RootState) => + userXId ? state.userX[screenType][userXId] : state.user, + ); - const {moments} = userXId - ? useSelector((state: RootState) => state.userX[screenType][userXId]) - : useSelector((state: RootState) => state.moments); + const {moments} = useSelector((state: RootState) => + userXId ? state.userX[screenType][userXId] : state.moments, + ); const isOwnProfile = username === loggedInUsername; const momentData = moments.filter( diff --git a/src/screens/profile/InviteFriendsScreen.tsx b/src/screens/profile/InviteFriendsScreen.tsx index a9fa1404..ad9e382e 100644 --- a/src/screens/profile/InviteFriendsScreen.tsx +++ b/src/screens/profile/InviteFriendsScreen.tsx @@ -203,7 +203,7 @@ const InviteFriendsScreen: React.FC<InviteFriendsScreenProps> = ({route}) => { </Animated.View> </View> <View style={styles.subheader}> - <Text style={styles.subheaderText}>Contacts on tagg</Text> + <Text style={styles.subheaderText}>Contacts on Tagg</Text> <UsersFromContacts /> </View> <View style={styles.subheader}> diff --git a/src/screens/profile/MomentUploadPromptScreen.tsx b/src/screens/profile/MomentUploadPromptScreen.tsx index f79c81b4..f0aaffc4 100644 --- a/src/screens/profile/MomentUploadPromptScreen.tsx +++ b/src/screens/profile/MomentUploadPromptScreen.tsx @@ -8,7 +8,7 @@ import {Moment} from '../../components'; import {Image} from 'react-native-animatable'; import {UPLOAD_MOMENT_PROMPT_ONE_MESSAGE} from '../../constants/strings'; import {PROFILE_CUTOUT_BOTTOM_Y} from '../../constants'; -import {isIPhoneX, normalize} from '../../utils'; +import {normalize} from '../../utils'; type MomentUploadPromptScreenRouteProp = RouteProp< MainStackParams, @@ -28,7 +28,12 @@ const MomentUploadPromptScreen: React.FC<MomentUploadPromptScreenProps> = ({ route, navigation, }) => { - const {screenType, momentCategory, profileBodyHeight} = route.params; + const { + screenType, + momentCategory, + profileBodyHeight, + socialsBarHeight, + } = route.params; return ( <View style={styles.container}> <CloseIcon @@ -61,9 +66,7 @@ const MomentUploadPromptScreen: React.FC<MomentUploadPromptScreenProps> = ({ externalStyles={{ container: { ...styles.momentContainer, - top: isIPhoneX() - ? profileBodyHeight + 615 - : profileBodyHeight + 500, + top: PROFILE_CUTOUT_BOTTOM_Y + profileBodyHeight + socialsBarHeight, }, titleText: styles.momentHeaderText, header: styles.momentHeader, @@ -103,20 +106,21 @@ const styles = StyleSheet.create({ //Styles to adjust moment container momentScrollContainer: { backgroundColor: 'transparent', + marginTop: 10, }, momentContainer: { ...StyleSheet.absoluteFillObject, backgroundColor: 'transparent', - height: 170, + height: 175, }, momentHeaderText: { ...StyleSheet.absoluteFillObject, marginLeft: 12, - marginTop: 10, + paddingVertical: 5, }, momentHeader: { + marginTop: 7, backgroundColor: 'transparent', - paddingVertical: 20, }, }); diff --git a/src/screens/profile/ProfileScreen.tsx b/src/screens/profile/ProfileScreen.tsx index 313e2f2c..6d9ef020 100644 --- a/src/screens/profile/ProfileScreen.tsx +++ b/src/screens/profile/ProfileScreen.tsx @@ -1,17 +1,8 @@ import React from 'react'; import {StatusBar} from 'react-native'; -import Animated from 'react-native-reanimated'; -import {Content, Cover, TabsGradient} from '../../components'; -import {RouteProp, useFocusEffect} from '@react-navigation/native'; +import {Content, TabsGradient} from '../../components'; +import {RouteProp} from '@react-navigation/native'; import {MainStackParams} from '../../routes/'; -import {resetScreenType} from '../../store/actions'; -import {useDispatch, useStore} from 'react-redux'; -import {DUMMY_USERID} from '../../store/initialStates'; - -/**r - * Profile Screen for a user's profile - * including posts, messaging, and settings - */ type ProfileScreenRouteProps = RouteProp<MainStackParams, 'Profile'>; @@ -22,32 +13,11 @@ interface ProfileOnboardingProps { const ProfileScreen: React.FC<ProfileOnboardingProps> = ({route}) => { const {screenType} = route.params; let {userXId} = route.params; - const y = Animated.useValue(0); - const dispatch = useDispatch(); - - /** - * This is a double safety check to avoid app crash. - * Checks if the required userXId is present in the store, if not userXId is set to dummy id - */ - // if (userXId && !(userXId in useStore().getState().userX[screenType])) { - // userXId = DUMMY_USERID; - // } - - /** - * Code under useFocusEffect gets executed every time the screen comes under focus / is being viewed by the user. - * This is done to reset the users stored in our store for the Search screen. - * Read more about useFocusEffect here : https://reactnavigation.org/docs/function-after-focusing-screen/ - */ - // useFocusEffect(() => { - // if (!userXId) { - // dispatch(resetScreenType(screenType)); - // } - // }); return ( <> <StatusBar barStyle="dark-content" /> - <Content {...{y, userXId, screenType}} /> + <Content {...{userXId, screenType}} /> <TabsGradient /> </> ); diff --git a/src/screens/profile/SettingsScreen.tsx b/src/screens/profile/SettingsScreen.tsx index 05e051b5..ecc3bafd 100644 --- a/src/screens/profile/SettingsScreen.tsx +++ b/src/screens/profile/SettingsScreen.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useContext} from 'react'; import { SafeAreaView, SectionList, @@ -17,6 +17,7 @@ import {BackgroundGradientType} from '../../types'; import {normalize, SCREEN_HEIGHT} from '../../utils/layouts'; import SettingsCell from './SettingsCell'; import {useNavigation} from '@react-navigation/core'; +import {ChatContext} from '../../App'; const SettingsScreen: React.FC = () => { const dispatch = useDispatch(); @@ -24,6 +25,7 @@ const SettingsScreen: React.FC = () => { const {suggested_people_linked} = useSelector( (state: RootState) => state.user.profile, ); + const {chatClient} = useContext(ChatContext); return ( <> @@ -49,7 +51,7 @@ const SettingsScreen: React.FC = () => { <TouchableOpacity style={styles.logoutContainerStyles} onPress={() => { - dispatch(logout()); + dispatch(logout(chatClient)); navigation.reset({ index: 0, routes: [{name: 'SuggestedPeople'}], diff --git a/src/screens/search/DiscoverUsers.tsx b/src/screens/search/DiscoverUsers.tsx index b87bfc37..f67585f2 100644 --- a/src/screens/search/DiscoverUsers.tsx +++ b/src/screens/search/DiscoverUsers.tsx @@ -126,7 +126,7 @@ const DiscoverUsers: React.FC<DiscoverUsersProps> = ({route}) => { ListFooterComponent={() => ( <> <Text style={styles.otherGroups}>Other Groups</Text> - <SearchCategories darkStyle={true} /> + <SearchCategories useSuggestions={true} darkStyle={true} /> </> )} /> diff --git a/src/screens/search/SearchScreen.tsx b/src/screens/search/SearchScreen.tsx index 4f0cabb4..f7e1c467 100644 --- a/src/screens/search/SearchScreen.tsx +++ b/src/screens/search/SearchScreen.tsx @@ -1,8 +1,19 @@ import AsyncStorage from '@react-native-community/async-storage'; import {useFocusEffect} from '@react-navigation/native'; import React, {useEffect, useState} from 'react'; -import {Keyboard, ScrollView, StatusBar, StyleSheet} from 'react-native'; -import Animated, {Easing, timing} from 'react-native-reanimated'; +import { + Keyboard, + StatusBar, + StyleSheet, + LayoutChangeEvent, + SectionListData, +} from 'react-native'; +import { + useSharedValue, + withTiming, + Easing, + runOnJS, +} from 'react-native-reanimated'; import {SafeAreaView} from 'react-native-safe-area-context'; import {useDispatch, useSelector} from 'react-redux'; import { @@ -13,22 +24,14 @@ import { SearchResultsBackground, TabsGradient, } from '../../components'; -import {SEARCH_ENDPOINT, TAGG_LIGHT_BLUE} from '../../constants'; +import {SEARCH_ENDPOINT} from '../../constants'; import {loadSearchResults} from '../../services'; import {resetScreenType} from '../../store/actions'; import {RootState} from '../../store/rootReducer'; -import { - CategoryPreviewType, - ProfilePreviewType, - ScreenType, - SearchCategoryType, -} from '../../types'; +import {CategoryPreviewType, ProfilePreviewType, ScreenType} from '../../types'; import { getRecentlySearchedCategories, getRecentlySearchedUsers, - normalize, - SCREEN_HEIGHT, - SCREEN_WIDTH, } from '../../utils'; /** @@ -38,11 +41,8 @@ import { const SearchScreen: React.FC = () => { const {recentSearches} = useSelector((state: RootState) => state.taggUsers); - const { - profile: {university = ''}, - } = useSelector((state: RootState) => state.user); const [query, setQuery] = useState<string>(''); - const [results, setResults] = useState<Array<any> | undefined>(undefined); + const [results, setResults] = useState<SectionListData<any>[] | undefined>(); const [recents, setRecents] = useState<Array<ProfilePreviewType>>( recentSearches ?? [], ); @@ -50,26 +50,12 @@ const SearchScreen: React.FC = () => { CategoryPreviewType[] >([]); const [searching, setSearching] = useState(false); - const top = Animated.useValue(-SCREEN_HEIGHT); - const defaultButtons: SearchCategoryType[] = [21, 22, 23, 24].map((year) => ({ - id: -1, - name: `${university.split(' ')[0]} '${year}`, - category: university, - })); - const [keyboardVisible, setKeyboardVisible] = React.useState( - 'keyboardVisible', - ); - useEffect(() => { - const showKeyboard = () => setKeyboardVisible('keyboardVisibleTrue'); - Keyboard.addListener('keyboardWillShow', showKeyboard); - return () => Keyboard.removeListener('keyboardWillShow', showKeyboard); - }, []); + /* + * Animated value + */ + const animationProgress = useSharedValue<number>(0); + const [searchBarHeight, setSearchBarHeight] = useState<number>(0); - useEffect(() => { - const hideKeyboard = () => setKeyboardVisible('keyboardVisibleFalse'); - Keyboard.addListener('keyboardWillHide', hideKeyboard); - return () => Keyboard.removeListener('keyboardWillHide', hideKeyboard); - }, []); const dispatch = useDispatch(); /* @@ -122,12 +108,22 @@ const SearchScreen: React.FC = () => { useEffect(() => { if (searching) { loadRecentlySearched().then(() => { - timing(top, topInConfig).start(); + animationProgress.value = withTiming(1, { + duration: 180, + easing: Easing.bezier(0.31, 0.14, 0.66, 0.82), + }); }); } else { setQuery(''); handleBlur(); - timing(top, topOutConfig).start(() => setResults(undefined)); + animationProgress.value = withTiming( + 0, + {duration: 180, easing: Easing.inOut(Easing.ease)}, + () => { + 'worklet'; + runOnJS(setResults)(undefined); + }, + ); } }, [searching]); @@ -153,16 +149,6 @@ const SearchScreen: React.FC = () => { } }; - const topInConfig = { - duration: 180, - toValue: 0, - easing: Easing.bezier(0.31, 0.14, 0.66, 0.82), - }; - const topOutConfig = { - duration: 180, - toValue: -SCREEN_HEIGHT, - easing: Easing.inOut(Easing.ease), - }; const handleFocus = () => { setSearching(true); }; @@ -172,9 +158,12 @@ const SearchScreen: React.FC = () => { const handleCancel = () => { setSearching(false); }; + const onSearchBarLayout = (e: LayoutChangeEvent) => { + setSearchBarHeight(e.nativeEvent.layout.height); + }; return ( - <SafeAreaView style={styles.screenContainer}> + <SafeAreaView style={styles.container}> <StatusBar barStyle="dark-content" /> <SearchBar onCancel={handleCancel} @@ -182,98 +171,39 @@ const SearchScreen: React.FC = () => { onBlur={handleBlur} onFocus={handleFocus} value={query} - {...{top, searching}} + onLayout={onSearchBarLayout} + {...{animationProgress, searching}} /> - <ScrollView - scrollEnabled={!searching} - keyboardShouldPersistTaps={'always'} - stickyHeaderIndices={[4]} - contentContainerStyle={styles.contentContainer} - showsVerticalScrollIndicator={false}> - <SearchCategories defaultButtons={defaultButtons} /> - <SearchResultsBackground {...{top}}> - {results === undefined && - recents.length + recentCategories.length !== 0 ? ( + <SearchCategories useSuggestions={false} /> + <SearchResultsBackground + {...{searching, searchBarHeight, animationProgress}}> + {results === undefined ? ( + recents.length + recentCategories.length > 0 && ( <RecentSearches sectionTitle="Recent" onPress={clearRecentlySearched} screenType={ScreenType.Search} {...{recents, recentCategories}} /> - ) : ( - <SearchResultList - {...{results}} - keyboardVisible={keyboardVisible === 'keyboardVisibleTrue'} - previewType={'Search'} - screenType={ScreenType.Search} - /> - )} - </SearchResultsBackground> - </ScrollView> + ) + ) : ( + <SearchResultList + {...{results}} + previewType={'Search'} + screenType={ScreenType.Search} + /> + )} + </SearchResultsBackground> <TabsGradient /> </SafeAreaView> ); }; const styles = StyleSheet.create({ - screenContainer: { + container: { + flex: 1, paddingTop: 15, backgroundColor: '#fff', }, - contentContainer: { - height: SCREEN_HEIGHT, - paddingTop: '2%', - paddingBottom: SCREEN_HEIGHT / 3, - paddingHorizontal: '3%', - }, - header: { - marginVertical: 20, - zIndex: 1, - }, - recentsHeaderContainer: { - flexDirection: 'row', - }, - recentsHeader: { - fontSize: 17, - fontWeight: 'bold', - flexGrow: 1, - }, - clear: { - fontSize: normalize(17), - fontWeight: 'bold', - color: TAGG_LIGHT_BLUE, - }, - image: { - width: SCREEN_WIDTH, - height: SCREEN_WIDTH, - }, - textContainer: { - marginTop: '10%', - }, - headerText: { - color: '#fff', - fontSize: normalize(32), - fontWeight: '600', - textAlign: 'center', - marginBottom: '4%', - marginHorizontal: '10%', - }, - subtext: { - color: '#fff', - fontSize: normalize(16), - fontWeight: '600', - textAlign: 'center', - marginHorizontal: '10%', - }, - cancelButton: { - position: 'absolute', - height: '100%', - justifyContent: 'center', - paddingHorizontal: 5, - }, - cancelText: { - color: '#818181', - fontWeight: '600', - }, }); export default SearchScreen; diff --git a/src/screens/suggestedPeople/SPBody.tsx b/src/screens/suggestedPeople/SPBody.tsx index 824f8b1c..fa69d812 100644 --- a/src/screens/suggestedPeople/SPBody.tsx +++ b/src/screens/suggestedPeople/SPBody.tsx @@ -3,26 +3,18 @@ import React, {Fragment, useEffect, useMemo, useState} from 'react'; import {StyleSheet, Text, View} from 'react-native'; import {Image} from 'react-native-animatable'; import {TouchableOpacity} from 'react-native-gesture-handler'; -import Animated from 'react-native-reanimated'; -import {useStore} from 'react-redux'; import RequestedButton from '../../assets/ionicons/requested-button.svg'; -import {TaggsBar} from '../../components'; +import {SPTaggsBar} from '../../components'; import {BadgesDropdown, MutualFriends} from '../../components/suggestedPeople'; import {BADGE_DATA} from '../../constants/badges'; -import {RootState} from '../../store/rootReducer'; import { ProfilePreviewType, ScreenType, SuggestedPeopleDataType, UniversityBadge, } from '../../types'; -import { - canViewProfile, - isIPhoneX, - normalize, - SCREEN_HEIGHT, - SCREEN_WIDTH, -} from '../../utils'; +import {isIPhoneX, normalize, SCREEN_HEIGHT, SCREEN_WIDTH} from '../../utils'; +import {useSharedValue} from 'react-native-reanimated'; interface SPBodyProps { item: SuggestedPeopleDataType; @@ -56,7 +48,6 @@ const SPBody: React.FC<SPBodyProps> = ({ }[] >([]); const navigation = useNavigation(); - const state: RootState = useStore().getState(); useEffect(() => { const newBadges: {badge: UniversityBadge; img: any}[] = []; const findBadgeIcons = (badge: UniversityBadge) => { @@ -159,12 +150,9 @@ const SPBody: React.FC<SPBodyProps> = ({ {user.id !== loggedInUserId && <FriendButton />} </View> </View> - <TaggsBar - y={Animated.useValue(0)} + <SPTaggsBar userXId={user.id === loggedInUserId ? undefined : user.id} - profileBodyHeight={0} screenType={screenType} - whiteRing={true} linkedSocials={social_links} /> <View style={styles.marginManager}> diff --git a/src/screens/suggestedPeople/SuggestedPeopleScreen.tsx b/src/screens/suggestedPeople/SuggestedPeopleScreen.tsx index a296351f..d6812f41 100644 --- a/src/screens/suggestedPeople/SuggestedPeopleScreen.tsx +++ b/src/screens/suggestedPeople/SuggestedPeopleScreen.tsx @@ -226,7 +226,7 @@ const SuggestedPeopleScreen: React.FC = () => { /> ); }} - keyExtractor={(item, index) => index.toString()} + keyExtractor={(_, index) => index.toString()} showsVerticalScrollIndicator={false} onViewableItemsChanged={onViewableItemsChanged} onEndReached={() => setPage(page + 1)} diff --git a/src/store/actions/user.ts b/src/store/actions/user.ts index e7d985ac..3ebd4190 100644 --- a/src/store/actions/user.ts +++ b/src/store/actions/user.ts @@ -1,4 +1,5 @@ import AsyncStorage from '@react-native-community/async-storage'; +import {StreamChat} from 'stream-chat'; import {Action, ThunkAction} from '@reduxjs/toolkit'; import { getProfilePic, @@ -164,13 +165,16 @@ export const updateReplyPosted = ( } }; -export const logout = (): ThunkAction< - Promise<void>, - RootState, - unknown, - Action<string> -> => async (dispatch) => { +export const logout = ( + client?: StreamChat, +): ThunkAction<Promise<void>, RootState, unknown, Action<string>> => async ( + dispatch, +) => { try { + // do our best effort here to gracefully disconnect the user + if (client) { + client.disconnectUser(); + } await AsyncStorage.clear(); dispatch({type: userLoggedIn.type, payload: {userId: '', username: ''}}); } catch (error) { diff --git a/src/types/types.ts b/src/types/types.ts index 766bf798..376c4be0 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -1,4 +1,5 @@ import Animated from 'react-native-reanimated'; +import {Channel as ChannelType, StreamChat} from 'stream-chat'; export interface UserType { userId: string; @@ -142,6 +143,7 @@ export enum ScreenType { Search, Notifications, SuggestedPeople, + Chat, } /** @@ -214,6 +216,7 @@ export interface ContentProps { screenType: ScreenType; setScrollEnabled: (enabled: boolean) => void; profileBodyHeight: number; + socialsBarHeight: number; scrollViewRef: React.MutableRefObject<null>; } @@ -287,3 +290,41 @@ export type ContactType = { export type UniversityBadgeType = 'Search' | 'Crest'; export type BadgeDataType = Record<UniversityType, any[]>; + +// Stream Chat Types +export type LocalAttachmentType = Record<string, unknown>; +export type LocalChannelType = Record<string, unknown>; +export type LocalCommandType = string; +export type LocalEventType = Record<string, unknown>; +export type LocalMessageType = Record<string, unknown>; +export type LocalResponseType = Record<string, unknown>; +export type LocalReactionType = Record<string, unknown>; +export type LocalUserType = Record<string, unknown>; +export type ChannelGroupedType = ChannelType< + LocalAttachmentType, + LocalChannelType, + LocalCommandType, + LocalEventType, + LocalMessageType, + LocalResponseType, + LocalUserType +>; + +export type ChatContextType = { + channel: ChannelGroupedType | undefined; + setChannel: React.Dispatch< + React.SetStateAction< + | ChannelType< + LocalAttachmentType, + LocalChannelType, + LocalCommandType, + LocalEventType, + LocalMessageType, + LocalResponseType, + LocalUserType + > + | undefined + > + >; + chatClient: StreamChat; +}; diff --git a/src/utils/common.ts b/src/utils/common.ts index 4f31af8e..0900a084 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -1,14 +1,14 @@ +import AsyncStorage from '@react-native-community/async-storage'; +import moment from 'moment'; +import {Linking} from 'react-native'; +import {getAll} from 'react-native-contacts'; +import {BROWSABLE_SOCIAL_URLS, TOGGLE_BUTTON_TYPE} from '../constants'; import { ContactType, NotificationType, - UniversityType, UniversityBadgeType, + UniversityType, } from './../types/types'; -import moment from 'moment'; -import {Linking} from 'react-native'; -import {BROWSABLE_SOCIAL_URLS, TOGGLE_BUTTON_TYPE} from '../constants'; -import AsyncStorage from '@react-native-community/async-storage'; -import {getAll} from 'react-native-contacts'; export const getToggleButtonText: ( buttonType: string, @@ -173,3 +173,21 @@ const _crestIcon = (university: UniversityType) => { return require('../assets/images/bwbadges.png'); } }; + +export const createChannel = async ( + loggedInUser: string, + id: string, + chatClient: any, +) => { + console.log(loggedInUser, id, chatClient); + try { + const channel = chatClient.channel('messaging', { + members: [loggedInUser, id], + }); + await channel.watch(); + return channel; + } catch (error) { + console.log(error); + throw error; + } +}; diff --git a/src/utils/layouts.ts b/src/utils/layouts.ts index e2f1f0b1..4d0d557d 100644 --- a/src/utils/layouts.ts +++ b/src/utils/layouts.ts @@ -31,6 +31,7 @@ export const StatusBarHeight = Platform.select({ }); export const AvatarHeaderHeight = (HeaderHeight + StatusBarHeight) * 1.3; +export const ChatHeaderHeight = (HeaderHeight + StatusBarHeight) * 1.1; /** * This is a function for normalizing the font size for different devices, based on iphone 8. diff --git a/src/utils/messages.ts b/src/utils/messages.ts new file mode 100644 index 00000000..d63f2b7a --- /dev/null +++ b/src/utils/messages.ts @@ -0,0 +1,83 @@ +import moment from 'moment'; +import {RootState} from '../store/rootReducer'; +import {ChannelGroupedType} from '../types'; + +/** + * Finds the difference in time in minutes + * @param lastActive given time e.g. "2021-04-08T19:07:09.361300983Z" + * @returns diff in minutes + */ +const _diffInMinutes = (lastActive: string | undefined) => { + if (!lastActive) { + return undefined; + } + return moment().diff(moment(lastActive), 'minutes'); +}; + +/** + * Formats the last activity status. + * - "Active now" (≤ 5 minutes) + * - "Seen X minutes ago" (5 > x ≥ 59 minutes) + * - "Seen X hours ago" (x = [1, 2]) + * - "Offline" + * @param lastActive given time e.g. "2021-04-08T19:07:09.361300983Z" + * @returns + */ +export const formatLastSeenText = (lastActive: string | undefined) => { + const diff = _diffInMinutes(lastActive); + if (!diff) { + return 'Offline'; + } + if (diff <= 5) { + return 'Active now'; + } + if (diff <= 59) { + return `Seen ${diff} minutes ago`; + } + if (diff <= 180) { + const hours = Math.floor(diff / 60); + return `Seen ${hours} hours ago`; + } + return 'Offline'; +}; + +/** + * Checks if a lastActive timestamp is considered Online or not. + * + * A user is online if last active is ≤ 15 minutes. + * + * @param lastActive given time e.g. "2021-04-08T19:07:09.361300983Z" + * @returns True if active + */ +export const isOnline = (lastActive: string | undefined) => { + if (!lastActive) { + return false; + } + const diff = _diffInMinutes(lastActive); + if (!diff) { + return false; + } + return diff <= 15; +}; + +/** + * Gets the other member in the channel. + * @param channel the current chat channel + * @param state the current redux state + * @returns other member or undefined + */ +export const getMember = ( + channel: ChannelGroupedType | undefined, + state: RootState, +) => { + if (!channel) { + return undefined; + } + const loggedInUserId = state.user.user.userId; + const otherMembers = channel + ? Object.values(channel.state.members).filter( + (member) => member.user?.id !== loggedInUserId, + ) + : []; + return otherMembers.length === 1 ? otherMembers[0] : undefined; +}; diff --git a/src/utils/moments.ts b/src/utils/moments.ts index 7428b1ac..87f062af 100644 --- a/src/utils/moments.ts +++ b/src/utils/moments.ts @@ -1,15 +1,17 @@ import moment from 'moment'; -//A util that calculates the difference between a given time and current time -//Returns the difference in the largest possible unit of time (days > hours > minutes > seconds) - +/** + * Formats elapsed time from a given time. + * @param date_time given time + * @returns difference in the largest possible unit of time (days > hours > minutes > seconds) + */ export const getTimePosted = (date_time: string) => { const datePosted = moment(date_time); const now = moment(); var time = date_time; var difference = now.diff(datePosted, 'seconds'); - //Creating elapsedTime string to display to user + // Creating elapsedTime string to display to user // 0 to less than 1 minute if (difference < 60) { time = difference + ' seconds'; @@ -19,15 +21,19 @@ export const getTimePosted = (date_time: string) => { difference = now.diff(datePosted, 'minutes'); time = difference + (difference === 1 ? ' minute' : ' minutes'); } - //1 hour to less than 1 day + // 1 hour to less than 1 day else if (difference >= 60 * 60 && difference < 24 * 60 * 60) { difference = now.diff(datePosted, 'hours'); time = difference + (difference === 1 ? ' hour' : ' hours'); } - //Any number of days - else if (difference >= 24 * 60 * 60) { + // Any number of days + else if (difference >= 24 * 60 * 60 && difference < 24 * 60 * 60 * 3) { difference = now.diff(datePosted, 'days'); time = difference + (difference === 1 ? ' day' : ' days'); } + // More than 3 days + else if (difference >= 24 * 60 * 60 * 3) { + time = datePosted.format('MMMM D, YYYY'); + } return time; }; |