aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorIvan Chen <ivan@tagg.id>2021-05-05 18:36:39 -0400
committerGitHub <noreply@github.com>2021-05-05 18:36:39 -0400
commitc9d32e68fbb9d1bc175722bfda49454a6f627eae (patch)
tree5f7b3cf0937ca073f03dde2f84ce5c0e50a038a3
parentd4a04e31144f6cfaebb0b892e3593bb02bd49ed5 (diff)
parent32a61c00756afb1aee0eb471ed643f24da1d3e85 (diff)
Merge pull request #401 from IvanIFChen/tma296-add-mentions
[TMA-296] Add mentions
-rw-r--r--package.json3
-rw-r--r--src/components/comments/AddComment.tsx87
-rw-r--r--src/components/comments/CommentTile.tsx141
-rw-r--r--src/components/comments/CommentsContainer.tsx116
-rw-r--r--src/components/common/TaggTypeahead.tsx75
-rw-r--r--src/components/common/TaggUserRowCell.tsx52
-rw-r--r--src/components/common/index.ts2
-rw-r--r--src/components/moments/MomentPostContent.tsx23
-rw-r--r--src/screens/profile/CaptionScreen.tsx17
-rw-r--r--src/screens/profile/MomentCommentsScreen.tsx124
-rw-r--r--src/types/types.ts2
-rw-r--r--src/utils/comments.tsx87
-rw-r--r--src/utils/users.ts24
-rw-r--r--yarn.lock32
14 files changed, 522 insertions, 263 deletions
diff --git a/package.json b/package.json
index 2612e67f..16932be4 100644
--- a/package.json
+++ b/package.json
@@ -32,6 +32,7 @@
"react-native-animatable": "^1.3.3",
"react-native-confirmation-code-field": "^6.5.0",
"react-native-contacts": "^6.0.4",
+ "react-native-controlled-mentions": "^2.2.5",
"react-native-date-picker": "^3.2.5",
"react-native-device-info": "^7.3.1",
"react-native-document-picker": "^5.0.3",
@@ -101,4 +102,4 @@
"./node_modules/react-native-gesture-handler/jestSetup.js"
]
}
-}
+} \ No newline at end of file
diff --git a/src/components/comments/AddComment.tsx b/src/components/comments/AddComment.tsx
index 2a8c773e..dd016109 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}
/>
<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..ce346af5 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 {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,17 @@ 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,
+ 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 +162,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 +185,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..d5d02a92 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,14 +27,12 @@ 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,
);
@@ -45,57 +42,30 @@ const CommentsContainer: React.FC<CommentsContainerProps> = ({
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]);
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()) {
+ } else if (!isThread && !commentTapped) {
setTimeout(() => {
ref.current?.scrollToEnd({animated: true});
}, 500);
@@ -108,41 +78,47 @@ const CommentsContainer: React.FC<CommentsContainerProps> = ({
//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]);
+ // 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);
+ }
+ };
const ITEM_HEIGHT = SCREEN_HEIGHT / 7.0;
- 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/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..03034925 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 {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,12 @@ const MomentPostContent: React.FC<MomentPostContentProps> = ({
/>
<Text style={styles.text}>{elapsedTime}</Text>
</View>
- <Text style={styles.captionText}>{caption}</Text>
+ {renderTextWithMentions({
+ value: caption,
+ styles: styles.captionText,
+ onPress: (user: UserType) =>
+ navigateToProfile(state, dispatch, navigation, screenType, user),
+ })}
</View>
);
};
diff --git a/src/screens/profile/CaptionScreen.tsx b/src/screens/profile/CaptionScreen.tsx
index 156ee41c..041f0da2 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}
/>
</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/types/types.ts b/src/types/types.ts
index ce39947c..690d6fb9 100644
--- a/src/types/types.ts
+++ b/src/types/types.ts
@@ -220,8 +220,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..a71e3857
--- /dev/null
+++ b/src/utils/comments.tsx
@@ -0,0 +1,87 @@
+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>;
+ 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,
+ onPress,
+}) => {
+ const {parts} = parseValue(value, mentionPartTypes);
+ return (
+ <Text style={styles}>
+ {parts.map((part, index) => renderPart(part, index, onPress))}
+ </Text>
+ );
+};
+
+export const mentionPartTypes: PartType[] = [
+ {
+ trigger: '@',
+ renderSuggestions: (props) => <TaggTypeahead {...props} />,
+ allowedSpacesCount: 0,
+ isInsertSpaceAfterMention: true,
+ textStyle: {color: TAGG_LIGHT_BLUE},
+ },
+];
diff --git a/src/utils/users.ts b/src/utils/users.ts
index abadaf6e..754382b3 100644
--- a/src/utils/users.ts
+++ b/src/utils/users.ts
@@ -17,8 +17,8 @@ import {loadUserX} from './../store/actions/userX';
import {AppDispatch} from './../store/configureStore';
import {RootState} from './../store/rootReducer';
import {
- ProfilePreviewType,
ProfileInfoType,
+ ProfilePreviewType,
ScreenType,
UserType,
} from './../types/types';
@@ -199,3 +199,25 @@ export const canViewProfile = (
}
return false;
};
+
+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,
+ });
+};
diff --git a/yarn.lock b/yarn.lock
index d9aab38e..9088af02 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2692,6 +2692,11 @@ diff-sequences@^24.9.0:
resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-24.9.0.tgz#5715d6244e2aa65f48bba0bc972db0b0b11e95b5"
integrity sha512-Dj6Wk3tWyTE+Fo1rW8v0Xhwk80um6yFYKbuAxc9c3EZxIHFDYwbi34Uk42u1CdnIiVorvt4RmlSDjIPyzGC2ew==
+diff@5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/diff/-/diff-5.0.0.tgz#7ed6ad76d859d030787ec35855f5b1daf31d852b"
+ integrity sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==
+
doctrine@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d"
@@ -3943,7 +3948,7 @@ inquirer@^7.0.0:
strip-ansi "^6.0.0"
through "^2.3.6"
-internal-slot@^1.0.3:
+internal-slot@^1.0.2, internal-slot@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.3.tgz#7347e307deeea2faac2ac6205d4bc7d34967f59c"
integrity sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==
@@ -6260,6 +6265,14 @@ react-native-contacts@^6.0.4:
resolved "https://registry.yarnpkg.com/react-native-contacts/-/react-native-contacts-6.0.5.tgz#26503c862a197eef790aee243053c0ceb23d7a0f"
integrity sha512-2BPW1bODJPnKbi/Wr2+m43hlrxcYrzMkWVAre3nMwqekSrsapMu/iSpQbxcB3Ia8cvBNH6Cu2tSqGpqY4Lc/lg==
+react-native-controlled-mentions@^2.2.5:
+ version "2.2.5"
+ resolved "https://registry.yarnpkg.com/react-native-controlled-mentions/-/react-native-controlled-mentions-2.2.5.tgz#2eddcdf6b6a0ead0c448be4bb0239e10eca84a29"
+ integrity sha512-fNIQWZMPy9OvPKO3MeLX+J76Ld7OuvrrLn4xwXMOjsYjuEWtVBNc4t/ADxNc3woQCfuSEkST+SbafFpUwwLMIA==
+ dependencies:
+ diff "5.0.0"
+ string.prototype.matchall "4.0.3"
+
react-native-date-picker@^3.2.5:
version "3.2.10"
resolved "https://registry.yarnpkg.com/react-native-date-picker/-/react-native-date-picker-3.2.10.tgz#5e690db8e628255d8390e33b1e6f8fa9477f6fb9"
@@ -6631,7 +6644,7 @@ regex-not@^1.0.0, regex-not@^1.0.2:
extend-shallow "^3.0.2"
safe-regex "^1.1.0"
-regexp.prototype.flags@^1.3.1:
+regexp.prototype.flags@^1.3.0, regexp.prototype.flags@^1.3.1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.3.1.tgz#7ef352ae8d159e758c0eadca6f8fcb4eef07be26"
integrity sha512-JiBdRBq91WlY7uRJ0ds7R+dU02i6LKi8r3BuQhNXn+kmeLN+EfHhfjqMRis1zJxnlu88hq/4dx0P2OP3APRTOA==
@@ -7064,7 +7077,7 @@ shellwords@^0.1.1:
resolved "https://registry.yarnpkg.com/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b"
integrity sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==
-side-channel@^1.0.4:
+side-channel@^1.0.3, side-channel@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf"
integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==
@@ -7385,6 +7398,19 @@ string-width@^4.1.0, string-width@^4.2.0:
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.0"
+string.prototype.matchall@4.0.3:
+ version "4.0.3"
+ resolved "https://registry.yarnpkg.com/string.prototype.matchall/-/string.prototype.matchall-4.0.3.tgz#24243399bc31b0a49d19e2b74171a15653ec996a"
+ integrity sha512-OBxYDA2ifZQ2e13cP82dWFMaCV9CGF8GzmN4fljBVw5O5wep0lu4gacm1OL6MjROoUnB8VbkWRThqkV2YFLNxw==
+ dependencies:
+ call-bind "^1.0.0"
+ define-properties "^1.1.3"
+ es-abstract "^1.18.0-next.1"
+ has-symbols "^1.0.1"
+ internal-slot "^1.0.2"
+ regexp.prototype.flags "^1.3.0"
+ side-channel "^1.0.3"
+
string.prototype.matchall@^4.0.2, string.prototype.matchall@^4.0.4:
version "4.0.4"
resolved "https://registry.yarnpkg.com/string.prototype.matchall/-/string.prototype.matchall-4.0.4.tgz#608f255e93e072107f5de066f81a2dfb78cf6b29"