diff options
author | Ivan Chen <ivan@tagg.id> | 2021-02-01 16:01:03 -0500 |
---|---|---|
committer | Ivan Chen <ivan@tagg.id> | 2021-02-01 16:01:03 -0500 |
commit | 8d1013e86cf2d66671c337d49a80da157802ad86 (patch) | |
tree | 656b1656068bb6636919359d4faaf7051994ff74 /src/components/comments | |
parent | 951d85348acef13ec7830629205c30ad5f766bee (diff) | |
parent | 7a09cc96bf1fe468a612bb44362bbef24fccc773 (diff) |
Merge branch 'master' into TMA-546-Onboarding-Page
Diffstat (limited to 'src/components/comments')
-rw-r--r-- | src/components/comments/AddComment.tsx | 198 | ||||
-rw-r--r-- | src/components/comments/CommentTile.tsx | 261 | ||||
-rw-r--r-- | src/components/comments/CommentsContainer.tsx | 171 |
3 files changed, 534 insertions, 96 deletions
diff --git a/src/components/comments/AddComment.tsx b/src/components/comments/AddComment.tsx index f8c0b6bc..56011f05 100644 --- a/src/components/comments/AddComment.tsx +++ b/src/components/comments/AddComment.tsx @@ -1,17 +1,20 @@ -import * as React from 'react'; +import React, {useEffect, useRef} from 'react'; import { Image, + Keyboard, KeyboardAvoidingView, Platform, StyleSheet, View, } from 'react-native'; -import AsyncStorage from '@react-native-community/async-storage'; -import {TaggBigInput} from '../onboarding'; -import {postMomentComment} from '../../services'; -import {logout} from '../../store/actions'; -import {useSelector, useDispatch} from 'react-redux'; +import {TextInput, 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 {postComment} from '../../services'; +import {updateReplyPosted} from '../../store/actions'; import {RootState} from '../../store/rootreducer'; +import {SCREEN_HEIGHT, SCREEN_WIDTH} from '../../utils'; /** * This file provides the add comment view for a user. @@ -21,95 +24,154 @@ import {RootState} from '../../store/rootreducer'; export interface AddCommentProps { setNewCommentsAvailable: Function; - moment_id: string; + objectId: string; + placeholderText: string; + isCommentInFocus: boolean; } const AddComment: React.FC<AddCommentProps> = ({ setNewCommentsAvailable, - moment_id, + objectId, + placeholderText, + isCommentInFocus, }) => { const [comment, setComment] = React.useState(''); + const [keyboardVisible, setKeyboardVisible] = React.useState(false); + const {avatar} = useSelector((state: RootState) => state.user); const dispatch = useDispatch(); - const { - avatar, - user: {userId}, - } = useSelector((state: RootState) => state.user); - const handleCommentUpdate = (comment: string) => { - setComment(comment); - }; - - const postComment = async () => { - try { - const token = await AsyncStorage.getItem('token'); - if (!token) { - dispatch(logout()); - return; - } - const postedComment = await postMomentComment( - userId, - comment, - moment_id, - token, - ); + const addComment = async () => { + const trimmed = comment.trim(); + if (trimmed === '') { + return; + } + const postedComment = await postComment( + trimmed, + objectId, + isCommentInFocus, + ); - if (postedComment) { - //Set the current comment to en empty string if the comment was posted successfully. - handleCommentUpdate(''); + if (postedComment) { + setComment(''); - //Indicate the MomentCommentsScreen that it needs to download the new comments again - setNewCommentsAvailable(true); + //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) { + dispatch( + updateReplyPosted({ + comment_id: postedComment.comment_id, + parent_comment: {comment_id: objectId}, + }), + ); } - } catch (err) { - console.log('Error while posting comment!'); + setNewCommentsAvailable(true); } }; + useEffect(() => { + const showKeyboard = () => setKeyboardVisible(true); + Keyboard.addListener('keyboardWillShow', showKeyboard); + return () => Keyboard.removeListener('keyboardWillShow', showKeyboard); + }, []); + + useEffect(() => { + const hideKeyboard = () => setKeyboardVisible(false); + Keyboard.addListener('keyboardWillHide', hideKeyboard); + 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) { + ref.current?.focus(); + } + }, [isCommentInFocus]); + return ( <KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} - keyboardVerticalOffset={130}> - <View style={styles.container}> - <Image - style={styles.avatar} - source={ - avatar - ? {uri: avatar} - : require('../../assets/images/avatar-placeholder.png') - } - /> - <TaggBigInput - style={styles.text} - multiline - placeholder="Add a comment....." - placeholderTextColor="gray" - onChangeText={handleCommentUpdate} - onSubmitEditing={postComment} - value={comment} - /> + keyboardVerticalOffset={SCREEN_HEIGHT * 0.1}> + <View + style={[ + styles.container, + keyboardVisible ? styles.whiteBackround : {}, + ]}> + <View style={styles.textContainer}> + <Image + style={styles.avatar} + source={ + avatar + ? {uri: avatar} + : require('../../assets/images/avatar-placeholder.png') + } + /> + <TextInput + style={styles.text} + placeholder={placeholderText} + placeholderTextColor="grey" + onChangeText={setComment} + value={comment} + autoCorrect={false} + multiline={true} + ref={ref} + /> + <View style={styles.submitButton}> + <TouchableOpacity style={styles.submitButton} onPress={addComment}> + <UpArrowIcon width={35} height={35} color={'white'} /> + </TouchableOpacity> + </View> + </View> </View> </KeyboardAvoidingView> ); }; + const styles = StyleSheet.create({ - container: {flexDirection: 'row'}, + container: { + backgroundColor: '#f7f7f7', + alignItems: 'center', + width: SCREEN_WIDTH, + }, + textContainer: { + width: '95%', + flexDirection: 'row', + backgroundColor: '#e8e8e8', + alignItems: 'center', + justifyContent: 'space-between', + margin: '3%', + borderRadius: 25, + }, text: { - position: 'relative', - right: '18%', - backgroundColor: 'white', - width: '70%', - paddingLeft: '2%', - paddingRight: '2%', - paddingBottom: '1%', - paddingTop: '1%', - height: 60, + flex: 1, + padding: '1%', + marginHorizontal: '1%', }, avatar: { - height: 40, - width: 40, + height: 35, + width: 35, borderRadius: 30, - marginRight: 15, + marginRight: 10, + marginLeft: '3%', + marginVertical: '2%', + alignSelf: 'flex-end', + }, + submitButton: { + height: 35, + width: 35, + backgroundColor: TAGG_LIGHT_BLUE, + borderRadius: 999, + justifyContent: 'center', + alignItems: 'center', + marginRight: '3%', + marginVertical: '2%', + alignSelf: 'flex-end', + }, + whiteBackround: { + backgroundColor: '#fff', }, }); diff --git a/src/components/comments/CommentTile.tsx b/src/components/comments/CommentTile.tsx index 47f25a53..be113523 100644 --- a/src/components/comments/CommentTile.tsx +++ b/src/components/comments/CommentTile.tsx @@ -1,10 +1,21 @@ -import React from 'react'; +/* 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} from '../../types'; -import {StyleSheet} from 'react-native'; -import {getTimePosted} from '../../utils'; +import {CommentType, ScreenType, TypeOfComment} from '../../types'; +import {Alert, Animated, StyleSheet} from 'react-native'; import ClockIcon from '../../assets/icons/clock-icon-01.svg'; +import {TAGG_LIGHT_BLUE} from '../../constants'; +import {RectButton, TouchableOpacity} from 'react-native-gesture-handler'; +import {getTimePosted, normalize, SCREEN_WIDTH} from '../../utils'; +import Arrow from '../../assets/icons/back-arrow-colored.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 {ERROR_FAILED_TO_DELETE_COMMENT} from '../../constants/strings'; +import {useSelector} from 'react-redux'; +import {RootState} from '../../store/rootReducer'; /** * Displays users's profile picture, comment posted by them and the time difference between now and when a comment was posted. @@ -13,54 +24,205 @@ import ClockIcon from '../../assets/icons/clock-icon-01.svg'; interface CommentTileProps { comment_object: CommentType; screenType: ScreenType; + typeOfComment: TypeOfComment; + setCommentObjectInFocus?: (comment: CommentType | undefined) => void; + newCommentsAvailable: boolean; + setNewCommentsAvailable: (available: boolean) => void; + canDelete: boolean; } const CommentTile: React.FC<CommentTileProps> = ({ comment_object, screenType, + typeOfComment, + setCommentObjectInFocus, + newCommentsAvailable, + setNewCommentsAvailable, + canDelete, }) => { const timePosted = getTimePosted(comment_object.date_created); + const [showReplies, setShowReplies] = useState<boolean>(false); + const [showKeyboard, setShowKeyboard] = useState<boolean>(false); + const [newThreadAvailable, setNewThreadAvailable] = useState(true); + const swipeRef = useRef<Swipeable>(null); + const isThread = typeOfComment === 'Thread'; + + const {replyPosted} = useSelector((state: RootState) => state.user); + + /** + * Bubbling up, for handling a new comment in a thread. + */ + useEffect(() => { + if (newCommentsAvailable) { + setNewThreadAvailable(true); + } + }, [newCommentsAvailable]); + + useEffect(() => { + if (replyPosted && typeOfComment === 'Comment') { + if (replyPosted.parent_comment.comment_id === comment_object.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); + } + }; + + const toggleReplies = async () => { + if (showReplies) { + //To update count of replies in case we deleted a reply + comment_object.replies_count = parseInt( + await getCommentsCount(comment_object.comment_id, true), + ); + } + setNewThreadAvailable(true); + setShowReplies(!showReplies); + }; + + /** + * Method to compute text to be shown for replies button + */ + const getRepliesText = () => + showReplies + ? 'Hide' + : comment_object.replies_count > 0 + ? `Replies (${comment_object.replies_count})` + : 'Replies'; + + const renderRightAction = (text: string, color: string, progress) => { + const pressHandler = async () => { + swipeRef.current?.close(); + const success = await deleteComment(comment_object.comment_id, isThread); + if (success) { + setNewCommentsAvailable(true); + } else { + Alert.alert(ERROR_FAILED_TO_DELETE_COMMENT); + } + }; + return ( + <Animated.View> + <RectButton + style={[styles.rightAction, {backgroundColor: color}]} + onPress={pressHandler}> + <Trash width={normalize(25)} height={normalize(25)} color={'white'} /> + <Text style={styles.actionText}>{text}</Text> + </RectButton> + </Animated.View> + ); + }; + + const renderRightActions = (progress: Animated.AnimatedInterpolation) => + canDelete ? ( + <View style={styles.swipeActions}> + {renderRightAction('Delete', '#c42634', progress)} + </View> + ) : ( + <Fragment /> + ); + return ( - <View style={styles.container}> - <ProfilePreview - profilePreview={{ - id: comment_object.commenter.id, - username: comment_object.commenter.username, - first_name: comment_object.commenter.first_name, - last_name: comment_object.commenter.last_name, - }} - previewType={'Comment'} - screenType={screenType} - /> - <View style={styles.body}> - <Text style={styles.comment}>{comment_object.comment}</Text> - <View style={styles.clockIconAndTime}> - <ClockIcon style={styles.clockIcon} /> - <Text style={styles.date_time}>{' ' + timePosted}</Text> - </View> + <Swipeable + ref={swipeRef} + renderRightActions={renderRightActions} + rightThreshold={40} + friction={2} + containerStyle={styles.swipableContainer}> + <View + style={[styles.container, isThread ? styles.moreMarginWithThread : {}]}> + <ProfilePreview + profilePreview={comment_object.commenter} + previewType={'Comment'} + screenType={screenType} + /> + <TouchableOpacity style={styles.body} onPress={toggleAddComment}> + <Text style={styles.comment}>{comment_object.comment}</Text> + <View style={styles.clockIconAndTime}> + <ClockIcon style={styles.clockIcon} /> + <Text style={styles.date_time}>{' ' + timePosted}</Text> + <View style={styles.flexer} /> + </View> + </TouchableOpacity> + {/*** Show replies text only if there are some replies present */} + {typeOfComment === 'Comment' && comment_object.replies_count > 0 && ( + <TouchableOpacity + style={styles.repliesTextAndIconContainer} + onPress={toggleReplies}> + <Text style={styles.repliesText}>{getRepliesText()}</Text> + <Arrow + width={12} + height={11} + color={TAGG_LIGHT_BLUE} + style={ + !showReplies ? styles.repliesDownArrow : styles.repliesUpArrow + } + /> + </TouchableOpacity> + )} </View> - </View> + + {/*** Show replies if toggle state is true */} + {showReplies && ( + <View> + <CommentsContainer + objectId={comment_object.comment_id} + screenType={screenType} + setNewCommentsAvailable={setNewThreadAvailable} + newCommentsAvailable={newThreadAvailable} + typeOfComment={'Thread'} + commentId={replyPosted?.comment_id} + /> + </View> + )} + </Swipeable> ); }; const styles = StyleSheet.create({ container: { - marginLeft: '3%', - marginRight: '3%', borderBottomWidth: 1, borderColor: 'lightgray', - marginBottom: '3%', + backgroundColor: 'white', + flexDirection: 'column', + flex: 1, + paddingTop: '3%', + paddingBottom: '5%', + marginLeft: '7%', + }, + swipeActions: { + flexDirection: 'row', + }, + moreMarginWithThread: { + marginLeft: '14%', }, body: { marginLeft: 56, }, comment: { - position: 'relative', - top: -5, marginBottom: '2%', + marginRight: '2%', }, date_time: { color: 'gray', + fontSize: normalize(12), }, clockIcon: { width: 12, @@ -69,7 +231,50 @@ const styles = StyleSheet.create({ }, clockIconAndTime: { flexDirection: 'row', - marginBottom: '3%', + marginTop: '3%', + }, + flexer: { + flex: 1, + }, + repliesTextAndIconContainer: { + flexDirection: 'row', + alignItems: 'center', + marginTop: '5%', + marginLeft: 56, + }, + repliesText: { + color: TAGG_LIGHT_BLUE, + fontWeight: '500', + fontSize: normalize(12), + marginRight: '1%', + }, + repliesBody: { + width: SCREEN_WIDTH, + }, + repliesDownArrow: { + transform: [{rotate: '270deg'}], + marginTop: '1%', + }, + repliesUpArrow: { + transform: [{rotate: '90deg'}], + marginTop: '1%', + }, + actionText: { + color: 'white', + fontSize: normalize(12), + fontWeight: '500', + backgroundColor: 'transparent', + paddingHorizontal: '5%', + marginTop: '5%', + }, + rightAction: { + alignItems: 'center', + flex: 1, + justifyContent: 'center', + flexDirection: 'column', + }, + swipableContainer: { + backgroundColor: 'white', }, }); diff --git a/src/components/comments/CommentsContainer.tsx b/src/components/comments/CommentsContainer.tsx new file mode 100644 index 00000000..c72da2b7 --- /dev/null +++ b/src/components/comments/CommentsContainer.tsx @@ -0,0 +1,171 @@ +import React, {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 '.'; +import {getComments} from '../../services'; +import {updateReplyPosted} from '../../store/actions'; +import {RootState} from '../../store/rootReducer'; +import {CommentType, ScreenType, TypeOfComment} from '../../types'; +import {SCREEN_HEIGHT} from '../../utils'; +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; +}; + +/** + * Comments Container to be used for both comments and replies + */ + +const CommentsContainer: React.FC<CommentsContainerProps> = ({ + screenType, + objectId, + setCommentsLength, + newCommentsAvailable, + setNewCommentsAvailable, + typeOfComment, + setCommentObjectInFocus, + commentObjectInFocus, + commentId, +}) => { + const {username: loggedInUsername} = useSelector( + (state: RootState) => state.user.user, + ); + const [commentsList, setCommentsList] = useState<CommentType[]>([]); + const dispatch = useDispatch(); + const ref = useRef<FlatList<CommentType>>(null); + + useEffect(() => { + const loadComments = async () => { + await getComments(objectId, typeOfComment === 'Thread').then( + (comments) => { + if (comments && subscribedToLoadComments) { + setCommentsList(comments); + if (setCommentsLength) { + setCommentsLength(comments.length); + } + setNewCommentsAvailable(false); + } + }, + ); + }; + let subscribedToLoadComments = true; + if (newCommentsAvailable) { + 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); + } + }; + + 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 (commentsList) { + //Bring the relevant comment to top if a comment id is present else scroll if necessary + performAction(); + } + + //Clean up the reply id present in store + return () => { + if (commentId && typeOfComment === 'Thread') { + 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; + + const renderComment = ({item}: {item: CommentType}) => ( + <CommentTile + key={item.comment_id} + comment_object={item} + screenType={screenType} + typeOfComment={typeOfComment} + setCommentObjectInFocus={setCommentObjectInFocus} + newCommentsAvailable={newCommentsAvailable} + setNewCommentsAvailable={setNewCommentsAvailable} + canDelete={item.commenter.username === loggedInUsername} + /> + ); + + return ( + <FlatList + data={commentsList} + ref={ref} + keyExtractor={(item, index) => index.toString()} + decelerationRate={'fast'} + snapToAlignment={'start'} + snapToInterval={ITEM_HEIGHT} + renderItem={renderComment} + showsVerticalScrollIndicator={false} + contentContainerStyle={styles.scrollViewContent} + getItemLayout={(data, index) => ({ + length: ITEM_HEIGHT, + offset: ITEM_HEIGHT * index, + index, + })} + pagingEnabled + /> + ); +}; + +const styles = StyleSheet.create({ + scrollView: {}, + scrollViewContent: { + justifyContent: 'center', + }, +}); + +export default CommentsContainer; |