diff options
Diffstat (limited to 'src')
51 files changed, 1082 insertions, 250 deletions
diff --git a/src/assets/badges/aap.png b/src/assets/badges/aap.png Binary files differnew file mode 100644 index 00000000..dd150b8f --- /dev/null +++ b/src/assets/badges/aap.png diff --git a/src/assets/badges/cals.png b/src/assets/badges/cals.png Binary files differnew file mode 100644 index 00000000..fbf0717b --- /dev/null +++ b/src/assets/badges/cals.png diff --git a/src/assets/badges/college_of_arts_and_sciences.png b/src/assets/badges/college_of_arts_and_sciences.png Binary files differnew file mode 100644 index 00000000..146a06ed --- /dev/null +++ b/src/assets/badges/college_of_arts_and_sciences.png diff --git a/src/assets/badges/college_of_engineering.png b/src/assets/badges/college_of_engineering.png Binary files differnew file mode 100644 index 00000000..c3f9e889 --- /dev/null +++ b/src/assets/badges/college_of_engineering.png diff --git a/src/assets/badges/college_of_human_ecology.png b/src/assets/badges/college_of_human_ecology.png Binary files differnew file mode 100644 index 00000000..14a9fd80 --- /dev/null +++ b/src/assets/badges/college_of_human_ecology.png diff --git a/src/assets/badges/college_of_veterinary_medicine.png b/src/assets/badges/college_of_veterinary_medicine.png Binary files differnew file mode 100644 index 00000000..1814aa97 --- /dev/null +++ b/src/assets/badges/college_of_veterinary_medicine.png diff --git a/src/assets/badges/cornell_law_school.png b/src/assets/badges/cornell_law_school.png Binary files differnew file mode 100644 index 00000000..43104e41 --- /dev/null +++ b/src/assets/badges/cornell_law_school.png diff --git a/src/assets/badges/cornell_tech.png b/src/assets/badges/cornell_tech.png Binary files differnew file mode 100644 index 00000000..c40d3889 --- /dev/null +++ b/src/assets/badges/cornell_tech.png diff --git a/src/assets/badges/dyson_school.png b/src/assets/badges/dyson_school.png Binary files differnew file mode 100644 index 00000000..d17663b7 --- /dev/null +++ b/src/assets/badges/dyson_school.png diff --git a/src/assets/badges/entrepreneurship_at_cornell.png b/src/assets/badges/entrepreneurship_at_cornell.png Binary files differnew file mode 100644 index 00000000..6b86abd4 --- /dev/null +++ b/src/assets/badges/entrepreneurship_at_cornell.png diff --git a/src/assets/badges/graduate_school.png b/src/assets/badges/graduate_school.png Binary files differnew file mode 100644 index 00000000..f1c4006e --- /dev/null +++ b/src/assets/badges/graduate_school.png diff --git a/src/assets/badges/hotel_administration.png b/src/assets/badges/hotel_administration.png Binary files differnew file mode 100644 index 00000000..a92cfa3d --- /dev/null +++ b/src/assets/badges/hotel_administration.png diff --git a/src/assets/badges/ilr.png b/src/assets/badges/ilr.png Binary files differnew file mode 100644 index 00000000..549d6955 --- /dev/null +++ b/src/assets/badges/ilr.png diff --git a/src/assets/badges/sc_johnson_school_of_management.png b/src/assets/badges/sc_johnson_school_of_management.png Binary files differnew file mode 100644 index 00000000..3fc24aa4 --- /dev/null +++ b/src/assets/badges/sc_johnson_school_of_management.png diff --git a/src/assets/badges/student_agencies.png b/src/assets/badges/student_agencies.png Binary files differnew file mode 100644 index 00000000..ac31a1ee --- /dev/null +++ b/src/assets/badges/student_agencies.png diff --git a/src/assets/badges/weill_cornell_medical_sciences.png b/src/assets/badges/weill_cornell_medical_sciences.png Binary files differnew file mode 100644 index 00000000..2167df79 --- /dev/null +++ b/src/assets/badges/weill_cornell_medical_sciences.png diff --git a/src/assets/badges/weill_cornell_medicine.png b/src/assets/badges/weill_cornell_medicine.png Binary files differnew file mode 100644 index 00000000..3e8a60b3 --- /dev/null +++ b/src/assets/badges/weill_cornell_medicine.png diff --git a/src/assets/icons/grey-purple-plus.svg b/src/assets/icons/grey-purple-plus.svg new file mode 100644 index 00000000..2053d4a7 --- /dev/null +++ b/src/assets/icons/grey-purple-plus.svg @@ -0,0 +1,5 @@ +<svg width="31" height="31" viewBox="0 0 31 31" fill="none" xmlns="http://www.w3.org/2000/svg"> +<circle cx="15.5" cy="15.5" r="15.5" fill="white"/> +<rect width="2.38462" height="16.6923" rx="1.19231" transform="matrix(-1 0 0 1 16.6934 7.15381)" fill="#8F00FF"/> +<rect width="2.38462" height="16.6923" rx="1.19231" transform="matrix(0.00550217 0.999985 0.999985 -0.00550217 7.1543 14.4004)" fill="#8F00FF"/> +</svg> diff --git a/src/assets/icons/purple-plus.svg b/src/assets/icons/purple-plus.svg new file mode 100644 index 00000000..20949b6d --- /dev/null +++ b/src/assets/icons/purple-plus.svg @@ -0,0 +1,15 @@ +<svg width="23" height="23" viewBox="0 0 23 23" fill="none" xmlns="http://www.w3.org/2000/svg"> +<circle cx="11.5" cy="11.5" r="11.25" fill="url(#paint0_linear)" stroke="url(#paint1_linear)" stroke-width="0.5"/> +<rect width="1.76923" height="12.3846" rx="0.884615" transform="matrix(-1 0 0 1 12.3848 5.30762)" fill="white"/> +<rect width="1.76923" height="12.3846" rx="0.884615" transform="matrix(0.00550217 0.999985 0.999985 -0.00550217 5.30859 10.6841)" fill="white"/> +<defs> +<linearGradient id="paint0_linear" x1="11.5" y1="0" x2="10.9524" y2="30.119" gradientUnits="userSpaceOnUse"> +<stop stop-color="#8F01FF"/> +<stop offset="1" stop-color="#6EE7E7"/> +</linearGradient> +<linearGradient id="paint1_linear" x1="11.5" y1="0" x2="10.9524" y2="32.8571" gradientUnits="userSpaceOnUse"> +<stop stop-color="#8F01FF"/> +<stop offset="1" stop-color="#6EE7E7"/> +</linearGradient> +</defs> +</svg> diff --git a/src/assets/images/heart-filled.png b/src/assets/images/heart-filled.png Binary files differnew file mode 100644 index 00000000..59bf0ab1 --- /dev/null +++ b/src/assets/images/heart-filled.png diff --git a/src/assets/images/heart-outlined.png b/src/assets/images/heart-outlined.png Binary files differnew file mode 100644 index 00000000..aeb87a99 --- /dev/null +++ b/src/assets/images/heart-outlined.png diff --git a/src/components/comments/AddComment.tsx b/src/components/comments/AddComment.tsx index 9cf10b5e..befaa8fe 100644 --- a/src/components/comments/AddComment.tsx +++ b/src/components/comments/AddComment.tsx @@ -7,7 +7,6 @@ import { TextInput, View, } from 'react-native'; -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'; @@ -20,6 +19,7 @@ import {CommentThreadType, CommentType} from '../../types'; import {SCREEN_HEIGHT, SCREEN_WIDTH} from '../../utils'; import {mentionPartTypes} from '../../utils/comments'; import {Avatar} from '../common'; +import {MentionInputControlled} from './MentionInputControlled'; export interface AddCommentProps { momentId: string; @@ -112,7 +112,7 @@ const AddComment: React.FC<AddCommentProps> = ({momentId, placeholderText}) => { ]}> <View style={styles.textContainer}> <Avatar style={styles.avatar} uri={avatar} /> - <MentionInput + <MentionInputControlled containerStyle={styles.text} placeholder={placeholderText} value={inReplyToMention + comment} diff --git a/src/components/comments/CommentTile.tsx b/src/components/comments/CommentTile.tsx index ecdb4c30..a1810b71 100644 --- a/src/components/comments/CommentTile.tsx +++ b/src/components/comments/CommentTile.tsx @@ -11,7 +11,11 @@ import Trash from '../../assets/ionicons/trash-outline.svg'; import {TAGG_LIGHT_BLUE} from '../../constants'; import {ERROR_FAILED_TO_DELETE_COMMENT} from '../../constants/strings'; import {CommentContext} from '../../screens/profile/MomentCommentsScreen'; -import {deleteComment, getCommentsCount} from '../../services'; +import { + deleteComment, + getCommentsCount, + handleLikeUnlikeComment, +} from '../../services'; import {RootState} from '../../store/rootReducer'; import { CommentThreadType, @@ -19,13 +23,9 @@ import { ScreenType, UserType, } from '../../types'; -import { - getTimePosted, - navigateToProfile, - normalize, - SCREEN_WIDTH, -} from '../../utils'; +import {getTimePosted, navigateToProfile, normalize} from '../../utils'; import {mentionPartTypes, renderTextWithMentions} from '../../utils/comments'; +import {LikeButton} from '../common'; import {ProfilePreview} from '../profile'; import CommentsContainer from './CommentsContainer'; @@ -55,6 +55,7 @@ const CommentTile: React.FC<CommentTileProps> = ({ const [showReplies, setShowReplies] = useState<boolean>(false); const [showKeyboard, setShowKeyboard] = useState<boolean>(false); const [shouldUpdateChild, setShouldUpdateChild] = useState(true); + const [liked, setLiked] = useState(commentObject.user_reaction !== null); const swipeRef = useRef<Swipeable>(null); const {replyPosted} = useSelector((state: RootState) => state.user); const state: RootState = useStore().getState(); @@ -100,7 +101,7 @@ const CommentTile: React.FC<CommentTileProps> = ({ showReplies ? 'Hide' : comment.replies_count > 0 - ? `Replies (${comment.replies_count})` + ? `Replies (${comment.replies_count}) ` : 'Replies'; const renderRightAction = (text: string, color: string) => { @@ -143,11 +144,19 @@ const CommentTile: React.FC<CommentTileProps> = ({ containerStyle={styles.swipableContainer}> <View style={[styles.container, isThread ? styles.moreMarginWithThread : {}]}> - <ProfilePreview - profilePreview={commentObject.commenter} - previewType={'Comment'} - screenType={screenType} - /> + <View style={styles.commentHeaderContainer}> + <ProfilePreview + profilePreview={commentObject.commenter} + previewType={'Comment'} + screenType={screenType} + /> + <LikeButton + liked={liked} + setLiked={setLiked} + onPress={() => handleLikeUnlikeComment(commentObject, liked)} + style={styles.likeButton} + /> + </View> <TouchableOpacity style={styles.body} onPress={toggleAddComment}> {renderTextWithMentions({ value: commentObject.comment, @@ -156,33 +165,53 @@ const CommentTile: React.FC<CommentTileProps> = ({ onPress: (user: UserType) => navigateToProfile(state, dispatch, navigation, screenType, user), })} - <View style={styles.clockIconAndTime}> - <ClockIcon style={styles.clockIcon} /> - <Text style={styles.date_time}>{' ' + timePosted}</Text> - <View style={styles.flexer} /> + <View style={styles.commentInfoContainer}> + <View style={styles.row}> + <ClockIcon style={styles.clockIcon} /> + <Text style={styles.date_time}>{' ' + timePosted}</Text> + </View> + <View style={styles.row}> + <TouchableOpacity + style={styles.row} + disabled={commentObject.reaction_count === 0 && !liked} + onPress={() => { + navigation.navigate('CommentReactionScreen', { + comment: commentObject, + screenType: screenType, + }); + }}> + <Text style={[styles.date_time, styles.likeCount]}> + {commentObject.user_reaction !== null + ? commentObject.reaction_count + (liked ? 0 : -1) + : commentObject.reaction_count + (liked ? 1 : 0)} + </Text> + <Text style={styles.date_time}>Likes</Text> + </TouchableOpacity> + {/* Show replies text only if there are some replies present */} + {!isThread && (commentObject as CommentType).replies_count > 0 && ( + <TouchableOpacity + style={styles.repliesTextAndIconContainer} + onPress={toggleReplies}> + <Text style={styles.repliesText}> + {getRepliesText(commentObject as CommentType)} + </Text> + <Arrow + width={12} + height={11} + color={TAGG_LIGHT_BLUE} + style={ + !showReplies + ? styles.repliesDownArrow + : styles.repliesUpArrow + } + /> + </TouchableOpacity> + )} + </View> </View> </TouchableOpacity> - {/*** Show replies text only if there are some replies present */} - {!isThread && (commentObject as CommentType).replies_count > 0 && ( - <TouchableOpacity - style={styles.repliesTextAndIconContainer} - onPress={toggleReplies}> - <Text style={styles.repliesText}> - {getRepliesText(commentObject as CommentType)} - </Text> - <Arrow - width={12} - height={11} - color={TAGG_LIGHT_BLUE} - style={ - !showReplies ? styles.repliesDownArrow : styles.repliesUpArrow - } - /> - </TouchableOpacity> - )} </View> - - {/*** Show replies if toggle state is true */} + {/* Show replies if toggle state is true */} {showReplies && ( <View> <CommentsContainer @@ -206,8 +235,8 @@ const styles = StyleSheet.create({ flexDirection: 'column', flex: 1, paddingTop: '3%', - paddingBottom: '5%', - marginLeft: '7%', + marginLeft: '5%', + paddingBottom: '2%', }, swipeActions: { flexDirection: 'row', @@ -215,6 +244,14 @@ const styles = StyleSheet.create({ moreMarginWithThread: { marginLeft: '14%', }, + commentHeaderContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + likeButton: { + marginRight: 10, + }, body: { marginLeft: 56, }, @@ -231,18 +268,23 @@ const styles = StyleSheet.create({ height: 12, alignSelf: 'center', }, - clockIconAndTime: { + commentInfoContainer: { flexDirection: 'row', marginTop: '3%', + justifyContent: 'space-between', + alignItems: 'center', }, - flexer: { - flex: 1, + likeCount: { + color: 'black', + marginRight: 5, + }, + row: { + flexDirection: 'row', }, repliesTextAndIconContainer: { flexDirection: 'row', alignItems: 'center', - marginTop: '5%', - marginLeft: 56, + paddingLeft: 10, }, repliesText: { color: TAGG_LIGHT_BLUE, @@ -250,9 +292,6 @@ const styles = StyleSheet.create({ fontSize: normalize(12), marginRight: '1%', }, - repliesBody: { - width: SCREEN_WIDTH, - }, repliesDownArrow: { transform: [{rotate: '270deg'}], marginTop: '1%', diff --git a/src/components/comments/CommentsContainer.tsx b/src/components/comments/CommentsContainer.tsx index 0bfd5ad6..595ec743 100644 --- a/src/components/comments/CommentsContainer.tsx +++ b/src/components/comments/CommentsContainer.tsx @@ -136,7 +136,6 @@ const CommentsContainer: React.FC<CommentsContainerProps> = ({ }; const styles = StyleSheet.create({ - scrollView: {}, scrollViewContent: { justifyContent: 'center', }, diff --git a/src/components/comments/MentionInputControlled.tsx b/src/components/comments/MentionInputControlled.tsx new file mode 100644 index 00000000..6abcb566 --- /dev/null +++ b/src/components/comments/MentionInputControlled.tsx @@ -0,0 +1,195 @@ +import React, {FC, MutableRefObject, useMemo, useRef, useState} from 'react'; +import { + NativeSyntheticEvent, + Text, + TextInput, + TextInputSelectionChangeEventData, + View, +} from 'react-native'; + +import { + MentionInputProps, + MentionPartType, + Suggestion, +} from 'react-native-controlled-mentions/dist/types'; +import { + defaultMentionTextStyle, + generateValueFromPartsAndChangedText, + generateValueWithAddedSuggestion, + getMentionPartSuggestionKeywords, + isMentionPartType, + parseValue, +} from 'react-native-controlled-mentions/dist/utils'; + +const MentionInputControlled: FC<MentionInputProps> = ({ + value, + onChange, + + partTypes = [], + + inputRef: propInputRef, + + containerStyle, + + onSelectionChange, + + ...textInputProps +}) => { + const textInput = useRef<TextInput | null>(null); + + const [selection, setSelection] = useState({start: 0, end: 0}); + + const [keyboardText, setKeyboardText] = useState<string>(''); + + const validRegex = () => { + if (partTypes.length === 0) { + return /.*\@[^ ]*$/; + } else { + return new RegExp(`.*\@${keywordByTrigger[partTypes[0].trigger]}.*$`); + } + }; + + const {plainText, parts} = useMemo(() => parseValue(value, partTypes), [ + value, + partTypes, + ]); + + const handleSelectionChange = ( + event: NativeSyntheticEvent<TextInputSelectionChangeEventData>, + ) => { + setSelection(event.nativeEvent.selection); + + onSelectionChange && onSelectionChange(event); + }; + + /** + * Callback that trigger on TextInput text change + * + * @param changedText + */ + const onChangeInput = (changedText: string) => { + setKeyboardText(changedText); + onChange( + generateValueFromPartsAndChangedText(parts, plainText, changedText), + ); + }; + + /** + * We memoize the keyword to know should we show mention suggestions or not + */ + const keywordByTrigger = useMemo(() => { + return getMentionPartSuggestionKeywords( + parts, + plainText, + selection, + partTypes, + ); + }, [parts, plainText, selection, partTypes]); + + /** + * Callback on mention suggestion press. We should: + * - Get updated value + * - Trigger onChange callback with new value + */ + const onSuggestionPress = (mentionType: MentionPartType) => ( + suggestion: Suggestion, + ) => { + const newValue = generateValueWithAddedSuggestion( + parts, + mentionType, + plainText, + selection, + suggestion, + ); + + if (!newValue) { + return; + } + + onChange(newValue); + + /** + * Move cursor to the end of just added mention starting from trigger string and including: + * - Length of trigger string + * - Length of mention name + * - Length of space after mention (1) + * + * Not working now due to the RN bug + */ + // const newCursorPosition = currentPart.position.start + triggerPartIndex + trigger.length + + // suggestion.name.length + 1; + + // textInput.current?.setNativeProps({selection: {start: newCursorPosition, end: newCursorPosition}}); + }; + + const handleTextInputRef = (ref: TextInput) => { + textInput.current = ref as TextInput; + + if (propInputRef) { + if (typeof propInputRef === 'function') { + propInputRef(ref); + } else { + (propInputRef as MutableRefObject<TextInput>).current = ref as TextInput; + } + } + }; + + const renderMentionSuggestions = (mentionType: MentionPartType) => ( + <React.Fragment key={mentionType.trigger}> + {mentionType.renderSuggestions && + mentionType.renderSuggestions({ + keyword: keywordByTrigger[mentionType.trigger], + onSuggestionPress: onSuggestionPress(mentionType), + })} + </React.Fragment> + ); + + const validateInput = (testString: string) => { + return validRegex().test(testString); + }; + + return ( + <View style={containerStyle}> + {validateInput(keyboardText) + ? (partTypes.filter( + (one) => + isMentionPartType(one) && + one.renderSuggestions != null && + !one.isBottomMentionSuggestionsRender, + ) as MentionPartType[]).map(renderMentionSuggestions) + : null} + + <TextInput + multiline + {...textInputProps} + ref={handleTextInputRef} + onChangeText={onChangeInput} + onSelectionChange={handleSelectionChange}> + <Text> + {parts.map(({text, partType, data}, index) => + partType ? ( + <Text + key={`${index}-${data?.trigger ?? 'pattern'}`} + style={partType.textStyle ?? defaultMentionTextStyle}> + {text} + </Text> + ) : ( + <Text key={index}>{text}</Text> + ), + )} + </Text> + </TextInput> + + {validateInput(keyboardText) + ? (partTypes.filter( + (one) => + isMentionPartType(one) && + one.renderSuggestions != null && + one.isBottomMentionSuggestionsRender, + ) as MentionPartType[]).map(renderMentionSuggestions) + : null} + </View> + ); +}; + +export {MentionInputControlled}; diff --git a/src/components/common/BasicButton.tsx b/src/components/common/BasicButton.tsx index 1fe29cd9..e2274dbd 100644 --- a/src/components/common/BasicButton.tsx +++ b/src/components/common/BasicButton.tsx @@ -1,5 +1,12 @@ import React from 'react'; -import {StyleProp, StyleSheet, Text, View, ViewStyle} from 'react-native'; +import { + StyleProp, + StyleSheet, + Text, + TextStyle, + View, + ViewStyle, +} from 'react-native'; import {TAGG_LIGHT_BLUE} from '../../constants'; import {TouchableOpacity} from 'react-native-gesture-handler'; import {normalize} from '../../utils'; @@ -8,7 +15,7 @@ interface BasicButtonProps { title: string; onPress: () => void; solid?: boolean; - externalStyles?: Record<string, StyleProp<ViewStyle>>; + externalStyles?: Record<string, StyleProp<ViewStyle | TextStyle>>; } const BasicButton: React.FC<BasicButtonProps> = ({ title, @@ -27,6 +34,7 @@ const BasicButton: React.FC<BasicButtonProps> = ({ <Text style={[ styles.buttonTitle, + externalStyles?.buttonTitle, solid ? styles.solidButtonTitleColor : styles.outlineButtonTitleColor, diff --git a/src/components/common/LikeButton.tsx b/src/components/common/LikeButton.tsx new file mode 100644 index 00000000..81383eca --- /dev/null +++ b/src/components/common/LikeButton.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import {Image, ImageStyle, StyleSheet, TouchableOpacity} from 'react-native'; +import {normalize} from '../../utils'; + +interface LikeButtonProps { + onPress: () => void; + style: ImageStyle; + liked: boolean; + setLiked: (liked: boolean) => void; +} +const LikeButton: React.FC<LikeButtonProps> = ({ + onPress, + style, + liked, + setLiked, +}) => { + const uri = liked + ? require('../../assets/images/heart-filled.png') + : require('../../assets/images/heart-outlined.png'); + return ( + <TouchableOpacity + onPress={() => { + setLiked(!liked); + onPress(); + }}> + <Image style={[styles.image, style]} source={uri} /> + </TouchableOpacity> + ); +}; + +const styles = StyleSheet.create({ + image: { + width: normalize(18), + height: normalize(15), + }, +}); + +export default LikeButton; diff --git a/src/components/common/index.ts b/src/components/common/index.ts index b38056c6..48abb8b8 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -26,3 +26,4 @@ export {default as BasicButton} from './BasicButton'; export {default as Avatar} from './Avatar'; export {default as TaggTypeahead} from './TaggTypeahead'; export {default as TaggUserRowCell} from './TaggUserRowCell'; +export {default as LikeButton} from './LikeButton'; diff --git a/src/components/messages/MessageButton.tsx b/src/components/messages/MessageButton.tsx new file mode 100644 index 00000000..5ac42c4c --- /dev/null +++ b/src/components/messages/MessageButton.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import {Fragment, useContext} from 'react'; +import {useStore} from 'react-redux'; +import {ChatContext} from '../../App'; +import {RootState} from '../../store/rootReducer'; +import {FriendshipStatusType} from '../../types'; +import {createChannel} from '../../utils'; +import {Alert, StyleProp, TextStyle, ViewStyle} from 'react-native'; +import {BasicButton} from '../common'; +import {useNavigation} from '@react-navigation/native'; +import {ERROR_UNABLE_CONNECT_CHAT} from '../../constants/strings'; + +interface MessageButtonProps { + userXId: string; + isBlocked: boolean; + friendship_status: FriendshipStatusType; + friendship_requester_id?: string; + solid?: boolean; + externalStyles?: Record<string, StyleProp<ViewStyle | TextStyle>>; +} + +const MessageButton: React.FC<MessageButtonProps> = ({ + userXId, + isBlocked, + friendship_status, + friendship_requester_id, + solid, + externalStyles, +}) => { + const navigation = useNavigation(); + const {chatClient, setChannel} = useContext(ChatContext); + + const state: RootState = useStore().getState(); + const loggedInUserId = state.user.user.userId; + + const canMessage = () => { + if ( + userXId && + !isBlocked && + (friendship_status === 'no_record' || + friendship_status === 'friends' || + (friendship_status === 'requested' && + friendship_requester_id === loggedInUserId)) + ) { + return true; + } else { + return false; + } + }; + + const onPressMessage = async () => { + if (chatClient.user && userXId) { + const channel = await createChannel(loggedInUserId, userXId, chatClient); + setChannel(channel); + navigation.navigate('Chat'); + } else { + Alert.alert(ERROR_UNABLE_CONNECT_CHAT); + } + }; + + return canMessage() ? ( + <BasicButton + title={'Message'} + onPress={onPressMessage} + externalStyles={externalStyles} + solid={solid ? solid : false} + /> + ) : ( + <Fragment /> + ); +}; + +export default MessageButton; diff --git a/src/components/messages/index.ts b/src/components/messages/index.ts index b19067ca..7270e2e2 100644 --- a/src/components/messages/index.ts +++ b/src/components/messages/index.ts @@ -7,3 +7,4 @@ export {default as MessageAvatar} from './MessageAvatar'; export {default as TypingIndicator} from './TypingIndicator'; export {default as MessageFooter} from './MessageFooter'; export {default as DateHeader} from './DateHeader'; +export {default as MessageButton} from './MessageButton'; diff --git a/src/components/moments/MomentPostContent.tsx b/src/components/moments/MomentPostContent.tsx index 45186ba1..193bf40c 100644 --- a/src/components/moments/MomentPostContent.tsx +++ b/src/components/moments/MomentPostContent.tsx @@ -10,6 +10,7 @@ import { navigateToProfile, SCREEN_HEIGHT, SCREEN_WIDTH, + normalize, } from '../../utils'; import {mentionPartTypes, renderTextWithMentions} from '../../utils/comments'; import {CommentsCount} from '../comments'; @@ -103,6 +104,9 @@ const styles = StyleSheet.create({ marginRight: '5%', color: '#ffffff', fontWeight: '500', + fontSize: normalize(13), + lineHeight: normalize(15.51), + letterSpacing: normalize(0.6), }, }); export default MomentPostContent; diff --git a/src/components/notifications/Notification.tsx b/src/components/notifications/Notification.tsx index ae884b42..cb62047a 100644 --- a/src/components/notifications/Notification.tsx +++ b/src/components/notifications/Notification.tsx @@ -34,6 +34,7 @@ import { } from '../../utils'; import {Avatar} from '../common'; import AcceptDeclineButtons from '../common/AcceptDeclineButtons'; +import {MessageButton} from '../messages'; interface NotificationProps { item: NotificationType; @@ -61,6 +62,10 @@ const Notification: React.FC<NotificationProps> = (props) => { const [avatar, setAvatar] = useState<string | undefined>(undefined); const [momentURI, setMomentURI] = useState<string | undefined>(undefined); + const notification_title = + notification_type === 'FRD_ACPT' + ? `Say Hi to ${first_name}!` + : `${first_name} ${last_name}`; useEffect(() => { (async () => { @@ -246,9 +251,7 @@ const Notification: React.FC<NotificationProps> = (props) => { {/* Text content: Actor name and verbage*/} <View style={styles.contentContainer}> <TouchableWithoutFeedback onPress={navigateToProfile}> - <Text style={styles.actorName}> - {first_name} {last_name} - </Text> + <Text style={styles.actorName}>{notification_title}</Text> </TouchableWithoutFeedback> <TouchableWithoutFeedback style={styles.textContainerStyles} @@ -273,6 +276,30 @@ const Notification: React.FC<NotificationProps> = (props) => { /> </View> )} + {notification_type === 'FRD_ACPT' && ( + <View style={styles.buttonsContainer}> + <MessageButton + userXId={id} + isBlocked={false} + friendship_status={'friends'} + externalStyles={{ + container: { + width: normalize(63), + height: normalize(21), + marginTop: '7%', + }, + buttonTitle: { + fontSize: normalize(11), + lineHeight: normalize(13.13), + letterSpacing: normalize(0.5), + fontWeight: '700', + textAlign: 'center', + }, + }} + solid + /> + </View> + )} {/* Moment Image Preview */} {(notification_type === 'CMT' || notification_type === 'MOM_3+' || @@ -306,7 +333,7 @@ const styles = StyleSheet.create({ flex: 1, alignSelf: 'center', alignItems: 'center', - paddingHorizontal: '8%', + paddingHorizontal: '6.3%', }, avatarContainer: { height: 42, @@ -348,9 +375,9 @@ const styles = StyleSheet.create({ lineHeight: normalize(13.13), }, timeStampStyles: { - fontWeight: '700', - fontSize: normalize(12), - lineHeight: normalize(14.32), + fontWeight: '500', + fontSize: normalize(11), + lineHeight: normalize(13.13), marginHorizontal: 2, color: '#828282', textAlignVertical: 'center', diff --git a/src/components/profile/Cover.tsx b/src/components/profile/Cover.tsx index 27777b64..5d5b4234 100644 --- a/src/components/profile/Cover.tsx +++ b/src/components/profile/Cover.tsx @@ -1,28 +1,93 @@ -import React from 'react'; -import {Image, StyleSheet, View} from 'react-native'; -import {useSelector} from 'react-redux'; +import React, {useState, useEffect} from 'react'; +import { + Image, + StyleSheet, + View, + TouchableOpacity, + Text, + ImageBackground, +} from 'react-native'; import {COVER_HEIGHT, IMAGE_WIDTH} from '../../constants'; -import {RootState} from '../../store/rootreducer'; import {ScreenType} from '../../types'; +import GreyPurplePlus from '../../assets/icons/grey-purple-plus.svg'; +import {useDispatch, useSelector} from 'react-redux'; +import {loadUserData, resetHeaderAndProfileImage} from '../../store/actions'; +import {RootState} from '../../store/rootreducer'; +import {normalize, patchProfile, validateImageLink} from '../../utils'; interface CoverProps { userXId: string | undefined; screenType: ScreenType; } const Cover: React.FC<CoverProps> = ({userXId, screenType}) => { - const {cover} = useSelector((state: RootState) => + const dispatch = useDispatch(); + const {cover, user} = useSelector((state: RootState) => userXId ? state.userX[screenType][userXId] : state.user, ); - return ( - <View style={[styles.container]}> - <Image - style={styles.image} - defaultSource={require('../../assets/images/cover-placeholder.png')} - source={{uri: cover, cache: 'reload'}} - /> - </View> - ); + const [needsUpdate, setNeedsUpdate] = useState(false); + const [loading, setLoading] = useState(false); + const [validImage, setValidImage] = useState<boolean>(true); + + useEffect(() => { + checkAvatar(cover); + }, []); + + useEffect(() => { + if (needsUpdate) { + const userId = user.userId; + const username = user.username; + dispatch(resetHeaderAndProfileImage()); + dispatch(loadUserData({userId, username})); + } + }, [dispatch, needsUpdate]); + + const handleNewImage = async () => { + setLoading(true); + const result = await patchProfile('header', user.userId); + setLoading(true); + if (result) { + setNeedsUpdate(true); + } else { + setLoading(false); + } + }; + + const checkAvatar = async (url: string | undefined) => { + const valid = await validateImageLink(url); + if (valid !== validImage) { + setValidImage(valid); + } + }; + + if (!validImage && userXId === undefined && !loading) { + return ( + <View style={[styles.container]}> + <ImageBackground + style={styles.image} + defaultSource={require('../../assets/images/cover-placeholder.png')} + source={{uri: cover, cache: 'reload'}}> + <TouchableOpacity + accessible={true} + accessibilityLabel="ADD HEADER PICTURE" + onPress={() => handleNewImage()}> + <GreyPurplePlus style={styles.plus} /> + <Text style={styles.text}>Add Picture</Text> + </TouchableOpacity> + </ImageBackground> + </View> + ); + } else { + return ( + <View style={styles.container}> + <Image + style={styles.image} + defaultSource={require('../../assets/images/cover-placeholder.png')} + source={{uri: cover, cache: 'reload'}} + /> + </View> + ); + } }; const styles = StyleSheet.create({ @@ -33,5 +98,20 @@ const styles = StyleSheet.create({ width: IMAGE_WIDTH, height: COVER_HEIGHT, }, + plus: { + position: 'absolute', + top: 75, + right: 125, + }, + text: { + color: 'white', + position: 'absolute', + fontSize: normalize(16), + top: 80, + right: 20, + }, + touch: { + flex: 1, + }, }); export default Cover; diff --git a/src/components/profile/Friends.tsx b/src/components/profile/Friends.tsx index a7a06567..f800597b 100644 --- a/src/components/profile/Friends.tsx +++ b/src/components/profile/Friends.tsx @@ -1,98 +1,39 @@ -import React, {useEffect, useState} from 'react'; +import React from 'react'; import {ScrollView, StyleSheet, Text, View} from 'react-native'; -import {checkPermission} from 'react-native-contacts'; import {TouchableOpacity} from 'react-native-gesture-handler'; import {useDispatch, useStore} from 'react-redux'; import {TAGG_LIGHT_BLUE} from '../../constants'; -import {usersFromContactsService} from '../../services'; import {NO_USER} from '../../store/initialStates'; import {RootState} from '../../store/rootReducer'; import {ProfilePreviewType, ScreenType} from '../../types'; -import { - extractContacts, - normalize, - SCREEN_HEIGHT, - SCREEN_WIDTH, -} from '../../utils'; -import {handleAddFriend, handleUnfriend} from '../../utils/friends'; +import {normalize, SCREEN_HEIGHT, SCREEN_WIDTH} from '../../utils'; +import {handleUnfriend} from '../../utils/friends'; import {ProfilePreview} from '../profile'; interface FriendsProps { result: Array<ProfilePreviewType>; screenType: ScreenType; userId: string | undefined; + hideFriendsFeature?: boolean; } -const Friends: React.FC<FriendsProps> = ({result, screenType, userId}) => { +const Friends: React.FC<FriendsProps> = ({ + result, + screenType, + userId, + hideFriendsFeature, +}) => { const state: RootState = useStore().getState(); const dispatch = useDispatch(); const {user: loggedInUser = NO_USER} = state.user; - const [usersFromContacts, setUsersFromContacts] = useState< - ProfilePreviewType[] - >([]); - - useEffect(() => { - const handleFindFriends = () => { - extractContacts().then(async (contacts) => { - const permission = await checkPermission(); - if (permission === 'authorized') { - let response = await usersFromContactsService(contacts); - setUsersFromContacts(response.existing_tagg_users); - } else { - console.log('Authorize access to contacts'); - } - }); - }; - handleFindFriends(); - }, []); - - const UsersFromContacts = () => ( - <> - {usersFromContacts?.splice(0, 2).map((profilePreview) => ( - <View key={profilePreview.id} style={styles.container}> - <View style={styles.friend}> - <ProfilePreview - {...{profilePreview}} - previewType={'Friend'} - screenType={screenType} - /> - </View> - <TouchableOpacity - style={styles.addFriendButton} - onPress={() => { - handleAddFriend(screenType, profilePreview, dispatch, state).then( - (success) => { - if (success) { - let users = usersFromContacts; - setUsersFromContacts( - users.filter( - (user) => user.username !== profilePreview.username, - ), - ); - } - }, - ); - }}> - <Text style={styles.addFriendButtonTitle}>Add Friend</Text> - </TouchableOpacity> - </View> - ))} - </> - ); return ( <> - {loggedInUser.userId === userId && usersFromContacts.length !== 0 && ( - <View style={styles.subheader}> - <View style={styles.addFriendHeaderContainer}> - <Text style={[styles.subheaderText]}>Contacts on Tagg</Text> - </View> - <UsersFromContacts /> - </View> + {!hideFriendsFeature && ( + <Text style={[styles.subheaderText, styles.friendsSubheaderText]}> + Friends + </Text> )} - <Text style={[styles.subheaderText, styles.friendsSubheaderText]}> - Friends - </Text> <ScrollView keyboardShouldPersistTaps={'always'} style={styles.scrollView} @@ -129,7 +70,6 @@ const styles = StyleSheet.create({ alignSelf: 'center', width: SCREEN_WIDTH * 0.85, }, - firstScrollView: {}, scrollViewContent: { alignSelf: 'center', paddingBottom: SCREEN_HEIGHT / 7, @@ -142,7 +82,6 @@ const styles = StyleSheet.create({ marginBottom: '3%', marginTop: '2%', }, - header: {flexDirection: 'row'}, subheader: { alignSelf: 'center', width: SCREEN_WIDTH * 0.85, @@ -154,20 +93,12 @@ const styles = StyleSheet.create({ fontWeight: '600', lineHeight: normalize(14.32), }, - findFriendsButton: {flexDirection: 'row'}, friendsSubheaderText: { alignSelf: 'center', width: SCREEN_WIDTH * 0.85, marginVertical: '1%', marginBottom: '2%', }, - findFriendsSubheaderText: { - marginLeft: '5%', - color: '#08E2E2', - fontSize: normalize(12), - fontWeight: '600', - lineHeight: normalize(14.32), - }, container: { alignSelf: 'center', flexDirection: 'row', diff --git a/src/components/profile/ProfileBody.tsx b/src/components/profile/ProfileBody.tsx index 3d654724..7557de00 100644 --- a/src/components/profile/ProfileBody.tsx +++ b/src/components/profile/ProfileBody.tsx @@ -1,17 +1,7 @@ -import {useNavigation} from '@react-navigation/core'; -import React, {useContext} from 'react'; -import { - Alert, - LayoutChangeEvent, - Linking, - StyleSheet, - Text, - View, -} from 'react-native'; +import React from 'react'; +import {LayoutChangeEvent, Linking, StyleSheet, Text, View} from 'react-native'; 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, @@ -22,14 +12,14 @@ 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 {canViewProfile} from '../../utils/users'; -import {BasicButton, FriendsButton} from '../common'; +import {FriendsButton} from '../common'; +import {MessageButton} from '../messages'; import ToggleButton from './ToggleButton'; interface ProfileBodyProps { @@ -47,7 +37,6 @@ const ProfileBody: React.FC<ProfileBodyProps> = ({ screenType, }) => { const dispatch = useDispatch(); - const navigation = useNavigation(); const {profile = NO_PROFILE, user} = useSelector((state: RootState) => userXId ? state.userX[screenType][userXId] : state.user, @@ -65,10 +54,7 @@ const ProfileBody: React.FC<ProfileBodyProps> = ({ profile, ); - const {chatClient, setChannel} = useContext(ChatContext); - const state: RootState = useStore().getState(); - const loggedInUserId = state.user.user.userId; const handleAcceptRequest = async () => { await dispatch(acceptFriendRequest({id, username, first_name, last_name})); @@ -81,32 +67,6 @@ const ProfileBody: React.FC<ProfileBodyProps> = ({ dispatch(updateUserXProfileAllScreens(id, state)); }; - const canMessage = () => { - if ( - userXId && - !isBlocked && - (friendship_status === 'no_record' || - friendship_status === 'friends' || - (friendship_status === 'requested' && - friendship_requester_id === loggedInUserId)) && - canViewProfile(state, userXId, screenType) - ) { - return true; - } else { - return false; - } - }; - - const onPressMessage = async () => { - if (chatClient.user && userXId) { - const channel = await createChannel(loggedInUserId, userXId, chatClient); - setChannel(channel); - navigation.navigate('Chat'); - } else { - Alert.alert(ERROR_UNABLE_CONNECT_CHAT); - } - }; - return ( <View onLayout={onLayout} style={styles.container}> <Text style={styles.username}>{`@${username}`}</Text> @@ -142,10 +102,12 @@ const ProfileBody: React.FC<ProfileBodyProps> = ({ onAcceptRequest={handleAcceptRequest} onRejectRequest={handleDeclineFriendRequest} /> - {canMessage() && ( - <BasicButton - title={'Message'} - onPress={onPressMessage} + {canViewProfile(state, userXId, screenType) && ( + <MessageButton + userXId={userXId} + isBlocked={isBlocked} + friendship_status={friendship_status} + friendship_requester_id={friendship_requester_id} externalStyles={{ container: { width: SCREEN_WIDTH * 0.42, diff --git a/src/components/profile/TaggAvatar.tsx b/src/components/profile/TaggAvatar.tsx index ea0bdb65..304b9e3a 100644 --- a/src/components/profile/TaggAvatar.tsx +++ b/src/components/profile/TaggAvatar.tsx @@ -1,9 +1,12 @@ -import React from 'react'; -import {StyleSheet} from 'react-native'; -import {useSelector} from 'react-redux'; +import React, {useState, useEffect} from 'react'; +import {StyleSheet, TouchableOpacity} from 'react-native'; import {RootState} from '../../store/rootreducer'; import {ScreenType} from '../../types'; import {Avatar} from '../common'; +import {useDispatch, useSelector} from 'react-redux'; +import {loadUserData, resetHeaderAndProfileImage} from '../../store/actions'; +import PurplePlus from '../../assets/icons/purple-plus.svg'; +import {patchProfile, validateImageLink} from '../../utils'; const PROFILE_DIM = 100; @@ -20,8 +23,59 @@ const TaggAvatar: React.FC<TaggAvatarProps> = ({ const {avatar} = useSelector((state: RootState) => userXId ? state.userX[screenType][userXId] : state.user, ); + const dispatch = useDispatch(); + const [needsUpdate, setNeedsUpdate] = useState(false); + const [loading, setLoading] = useState(false); + const [validImage, setValidImage] = useState<boolean>(true); + const {user} = useSelector((state: RootState) => + userXId ? state.userX[screenType][userXId] : state.user, + ); + + useEffect(() => { + checkAvatar(avatar); + }, []); + + useEffect(() => { + if (needsUpdate) { + const userId = user.userId; + const username = user.username; + dispatch(resetHeaderAndProfileImage()); + dispatch(loadUserData({userId, username})); + } + }, [dispatch, needsUpdate]); - return <Avatar style={[styles.image, style]} uri={avatar} />; + const handleNewImage = async () => { + setLoading(true); + const result = await patchProfile('profile', user.userId); + if (result) { + setNeedsUpdate(true); + } else { + setLoading(false); + } + }; + + const checkAvatar = async (url: string | undefined) => { + const valid = await validateImageLink(url); + if (valid !== validImage) { + setValidImage(valid); + } + }; + + if (!validImage && userXId === undefined && !loading) { + return ( + <> + <Avatar style={[styles.image, style]} uri={avatar} /> + <TouchableOpacity + accessible={true} + accessibilityLabel="ADD PROFILE PICTURE" + onPress={() => handleNewImage()}> + <PurplePlus style={styles.plus} /> + </TouchableOpacity> + </> + ); + } else { + return <Avatar style={[styles.image, style]} uri={avatar} />; + } }; const styles = StyleSheet.create({ @@ -30,6 +84,11 @@ const styles = StyleSheet.create({ width: PROFILE_DIM, borderRadius: PROFILE_DIM / 2, }, + plus: { + position: 'absolute', + bottom: 35, + right: 0, + }, }); export default TaggAvatar; diff --git a/src/components/taggs/TaggsBar.tsx b/src/components/taggs/TaggsBar.tsx index 4d567b25..a7e8fc7a 100644 --- a/src/components/taggs/TaggsBar.tsx +++ b/src/components/taggs/TaggsBar.tsx @@ -113,13 +113,11 @@ const TaggsBar: React.FC<TaggsBarProps> = ({ loadData(); } }, [taggsNeedUpdate, user]); - const paddingTopStylesProgress = useDerivedValue(() => - interpolate( - y.value, - [PROFILE_CUTOUT_BOTTOM_Y, PROFILE_CUTOUT_BOTTOM_Y + profileBodyHeight], - [0, 1], - Extrapolate.CLAMP, - ), + const paddingTopStylesProgress = interpolate( + y.value, + [PROFILE_CUTOUT_BOTTOM_Y, PROFILE_CUTOUT_BOTTOM_Y + profileBodyHeight], + [0, 1], + Extrapolate.CLAMP, ); const shadowOpacityStylesProgress = useDerivedValue(() => interpolate( @@ -134,7 +132,7 @@ const TaggsBar: React.FC<TaggsBarProps> = ({ ); const animatedStyles = useAnimatedStyle(() => ({ shadowOpacity: shadowOpacityStylesProgress.value / 5, - paddingTop: paddingTopStylesProgress.value * insetTop, + paddingTop: paddingTopStylesProgress + insetTop, })); return taggs.length > 0 ? ( diff --git a/src/constants/api.ts b/src/constants/api.ts index 41c54d6f..3c7e669e 100644 --- a/src/constants/api.ts +++ b/src/constants/api.ts @@ -33,6 +33,8 @@ export const MOMENTS_ENDPOINT: string = API_URL + 'moments/'; export const MOMENT_THUMBNAIL_ENDPOINT: string = API_URL + 'moment-thumbnail/'; export const VERIFY_INVITATION_CODE_ENDPOUNT: string = API_URL + 'verify-code/'; export const COMMENTS_ENDPOINT: string = API_URL + 'comments/'; +export const COMMENT_REACTIONS_ENDPOINT: string = API_URL + 'reaction-comment/'; +export const COMMENT_REACTIONS_REPLY_ENDPOINT: string = API_URL + 'reaction-reply/'; export const FRIENDS_ENDPOINT: string = API_URL + 'friends/'; export const ALL_USERS_ENDPOINT: string = API_URL + 'users/'; export const REPORT_ISSUE_ENDPOINT: string = API_URL + 'report/'; diff --git a/src/constants/badges.ts b/src/constants/badges.ts index aca53f26..54979ecd 100644 --- a/src/constants/badges.ts +++ b/src/constants/badges.ts @@ -1,7 +1,7 @@ import {BadgeDataType} from '../types'; export const _badgeImages = { - iff: require('../assets/badges/iff.png'), + aap: require('../assets/badges/aap.png'), acacia: require('../assets/badges/acacia.png'), acapella: require('../assets/badges/acapella.png'), alpha_chi_omega: require('../assets/badges/alpha_chi_omega.png'), @@ -26,8 +26,15 @@ export const _badgeImages = { brown_womens_collective: require('../assets/badges/brown_womens_collective.png'), bsu: require('../assets/badges/bsu.png'), buxton_international: require('../assets/badges/buxton_international.png'), + cals: require('../assets/badges/cals.png'), chi_phi: require('../assets/badges/chi_phi.png'), chi_psi: require('../assets/badges/chi_psi.png'), + college_of_arts_and_sciences: require('../assets/badges/college_of_arts_and_sciences.png'), + college_of_engineering: require('../assets/badges/college_of_engineering.png'), + college_of_human_ecology: require('../assets/badges/college_of_human_ecology.png'), + college_of_veterinary_medicine: require('../assets/badges/college_of_veterinary_medicine.png'), + cornell_law_school: require('../assets/badges/cornell_law_school.png'), + cornell_tech: require('../assets/badges/cornell_tech.png'), delta_chi: require('../assets/badges/delta_chi.png'), delta_delta_delta: require('../assets/badges/delta_delta_delta.png'), delta_gamma: require('../assets/badges/delta_gamma.png'), @@ -36,13 +43,19 @@ export const _badgeImages = { delta_tau: require('../assets/badges/delta_tau.png'), delta_tau_delta: require('../assets/badges/delta_tau_delta.png'), delta_upsilon: require('../assets/badges/delta_upsilon.png'), + dyson_school: require('../assets/badges/dyson_school.png'), + entrepreneurship_at_cornell: require('../assets/badges/entrepreneurship_at_cornell.png'), fashion_at_brown: require('../assets/badges/fashion_at_brown.png'), fencing: require('../assets/badges/fencing.png'), field_hockey: require('../assets/badges/field_hockey.png'), football: require('../assets/badges/football.png'), golf: require('../assets/badges/golf.png'), + graduate_school: require('../assets/badges/graduate_school.png'), gymnastics: require('../assets/badges/gymnastics.png'), hockey: require('../assets/badges/hockey.png'), + hotel_administration: require('../assets/badges/hotel_administration.png'), + iff: require('../assets/badges/iff.png'), + ilr: require('../assets/badges/ilr.png'), impulse_and_mezcla: require('../assets/badges/impulse_and_mezcla.png'), kappa_alpha_theta: require('../assets/badges/kappa_alpha_theta.png'), kappa_delta: require('../assets/badges/kappa_delta.png'), @@ -69,6 +82,7 @@ export const _badgeImages = { polo: require('../assets/badges/polo.png'), rowing: require('../assets/badges/rowing.png'), sailing: require('../assets/badges/sailing.png'), + sc_johnson_school_of_management: require('../assets/badges/sc_johnson_school_of_management.png'), sigma_alpha_mu: require('../assets/badges/sigma_alpha_mu.png'), sigma_chi: require('../assets/badges/sigma_chi.png'), sigma_delta_tau: require('../assets/badges/sigma_delta_tau.png'), @@ -77,6 +91,7 @@ export const _badgeImages = { soccer: require('../assets/badges/soccer.png'), softball: require('../assets/badges/softball.png'), squash: require('../assets/badges/squash.png'), + student_agencies: require('../assets/badges/student_agencies.png'), swimming_and_diving: require('../assets/badges/swimming_and_diving.png'), tap: require('../assets/badges/tap.png'), tennis: require('../assets/badges/tennis.png'), @@ -86,6 +101,8 @@ export const _badgeImages = { track_and_field: require('../assets/badges/track_and_field.png'), ucs: require('../assets/badges/ucs.png'), volleyball: require('../assets/badges/volleyball.png'), + weill_cornell_medical_sciences: require('../assets/badges/weill_cornell_medical_sciences.png'), + weill_cornell_medicine: require('../assets/badges/weill_cornell_medicine.png'), women_in_business: require('../assets/badges/women_in_business.png'), wrestling: require('../assets/badges/wrestling.png'), zeta_beta_tau: require('../assets/badges/zeta_beta_tau.png'), @@ -244,13 +261,13 @@ const _brownUniversityBadges = [ badgeImage: _badgeImages.fashion_at_brown, }, { - badgeName: 'Impulse', - badgeImage: _badgeImages.impulse_and_mezcla, - }, - { badgeName: 'Ivy Film Festival ', badgeImage: _badgeImages.iff, }, + { + badgeName: 'Impulse', + badgeImage: _badgeImages.impulse_and_mezcla, + }, ], }, { @@ -629,6 +646,89 @@ const _cornellUniversityBadges = [ }, ], }, + { + title: 'School', + data: [ + { + badgeName: 'College of Agriculture and Life Sciences or (CALS)', + badgeImage: _badgeImages.cals, + }, + { + badgeName: 'College of Architecture, Art and Planning or (AAP)', + badgeImage: _badgeImages.aap, + }, + { + badgeName: 'College of Arts and Sciences ', + badgeImage: _badgeImages.college_of_arts_and_sciences, + }, + { + badgeName: 'Hotel Administration ', + badgeImage: _badgeImages.hotel_administration, + }, + { + badgeName: 'Dyson School', + badgeImage: _badgeImages.dyson_school, + }, + { + badgeName: 'College of Engineering ', + badgeImage: _badgeImages.college_of_engineering, + }, + { + badgeName: 'College of Human Ecology', + badgeImage: _badgeImages.college_of_human_ecology, + }, + { + badgeName: 'School of Industrial and Labor Relations or ILR', + badgeImage: _badgeImages.ilr, + }, + ], + }, + { + title: 'Graduate Program', + data: [ + { + badgeName: 'SC Johnson School of Management', + badgeImage: _badgeImages.sc_johnson_school_of_management, + }, + { + badgeName: 'Conrell Tech (NYC)', + badgeImage: _badgeImages.cornell_tech, + }, + { + badgeName: 'Cornell Law School', + badgeImage: _badgeImages.cornell_law_school, + }, + { + badgeName: 'College of Veterinary Medicine', + badgeImage: _badgeImages.college_of_veterinary_medicine, + }, + { + badgeName: 'Graduate School', + badgeImage: _badgeImages.graduate_school, + }, + { + badgeName: 'Weill Cornell Medicine (NYC)', + badgeImage: _badgeImages.weill_cornell_medicine, + }, + { + badgeName: 'Weill Cornell Medical Sciences (NYC)', + badgeImage: _badgeImages.weill_cornell_medical_sciences, + }, + ], + }, + { + title: 'Network', + data: [ + { + badgeName: 'Entrepreneurship @ Cornell ', + badgeImage: _badgeImages.entrepreneurship_at_cornell, + }, + { + badgeName: 'Student Agencies, INC', + badgeImage: _badgeImages.student_agencies, + }, + ], + }, ]; export const BADGE_DATA: BadgeDataType = { diff --git a/src/routes/main/MainStackNavigator.tsx b/src/routes/main/MainStackNavigator.tsx index 1f173569..3b183cc0 100644 --- a/src/routes/main/MainStackNavigator.tsx +++ b/src/routes/main/MainStackNavigator.tsx @@ -3,7 +3,12 @@ */ import {createStackNavigator} from '@react-navigation/stack'; import {Image} from 'react-native-image-crop-picker'; -import {MomentType, ScreenType, SearchCategoryType} from '../../types'; +import { + CommentBaseType, + MomentType, + ScreenType, + SearchCategoryType, +} from '../../types'; export type MainStackParams = { SuggestedPeople: { @@ -46,6 +51,10 @@ export type MainStackParams = { screenType: ScreenType; comment_id?: string; }; + CommentReactionScreen: { + comment: CommentBaseType; + screenType: ScreenType; + }; FriendsListScreen: { userXId: string | undefined; screenType: ScreenType; diff --git a/src/routes/main/MainStackScreen.tsx b/src/routes/main/MainStackScreen.tsx index f5100e58..d76f9137 100644 --- a/src/routes/main/MainStackScreen.tsx +++ b/src/routes/main/MainStackScreen.tsx @@ -12,6 +12,7 @@ import { CategorySelection, ChatListScreen, ChatScreen, + CommentReactionScreen, CreateCustomCategory, DiscoverUsers, EditProfile, @@ -217,6 +218,13 @@ const MainStackScreen: React.FC<MainStackProps> = ({route}) => { }} /> <MainStack.Screen + name="CommentReactionScreen" + component={CommentReactionScreen} + options={{ + ...headerBarOptions('black', 'Likes'), + }} + /> + <MainStack.Screen name="MomentUploadPrompt" component={MomentUploadPromptScreen} initialParams={{screenType}} diff --git a/src/screens/badge/BadgeItem.tsx b/src/screens/badge/BadgeItem.tsx index 1051d4a7..e4f1b1da 100644 --- a/src/screens/badge/BadgeItem.tsx +++ b/src/screens/badge/BadgeItem.tsx @@ -43,9 +43,15 @@ const BadgeItem: React.FC<BadgeItemProps> = ({ style={styles.item}> <View style={styles.detailContainer}> <Image source={resourcePath} style={styles.imageStyles} /> - <View style={styles.textContainer}> - <Text style={styles.title}>{title}</Text> - </View> + <Text + style={[ + styles.title, + title.length > 30 + ? {fontSize: normalize(12), lineHeight: normalize(16)} + : {}, + ]}> + {title} + </Text> </View> </LinearGradient> </TouchableOpacity> @@ -53,33 +59,30 @@ const BadgeItem: React.FC<BadgeItemProps> = ({ ); }; +const ITEM_WIDTH = SCREEN_WIDTH / 3 - 20; + const styles = StyleSheet.create({ border: { - width: SCREEN_WIDTH / 3 - 20 + 6, - height: 146, + width: ITEM_WIDTH + 6, + height: 156, marginLeft: 10, marginBottom: 12, borderRadius: 8, }, item: { - width: SCREEN_WIDTH / 3 - 20, - height: 140, + width: ITEM_WIDTH, + height: 150, borderRadius: 8, }, detailContainer: { flexGrow: 1, - justifyContent: 'center', + justifyContent: 'space-evenly', alignItems: 'center', - borderWidth: 3, - borderRadius: 8, - borderColor: 'transparent', }, imageStyles: { - width: 40, - height: 40, - marginTop: '11%', + width: normalize(50), + height: normalize(50), }, - textContainer: {marginTop: '16%'}, title: { fontSize: normalize(15), fontWeight: '500', diff --git a/src/screens/profile/CommentReactionScreen.tsx b/src/screens/profile/CommentReactionScreen.tsx new file mode 100644 index 00000000..0596a184 --- /dev/null +++ b/src/screens/profile/CommentReactionScreen.tsx @@ -0,0 +1,69 @@ +import {RouteProp, useNavigation} from '@react-navigation/native'; +import React, {useEffect, useState} from 'react'; +import {Alert, ScrollView, StyleSheet, View} from 'react-native'; +import {SafeAreaView} from 'react-native-safe-area-context'; +import {Friends} from '../../components'; +import {ERROR_SOMETHING_WENT_WRONG} from '../../constants/strings'; +import {MainStackParams} from '../../routes/main'; +import {getUsersReactedToAComment} from '../../services'; +import {ProfilePreviewType} from '../../types'; +import {HeaderHeight, SCREEN_HEIGHT, SCREEN_WIDTH} from '../../utils'; + +type CommentReactionScreenRouteProps = RouteProp< + MainStackParams, + 'CommentReactionScreen' +>; + +interface CommentReactionScreenProps { + route: CommentReactionScreenRouteProps; +} + +const CommentReactionScreen: React.FC<CommentReactionScreenProps> = ({ + route, +}) => { + const navigation = useNavigation(); + const {comment, screenType} = route.params; + const [users, setUsers] = useState<ProfilePreviewType[]>([]); + + useEffect(() => { + const loadUsers = async () => { + const response = await getUsersReactedToAComment(comment); + if (response.length !== 0) { + setUsers(response); + } else { + Alert.alert(ERROR_SOMETHING_WENT_WRONG); + navigation.goBack(); + } + }; + loadUsers(); + }, []); + + return ( + <View style={styles.background}> + <SafeAreaView> + <ScrollView style={styles.container}> + <Friends + result={users} + screenType={screenType} + userId={undefined} + hideFriendsFeature + /> + </ScrollView> + </SafeAreaView> + </View> + ); +}; + +const styles = StyleSheet.create({ + background: { + backgroundColor: 'white', + width: SCREEN_WIDTH, + height: SCREEN_HEIGHT, + }, + container: { + marginTop: HeaderHeight, + height: SCREEN_HEIGHT - HeaderHeight, + }, +}); + +export default CommentReactionScreen; diff --git a/src/screens/profile/FriendsListScreen.tsx b/src/screens/profile/FriendsListScreen.tsx index 1d10bc86..73364f3b 100644 --- a/src/screens/profile/FriendsListScreen.tsx +++ b/src/screens/profile/FriendsListScreen.tsx @@ -36,10 +36,6 @@ const FriendsListScreen: React.FC<FriendsListScreenProps> = ({route}) => { }; const styles = StyleSheet.create({ - background: { - backgroundColor: 'white', - height: '100%', - }, backButton: { marginLeft: 10, }, diff --git a/src/screens/profile/MomentCommentsScreen.tsx b/src/screens/profile/MomentCommentsScreen.tsx index bf07ae30..4b332b56 100644 --- a/src/screens/profile/MomentCommentsScreen.tsx +++ b/src/screens/profile/MomentCommentsScreen.tsx @@ -102,7 +102,7 @@ const styles = StyleSheet.create({ }, body: { marginTop: HeaderHeight, - width: SCREEN_WIDTH * 0.9, + width: SCREEN_WIDTH * 0.95, height: SCREEN_HEIGHT * 0.8, paddingTop: '3%', }, diff --git a/src/screens/profile/index.ts b/src/screens/profile/index.ts index d5377494..ea0505a2 100644 --- a/src/screens/profile/index.ts +++ b/src/screens/profile/index.ts @@ -12,3 +12,4 @@ export {default as PrivacyScreen} from './PrivacyScreen'; export {default as AccountType} from './AccountType'; export {default as CategorySelection} from './CategorySelection'; export {default as CreateCustomCategory} from './CreateCustomCategory'; +export {default as CommentReactionScreen} from './CommentReactionScreen'; diff --git a/src/services/CommentService.ts b/src/services/CommentService.ts index 2faaa8db..6d71ce9c 100644 --- a/src/services/CommentService.ts +++ b/src/services/CommentService.ts @@ -1,8 +1,18 @@ import AsyncStorage from '@react-native-community/async-storage'; import {Alert} from 'react-native'; -import {COMMENTS_ENDPOINT, COMMENT_THREAD_ENDPOINT} from '../constants'; +import { + COMMENTS_ENDPOINT, + COMMENT_REACTIONS_ENDPOINT, + COMMENT_REACTIONS_REPLY_ENDPOINT, + COMMENT_THREAD_ENDPOINT, +} from '../constants'; import {ERROR_FAILED_TO_COMMENT} from '../constants/strings'; -import {CommentType} from '../types'; +import { + CommentThreadType, + CommentType, + ProfilePreviewType, + ReactionOptionsType, +} from '../types'; export const getComments = async ( objectId: string, @@ -116,3 +126,84 @@ export const deleteComment = async (id: string, isThread: boolean) => { return false; } }; + +/** + * If `user_reaction` is undefined, we like the comment, if `user_reaction` + * is defined, we unlike the comment. + * + * @param comment the comment object that contains `user_reaction` (or not) + * @returns + */ +export const handleLikeUnlikeComment = async ( + comment: CommentType | CommentThreadType, + liked: boolean, +) => { + try { + const isReply = 'parent_comment' in comment; + const token = await AsyncStorage.getItem('token'); + let url = isReply + ? COMMENT_REACTIONS_REPLY_ENDPOINT + : COMMENT_REACTIONS_ENDPOINT; + if (liked) { + // unlike a comment + url += `${comment.comment_id}/?reaction_type=LIKE`; + const response = await fetch(url, { + method: 'DELETE', + headers: { + Authorization: 'Token ' + token, + }, + }); + return response.status === 200; + } else { + // like a comment + const form = new FormData(); + form.append('comment_id', comment.comment_id); + form.append('reaction_type', ReactionOptionsType.Like); + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'multipart/form-data', + Authorization: 'Token ' + token, + }, + body: form, + }); + return response.status === 200; + } + } catch (error) { + console.log('Unable to like/unlike a comment'); + console.error(error); + } +}; + +export const getUsersReactedToAComment = async ( + comment: CommentType | CommentThreadType, +) => { + try { + const isReply = 'parent_comment' in comment; + const token = await AsyncStorage.getItem('token'); + let url = isReply + ? COMMENT_REACTIONS_REPLY_ENDPOINT + : COMMENT_REACTIONS_ENDPOINT; + url += `?comment_id=${comment.comment_id}`; + const response = await fetch(url, { + method: 'GET', + headers: { + Authorization: 'Token ' + token, + }, + }); + const typedResponse: { + reaction: ReactionOptionsType; + user_list: ProfilePreviewType[]; + }[] = await response.json(); + for (const obj of typedResponse) { + if (obj.reaction === ReactionOptionsType.Like) { + return obj.user_list; + } + } + return []; + } catch (error) { + console.log('Unable to fetch list of users whom reacted to a comment'); + console.error(error); + } + return []; +}; diff --git a/src/types/types.ts b/src/types/types.ts index 00501d49..e9975529 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -122,6 +122,8 @@ export interface CommentBaseType { comment: string; date_created: string; commenter: ProfilePreviewType; + user_reaction: ReactionType | null; + reaction_count: number; } export interface CommentType extends CommentBaseType { @@ -316,3 +318,12 @@ export type ChatContextType = { >; chatClient: StreamChat; }; + +export enum ReactionOptionsType { + Like = 'LIKE', +} + +export type ReactionType = { + id: string; + type: ReactionOptionsType; +}; diff --git a/src/utils/comments.tsx b/src/utils/comments.tsx index 0d551682..5c17cefe 100644 --- a/src/utils/comments.tsx +++ b/src/utils/comments.tsx @@ -6,9 +6,11 @@ import { Part, PartType, } from 'react-native-controlled-mentions'; +import {TouchableOpacity} from 'react-native-gesture-handler'; import TaggTypeahead from '../components/common/TaggTypeahead'; import {TAGG_LIGHT_BLUE} from '../constants'; import {UserType} from '../types'; +import {normalize} from './layouts'; /** * Part renderer @@ -28,9 +30,8 @@ const renderPart = ( // Mention type part if (isMentionPartType(part.partType)) { return ( - <Text + <TouchableOpacity key={`${index}-${part.data?.trigger}`} - style={part.partType.textStyle} onPress={() => { if (part.data) { handlePress({ @@ -39,8 +40,8 @@ const renderPart = ( }); } }}> - {part.text} - </Text> + <Text style={part.partType.textStyle}>{part.text}</Text> + </TouchableOpacity> ); } @@ -89,8 +90,15 @@ export const mentionPartTypes: (style: 'blue' | 'white') => PartType[] = ( isInsertSpaceAfterMention: true, textStyle: style === 'blue' - ? {color: TAGG_LIGHT_BLUE} - : {color: 'white', fontWeight: '800'}, + ? { + color: TAGG_LIGHT_BLUE, + top: normalize(3), + } + : { + color: 'white', + fontWeight: '800', + top: normalize(7.5), + }, }, ]; }; diff --git a/src/utils/common.ts b/src/utils/common.ts index ce4ab7d1..95e77f64 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -180,3 +180,20 @@ const _crestIcon = (university: UniversityType) => { return require('../assets/images/bwbadges.png'); } }; + +export const validateImageLink = async (url: string | undefined) => { + if (!url) { + return false; + } + return fetch(url) + .then((res) => { + if (res.status === 200) { + return true; + } else { + return false; + } + }) + .catch((_) => { + return false; + }); +}; diff --git a/src/utils/users.ts b/src/utils/users.ts index 334cb3c0..430c843f 100644 --- a/src/utils/users.ts +++ b/src/utils/users.ts @@ -1,3 +1,4 @@ +import {Alert} from 'react-native'; import AsyncStorage from '@react-native-community/async-storage'; import {INTEGRATED_SOCIAL_LIST} from '../constants'; import {isUserBlocked, loadSocialPosts, removeBadgesService} from '../services'; @@ -24,6 +25,8 @@ import { UserType, UniversityBadge, } from './../types/types'; +import ImagePicker from 'react-native-image-crop-picker'; +import {patchEditProfile} from '../services'; const loadData = async (dispatch: AppDispatch, user: UserType) => { await Promise.all([ @@ -240,3 +243,82 @@ export const navigateToProfile = async ( screenType, }); }; + +export const patchProfile = async ( + title: 'profile' | 'header', + userId: string, +) => { + let imageSettings = {}; + let screenTitle: string; + let requestTitle: string; + let fileName: string; + switch (title) { + case 'header': + screenTitle = 'Select Header Picture'; + requestTitle = 'largeProfilePicture'; + fileName = 'large_profile_pic.jpg'; + imageSettings = { + smartAlbums: [ + 'Favorites', + 'RecentlyAdded', + 'SelfPortraits', + 'Screenshots', + 'UserLibrary', + ], + width: 580, + height: 580, + cropping: true, + cropperToolbarTitle: screenTitle, + mediaType: 'photo', + }; + break; + case 'profile': + screenTitle = 'Select Profile Picture'; + requestTitle = 'smallProfilePicture'; + fileName = 'small_profile_pic.jpg'; + imageSettings = { + smartAlbums: [ + 'Favorites', + 'RecentlyAdded', + 'SelfPortraits', + 'Screenshots', + 'UserLibrary', + ], + width: 580, + height: 580, + cropping: true, + cropperToolbarTitle: screenTitle, + mediaType: 'photo', + cropperCircleOverlay: true, + }; + break; + default: + screenTitle = ''; + requestTitle = ''; + fileName = ''; + } + + return await ImagePicker.openPicker(imageSettings) + .then((picture) => { + if ('path' in picture) { + const request = new FormData(); + request.append(requestTitle, { + uri: picture.path, + name: fileName, + type: 'image/jpg', + }); + + return patchEditProfile(request, userId) + .then((_) => { + return true; + }) + .catch((error) => { + Alert.alert(error); + return false; + }); + } + }) + .catch((_) => { + return false; + }); +}; |