diff options
Diffstat (limited to 'src')
33 files changed, 830 insertions, 408 deletions
diff --git a/src/assets/icons/notificationPrompts/message_notification-07.png b/src/assets/icons/notificationPrompts/message_notification-07.png Binary files differnew file mode 100644 index 00000000..b0db08ea --- /dev/null +++ b/src/assets/icons/notificationPrompts/message_notification-07.png diff --git a/src/components/comments/AddComment.tsx b/src/components/comments/AddComment.tsx index 2a8c773e..9cf10b5e 100644 --- a/src/components/comments/AddComment.tsx +++ b/src/components/comments/AddComment.tsx @@ -1,45 +1,50 @@ -import React, {useEffect, useRef} from 'react'; +import React, {useContext, useEffect, useRef, useState} from 'react'; import { Keyboard, KeyboardAvoidingView, Platform, StyleSheet, + TextInput, View, } from 'react-native'; -import {TextInput, TouchableOpacity} from 'react-native-gesture-handler'; +import {MentionInput} from 'react-native-controlled-mentions'; +import {TouchableOpacity} from 'react-native-gesture-handler'; import {useDispatch, useSelector} from 'react-redux'; import UpArrowIcon from '../../assets/icons/up_arrow.svg'; import {TAGG_LIGHT_BLUE} from '../../constants'; +import {CommentContext} from '../../screens/profile/MomentCommentsScreen'; import {postComment} from '../../services'; import {updateReplyPosted} from '../../store/actions'; import {RootState} from '../../store/rootreducer'; +import {CommentThreadType, CommentType} from '../../types'; import {SCREEN_HEIGHT, SCREEN_WIDTH} from '../../utils'; +import {mentionPartTypes} from '../../utils/comments'; import {Avatar} from '../common'; -/** - * This file provides the add comment view for a user. - * Displays the logged in user's profile picture to the left and then provides space to add a comment. - * Comment is posted when enter is pressed as requested by product team. - */ - export interface AddCommentProps { - setNewCommentsAvailable: Function; - objectId: string; + momentId: string; placeholderText: string; - isCommentInFocus: boolean; } -const AddComment: React.FC<AddCommentProps> = ({ - setNewCommentsAvailable, - objectId, - placeholderText, - isCommentInFocus, -}) => { - const [comment, setComment] = React.useState(''); - const [keyboardVisible, setKeyboardVisible] = React.useState(false); - +const AddComment: React.FC<AddCommentProps> = ({momentId, placeholderText}) => { + const {setShouldUpdateAllComments, commentTapped} = useContext( + CommentContext, + ); + const [inReplyToMention, setInReplyToMention] = useState(''); + const [comment, setComment] = useState(''); + const [keyboardVisible, setKeyboardVisible] = useState(false); const {avatar} = useSelector((state: RootState) => state.user); const dispatch = useDispatch(); + const ref = useRef<TextInput>(null); + const isReplyingToComment = + commentTapped !== undefined && !('parent_comment' in commentTapped); + const isReplyingToReply = + commentTapped !== undefined && 'parent_comment' in commentTapped; + const objectId: string = commentTapped + ? 'parent_comment' in commentTapped + ? (commentTapped as CommentThreadType).parent_comment.comment_id + : (commentTapped as CommentType).comment_id + : momentId; const addComment = async () => { const trimmed = comment.trim(); @@ -47,18 +52,19 @@ const AddComment: React.FC<AddCommentProps> = ({ return; } const postedComment = await postComment( - trimmed, + inReplyToMention + trimmed, objectId, - isCommentInFocus, + isReplyingToComment || isReplyingToReply, ); if (postedComment) { setComment(''); + setInReplyToMention(''); //Set new reply posted object //This helps us show the latest reply on top //Data set is kind of stale but it works - if (isCommentInFocus) { + if (isReplyingToComment || isReplyingToReply) { dispatch( updateReplyPosted({ comment_id: postedComment.comment_id, @@ -66,7 +72,7 @@ const AddComment: React.FC<AddCommentProps> = ({ }), ); } - setNewCommentsAvailable(true); + setShouldUpdateAllComments(true); } }; @@ -82,14 +88,18 @@ const AddComment: React.FC<AddCommentProps> = ({ return () => Keyboard.removeListener('keyboardWillHide', hideKeyboard); }, []); - const ref = useRef<TextInput>(null); - - //If a comment is in Focus, bring the keyboard up so user is able to type in a reply useEffect(() => { - if (isCommentInFocus) { + if (isReplyingToComment || isReplyingToReply) { + // bring up keyboard ref.current?.focus(); } - }, [isCommentInFocus]); + if (commentTapped && isReplyingToReply) { + const commenter = (commentTapped as CommentThreadType).commenter; + setInReplyToMention(`@[${commenter.username}](${commenter.id}) `); + } else { + setInReplyToMention(''); + } + }, [isReplyingToComment, isReplyingToReply, commentTapped]); return ( <KeyboardAvoidingView @@ -102,14 +112,18 @@ const AddComment: React.FC<AddCommentProps> = ({ ]}> <View style={styles.textContainer}> <Avatar style={styles.avatar} uri={avatar} /> - <TextInput - style={styles.text} + <MentionInput + containerStyle={styles.text} placeholder={placeholderText} - placeholderTextColor="grey" - onChangeText={setComment} - value={comment} - multiline={true} - ref={ref} + value={inReplyToMention + comment} + onChange={(newText: string) => { + // skipping the `inReplyToMention` text + setComment( + newText.substring(inReplyToMention.length, newText.length), + ); + }} + inputRef={ref} + partTypes={mentionPartTypes('blue')} /> <View style={styles.submitButton}> <TouchableOpacity style={styles.submitButton} onPress={addComment}> @@ -141,6 +155,7 @@ const styles = StyleSheet.create({ flex: 1, padding: '1%', marginHorizontal: '1%', + maxHeight: 100, }, avatar: { height: 35, diff --git a/src/components/comments/CommentTile.tsx b/src/components/comments/CommentTile.tsx index 34eef418..ecdb4c30 100644 --- a/src/components/comments/CommentTile.tsx +++ b/src/components/comments/CommentTile.tsx @@ -1,119 +1,114 @@ -/* eslint-disable radix */ -import React, {Fragment, useEffect, useRef, useState} from 'react'; -import {Text, View} from 'react-native-animatable'; -import {ProfilePreview} from '../profile'; -import {CommentType, ScreenType, TypeOfComment} from '../../types'; +import {useNavigation} from '@react-navigation/native'; +import React, {Fragment, useContext, useEffect, useRef, useState} from 'react'; import {Alert, Animated, StyleSheet} from 'react-native'; -import ClockIcon from '../../assets/icons/clock-icon-01.svg'; -import {TAGG_LIGHT_BLUE} from '../../constants'; +import {Text, View} from 'react-native-animatable'; import {RectButton, TouchableOpacity} from 'react-native-gesture-handler'; -import {getTimePosted, normalize, SCREEN_WIDTH} from '../../utils'; +import Swipeable from 'react-native-gesture-handler/Swipeable'; +import {useDispatch, useSelector, useStore} from 'react-redux'; import Arrow from '../../assets/icons/back-arrow-colored.svg'; +import ClockIcon from '../../assets/icons/clock-icon-01.svg'; import Trash from '../../assets/ionicons/trash-outline.svg'; -import CommentsContainer from './CommentsContainer'; -import Swipeable from 'react-native-gesture-handler/Swipeable'; -import {deleteComment, getCommentsCount} from '../../services'; +import {TAGG_LIGHT_BLUE} from '../../constants'; import {ERROR_FAILED_TO_DELETE_COMMENT} from '../../constants/strings'; -import {useSelector} from 'react-redux'; +import {CommentContext} from '../../screens/profile/MomentCommentsScreen'; +import {deleteComment, getCommentsCount} from '../../services'; import {RootState} from '../../store/rootReducer'; +import { + CommentThreadType, + CommentType, + ScreenType, + UserType, +} from '../../types'; +import { + getTimePosted, + navigateToProfile, + normalize, + SCREEN_WIDTH, +} from '../../utils'; +import {mentionPartTypes, renderTextWithMentions} from '../../utils/comments'; +import {ProfilePreview} from '../profile'; +import CommentsContainer from './CommentsContainer'; /** * Displays users's profile picture, comment posted by them and the time difference between now and when a comment was posted. */ interface CommentTileProps { - comment_object: CommentType; + commentObject: CommentType | CommentThreadType; screenType: ScreenType; - typeOfComment: TypeOfComment; - setCommentObjectInFocus?: (comment: CommentType | undefined) => void; - newCommentsAvailable: boolean; - setNewCommentsAvailable: (available: boolean) => void; + isThread: boolean; + shouldUpdateParent: boolean; + setShouldUpdateParent: (update: boolean) => void; canDelete: boolean; } const CommentTile: React.FC<CommentTileProps> = ({ - comment_object, + commentObject, screenType, - typeOfComment, - setCommentObjectInFocus, - newCommentsAvailable, - setNewCommentsAvailable, + setShouldUpdateParent, + shouldUpdateParent, canDelete, + isThread, }) => { - const timePosted = getTimePosted(comment_object.date_created); + const {setCommentTapped} = useContext(CommentContext); + const timePosted = getTimePosted(commentObject.date_created); const [showReplies, setShowReplies] = useState<boolean>(false); const [showKeyboard, setShowKeyboard] = useState<boolean>(false); - const [newThreadAvailable, setNewThreadAvailable] = useState(true); + const [shouldUpdateChild, setShouldUpdateChild] = useState(true); const swipeRef = useRef<Swipeable>(null); - const isThread = typeOfComment === 'Thread'; - const {replyPosted} = useSelector((state: RootState) => state.user); + const state: RootState = useStore().getState(); + const navigation = useNavigation(); + const dispatch = useDispatch(); - /** - * Bubbling up, for handling a new comment in a thread. - */ useEffect(() => { - if (newCommentsAvailable) { - setNewThreadAvailable(true); + if (shouldUpdateParent) { + setShouldUpdateChild(true); } - }, [newCommentsAvailable]); + }, [shouldUpdateParent]); useEffect(() => { - if (replyPosted && typeOfComment === 'Comment') { - if (replyPosted.parent_comment.comment_id === comment_object.comment_id) { + if (replyPosted && !isThread) { + if (replyPosted.parent_comment.comment_id === commentObject.comment_id) { setShowReplies(true); } } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [replyPosted]); - /** - * Case : A COMMENT IS IN FOCUS && REPLY SECTION IS HIDDEN - * Bring the current comment to focus - * Case : No COMMENT IS IN FOCUS && REPLY SECTION IS SHOWN - * Unfocus comment in focus - */ const toggleAddComment = () => { - //Do not allow user to reply to a thread - if (!isThread) { - if (setCommentObjectInFocus) { - if (!showKeyboard) { - setCommentObjectInFocus(comment_object); - } else { - setCommentObjectInFocus(undefined); - } - } - setShowKeyboard(!showKeyboard); - } + setCommentTapped(commentObject); + setShowKeyboard(!showKeyboard); }; const toggleReplies = async () => { - if (showReplies) { + if (showReplies && isThread) { + const comment = (commentObject as CommentThreadType).parent_comment; //To update count of replies in case we deleted a reply - comment_object.replies_count = parseInt( - await getCommentsCount(comment_object.comment_id, true), + comment.replies_count = parseInt( + await getCommentsCount(comment.comment_id, true), + 10, ); } - setNewThreadAvailable(true); + setShouldUpdateChild(true); setShowReplies(!showReplies); }; /** * Method to compute text to be shown for replies button */ - const getRepliesText = () => + const getRepliesText = (comment: CommentType) => showReplies ? 'Hide' - : comment_object.replies_count > 0 - ? `Replies (${comment_object.replies_count})` + : comment.replies_count > 0 + ? `Replies (${comment.replies_count})` : 'Replies'; const renderRightAction = (text: string, color: string) => { const pressHandler = async () => { swipeRef.current?.close(); - const success = await deleteComment(comment_object.comment_id, isThread); + const success = await deleteComment(commentObject.comment_id, isThread); if (success) { - setNewCommentsAvailable(true); + setShouldUpdateParent(true); } else { Alert.alert(ERROR_FAILED_TO_DELETE_COMMENT); } @@ -149,12 +144,18 @@ const CommentTile: React.FC<CommentTileProps> = ({ <View style={[styles.container, isThread ? styles.moreMarginWithThread : {}]}> <ProfilePreview - profilePreview={comment_object.commenter} + profilePreview={commentObject.commenter} previewType={'Comment'} screenType={screenType} /> <TouchableOpacity style={styles.body} onPress={toggleAddComment}> - <Text style={styles.comment}>{comment_object.comment}</Text> + {renderTextWithMentions({ + value: commentObject.comment, + styles: styles.comment, + partTypes: mentionPartTypes('blue'), + onPress: (user: UserType) => + navigateToProfile(state, dispatch, navigation, screenType, user), + })} <View style={styles.clockIconAndTime}> <ClockIcon style={styles.clockIcon} /> <Text style={styles.date_time}>{' ' + timePosted}</Text> @@ -162,11 +163,13 @@ const CommentTile: React.FC<CommentTileProps> = ({ </View> </TouchableOpacity> {/*** Show replies text only if there are some replies present */} - {typeOfComment === 'Comment' && comment_object.replies_count > 0 && ( + {!isThread && (commentObject as CommentType).replies_count > 0 && ( <TouchableOpacity style={styles.repliesTextAndIconContainer} onPress={toggleReplies}> - <Text style={styles.repliesText}>{getRepliesText()}</Text> + <Text style={styles.repliesText}> + {getRepliesText(commentObject as CommentType)} + </Text> <Arrow width={12} height={11} @@ -183,12 +186,11 @@ const CommentTile: React.FC<CommentTileProps> = ({ {showReplies && ( <View> <CommentsContainer - objectId={comment_object.comment_id} + objectId={commentObject.comment_id} screenType={screenType} - setNewCommentsAvailable={setNewThreadAvailable} - newCommentsAvailable={newThreadAvailable} - typeOfComment={'Thread'} - commentId={replyPosted?.comment_id} + shouldUpdate={shouldUpdateChild} + setShouldUpdate={setShouldUpdateChild} + isThread={true} /> </View> )} diff --git a/src/components/comments/CommentsContainer.tsx b/src/components/comments/CommentsContainer.tsx index 3dc8a71c..cd9ecb02 100644 --- a/src/components/comments/CommentsContainer.tsx +++ b/src/components/comments/CommentsContainer.tsx @@ -1,24 +1,23 @@ -import React, {useEffect, useRef, useState} from 'react'; +import moment from 'moment'; +import React, {useContext, useEffect, useRef, useState} from 'react'; import {StyleSheet} from 'react-native'; import {FlatList} from 'react-native-gesture-handler'; import {useDispatch, useSelector} from 'react-redux'; -import CommentTile from './CommentTile'; +import {CommentContext} from '../../screens/profile/MomentCommentsScreen'; import {getComments} from '../../services'; import {updateReplyPosted} from '../../store/actions'; import {RootState} from '../../store/rootReducer'; -import {CommentType, ScreenType, TypeOfComment} from '../../types'; +import {CommentThreadType, CommentType, ScreenType} from '../../types'; import {SCREEN_HEIGHT} from '../../utils'; +import CommentTile from './CommentTile'; + export type CommentsContainerProps = { screenType: ScreenType; - //objectId can be either moment_id or comment_id objectId: string; commentId?: string; - setCommentsLength?: (count: number) => void; - newCommentsAvailable: boolean; - setNewCommentsAvailable: (value: boolean) => void; - typeOfComment: TypeOfComment; - setCommentObjectInFocus?: (comment: CommentType | undefined) => void; - commentObjectInFocus?: CommentType; + shouldUpdate: boolean; + setShouldUpdate: (update: boolean) => void; + isThread: boolean; }; /** @@ -28,121 +27,85 @@ export type CommentsContainerProps = { const CommentsContainer: React.FC<CommentsContainerProps> = ({ screenType, objectId, - setCommentsLength, - newCommentsAvailable, - setNewCommentsAvailable, - typeOfComment, - setCommentObjectInFocus, - commentObjectInFocus, + isThread, + shouldUpdate, + setShouldUpdate, commentId, }) => { + const {setCommentsLength, commentTapped} = useContext(CommentContext); const {username: loggedInUsername} = useSelector( (state: RootState) => state.user.user, ); const [commentsList, setCommentsList] = useState<CommentType[]>([]); const dispatch = useDispatch(); const ref = useRef<FlatList<CommentType>>(null); + const ITEM_HEIGHT = SCREEN_HEIGHT / 7.0; useEffect(() => { const loadComments = async () => { - await getComments(objectId, typeOfComment === 'Thread').then( - (comments) => { - if (comments && subscribedToLoadComments) { - setCommentsList(comments); - if (setCommentsLength) { - setCommentsLength(comments.length); - } - setNewCommentsAvailable(false); + await getComments(objectId, isThread).then((comments) => { + if (comments && subscribedToLoadComments) { + setCommentsList(comments); + if (setCommentsLength) { + setCommentsLength(comments.length); } - }, - ); + setShouldUpdate(false); + } + }); }; let subscribedToLoadComments = true; - if (newCommentsAvailable) { + if (shouldUpdate) { loadComments(); } return () => { subscribedToLoadComments = false; }; - }, [ - dispatch, - objectId, - newCommentsAvailable, - setNewCommentsAvailable, - setCommentsLength, - typeOfComment, - ]); - - // eslint-disable-next-line no-shadow - const swapCommentTo = (commentId: string, toIndex: number) => { - const index = commentsList.findIndex( - (item) => item.comment_id === commentId, - ); - if (index > 0) { - let comments = [...commentsList]; - const temp = comments[index]; - comments[index] = comments[toIndex]; - comments[toIndex] = temp; - setCommentsList(comments); - } - }; + }, [shouldUpdate]); + // scrolls to the comment useEffect(() => { - //Scroll only if a new comment and not a reply was posted - const shouldScroll = () => - typeOfComment === 'Comment' && !commentObjectInFocus; - - const performAction = () => { - if (commentId) { - swapCommentTo(commentId, 0); - } else if (shouldScroll()) { - setTimeout(() => { - ref.current?.scrollToEnd({animated: true}); - }, 500); + if (commentId) { + const index = commentsList.findIndex( + (item) => item.comment_id === commentId, + ); + if (index > 0) { + let comments = [...commentsList]; + const temp = comments[index]; + comments[index] = comments[0]; + comments[0] = temp; + setCommentsList(comments); } - }; - if (commentsList) { - //Bring the relevant comment to top if a comment id is present else scroll if necessary - performAction(); + } else if (!isThread && !commentTapped) { + setTimeout(() => { + ref.current?.scrollToEnd({animated: true}); + }, 500); } - - //Clean up the reply id present in store return () => { - if (commentId && typeOfComment === 'Thread') { + if (commentId && isThread) { setTimeout(() => { dispatch(updateReplyPosted(undefined)); }, 200); } }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [commentsList, commentId]); - - //WIP : TODO : Bring the comment in focus above the keyboard - // useEffect(() => { - // if (commentObjectInFocus && commentsList.length >= 3) { - // swapCommentTo(commentObjectInFocus.comment_id, 2); - // } - // // eslint-disable-next-line react-hooks/exhaustive-deps - // }, [commentObjectInFocus]); - - const ITEM_HEIGHT = SCREEN_HEIGHT / 7.0; + }, [commentId]); - const renderComment = ({item}: {item: CommentType}) => ( + const renderComment = ({item}: {item: CommentType | CommentThreadType}) => ( <CommentTile key={item.comment_id} - comment_object={item} + commentObject={item} screenType={screenType} - typeOfComment={typeOfComment} - setCommentObjectInFocus={setCommentObjectInFocus} - newCommentsAvailable={newCommentsAvailable} - setNewCommentsAvailable={setNewCommentsAvailable} + isThread={isThread} + shouldUpdateParent={shouldUpdate} + setShouldUpdateParent={setShouldUpdate} canDelete={item.commenter.username === loggedInUsername} /> ); return ( <FlatList - data={commentsList} + data={commentsList.sort( + (a, b) => moment(a.date_created).unix() - moment(b.date_created).unix(), + )} ref={ref} keyExtractor={(item, index) => index.toString()} decelerationRate={'fast'} diff --git a/src/components/common/BadgeDetailView.tsx b/src/components/common/BadgeDetailView.tsx index bc4384e8..6504300c 100644 --- a/src/components/common/BadgeDetailView.tsx +++ b/src/components/common/BadgeDetailView.tsx @@ -1,24 +1,15 @@ import {useNavigation} from '@react-navigation/core'; import React, {useEffect, useState} from 'react'; -import { - ActivityIndicator, - FlatList, - Image, - Modal, - StyleSheet, - Text, - View, -} from 'react-native'; +import {FlatList, Image, Modal, StyleSheet, Text, View} from 'react-native'; import {TouchableOpacity} from 'react-native-gesture-handler'; import LinearGradient from 'react-native-linear-gradient'; -import {useSelector} from 'react-redux'; +import {useDispatch, useSelector} from 'react-redux'; import CloseIcon from '../../assets/ionicons/close-outline.svg'; import {BADGE_GRADIENT_FIRST} from '../../constants'; import {BADGE_DATA} from '../../constants/badges'; -import {getSuggestedPeopleProfile, removeBadgesService} from '../../services'; import {RootState} from '../../store/rootreducer'; -import {ScreenType, UniversityBadge} from '../../types'; -import {getUniversityBadge, normalize} from '../../utils'; +import {ScreenType} from '../../types'; +import {getUniversityBadge, normalize, removeUserBadge} from '../../utils'; interface BadgeDetailModalProps { userXId: string | undefined; @@ -35,38 +26,21 @@ const BadgeDetailView: React.FC<BadgeDetailModalProps> = ({ isEditable = true, setBadgeViewVisible, }) => { - const {user, profile} = useSelector((state: RootState) => + const dispatch = useDispatch(); + const { + user, + profile: {university, badges}, + } = useSelector((state: RootState) => userXId ? state.userX[screenType][userXId] : state.user, ); const navigation = useNavigation(); - const [selectedBadges, setSelectedBadges] = useState<UniversityBadge[]>([]); - const [isLoading, setIsLoading] = useState(true); const [selectedBadgesWithImage, setSelectedBadgesWithImage] = useState<any[]>( [], ); - const fetchBadges = async () => { - if (user.userId) { - const response = await getSuggestedPeopleProfile(user.userId); - if (response) { - const data = response.badges; - let extractedBadgeNames: UniversityBadge[] = []; - data.forEach((badge) => { - extractedBadgeNames.push(badge); - }); - setSelectedBadges(extractedBadgeNames); - } - } - }; - - useEffect(() => { - setIsLoading(true); - fetchBadges(); - }, []); - useEffect(() => { let badgesWithImage = []; - selectedBadges.forEach((e) => { + badges.forEach((e) => { const uniData = BADGE_DATA[e.university]; const categoryData = uniData.filter((u) => { return u.title === e.category; @@ -81,14 +55,11 @@ const BadgeDetailView: React.FC<BadgeDetailModalProps> = ({ }); setTimeout(() => { setSelectedBadgesWithImage(badgesWithImage); - setIsLoading(false); }, 250); - }, [selectedBadges]); + }, [badges]); - const removeBadgeCell = async (badge: string) => { - setIsLoading(true); - await removeBadgesService([badge], user.userId); - fetchBadges(); + const removeBadgeCell = async (badgeName: string) => { + await removeUserBadge(badges, badgeName, user.userId, dispatch); }; const badgeEditCell = ({item: {id, name, badgeImage}}) => { @@ -175,38 +146,27 @@ const BadgeDetailView: React.FC<BadgeDetailModalProps> = ({ <View style={styles.modalImageContainerStyles}> <Image style={styles.modalImageStyles} - source={getUniversityBadge(profile.university, 'Crest')} + source={getUniversityBadge(university, 'Crest')} /> </View> </View> {modalHeader()} - {!isLoading && ( - <View> - <FlatList - contentContainerStyle={styles.modalListStyles} - scrollEnabled={false} - data={selectedBadgesWithImage} - numColumns={3} - renderItem={badgeEditCell} - keyExtractor={(item) => item.id.toString()} - /> - </View> - )} - {isLoading && _loaderView()} + <View> + <FlatList + contentContainerStyle={styles.modalListStyles} + scrollEnabled={false} + data={selectedBadgesWithImage} + numColumns={3} + renderItem={badgeEditCell} + keyExtractor={(item) => item.id.toString()} + /> + </View> {isEditable && addButton()} </View> </View> ); }; - const _loaderView = () => { - return ( - <View style={styles.loaderStyles}> - <ActivityIndicator animating={isLoading} size="large" color="black" /> - </View> - ); - }; - return ( <Modal animationType="fade" @@ -269,7 +229,6 @@ const styles = StyleSheet.create({ lineHeight: normalize(20.29), textAlign: 'center', }, - loaderStyles: {justifyContent: 'center', marginVertical: 20}, modalSubheadingStyles: { fontWeight: '600', fontSize: normalize(11), diff --git a/src/components/common/TaggPrompt.tsx b/src/components/common/TaggPrompt.tsx index 6b59d4a5..0bf25c3c 100644 --- a/src/components/common/TaggPrompt.tsx +++ b/src/components/common/TaggPrompt.tsx @@ -1,13 +1,22 @@ import React from 'react'; -import {StyleSheet, Text, TouchableOpacity} from 'react-native'; +import { + ImageStyle, + StyleProp, + StyleSheet, + Text, + TouchableOpacity, +} from 'react-native'; import {Image, View} from 'react-native-animatable'; import CloseIcon from '../../assets/ionicons/close-outline.svg'; import {normalize, SCREEN_HEIGHT} from '../../utils'; +import {useNavigation} from '@react-navigation/core'; type TaggPromptProps = { messageHeader: string; messageBody: string | Element; - logoType: 'plus' | 'tagg' | 'invite_friends' | 'private_accounts'; + logoType: 'plus' | 'tagg' | 'invite_friends' | 'private_accounts' | 'chat'; + logoLink?: string; + externalStyles?: Record<string, StyleProp<ImageStyle>>; hideCloseButton?: boolean; noPadding?: boolean; onClose: () => void; @@ -17,6 +26,8 @@ const TaggPrompt: React.FC<TaggPromptProps> = ({ messageHeader, messageBody, logoType, + logoLink, + externalStyles, hideCloseButton, noPadding, onClose, @@ -33,6 +44,8 @@ const TaggPrompt: React.FC<TaggPromptProps> = ({ return require('../../assets/icons/notificationPrompts/invite-friends-prompt-icon.png'); case 'private_accounts': return require('../../assets/icons/notificationPrompts/private-accounts-prompt-icon.png'); + case 'chat': + return require('../../assets/icons/notificationPrompts/message_notification-07.png'); case 'tagg': default: return require('../../assets/images/logo-purple.png'); @@ -42,9 +55,18 @@ const TaggPrompt: React.FC<TaggPromptProps> = ({ const topPadding = {paddingTop: noPadding ? 0 : SCREEN_HEIGHT / 10}; const bottomPadding = {paddingBottom: noPadding ? 0 : SCREEN_HEIGHT / 50}; + const navigation = useNavigation(); + return ( <View style={[styles.container, topPadding, bottomPadding]}> - <Image style={styles.icon} source={logo()} /> + <TouchableOpacity + disabled={logoLink ? false : true} + onPress={() => logoLink && navigation.navigate(logoLink)}> + <Image + style={externalStyles?.icon ? externalStyles.icon : styles.icon} + source={logo()} + /> + </TouchableOpacity> <Text style={styles.header}>{messageHeader}</Text> <Text style={styles.subtext}>{messageBody}</Text> {!hideCloseButton && ( diff --git a/src/components/common/TaggTypeahead.tsx b/src/components/common/TaggTypeahead.tsx new file mode 100644 index 00000000..7cd99278 --- /dev/null +++ b/src/components/common/TaggTypeahead.tsx @@ -0,0 +1,75 @@ +import React, {Fragment, useEffect, useState} from 'react'; +import {ScrollView, StyleSheet} from 'react-native'; +import {MentionSuggestionsProps} from 'react-native-controlled-mentions'; +import {SEARCH_ENDPOINT_MESSAGES} from '../../constants'; +import {loadSearchResults} from '../../services'; +import {ProfilePreviewType} from '../../types'; +import {SCREEN_WIDTH} from '../../utils'; +import TaggUserRowCell from './TaggUserRowCell'; + +const TaggTypeahead: React.FC<MentionSuggestionsProps> = ({ + keyword, + onSuggestionPress, +}) => { + const [results, setResults] = useState<ProfilePreviewType[]>([]); + const [height, setHeight] = useState(0); + + useEffect(() => { + getQuerySuggested(); + }, [keyword]); + + const getQuerySuggested = async () => { + if (!keyword || keyword.length < 3) { + setResults([]); + return; + } + const searchResults = await loadSearchResults( + `${SEARCH_ENDPOINT_MESSAGES}?query=${keyword}`, + ); + if (searchResults && searchResults.users) { + setResults(searchResults.users); + } + }; + + if (results.length === 0) { + return <Fragment />; + } + + return ( + <ScrollView + style={[styles.container, {top: -(height + 30)}]} + showsVerticalScrollIndicator={false} + onLayout={(event) => { + setHeight(event.nativeEvent.layout.height); + }}> + {results.map((user) => ( + <TaggUserRowCell + onPress={() => { + onSuggestionPress({ + id: user.id, + name: user.username, + }); + setResults([]); + }} + user={user} + /> + ))} + </ScrollView> + ); +}; + +const styles = StyleSheet.create({ + container: { + marginLeft: SCREEN_WIDTH * 0.05, + width: SCREEN_WIDTH * 0.9, + maxHeight: 264, + borderRadius: 10, + backgroundColor: 'white', + position: 'absolute', + alignSelf: 'center', + zIndex: 1, + borderWidth: 1, + }, +}); + +export default TaggTypeahead; diff --git a/src/components/common/TaggUserRowCell.tsx b/src/components/common/TaggUserRowCell.tsx new file mode 100644 index 00000000..446dedc9 --- /dev/null +++ b/src/components/common/TaggUserRowCell.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import {StyleSheet, Text, TouchableOpacity, View} from 'react-native'; +import {ProfilePreviewType} from '../../types'; +import {normalize} from '../../utils'; +import Avatar from './Avatar'; + +type TaggUserRowCellProps = { + onPress: () => void; + user: ProfilePreviewType; +}; +const TaggUserRowCell: React.FC<TaggUserRowCellProps> = ({onPress, user}) => { + return ( + <TouchableOpacity onPress={onPress} style={styles.container}> + <Avatar style={styles.image} uri={user.thumbnail_url} /> + <View style={styles.textContent}> + <Text style={styles.username}>{`@${user.username}`}</Text> + <Text style={styles.name}> + {user.first_name} {user.last_name} + </Text> + </View> + </TouchableOpacity> + ); +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + paddingHorizontal: 25, + paddingVertical: 15, + width: '100%', + }, + image: { + width: normalize(30), + height: normalize(30), + borderRadius: 30, + }, + textContent: { + flexDirection: 'column', + justifyContent: 'space-between', + marginLeft: 20, + }, + username: { + fontWeight: '500', + fontSize: normalize(14), + }, + name: { + fontWeight: '500', + fontSize: normalize(12), + color: '#828282', + }, +}); +export default TaggUserRowCell; diff --git a/src/components/common/index.ts b/src/components/common/index.ts index 802cf505..b38056c6 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -24,3 +24,5 @@ export {default as TaggSquareButton} from './TaggSquareButton'; export {default as GradientBorderButton} from './GradientBorderButton'; export {default as BasicButton} from './BasicButton'; export {default as Avatar} from './Avatar'; +export {default as TaggTypeahead} from './TaggTypeahead'; +export {default as TaggUserRowCell} from './TaggUserRowCell'; diff --git a/src/components/moments/MomentPostContent.tsx b/src/components/moments/MomentPostContent.tsx index d68ceaa3..45186ba1 100644 --- a/src/components/moments/MomentPostContent.tsx +++ b/src/components/moments/MomentPostContent.tsx @@ -1,8 +1,17 @@ +import {useNavigation} from '@react-navigation/native'; import React, {useEffect} from 'react'; import {Image, StyleSheet, Text, View, ViewProps} from 'react-native'; +import {useDispatch, useStore} from 'react-redux'; import {getCommentsCount} from '../../services'; -import {ScreenType} from '../../types'; -import {getTimePosted, SCREEN_HEIGHT, SCREEN_WIDTH} from '../../utils'; +import {RootState} from '../../store/rootReducer'; +import {ScreenType, UserType} from '../../types'; +import { + getTimePosted, + navigateToProfile, + SCREEN_HEIGHT, + SCREEN_WIDTH, +} from '../../utils'; +import {mentionPartTypes, renderTextWithMentions} from '../../utils/comments'; import {CommentsCount} from '../comments'; interface MomentPostContentProps extends ViewProps { @@ -22,6 +31,9 @@ const MomentPostContent: React.FC<MomentPostContentProps> = ({ }) => { const [elapsedTime, setElapsedTime] = React.useState<string>(); const [comments_count, setCommentsCount] = React.useState(''); + const state: RootState = useStore().getState(); + const navigation = useNavigation(); + const dispatch = useDispatch(); useEffect(() => { const fetchCommentsCount = async () => { @@ -47,7 +59,13 @@ const MomentPostContent: React.FC<MomentPostContentProps> = ({ /> <Text style={styles.text}>{elapsedTime}</Text> </View> - <Text style={styles.captionText}>{caption}</Text> + {renderTextWithMentions({ + value: caption, + styles: styles.captionText, + partTypes: mentionPartTypes('white'), + onPress: (user: UserType) => + navigateToProfile(state, dispatch, navigation, screenType, user), + })} </View> ); }; @@ -84,7 +102,7 @@ const styles = StyleSheet.create({ marginLeft: '5%', marginRight: '5%', color: '#ffffff', - fontWeight: 'bold', + fontWeight: '500', }, }); export default MomentPostContent; diff --git a/src/components/moments/MomentPostHeader.tsx b/src/components/moments/MomentPostHeader.tsx index 20d9150a..d2e9fc49 100644 --- a/src/components/moments/MomentPostHeader.tsx +++ b/src/components/moments/MomentPostHeader.tsx @@ -1,4 +1,4 @@ -import React, {useState} from 'react'; +import React, {useEffect, useState} from 'react'; import { StyleSheet, Text, @@ -51,6 +51,10 @@ const MomentPostHeader: React.FC<MomentPostHeaderProps> = ({ }); }; + useEffect(() => { + setDrawerVisible(drawerVisible); + }, [drawerVisible]); + return ( <View style={[styles.container, style]}> <TouchableOpacity onPress={navigateToProfile} style={styles.header}> diff --git a/src/components/notifications/Notification.tsx b/src/components/notifications/Notification.tsx index a74480b4..ae884b42 100644 --- a/src/components/notifications/Notification.tsx +++ b/src/components/notifications/Notification.tsx @@ -26,6 +26,8 @@ import { } from '../../types'; import { fetchUserX, + getTimeInShorthand, + normalize, SCREEN_HEIGHT, SCREEN_WIDTH, userXInStore, @@ -47,6 +49,7 @@ const Notification: React.FC<NotificationProps> = (props) => { notification_type, notification_object, unread, + timestamp, }, screenType, loggedInUser, @@ -231,7 +234,12 @@ const Notification: React.FC<NotificationProps> = (props) => { {notification_type === 'SYSTEM_MSG' ? ( // Only verbage <View style={styles.contentContainer}> - <Text style={styles.actorName}>{verbage}</Text> + <View style={styles.textContainerStyles}> + <Text style={styles.actorName}>{verbage}</Text> + <Text style={styles.timeStampStyles}> + {getTimeInShorthand(timestamp)} + </Text> + </View> </View> ) : ( <> @@ -242,8 +250,17 @@ const Notification: React.FC<NotificationProps> = (props) => { {first_name} {last_name} </Text> </TouchableWithoutFeedback> - <TouchableWithoutFeedback onPress={onNotificationTap}> - <Text>{verbage}</Text> + <TouchableWithoutFeedback + style={styles.textContainerStyles} + onPress={onNotificationTap}> + <Text style={styles.verbageStyles}> + {verbage} + <Text style={styles.timeStampStyles}> + {' '} + {getTimeInShorthand(timestamp)} + </Text> + </Text> + {/* <Text style={styles.verbageStyles}>{verbage}</Text> */} </TouchableWithoutFeedback> </View> {/* Friend request accept/decline button */} @@ -304,22 +321,40 @@ const styles = StyleSheet.create({ contentContainer: { flex: 5, marginLeft: '5%', + marginRight: '3%', height: '80%', flexDirection: 'column', justifyContent: 'space-around', }, actorName: { - fontSize: 15, + fontSize: normalize(12), fontWeight: '700', + lineHeight: normalize(14.32), }, moment: { - height: 42, - width: 42, - right: '5%', + height: normalize(42), + width: normalize(42), }, buttonsContainer: { height: '80%', }, + textContainerStyles: { + flexDirection: 'row', + flexWrap: 'wrap', + }, + verbageStyles: { + fontWeight: '500', + fontSize: normalize(11), + lineHeight: normalize(13.13), + }, + timeStampStyles: { + fontWeight: '700', + fontSize: normalize(12), + lineHeight: normalize(14.32), + marginHorizontal: 2, + color: '#828282', + textAlignVertical: 'center', + }, imageFlex: { flex: 1, }, diff --git a/src/components/notifications/NotificationPrompts.tsx b/src/components/notifications/NotificationPrompts.tsx index 386b45e6..06f6ecc6 100644 --- a/src/components/notifications/NotificationPrompts.tsx +++ b/src/components/notifications/NotificationPrompts.tsx @@ -1,5 +1,6 @@ import React, {Fragment} from 'react'; import {Image, StyleSheet, Text} from 'react-native'; +import {normalize, SCREEN_WIDTH} from '../../utils'; import {TaggPrompt} from '../common'; export const InviteFriendsPrompt: React.FC = () => { @@ -32,6 +33,28 @@ export const PrivateAccountsPrompt: React.FC = () => { ); }; +export const NewChatPrompt: React.FC = () => { + const handWaveRegex = '\u{1F44B}'; + const message = `Introducing messaging, another way to engage with\nfriends on campus! Send a ${handWaveRegex} to a friend now!`; + return ( + <TaggPrompt + messageHeader={'Chat!'} + messageBody={message} + logoType={'chat'} + logoLink={'ChatList'} + externalStyles={{ + icon: { + width: SCREEN_WIDTH * 0.9, + height: normalize(70), + }, + }} + hideCloseButton={true} + noPadding={true} + onClose={() => {}} + /> + ); +}; + interface SPPromptNotificationProps { showSPNotifyPopUp: boolean; } diff --git a/src/components/profile/ProfileBody.tsx b/src/components/profile/ProfileBody.tsx index ea1e5166..3d654724 100644 --- a/src/components/profile/ProfileBody.tsx +++ b/src/components/profile/ProfileBody.tsx @@ -1,3 +1,4 @@ +import {useNavigation} from '@react-navigation/core'; import React, {useContext} from 'react'; import { Alert, @@ -7,30 +8,29 @@ import { Text, View, } from 'react-native'; -import {normalize} from 'react-native-elements'; import {useDispatch, useSelector, useStore} from 'react-redux'; +import {ChatContext} from '../../App'; import {TAGG_DARK_BLUE, TOGGLE_BUTTON_TYPE} from '../../constants'; +import {ERROR_UNABLE_CONNECT_CHAT} from '../../constants/strings'; import { acceptFriendRequest, declineFriendRequest, updateUserXFriends, updateUserXProfileAllScreens, } from '../../store/actions'; -import {canViewProfile} from '../../utils/users'; import {NO_PROFILE} from '../../store/initialStates'; import {RootState} from '../../store/rootReducer'; import {ScreenType} from '../../types'; import { createChannel, getUserAsProfilePreviewType, + normalize, SCREEN_HEIGHT, SCREEN_WIDTH, } from '../../utils'; -import {FriendsButton, BasicButton} from '../common'; +import {canViewProfile} from '../../utils/users'; +import {BasicButton, FriendsButton} from '../common'; import ToggleButton from './ToggleButton'; -import {ChatContext} from '../../App'; -import {useNavigation} from '@react-navigation/core'; -import {ERROR_UNABLE_CONNECT_CHAT} from '../../constants/strings'; interface ProfileBodyProps { onLayout: (event: LayoutChangeEvent) => void; @@ -185,15 +185,15 @@ const styles = StyleSheet.create({ }, username: { fontWeight: '600', - fontSize: normalize(12), + fontSize: normalize(13.5), marginBottom: '1%', }, biography: { - fontSize: normalize(12), + fontSize: normalize(13.5), marginBottom: '1.5%', }, website: { - fontSize: normalize(12), + fontSize: normalize(13.5), color: TAGG_DARK_BLUE, marginBottom: '1%', }, diff --git a/src/components/profile/ProfileHeader.tsx b/src/components/profile/ProfileHeader.tsx index 3102937b..14f7dc71 100644 --- a/src/components/profile/ProfileHeader.tsx +++ b/src/components/profile/ProfileHeader.tsx @@ -85,6 +85,10 @@ const ProfileHeader: React.FC<ProfileHeaderProps> = ({ } }; + useEffect(() => { + setDrawerVisible(drawerVisible); + }, [drawerVisible]); + return ( <View ref={containerRef} style={styles.container}> <ProfileMoreInfoDrawer @@ -95,6 +99,7 @@ const ProfileHeader: React.FC<ProfileHeaderProps> = ({ userXName={userXName} setIsOpen={setDrawerVisible} /> + {userId === loggedInUserId && measure && ( <BadgeTutorial uniIconProps={{ diff --git a/src/components/search/SearchBar.tsx b/src/components/search/SearchBar.tsx index ea36d58b..25ea3b59 100644 --- a/src/components/search/SearchBar.tsx +++ b/src/components/search/SearchBar.tsx @@ -1,6 +1,7 @@ import React, {useEffect, useState} from 'react'; import { Keyboard, + LayoutChangeEvent, NativeSyntheticEvent, StyleSheet, Text, @@ -10,14 +11,12 @@ import { TouchableOpacity, View, ViewStyle, - LayoutChangeEvent, } from 'react-native'; -import {normalize} from 'react-native-elements'; 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} from '../../utils'; +import {getSearchSuggestions, normalize} from '../../utils'; const AnimatedIcon = Animated.createAnimatedComponent(Icon); @@ -166,9 +165,9 @@ const styles = StyleSheet.create({ }, input: { flex: 1, - fontSize: 16, + fontSize: normalize(15), color: '#000', - letterSpacing: normalize(0.5), + letterSpacing: 0.5, }, cancelButton: { height: '100%', diff --git a/src/constants/api.ts b/src/constants/api.ts index 53392fb5..e5ce9e77 100644 --- a/src/constants/api.ts +++ b/src/constants/api.ts @@ -61,6 +61,7 @@ export const UPDATE_BADGES_ENDPOINT: string = SP_USERS_ENDPOINT + 'update_badges/'; export const REMOVE_BADGES_ENDPOINT: string = SP_USERS_ENDPOINT + 'remove_badges/'; +export const GET_USER_BADGES_ENDPOINT: string = SP_USERS_ENDPOINT + 'get_badges/'; // Register as FCM device export const FCM_ENDPOINT: string = API_URL + 'fcm/'; diff --git a/src/screens/badge/BadgeSelection.tsx b/src/screens/badge/BadgeSelection.tsx index 38a2b01c..d0dcfa4c 100644 --- a/src/screens/badge/BadgeSelection.tsx +++ b/src/screens/badge/BadgeSelection.tsx @@ -8,14 +8,20 @@ import LinearGradient from 'react-native-linear-gradient'; import {useDispatch, useSelector} from 'react-redux'; import {BACKGROUND_GRADIENT_MAP} from '../../constants'; import {BADGE_DATA} from '../../constants/badges'; -import {ERROR_BADGES_EXCEED_LIMIT} from '../../constants/strings'; +import { + ERROR_BADGES_EXCEED_LIMIT, + SUCCESS_BADGES_UPDATE, +} from '../../constants/strings'; import {MainStackParams} from '../../routes'; import { addBadgesService, - getSuggestedPeopleProfile, + getBadgesService, updateBadgesService, } from '../../services'; -import {suggestedPeopleBadgesFinished} from '../../store/actions'; +import { + suggestedPeopleBadgesFinished, + updateUserBadges, +} from '../../store/actions'; import {RootState} from '../../store/rootReducer'; import {BackgroundGradientType} from '../../types'; import {SCREEN_HEIGHT, StatusBarHeight} from '../../utils'; @@ -36,37 +42,47 @@ const BadgeSelection: React.FC<BadgeSelectionProps> = ({route}) => { const {editing} = route.params; const { user: {userId: loggedInUserId}, - profile: {university}, + profile: {university, badges}, } = useSelector((state: RootState) => state.user); const [selectedBadges, setSelectedBadges] = useState<string[]>([]); const dispatch = useDispatch(); const navigation = useNavigation(); - // Loading badges data and extracting into a string [] + // Extracting badges data into a string [] useEffect(() => { const loadData = async () => { - const response = await getSuggestedPeopleProfile(loggedInUserId); - if (response) { - const data = response.badges; - let extractedBadgeNames: string[] = []; - data.forEach((badge) => { - extractedBadgeNames.push(badge.name); - }); - setSelectedBadges(extractedBadgeNames); - } + let extractedBadgeNames: string[] = []; + badges.forEach((badge) => { + extractedBadgeNames.push(badge.name); + }); + setSelectedBadges(extractedBadgeNames); }; if (editing) { loadData(); } }, []); + // Retrieve updated badges using get badges service and udpate the store + const loadUserBadges = async () => { + const newBadges = await getBadgesService(loggedInUserId); + dispatch(updateUserBadges(newBadges)); + }; + navigation.setOptions({ headerRight: () => ( <TouchableOpacity style={styles.rightButtonContainer} onPress={async () => { if (editing) { - await updateBadgesService(selectedBadges, university); + const success = await updateBadgesService( + selectedBadges, + university, + ); + if (success === true) { + // Load updated badges to store + loadUserBadges(); + Alert.alert(SUCCESS_BADGES_UPDATE); + } if (navigation.canGoBack()) { navigation.goBack(); } else { @@ -82,6 +98,7 @@ const BadgeSelection: React.FC<BadgeSelectionProps> = ({route}) => { ); if (success) { dispatch(suggestedPeopleBadgesFinished()); + loadUserBadges(); navigation.navigate('SuggestedPeople'); } } else { diff --git a/src/screens/chat/ChatScreen.tsx b/src/screens/chat/ChatScreen.tsx index 5874b8b6..17618867 100644 --- a/src/screens/chat/ChatScreen.tsx +++ b/src/screens/chat/ChatScreen.tsx @@ -1,6 +1,6 @@ -import {useBottomTabBarHeight} from '@react-navigation/bottom-tabs'; +import {useFocusEffect} from '@react-navigation/core'; import {StackNavigationProp} from '@react-navigation/stack'; -import React, {useContext, useEffect} from 'react'; +import React, {useCallback, useContext, useEffect} from 'react'; import {StyleSheet} from 'react-native'; import {SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context'; import { @@ -9,8 +9,8 @@ import { DeepPartial, MessageInput, MessageList, - useAttachmentPickerContext, Theme, + useAttachmentPickerContext, } from 'stream-chat-react-native'; import {ChatContext} from '../../App'; import { @@ -19,12 +19,11 @@ import { DateHeader, MessageAvatar, MessageFooter, - TabsGradient, TypingIndicator, } from '../../components'; import {MainStackParams} from '../../routes'; import {ScreenType} from '../../types'; -import {HeaderHeight, isIPhoneX, normalize, SCREEN_WIDTH} from '../../utils'; +import {HeaderHeight, normalize, SCREEN_WIDTH} from '../../utils'; type ChatScreenNavigationProp = StackNavigationProp<MainStackParams, 'Chat'>; interface ChatScreenProps { @@ -33,9 +32,8 @@ interface ChatScreenProps { /* * Screen that displays all of the user's active conversations. */ -const ChatScreen: React.FC<ChatScreenProps> = () => { +const ChatScreen: React.FC<ChatScreenProps> = ({navigation}) => { const {channel, chatClient} = useContext(ChatContext); - const tabbarHeight = useBottomTabBarHeight(); const {setTopInset} = useAttachmentPickerContext(); const insets = useSafeAreaInsets(); const chatTheme: DeepPartial<Theme> = { @@ -54,6 +52,9 @@ const ChatScreen: React.FC<ChatScreenProps> = () => { backgroundColor: '#f8f8f8', height: 70, }, + inputBox: { + fontSize: 16, + }, }, avatar: { container: { @@ -105,6 +106,11 @@ const ChatScreen: React.FC<ChatScreenProps> = () => { borderBottomRightRadius: 10, borderTopRightRadius: 10, }, + markdown: { + text: { + fontSize: 16, + }, + }, }, status: { statusContainer: {}, @@ -129,12 +135,25 @@ const ChatScreen: React.FC<ChatScreenProps> = () => { setTopInset(insets.top + HeaderHeight); }); + //Function to get the parent TabBar navigator and setting the option for this screen. + useFocusEffect( + useCallback(() => { + navigation.dangerouslyGetParent()?.setOptions({ + tabBarVisible: false, + }); + return () => { + navigation.dangerouslyGetParent()?.setOptions({ + tabBarVisible: true, + }); + }; + }, [navigation]), + ); + return ( <SafeAreaView style={[ styles.container, - // unable to figure out the padding issue, a hacky solution - {paddingBottom: isIPhoneX() ? tabbarHeight + 20 : tabbarHeight + 50}, + styles.textBoxStyles, // Update : removed hacky soln for a common height. Original : unable to figure out the padding issue, a hacky solution {paddingBottom: isIPhoneX() ? tabbarHeight + 20 : tabbarHeight + 50}, ]}> <ChatHeader screenType={ScreenType.Chat} /> <Chat client={chatClient} style={chatTheme}> @@ -157,7 +176,6 @@ const ChatScreen: React.FC<ChatScreenProps> = () => { <MessageInput Input={ChatInput} /> </Channel> </Chat> - <TabsGradient /> </SafeAreaView> ); }; @@ -167,6 +185,7 @@ const styles = StyleSheet.create({ backgroundColor: 'white', flex: 1, }, + textBoxStyles: {paddingBottom: 60}, }); export default ChatScreen; diff --git a/src/screens/chat/ChatSearchBar.tsx b/src/screens/chat/ChatSearchBar.tsx index 3531111b..91018d4c 100644 --- a/src/screens/chat/ChatSearchBar.tsx +++ b/src/screens/chat/ChatSearchBar.tsx @@ -10,8 +10,8 @@ import { TouchableOpacity, View, } from 'react-native'; -import {normalize} from 'react-native-elements'; import Animated from 'react-native-reanimated'; +import {normalize} from '../../utils'; interface SearchBarProps extends TextInputProps { onCancel: () => void; @@ -76,9 +76,9 @@ const styles = StyleSheet.create({ }, input: { flex: 1, - fontSize: 16, + fontSize: normalize(16), color: '#000', - letterSpacing: normalize(0.5), + letterSpacing: 0.5, }, cancelButton: { justifyContent: 'center', diff --git a/src/screens/main/NotificationsScreen.tsx b/src/screens/main/NotificationsScreen.tsx index 9fbc4cfe..7e2f082c 100644 --- a/src/screens/main/NotificationsScreen.tsx +++ b/src/screens/main/NotificationsScreen.tsx @@ -20,7 +20,7 @@ import {useDispatch, useSelector} from 'react-redux'; import FindFriendsBlueIcon from '../../assets/icons/findFriends/find-friends-blue-icon.svg'; import {TabsGradient} from '../../components'; import {Notification} from '../../components/notifications'; -import {PrivateAccountsPrompt} from '../../components/notifications/NotificationPrompts'; +import {NewChatPrompt} from '../../components/notifications/NotificationPrompts'; import { loadUserNotifications, updateNewNotificationReceived, @@ -297,7 +297,7 @@ const NotificationsScreen: React.FC = () => { renderItem={renderNotification} renderSectionHeader={renderSectionHeader} renderSectionFooter={renderSectionFooter} - ListHeaderComponent={<PrivateAccountsPrompt />} + ListHeaderComponent={<NewChatPrompt />} refreshControl={ <RefreshControl refreshing={refreshing} onRefresh={onRefresh} /> } diff --git a/src/screens/onboarding/legacy/OnboardingStepOne.tsx b/src/screens/onboarding/legacy/OnboardingStepOne.tsx index b25d41fd..ac0b646d 100644 --- a/src/screens/onboarding/legacy/OnboardingStepOne.tsx +++ b/src/screens/onboarding/legacy/OnboardingStepOne.tsx @@ -270,4 +270,4 @@ const styles = StyleSheet.create({ }, }); -export default OnboardingStepOne;
\ No newline at end of file +export default OnboardingStepOne; diff --git a/src/screens/onboarding/legacy/OnboardingStepTwo.tsx b/src/screens/onboarding/legacy/OnboardingStepTwo.tsx index 40130263..645fddef 100644 --- a/src/screens/onboarding/legacy/OnboardingStepTwo.tsx +++ b/src/screens/onboarding/legacy/OnboardingStepTwo.tsx @@ -378,4 +378,4 @@ const styles = StyleSheet.create({ }, }); -export default OnboardingStepTwo;
\ No newline at end of file +export default OnboardingStepTwo; diff --git a/src/screens/profile/CaptionScreen.tsx b/src/screens/profile/CaptionScreen.tsx index 156ee41c..a41abba6 100644 --- a/src/screens/profile/CaptionScreen.tsx +++ b/src/screens/profile/CaptionScreen.tsx @@ -11,9 +11,10 @@ import { TouchableWithoutFeedback, View, } from 'react-native'; +import {MentionInput} from 'react-native-controlled-mentions'; import {Button} from 'react-native-elements'; import {useDispatch, useSelector} from 'react-redux'; -import {SearchBackground, TaggBigInput} from '../../components'; +import {SearchBackground} from '../../components'; import {CaptionScreenHeader} from '../../components/'; import TaggLoadingIndicator from '../../components/common/TaggLoadingIndicator'; import {TAGG_LIGHT_BLUE_2} from '../../constants'; @@ -26,6 +27,7 @@ import { } from '../../store/actions'; import {RootState} from '../../store/rootReducer'; import {SCREEN_WIDTH, StatusBarHeight} from '../../utils'; +import {mentionPartTypes} from '../../utils/comments'; /** * Upload Screen to allow users to upload posts to Tagg @@ -49,10 +51,6 @@ const CaptionScreen: React.FC<CaptionScreenProps> = ({route, navigation}) => { const [caption, setCaption] = useState(''); const [loading, setLoading] = useState(false); - const handleCaptionUpdate = (newCaption: string) => { - setCaption(newCaption); - }; - const navigateToProfile = () => { //Since the logged In User is navigating to own profile, useXId is not required navigation.navigate('Profile', { @@ -112,12 +110,13 @@ const CaptionScreen: React.FC<CaptionScreenProps> = ({route, navigation}) => { source={{uri: image.path}} resizeMode={'cover'} /> - <TaggBigInput - style={styles.text} - multiline + <MentionInput + containerStyle={styles.text} placeholder="Write something....." placeholderTextColor="gray" - onChangeText={handleCaptionUpdate} + value={caption} + onChange={setCaption} + partTypes={mentionPartTypes('blue')} /> </View> </KeyboardAvoidingView> diff --git a/src/screens/profile/MomentCommentsScreen.tsx b/src/screens/profile/MomentCommentsScreen.tsx index b0208f6f..1a913e58 100644 --- a/src/screens/profile/MomentCommentsScreen.tsx +++ b/src/screens/profile/MomentCommentsScreen.tsx @@ -7,13 +7,8 @@ import {AddComment} from '../../components/'; import CommentsContainer from '../../components/comments/CommentsContainer'; import {ADD_COMMENT_TEXT} from '../../constants/strings'; import {headerBarOptions, MainStackParams} from '../../routes/main'; -import {CommentType} from '../../types'; -import { - HeaderHeight, - normalize, - SCREEN_HEIGHT, - SCREEN_WIDTH, -} from '../../utils'; +import {CommentThreadType, CommentType} from '../../types'; +import {HeaderHeight, SCREEN_HEIGHT, SCREEN_WIDTH} from '../../utils'; /** * Comments Screen for an image uploaded @@ -30,18 +25,35 @@ interface MomentCommentsScreenProps { route: MomentCommentsScreenRouteProps; } +type MomentCommentContextType = { + commentTapped: CommentType | CommentThreadType | undefined; + setCommentTapped: ( + comment: CommentType | CommentThreadType | undefined, + ) => void; + shouldUpdateAllComments: boolean; + setShouldUpdateAllComments: (available: boolean) => void; + commentsLength: number; + setCommentsLength: (length: number) => void; +}; + +export const CommentContext = React.createContext( + {} as MomentCommentContextType, +); + const MomentCommentsScreen: React.FC<MomentCommentsScreenProps> = ({route}) => { const navigation = useNavigation(); const {moment_id, screenType, comment_id} = route.params; //Receives comment length from child CommentsContainer const [commentsLength, setCommentsLength] = useState<number>(0); - const [newCommentsAvailable, setNewCommentsAvailable] = React.useState(true); + const [shouldUpdateAllComments, setShouldUpdateAllComments] = React.useState( + true, + ); //Keeps track of the current comments object in focus so that the application knows which comment to post a reply to - const [commentObjectInFocus, setCommentObjectInFocus] = useState< - CommentType | undefined - >(undefined); + const [commentTapped, setCommentTapped] = useState< + CommentType | CommentThreadType | undefined + >(); useEffect(() => { navigation.setOptions({ @@ -50,36 +62,39 @@ const MomentCommentsScreen: React.FC<MomentCommentsScreenProps> = ({route}) => { }, [commentsLength, navigation]); return ( - <View style={styles.background}> - <SafeAreaView> - <View style={styles.body}> - <CommentsContainer - objectId={moment_id} - commentId={comment_id} - screenType={screenType} - setCommentsLength={setCommentsLength} - newCommentsAvailable={newCommentsAvailable} - setNewCommentsAvailable={setNewCommentsAvailable} - setCommentObjectInFocus={setCommentObjectInFocus} - commentObjectInFocus={commentObjectInFocus} - typeOfComment={'Comment'} - /> - <AddComment - placeholderText={ - commentObjectInFocus - ? ADD_COMMENT_TEXT(commentObjectInFocus.commenter.username) - : ADD_COMMENT_TEXT() - } - setNewCommentsAvailable={setNewCommentsAvailable} - objectId={ - commentObjectInFocus ? commentObjectInFocus.comment_id : moment_id - } - isCommentInFocus={commentObjectInFocus ? true : false} - /> - </View> - </SafeAreaView> - <TabsGradient /> - </View> + <CommentContext.Provider + value={{ + commentTapped, + setCommentTapped, + shouldUpdateAllComments, + setShouldUpdateAllComments, + commentsLength, + setCommentsLength, + }}> + <View style={styles.background}> + <SafeAreaView> + <View style={styles.body}> + <CommentsContainer + objectId={moment_id} + commentId={comment_id} + screenType={screenType} + shouldUpdate={shouldUpdateAllComments} + setShouldUpdate={setShouldUpdateAllComments} + isThread={false} + /> + <AddComment + placeholderText={ + !commentTapped + ? ADD_COMMENT_TEXT() + : ADD_COMMENT_TEXT(commentTapped.commenter.username) + } + momentId={moment_id} + /> + </View> + </SafeAreaView> + <TabsGradient /> + </View> + </CommentContext.Provider> ); }; @@ -88,39 +103,12 @@ const styles = StyleSheet.create({ backgroundColor: 'white', height: '100%', }, - header: {justifyContent: 'center', padding: '3%'}, - headerText: { - position: 'absolute', - alignSelf: 'center', - fontSize: normalize(18), - fontWeight: '700', - lineHeight: normalize(21.48), - letterSpacing: normalize(1.3), - }, - headerButton: { - width: '5%', - aspectRatio: 1, - padding: 0, - marginLeft: '5%', - alignSelf: 'flex-start', - }, - headerButtonText: { - color: 'black', - fontSize: 18, - fontWeight: '400', - }, body: { marginTop: HeaderHeight, width: SCREEN_WIDTH * 0.9, height: SCREEN_HEIGHT * 0.8, paddingTop: '3%', }, - scrollView: { - paddingHorizontal: 20, - }, - scrollViewContent: { - justifyContent: 'center', - }, }); export default MomentCommentsScreen; diff --git a/src/services/SuggestedPeopleService.ts b/src/services/SuggestedPeopleService.ts index 7f5b4b8c..2ae8cf55 100644 --- a/src/services/SuggestedPeopleService.ts +++ b/src/services/SuggestedPeopleService.ts @@ -2,18 +2,22 @@ import AsyncStorage from '@react-native-community/async-storage'; import { ERROR_BADGES_EXCEED_LIMIT, ERROR_UPLOAD_BADGES, - SUCCESS_BADGES_UPDATE, } from '../constants/strings'; import { ADD_BADGES_ENDPOINT, EDIT_PROFILE_ENDPOINT, + GET_USER_BADGES_ENDPOINT, REMOVE_BADGES_ENDPOINT, SP_MUTUAL_BADGE_HOLDERS_ENDPOINT, SP_UPDATE_PICTURE_ENDPOINT, SP_USERS_ENDPOINT, UPDATE_BADGES_ENDPOINT, } from '../constants/api'; -import {ProfilePreviewType, SuggestedPeopleDataType} from '../types'; +import { + ProfilePreviewType, + SuggestedPeopleDataType, + UniversityBadge, +} from '../types'; import {Alert} from 'react-native'; export const sendSuggestedPeopleLinked = async ( @@ -166,6 +170,31 @@ export const addBadgesService = async ( } }; +export const getBadgesService = async (userId: string) => { + try { + const token = await AsyncStorage.getItem('token'); + const response = await fetch( + GET_USER_BADGES_ENDPOINT + '?user_id=' + userId, + { + method: 'GET', + headers: { + Authorization: 'Token ' + token, + }, + }, + ); + if (response.status === 200) { + const data: UniversityBadge[] = await response.json(); + return data ? data : []; + } else { + console.log('Error loading badges data'); + return []; + } + } catch (error) { + console.log('Exception occued while loading badges data, ', error); + return []; + } +}; + export const updateBadgesService = async ( selectedBadges: string[], university: string, @@ -185,11 +214,10 @@ export const updateBadgesService = async ( }); if (response.status === 400) { Alert.alert(ERROR_BADGES_EXCEED_LIMIT); - return; + return false; } if (response.status === 200) { - Alert.alert(SUCCESS_BADGES_UPDATE); - return; + return true; } } catch (error) { console.log(error); @@ -216,14 +244,14 @@ export const removeBadgesService = async ( }); if (response.status === 400) { Alert.alert(ERROR_BADGES_EXCEED_LIMIT); - return; + return false; } if (response.status === 200) { - Alert.alert(SUCCESS_BADGES_UPDATE); - return; + return true; } } catch (error) { console.log(error); Alert.alert(ERROR_UPLOAD_BADGES); + return false; } }; diff --git a/src/store/actions/user.ts b/src/store/actions/user.ts index 3ebd4190..96e636f6 100644 --- a/src/store/actions/user.ts +++ b/src/store/actions/user.ts @@ -6,10 +6,11 @@ import { loadProfileInfo, sendSuggestedPeopleLinked, } from '../../services'; -import {UserType} from '../../types/types'; +import {UniversityBadge, UserType} from '../../types/types'; import {getTokenOrLogout} from '../../utils'; import { clearHeaderAndProfileImages, + profileBadgesUpdated, profileCompletionStageUpdated, setIsOnboardedUser, setNewNotificationReceived, @@ -90,6 +91,25 @@ export const updateSocial = ( } }; +/** + * To update new user badges + * @param badges current selection of badges + */ +export const updateUserBadges = ( + badges: UniversityBadge[], +): ThunkAction<Promise<void>, RootState, unknown, Action<string>> => async ( + dispatch, +) => { + try { + dispatch({ + type: profileBadgesUpdated.type, + payload: {badges}, + }); + } catch (error) { + console.log(error); + } +}; + export const updateProfileCompletionStage = ( stage: number, ): ThunkAction<Promise<void>, RootState, unknown, Action<string>> => async ( diff --git a/src/store/initialStates.ts b/src/store/initialStates.ts index 7fd3ac5a..e0f9d776 100644 --- a/src/store/initialStates.ts +++ b/src/store/initialStates.ts @@ -18,6 +18,7 @@ export const NO_PROFILE: ProfileInfoType = { birthday: undefined, university_class: 2021, university: undefined, + badges: [], //Default to an invalid value and ignore it gracefully while showing tutorials / popups. profile_completion_stage: -1, suggested_people_linked: -1, diff --git a/src/store/reducers/userReducer.ts b/src/store/reducers/userReducer.ts index a8789c1d..97bf845c 100644 --- a/src/store/reducers/userReducer.ts +++ b/src/store/reducers/userReducer.ts @@ -42,6 +42,10 @@ const userDataSlice = createSlice({ } }, + profileBadgesUpdated: (state, action) => { + state.profile.badges = action.payload.badges; + }, + profileCompletionStageUpdated: (state, action) => { state.profile.profile_completion_stage = action.payload.stage; }, @@ -90,6 +94,7 @@ export const { setReplyPosted, setSuggestedPeopleImage, clearHeaderAndProfileImages, + profileBadgesUpdated, // setChatClientReady, } = userDataSlice.actions; export const userDataReducer = userDataSlice.reducer; diff --git a/src/types/types.ts b/src/types/types.ts index ce39947c..00501d49 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -49,6 +49,7 @@ export interface ProfileInfoType { gender: string; university_class: number; university: UniversityType; + badges: UniversityBadge[]; profile_completion_stage: number; suggested_people_linked: number; birthday: Date | undefined; @@ -220,8 +221,6 @@ export type NotificationType = { unread: boolean; }; -export type TypeOfComment = 'Comment' | 'Thread'; - export type TypeOfNotification = // notification_object is undefined | 'DFT' diff --git a/src/utils/comments.tsx b/src/utils/comments.tsx new file mode 100644 index 00000000..0d551682 --- /dev/null +++ b/src/utils/comments.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import {StyleProp, Text, TextStyle} from 'react-native'; +import { + isMentionPartType, + parseValue, + Part, + PartType, +} from 'react-native-controlled-mentions'; +import TaggTypeahead from '../components/common/TaggTypeahead'; +import {TAGG_LIGHT_BLUE} from '../constants'; +import {UserType} from '../types'; + +/** + * Part renderer + * + * https://github.com/dabakovich/react-native-controlled-mentions#rendering-mentioninputs-value + */ +const renderPart = ( + part: Part, + index: number, + handlePress: (user: UserType) => void, +) => { + // Just plain text + if (!part.partType) { + return <Text key={index}>{part.text}</Text>; + } + + // Mention type part + if (isMentionPartType(part.partType)) { + return ( + <Text + key={`${index}-${part.data?.trigger}`} + style={part.partType.textStyle} + onPress={() => { + if (part.data) { + handlePress({ + userId: part.data.id, + username: part.data.name, + }); + } + }}> + {part.text} + </Text> + ); + } + + // Other styled part types + return ( + <Text key={`${index}-pattern`} style={part.partType.textStyle}> + {part.text} + </Text> + ); +}; + +interface RenderProps { + value: string; + styles: StyleProp<TextStyle>; + partTypes: PartType[]; + onPress: (user: UserType) => void; +} + +/** + * Value renderer. Parsing value to parts array and then mapping the array using 'renderPart' + * + * https://github.com/dabakovich/react-native-controlled-mentions#rendering-mentioninputs-value + */ +export const renderTextWithMentions: React.FC<RenderProps> = ({ + value, + styles, + partTypes, + onPress, +}) => { + const {parts} = parseValue(value, partTypes); + return ( + <Text style={styles}> + {parts.map((part, index) => renderPart(part, index, onPress))} + </Text> + ); +}; + +export const mentionPartTypes: (style: 'blue' | 'white') => PartType[] = ( + style, +) => { + return [ + { + trigger: '@', + renderSuggestions: (props) => <TaggTypeahead {...props} />, + allowedSpacesCount: 0, + isInsertSpaceAfterMention: true, + textStyle: + style === 'blue' + ? {color: TAGG_LIGHT_BLUE} + : {color: 'white', fontWeight: '800'}, + }, + ]; +}; diff --git a/src/utils/moments.ts b/src/utils/moments.ts index 87f062af..90d69519 100644 --- a/src/utils/moments.ts +++ b/src/utils/moments.ts @@ -37,3 +37,37 @@ export const getTimePosted = (date_time: string) => { } return time; }; + +export const getTimeInShorthand = (date_time: string) => { + const datePosted = moment(date_time); + const now = moment(); + var time = date_time; + var difference = now.diff(datePosted, 's'); + + // Creating elapsedTime string to display to user + // 0 to less than 1 minute + if (difference < 60) { + time = difference + 's'; + } + // 1 minute to less than 1 hour + else if (difference >= 60 && difference < 60 * 60) { + difference = now.diff(datePosted, 'm'); + time = difference + 'm'; + } + // 1 hour to less than 1 day + else if (difference >= 60 * 60 && difference < 24 * 60 * 60) { + difference = now.diff(datePosted, 'h'); + time = difference + 'h'; + } + // Any number of days + else if (difference >= 24 * 60 * 60 && difference < 24 * 60 * 60 * 7) { + difference = now.diff(datePosted, 'd'); + time = difference + 'd'; + } + // More than 7 days + else if (difference >= 24 * 60 * 60 * 7) { + difference = now.diff(datePosted, 'w'); + time = difference + 'w'; + } + return time; +}; diff --git a/src/utils/users.ts b/src/utils/users.ts index abadaf6e..334cb3c0 100644 --- a/src/utils/users.ts +++ b/src/utils/users.ts @@ -1,6 +1,6 @@ import AsyncStorage from '@react-native-community/async-storage'; import {INTEGRATED_SOCIAL_LIST} from '../constants'; -import {isUserBlocked, loadSocialPosts} from '../services'; +import {isUserBlocked, loadSocialPosts, removeBadgesService} from '../services'; import { loadAllSocials, loadBlockedList, @@ -10,6 +10,7 @@ import { loadUserMoments, loadUserNotifications, logout, + updateUserBadges, } from '../store/actions'; import {NO_SOCIAL_ACCOUNTS} from '../store/initialStates'; import {loadUserMomentCategories} from './../store/actions/momentCategories'; @@ -17,10 +18,11 @@ import {loadUserX} from './../store/actions/userX'; import {AppDispatch} from './../store/configureStore'; import {RootState} from './../store/rootReducer'; import { - ProfilePreviewType, ProfileInfoType, + ProfilePreviewType, ScreenType, UserType, + UniversityBadge, } from './../types/types'; const loadData = async (dispatch: AppDispatch, user: UserType) => { @@ -199,3 +201,42 @@ export const canViewProfile = ( } return false; }; + +/* Function to call remove badge service, + * remove selected badge from list passed in and + * dispatch thunk action to update store + */ +export const removeUserBadge = async ( + badges: UniversityBadge[], + badgeName: string, + userId: string, + dispatch: AppDispatch, +) => { + const success = await removeBadgesService([badgeName], userId); + if (success === true) { + badges = badges.filter((badge) => badge.name !== badgeName); + dispatch(updateUserBadges(badges)); + } +}; + +export const navigateToProfile = async ( + state: RootState, + dispatch: any, + navigation: any, + screenType: ScreenType, + user: UserType, +) => { + const loggedInUserId = state.user.user.userId; + const {userId, username} = user; + if (!userXInStore(state, screenType, userId)) { + await fetchUserX( + dispatch, + {userId: userId, username: username}, + screenType, + ); + } + navigation.push('Profile', { + userXId: userId === loggedInUserId ? undefined : userId, + screenType, + }); +}; |