diff options
author | Ivan Chen <ivan@tagg.id> | 2021-06-11 17:01:43 -0400 |
---|---|---|
committer | Ivan Chen <ivan@tagg.id> | 2021-06-11 17:01:43 -0400 |
commit | bcfbaf00f60b65ab5f8c38ff8766644a2496bed1 (patch) | |
tree | af2fd669cf1f4cd427e832a39ac9deb2cd7a1430 | |
parent | 47b087816844473be858adf766b2f538ecf6d0aa (diff) | |
parent | 17d3f1255bd7692772b675b09685a92b305e8d9b (diff) |
Merge branch 'master' into tma904-moment-comment-revamp
# Conflicts:
# src/components/moments/MomentPost.tsx
# src/components/moments/MomentPostContent.tsx
# src/services/MomentService.ts
31 files changed, 610 insertions, 207 deletions
diff --git a/src/assets/images/Group 479.jpg b/src/assets/images/Group 479.jpg Binary files differnew file mode 100644 index 00000000..74abad92 --- /dev/null +++ b/src/assets/images/Group 479.jpg diff --git a/src/assets/images/Group 479.svg b/src/assets/images/Group 479.svg new file mode 100644 index 00000000..4e1eee01 --- /dev/null +++ b/src/assets/images/Group 479.svg @@ -0,0 +1,5 @@ +<svg width="16" height="18" viewBox="0 0 16 18" fill="none" xmlns="http://www.w3.org/2000/svg"> +<ellipse cx="7.75104" cy="5.91915" rx="2.00104" ry="1.93282" fill="white"/> +<path d="M7.75195 8.0791C5.67981 8.0791 4 9.6062 4 11.49H11.5039C11.5039 9.6062 9.8241 8.0791 7.75195 8.0791Z" fill="white"/> +<path d="M7.99349 1C4.13109 1 1 4.13109 1 7.99349C1 11.1633 3.10881 13.8405 6 14.6987L8 17L9.98697 14.6987C12.8782 13.8405 14.987 11.1633 14.987 7.99349C14.987 4.13109 11.8559 1 7.99349 1Z" stroke="white" stroke-linecap="round" stroke-linejoin="round"/> +</svg> diff --git a/src/assets/images/Profile Icon.png b/src/assets/images/Profile Icon.png Binary files differnew file mode 100644 index 00000000..f8eae388 --- /dev/null +++ b/src/assets/images/Profile Icon.png diff --git a/src/assets/images/pill-icon-1.png b/src/assets/images/pill-icon-1.png Binary files differnew file mode 100644 index 00000000..06956c6a --- /dev/null +++ b/src/assets/images/pill-icon-1.png diff --git a/src/assets/images/pill-icon-2.png b/src/assets/images/pill-icon-2.png Binary files differnew file mode 100644 index 00000000..b2370b80 --- /dev/null +++ b/src/assets/images/pill-icon-2.png diff --git a/src/assets/images/pill-icon-3.png b/src/assets/images/pill-icon-3.png Binary files differnew file mode 100644 index 00000000..6cdf0b15 --- /dev/null +++ b/src/assets/images/pill-icon-3.png diff --git a/src/assets/images/pill-icon-4.png b/src/assets/images/pill-icon-4.png Binary files differnew file mode 100644 index 00000000..6e132647 --- /dev/null +++ b/src/assets/images/pill-icon-4.png diff --git a/src/assets/images/purple-tip.png b/src/assets/images/purple-tip.png Binary files differnew file mode 100644 index 00000000..27f5a89a --- /dev/null +++ b/src/assets/images/purple-tip.png diff --git a/src/components/common/GenericMoreInfoDrawer.tsx b/src/components/common/GenericMoreInfoDrawer.tsx index 0928ed44..cfc45131 100644 --- a/src/components/common/GenericMoreInfoDrawer.tsx +++ b/src/components/common/GenericMoreInfoDrawer.tsx @@ -3,15 +3,16 @@ import { GestureResponderEvent, StyleSheet, Text, + TextStyle, TouchableOpacity, View, ViewProps, ViewStyle, } from 'react-native'; import {useSafeAreaInsets} from 'react-native-safe-area-context'; -import BottomDrawer from './BottomDrawer'; import {TAGG_LIGHT_BLUE} from '../../constants'; import {normalize, SCREEN_HEIGHT, SCREEN_WIDTH} from '../../utils'; +import BottomDrawer from './BottomDrawer'; // conforms the JSX onPress attribute type type OnPressHandler = (event: GestureResponderEvent) => void; @@ -20,13 +21,12 @@ interface GenericMoreInfoDrawerProps extends ViewProps { isOpen: boolean; setIsOpen: (visible: boolean) => void; showIcons: boolean; - textColor: string; // An array of title, onPressHandler, and icon component - buttons: [string, OnPressHandler, JSX.Element?][]; + buttons: [string, OnPressHandler, JSX.Element?, TextStyle?][]; } const GenericMoreInfoDrawer: React.FC<GenericMoreInfoDrawerProps> = (props) => { - const {buttons, showIcons, textColor} = props; + const {buttons, showIcons} = props; // each button is 80px high, cancel button is always there const initialSnapPosition = (buttons.length + 1) * 80 + useSafeAreaInsets().bottom; @@ -44,13 +44,11 @@ const GenericMoreInfoDrawer: React.FC<GenericMoreInfoDrawerProps> = (props) => { showHeader={false} initialSnapPosition={initialSnapPosition}> <View style={styles.panel}> - {buttons.map(([title, action, icon], index) => ( + {buttons.map(([title, action, icon, textStyle], index) => ( <View key={index}> <TouchableOpacity style={panelButtonStyle} onPress={action}> {showIcons && <View style={styles.icon}>{icon}</View>} - <Text style={[styles.panelButtonTitle, {color: textColor}]}> - {title} - </Text> + <Text style={[styles.panelButtonTitle, textStyle]}>{title}</Text> </TouchableOpacity> <View style={styles.divider} /> </View> diff --git a/src/components/moments/MomentPost.tsx b/src/components/moments/MomentPost.tsx index 3b2721a0..d87028e3 100644 --- a/src/components/moments/MomentPost.tsx +++ b/src/components/moments/MomentPost.tsx @@ -74,13 +74,14 @@ const MomentPost: React.FC<MomentPostProps> = ({ return ( <> <MomentPostHeader + style={styles.postHeader} userXId={userXId} screenType={screenType} username={isOwnProfile ? loggedInUsername : username} - momentId={moment.moment_id} - style={styles.postHeader} momentTagId={momentTagId} removeTag={removeTag} + moment={moment} + tags={tags} /> <MomentPostContent style={styles.postContent} diff --git a/src/components/moments/MomentPostContent.tsx b/src/components/moments/MomentPostContent.tsx index f1ab5db4..e43cba4f 100644 --- a/src/components/moments/MomentPostContent.tsx +++ b/src/components/moments/MomentPostContent.tsx @@ -38,10 +38,10 @@ const MomentPostContent: React.FC<MomentPostContentProps> = ({ momentTags, index, }) => { + const [tags, setTags] = useState<MomentTagType[]>(momentTags); const state: RootState = useStore().getState(); const navigation = useNavigation(); const dispatch = useDispatch(); - const [tags, setTags] = useState<MomentTagType[]>(momentTags); const imageRef = useRef(null); const [visible, setVisible] = useState(false); const [fadeValue, setFadeValue] = useState<Animated.Value<number>>( diff --git a/src/components/moments/MomentPostHeader.tsx b/src/components/moments/MomentPostHeader.tsx index 64581e06..5f26951a 100644 --- a/src/components/moments/MomentPostHeader.tsx +++ b/src/components/moments/MomentPostHeader.tsx @@ -10,7 +10,7 @@ import { import {useDispatch, useSelector, useStore} from 'react-redux'; import {loadUserMoments} from '../../store/actions'; import {RootState} from '../../store/rootReducer'; -import {ScreenType} from '../../types'; +import {MomentTagType, MomentType, ScreenType} from '../../types'; import {fetchUserX, userXInStore} from '../../utils'; import {MomentMoreInfoDrawer} from '../profile'; import TaggAvatar from '../profile/TaggAvatar'; @@ -19,19 +19,21 @@ interface MomentPostHeaderProps extends ViewProps { userXId?: string; screenType: ScreenType; username: string; - momentId: string; momentTagId: string; removeTag: () => Promise<void>; + moment: MomentType; + tags: MomentTagType[]; } const MomentPostHeader: React.FC<MomentPostHeaderProps> = ({ userXId, screenType, username, - momentId, style, momentTagId, removeTag, + moment, + tags, }) => { const [drawerVisible, setDrawerVisible] = useState(false); const dispatch = useDispatch(); @@ -69,7 +71,6 @@ const MomentPostHeader: React.FC<MomentPostHeaderProps> = ({ <MomentMoreInfoDrawer isOpen={drawerVisible} setIsOpen={setDrawerVisible} - momentId={momentId} isOwnProfile={isOwnProfile} momentTagId={momentTagId} removeTag={removeTag} @@ -77,6 +78,9 @@ const MomentPostHeader: React.FC<MomentPostHeaderProps> = ({ dispatch(loadUserMoments(loggedInUserId)); navigation.goBack(); }} + screenType={screenType} + moment={moment} + tags={tags} /> </View> ); diff --git a/src/components/notifications/Notification.tsx b/src/components/notifications/Notification.tsx index 3f9cc56a..fd1b11ac 100644 --- a/src/components/notifications/Notification.tsx +++ b/src/components/notifications/Notification.tsx @@ -2,9 +2,7 @@ import {useNavigation} from '@react-navigation/native'; import React, {useEffect, useState} from 'react'; import {Alert, Image, StyleSheet, Text, View} from 'react-native'; import {TouchableWithoutFeedback} from 'react-native-gesture-handler'; -import LinearGradient from 'react-native-linear-gradient'; import {useDispatch, useStore} from 'react-redux'; -import {BACKGROUND_GRADIENT_MAP} from '../../constants'; import {ERROR_DELETED_OBJECT} from '../../constants/strings'; import {loadImageFromURL} from '../../services'; import { @@ -49,7 +47,6 @@ const Notification: React.FC<NotificationProps> = (props) => { verbage, notification_type, notification_object, - unread, timestamp, }, screenType, @@ -323,13 +320,7 @@ const Notification: React.FC<NotificationProps> = (props) => { </View> ); - return unread ? ( - <LinearGradient colors={BACKGROUND_GRADIENT_MAP[2]} useAngle angle={90}> - {renderContent()} - </LinearGradient> - ) : ( - renderContent() - ); + return renderContent(); }; const styles = StyleSheet.create({ diff --git a/src/components/notifications/NotificationPill.tsx b/src/components/notifications/NotificationPill.tsx new file mode 100644 index 00000000..525cd7fa --- /dev/null +++ b/src/components/notifications/NotificationPill.tsx @@ -0,0 +1,209 @@ +import React, {useEffect, useState, useRef} from 'react'; +import {Image, StyleSheet, Text, View} from 'react-native'; +import LinearGradient from 'react-native-linear-gradient'; +import {SCREEN_WIDTH, isIPhoneX, numberWithCommas} from '../../utils'; +import { + NOTIFICATION_ICON_GRADIENT, + CHIN_HEIGHT, + NAV_BAR_HEIGHT, +} from '../../constants'; +import {getNotificationsUnreadCount} from '../../services'; +import {normalize} from 'react-native-elements'; +import PillIcon4 from '../../assets/images/Group 479.svg'; + +interface NotificationPillProps { + showIcon: boolean; +} + +export const NotificationPill: React.FC<NotificationPillProps> = ({ + showIcon, +}) => { + const [iconStart, setIconStart] = useState<number[]>([0, -100]); + const [tipStart, setTipStart] = useState<number[]>([0, -100]); + const [notificationSets, setNotificationSets] = useState<{ + CMT?: number; + FRD_REQ?: number; + P_VIEW?: number; + MOM_TAG?: number; + }>({}); + const [timeCount, setTimeCount] = useState<boolean>(false); + const [timeOut, setTimeOut] = useState<boolean>(false); + const iconRef = useRef(null); + const tipRef = useRef(null); + const pillTip = require('../../assets/images/purple-tip.png'); + + const navBarPos = 20; + + // If there are notifications, determines the size of the pill + // and sets points for correct placement + useEffect(() => { + setTimeout(() => { + if (iconRef.current) { + iconRef.current.measure( + ( + _fx: number, + _fy: number, + width: number, + height: number, + _px: number, + _py: number, + ) => { + if (tipRef.current) { + tipRef.current.measure( + ( + __fx: number, + __fy: number, + width2: number, + __height: number, + __px: number, + __py: number, + ) => { + const x = SCREEN_WIDTH / 2 - width / 2; + const y = isIPhoneX() + ? CHIN_HEIGHT + NAV_BAR_HEIGHT + navBarPos + : NAV_BAR_HEIGHT + navBarPos; + setIconStart([x, y]); + setTipStart([width / 2 - width2 / 2, height - 1]); + setTimeCount(true); + }, + ); + } + }, + ); + } else { + } + }, 100); + }, [notificationSets, iconRef, tipRef]); + + // Used so that pill disappears after 10 seconds + useEffect(() => { + if (timeCount) { + setTimeout(() => { + setTimeOut(true); + }, 10000); + } + }, [timeCount]); + + // Gets data from backend to check for unreads + useEffect(() => { + const getCount = async () => { + const data = await getNotificationsUnreadCount(); + setTimeout(() => { + if (data) { + setNotificationSets(data); + } + }, 100); + }; + + getCount(); + }, []); + + return ( + <> + {notificationSets && + Object.keys(notificationSets).length !== 0 && + showIcon && + !timeOut && ( + <View + style={[ + styles.purpleContainer, + {bottom: iconStart[1], left: iconStart[0]}, + ]} + ref={iconRef}> + <LinearGradient + colors={NOTIFICATION_ICON_GRADIENT} + style={styles.iconPurple}> + {notificationSets.CMT && ( + <> + <Image + source={require('../../assets/images/pill-icon-1.png')} + style={styles.indicationIcon} + /> + <Text style={styles.text}> + {numberWithCommas(notificationSets.CMT)} + </Text> + </> + )} + {notificationSets.FRD_REQ && ( + <> + <Image + source={require('../../assets/images/pill-icon-2.png')} + style={styles.indicationIcon} + /> + <Text style={styles.text}> + {numberWithCommas(notificationSets.FRD_REQ)} + </Text> + </> + )} + {notificationSets.P_VIEW && ( + <> + <Image + source={require('../../assets/images/pill-icon-3.png')} + style={styles.indicationIcon} + /> + <Text style={styles.text}> + {numberWithCommas(notificationSets.P_VIEW)} + </Text> + </> + )} + {notificationSets.MOM_TAG && ( + <> + <PillIcon4 style={styles.indicationIcon} /> + <Text style={styles.text}> + {numberWithCommas(notificationSets.MOM_TAG)} + </Text> + </> + )} + </LinearGradient> + <Image + style={[styles.tip, {top: tipStart[1], left: tipStart[0]}]} + source={pillTip} + ref={tipRef} + /> + </View> + )} + </> + ); +}; + +const styles = StyleSheet.create({ + purpleContainer: { + flex: 1, + justifyContent: 'center', + position: 'absolute', + zIndex: 999, + }, + iconPurple: { + padding: 5, + borderRadius: 15, + flex: 1, + flexDirection: 'row', + alignItems: 'center', + }, + text: { + margin: 2, + color: 'white', + fontSize: normalize(10), + justifyContent: 'center', + alignItems: 'center', + marginRight: 5, + }, + tip: { + position: 'absolute', + zIndex: 999, + height: 12, + flex: 1, + resizeMode: 'contain', + }, + indicationIcon: { + height: 14, + width: 14, + margin: 2, + marginLeft: 5, + }, + svgIndicationIcon: { + height: 14, + width: 14, + margin: 3, + }, +}); diff --git a/src/components/notifications/index.ts b/src/components/notifications/index.ts index 733b56f1..077c26a4 100644 --- a/src/components/notifications/index.ts +++ b/src/components/notifications/index.ts @@ -1,2 +1,3 @@ export {default as Notification} from './Notification'; export {InviteFriendsPrompt} from './NotificationPrompts'; +export {NotificationPill} from './NotificationPill'; diff --git a/src/components/profile/MomentMoreInfoDrawer.tsx b/src/components/profile/MomentMoreInfoDrawer.tsx index 1265497e..a796ffd8 100644 --- a/src/components/profile/MomentMoreInfoDrawer.tsx +++ b/src/components/profile/MomentMoreInfoDrawer.tsx @@ -1,44 +1,58 @@ +import {useNavigation} from '@react-navigation/core'; import React, {useEffect, useState} from 'react'; import { Alert, GestureResponderEvent, StyleSheet, + TextStyle, TouchableOpacity, ViewProps, } from 'react-native'; import MoreIcon from '../../assets/icons/more_horiz-24px.svg'; import {ERROR_DELETE_MOMENT, MOMENT_DELETED_MSG} from '../../constants/strings'; import {deleteMoment, sendReport} from '../../services'; +import {MomentTagType, MomentType, ScreenType} from '../../types/types'; import {GenericMoreInfoDrawer} from '../common'; enum MomentDrawerOptions { DeleteMoment = 'Delete Moment', ReportIssue = 'Report an Issue', RemoveTag = 'Remove yourself from moment', + EditMoment = 'Edit Moment', } interface MomentMoreInfoDrawerProps extends ViewProps { isOpen: boolean; setIsOpen: (visible: boolean) => void; - momentId: string; isOwnProfile: boolean; momentTagId: string; removeTag: () => Promise<void>; dismissScreenAndUpdate: () => void; + screenType: ScreenType; + moment: MomentType; + tags: MomentTagType[]; } const MomentMoreInfoDrawer: React.FC<MomentMoreInfoDrawerProps> = (props) => { const { - momentId, setIsOpen, isOwnProfile, dismissScreenAndUpdate, momentTagId, removeTag, + screenType, + moment, + tags, } = props; + const navigation = useNavigation(); + + const [drawerButtons, setDrawerButtons] = useState< + [string, (event: GestureResponderEvent) => void, JSX.Element?, TextStyle?][] + >([]); + const handleDeleteMoment = async () => { setIsOpen(false); - deleteMoment(momentId).then((success) => { + deleteMoment(moment.moment_id).then((success) => { if (success) { // set time out for UI transitions setTimeout(() => { @@ -88,7 +102,8 @@ const MomentMoreInfoDrawer: React.FC<MomentMoreInfoDrawerProps> = (props) => { [ { text: 'Mark as inappropriate', - onPress: () => sendReport(momentId, 'Mark as inappropriate'), + onPress: () => + sendReport(moment.moment_id, 'Mark as inappropriate'), }, { text: 'Cancel', @@ -96,7 +111,7 @@ const MomentMoreInfoDrawer: React.FC<MomentMoreInfoDrawerProps> = (props) => { }, { text: 'Mark as abusive', - onPress: () => sendReport(momentId, 'Mark as abusive'), + onPress: () => sendReport(moment.moment_id, 'Mark as abusive'), }, ], {cancelable: false}, @@ -104,42 +119,52 @@ const MomentMoreInfoDrawer: React.FC<MomentMoreInfoDrawerProps> = (props) => { }, 500); }; - const [drawerButtons, setDrawerButtons] = useState< - [string, (event: GestureResponderEvent) => void, JSX.Element?][] - >([ - isOwnProfile - ? [MomentDrawerOptions.DeleteMoment, handleDeleteMoment] - : [MomentDrawerOptions.ReportIssue, handleReportMoment], - ]); + const handleEditMoment = async () => { + setIsOpen(false); + navigation.navigate('CaptionScreen', { + screenType: screenType, + selectedTags: tags, + moment: moment, + }); + }; /* * Update bottom drawer options to contain/not contain 'remove tag' option */ useEffect(() => { - const setupBottomDrawer = () => { - const present = drawerButtons.findIndex( - (button) => button[0] === MomentDrawerOptions.RemoveTag, - ); - /* - * If user is not tagged but button is present, remove button from bottom drawer - * If user is tagged but button is not present, add button to bottom drawer - */ - if (momentTagId !== '' && present === -1) { - const localDrawerButtons = drawerButtons; - localDrawerButtons.push([ + let newButtons: [ + string, + (event: GestureResponderEvent) => void, + JSX.Element?, + TextStyle?, + ][] = []; + if (!isOwnProfile) { + newButtons.push([ + MomentDrawerOptions.ReportIssue, + handleReportMoment, + undefined, + {color: 'red'}, + ]); + // should we have the "delete moment" option? + if (momentTagId !== '') { + newButtons.push([ MomentDrawerOptions.RemoveTag, handleRemoveTag, + undefined, + {color: 'red'}, ]); - setDrawerButtons(localDrawerButtons); - } else if (momentTagId === '' && present !== -1) { - const filteredButtons = drawerButtons.filter( - (button) => button[0] !== MomentDrawerOptions.RemoveTag, - ); - setDrawerButtons(filteredButtons); } - }; - setupBottomDrawer(); - }, [momentTagId]); + } else { + newButtons.push([ + MomentDrawerOptions.DeleteMoment, + handleDeleteMoment, + undefined, + {color: 'red'}, + ]); + newButtons.push([MomentDrawerOptions.EditMoment, handleEditMoment]); + } + setDrawerButtons(newButtons); + }, [tags, momentTagId]); return ( <> @@ -153,7 +178,6 @@ const MomentMoreInfoDrawer: React.FC<MomentMoreInfoDrawerProps> = (props) => { <GenericMoreInfoDrawer {...props} showIcons={false} - textColor={'red'} buttons={drawerButtons} /> </> diff --git a/src/components/profile/ProfileMoreInfoDrawer.tsx b/src/components/profile/ProfileMoreInfoDrawer.tsx index ecc45211..656f81bb 100644 --- a/src/components/profile/ProfileMoreInfoDrawer.tsx +++ b/src/components/profile/ProfileMoreInfoDrawer.tsx @@ -55,12 +55,12 @@ const ProfileMoreInfoDrawer: React.FC<ProfileMoreInfoDrawerProps> = (props) => { <GenericMoreInfoDrawer {...props} showIcons={false} - textColor={'red'} buttons={[ [ (isBlocked ? 'Unblock' : 'Block') + ` ${userXName}`, onBlockUnblock, undefined, + {color: 'red'}, ], ]} /> @@ -68,7 +68,6 @@ const ProfileMoreInfoDrawer: React.FC<ProfileMoreInfoDrawerProps> = (props) => { <GenericMoreInfoDrawer {...props} showIcons={true} - textColor={'black'} buttons={[ [ 'Settings', @@ -77,6 +76,7 @@ const ProfileMoreInfoDrawer: React.FC<ProfileMoreInfoDrawerProps> = (props) => { source={require('../../assets/images/settings/settings.png')} style={styles.image} />, + {color: 'black'}, ], [ 'Edit Profile', @@ -85,6 +85,7 @@ const ProfileMoreInfoDrawer: React.FC<ProfileMoreInfoDrawerProps> = (props) => { source={require('../../assets/images/settings/edit-profile.png')} style={styles.image} />, + {color: 'black'}, ], ]} /> diff --git a/src/constants/api.ts b/src/constants/api.ts index f02ee407..b55489d9 100644 --- a/src/constants/api.ts +++ b/src/constants/api.ts @@ -45,6 +45,8 @@ export const BLOCK_USER_ENDPOINT: string = API_URL + 'block/'; export const PASSWORD_RESET_ENDPOINT: string = API_URL + 'password-reset/'; export const MOMENT_CATEGORY_ENDPOINT: string = API_URL + 'moment-category/'; export const NOTIFICATIONS_ENDPOINT: string = API_URL + 'notifications/'; +export const NOTIFICATIONS_COUNT_ENDPOINT: string = API_URL + 'notifications/unread_count/'; +export const NOTIFICATIONS_DATE: string = API_URL + 'notifications/seen/'; export const DISCOVER_ENDPOINT: string = API_URL + 'discover/'; export const SEARCH_BUTTONS_ENDPOPINT: string = DISCOVER_ENDPOINT + 'search_buttons/'; diff --git a/src/constants/constants.ts b/src/constants/constants.ts index a6d98883..f4ffd750 100644 --- a/src/constants/constants.ts +++ b/src/constants/constants.ts @@ -21,6 +21,8 @@ export const AVATAR_GRADIENT_DIM = 50; export const TAGG_ICON_DIM = 58; export const TAGG_RING_DIM = normalize(60); +// default height of the navigation bar, from react native library, unless on ipad +export const NAV_BAR_HEIGHT = 49; export const BADGE_LIMIT = 5; export const INTEGRATED_SOCIAL_LIST: string[] = [ @@ -91,6 +93,7 @@ export const BADGE_GRADIENT_REST = [ 'rgba(78, 54, 41, 1)', 'rgba(236, 32, 39, 1)', ]; +export const NOTIFICATION_ICON_GRADIENT = ['#8F01FF', '#7B02DA']; export const SOCIAL_FONT_COLORS = { INSTAGRAM: INSTAGRAM_FONT_COLOR, diff --git a/src/constants/regex.ts b/src/constants/regex.ts index 61523203..f934185d 100644 --- a/src/constants/regex.ts +++ b/src/constants/regex.ts @@ -36,7 +36,7 @@ export const nameRegex: RegExp = /^[A-Za-z'\-,. ]{2,20}$/; * - match alphanumerics, and special characters used in URLs */ export const websiteRegex: RegExp = - /^$|^(https?:\/\/)?(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,50}\.[a-zA-Z0-9()]{2,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]{0,35})$/; + /^$|^(https?:\/\/)?(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,50}\.[a-zA-Z0-9()]{2,6}\b([-a-zA-Z0-9()@:%_+.~#?&\/=]{0,35})$/; /** * The website regex has the following constraints diff --git a/src/routes/main/MainStackNavigator.tsx b/src/routes/main/MainStackNavigator.tsx index d22c1874..8fce5e2f 100644 --- a/src/routes/main/MainStackNavigator.tsx +++ b/src/routes/main/MainStackNavigator.tsx @@ -37,14 +37,14 @@ export type MainStackParams = { screenType: ScreenType; }; CaptionScreen: { - title: string; - image: Image; + title?: string; + image?: Image; screenType: ScreenType; selectedTags?: MomentTagType[]; + moment?: MomentType; }; TagFriendsScreen: { - image: Image; - screenType: ScreenType; + imagePath: string; selectedTags?: MomentTagType[]; }; TagSelectionScreen: { diff --git a/src/routes/tabs/NavigationBar.tsx b/src/routes/tabs/NavigationBar.tsx index 000ac614..c3a42739 100644 --- a/src/routes/tabs/NavigationBar.tsx +++ b/src/routes/tabs/NavigationBar.tsx @@ -4,9 +4,11 @@ import {useSelector} from 'react-redux'; import {NavigationIcon} from '../../components'; import {NO_NOTIFICATIONS} from '../../store/initialStates'; import {RootState} from '../../store/rootReducer'; +import {setNotificationsReadDate} from '../../services'; import {ScreenType} from '../../types'; import {haveUnreadNotifications} from '../../utils'; import MainStackScreen from '../main/MainStackScreen'; +import {NotificationPill} from '../../components/notifications'; const Tabs = createBottomTabNavigator(); @@ -18,10 +20,14 @@ const NavigationBar: React.FC = () => { const {notifications: {notifications} = NO_NOTIFICATIONS} = useSelector( (state: RootState) => state, ); + // Triggered if user clicks on Notifications page to close the pill + const [showIcon, setShowIcon] = useState<boolean>(true); const [unreadNotificationsPresent, setUnreadNotificationsPresent] = useState<boolean>(false); + // Prior to pill inclusion, determines if notification bell + // should have purple dot useEffect(() => { const determine = async () => { setUnreadNotificationsPresent( @@ -32,77 +38,88 @@ const NavigationBar: React.FC = () => { }, [notifications]); return ( - <Tabs.Navigator - screenOptions={({route}) => ({ - tabBarIcon: ({focused}) => { - switch (route.name) { - case 'Home': - return <NavigationIcon tab="Home" disabled={!focused} />; - case 'Search': - return <NavigationIcon tab="Search" disabled={!focused} />; - case 'Upload': - return <NavigationIcon tab="Upload" disabled={!focused} />; - case 'Notifications': - return ( - <NavigationIcon - newIcon={ - newNotificationReceived || unreadNotificationsPresent - } - tab="Notifications" - disabled={!focused} - /> - ); - case 'Chat': - return <NavigationIcon tab="Chat" disabled={!focused} />; - case 'Profile': - return <NavigationIcon tab="Profile" disabled={!focused} />; - case 'SuggestedPeople': - return ( - <NavigationIcon tab="SuggestedPeople" disabled={!focused} /> - ); - default: - return <Fragment />; - } - }, - })} - initialRouteName={isOnboardedUser ? 'Profile' : 'SuggestedPeople'} - tabBarOptions={{ - showLabel: false, - style: { - backgroundColor: 'transparent', - position: 'absolute', - borderTopWidth: 0, - left: 0, - right: 0, - bottom: '1%', - }, - }}> - <Tabs.Screen - name="SuggestedPeople" - component={MainStackScreen} - initialParams={{screenType: ScreenType.SuggestedPeople}} - /> - <Tabs.Screen - name="Search" - component={MainStackScreen} - initialParams={{screenType: ScreenType.Search}} - /> - <Tabs.Screen - name="Notifications" - component={MainStackScreen} - initialParams={{screenType: ScreenType.Notifications}} - /> - <Tabs.Screen - name="Chat" - component={MainStackScreen} - initialParams={{screenType: ScreenType.Chat}} - /> - <Tabs.Screen - name="Profile" - component={MainStackScreen} - initialParams={{screenType: ScreenType.Profile}} - /> - </Tabs.Navigator> + <> + <NotificationPill showIcon={showIcon} /> + <Tabs.Navigator + screenOptions={({route}) => ({ + tabBarIcon: ({focused}) => { + switch (route.name) { + case 'Home': + return <NavigationIcon tab="Home" disabled={!focused} />; + case 'Search': + return <NavigationIcon tab="Search" disabled={!focused} />; + case 'Upload': + return <NavigationIcon tab="Upload" disabled={!focused} />; + case 'Notifications': + return ( + <NavigationIcon + newIcon={ + newNotificationReceived || unreadNotificationsPresent + } + tab="Notifications" + disabled={!focused} + /> + ); + case 'Chat': + return <NavigationIcon tab="Chat" disabled={!focused} />; + case 'Profile': + return <NavigationIcon tab="Profile" disabled={!focused} />; + case 'SuggestedPeople': + return ( + <NavigationIcon tab="SuggestedPeople" disabled={!focused} /> + ); + default: + return <Fragment />; + } + }, + })} + initialRouteName={isOnboardedUser ? 'Profile' : 'SuggestedPeople'} + tabBarOptions={{ + showLabel: false, + style: { + backgroundColor: 'transparent', + position: 'absolute', + borderTopWidth: 0, + left: 0, + right: 0, + bottom: '1%', + }, + }}> + <Tabs.Screen + name="SuggestedPeople" + component={MainStackScreen} + initialParams={{screenType: ScreenType.SuggestedPeople}} + /> + <Tabs.Screen + name="Search" + component={MainStackScreen} + initialParams={{screenType: ScreenType.Search}} + /> + <Tabs.Screen + name="Notifications" + component={MainStackScreen} + initialParams={{screenType: ScreenType.Notifications}} + listeners={{ + tabPress: (_) => { + // Closes the pill once this screen has been opened + setShowIcon(false); + // Updates backend's date of reading notifications + setNotificationsReadDate(); + }, + }} + /> + <Tabs.Screen + name="Chat" + component={MainStackScreen} + initialParams={{screenType: ScreenType.Chat}} + /> + <Tabs.Screen + name="Profile" + component={MainStackScreen} + initialParams={{screenType: ScreenType.Profile}} + /> + </Tabs.Navigator> + </> ); }; diff --git a/src/screens/moments/TagFriendsScreen.tsx b/src/screens/moments/TagFriendsScreen.tsx index c8bca9f4..570c3776 100644 --- a/src/screens/moments/TagFriendsScreen.tsx +++ b/src/screens/moments/TagFriendsScreen.tsx @@ -30,7 +30,7 @@ interface TagFriendsScreenProps { route: TagFriendsScreenRouteProps; } const TagFriendsScreen: React.FC<TagFriendsScreenProps> = ({route}) => { - const {image, selectedTags} = route.params; + const {imagePath, selectedTags} = route.params; const navigation = useNavigation(); const imageRef = useRef(null); const [tags, setTags] = useState<MomentTagType[]>([]); @@ -85,7 +85,7 @@ const TagFriendsScreen: React.FC<TagFriendsScreenProps> = ({route}) => { <Image ref={imageRef} style={styles.image} - source={{uri: image.path}} + source={{uri: imagePath}} resizeMode={'cover'} /> </TouchableWithoutFeedback> diff --git a/src/screens/profile/CaptionScreen.tsx b/src/screens/profile/CaptionScreen.tsx index 8bffd82b..9e1b4674 100644 --- a/src/screens/profile/CaptionScreen.tsx +++ b/src/screens/profile/CaptionScreen.tsx @@ -21,9 +21,13 @@ import {SearchBackground} from '../../components'; import {CaptionScreenHeader} from '../../components/'; import TaggLoadingIndicator from '../../components/common/TaggLoadingIndicator'; import {TAGG_LIGHT_BLUE_2} from '../../constants'; -import {ERROR_UPLOAD, SUCCESS_PIC_UPLOAD} from '../../constants/strings'; +import { + ERROR_SOMETHING_WENT_WRONG_REFRESH, + ERROR_UPLOAD, + SUCCESS_PIC_UPLOAD, +} from '../../constants/strings'; import {MainStackParams} from '../../routes'; -import {postMoment, postMomentTags} from '../../services'; +import {patchMoment, postMoment, postMomentTags} from '../../services'; import { loadUserMoments, updateProfileCompletionStage, @@ -47,14 +51,16 @@ interface CaptionScreenProps { } const CaptionScreen: React.FC<CaptionScreenProps> = ({route, navigation}) => { - const {title, image, screenType, selectedTags} = route.params; + const {title, image, screenType, selectedTags, moment} = route.params; const { user: {userId}, } = useSelector((state: RootState) => state.user); const dispatch = useDispatch(); - const [caption, setCaption] = useState(''); + const [caption, setCaption] = useState(moment ? moment.caption : ''); const [loading, setLoading] = useState(false); - const [tags, setTags] = useState<MomentTagType[]>([]); + const [tags, setTags] = useState<MomentTagType[]>( + selectedTags ? selectedTags : [], + ); const [taggedList, setTaggedList] = useState<string>(''); useEffect(() => { @@ -84,22 +90,37 @@ const CaptionScreen: React.FC<CaptionScreenProps> = ({route, navigation}) => { }); }; - const handleShare = async () => { - const handleFailed = () => { - setLoading(false); - setTimeout(() => { - Alert.alert(ERROR_UPLOAD); - }, 500); - }; - const handleSuccess = () => { + const handleFailed = () => { + setLoading(false); + setTimeout(() => { + Alert.alert(moment ? ERROR_SOMETHING_WENT_WRONG_REFRESH : ERROR_UPLOAD); + }, 500); + }; + const handleSuccess = () => { + setLoading(false); + if (moment) { setLoading(false); + navigation.goBack(); + } else { navigateToProfile(); setTimeout(() => { Alert.alert(SUCCESS_PIC_UPLOAD); }, 500); - }; + } + }; + + const formattedTags = () => { + return tags.map((tag) => ({ + x: Math.floor(tag.x), + y: Math.floor(tag.y), + z: Math.floor(tag.z), + user_id: tag.user.id, + })); + }; + + const handleShare = async () => { setLoading(true); - if (!image.filename) { + if (!image?.filename || !title) { return; } const momentResponse = await postMoment( @@ -115,12 +136,7 @@ const CaptionScreen: React.FC<CaptionScreenProps> = ({route, navigation}) => { } const momentTagResponse = await postMomentTags( momentResponse.moment_id, - tags.map((tag) => ({ - x: Math.floor(tag.x), - y: Math.floor(tag.y), - z: Math.floor(tag.z), - user_id: tag.user.id, - })), + formattedTags(), ); if (!momentTagResponse) { handleFailed(); @@ -133,6 +149,23 @@ const CaptionScreen: React.FC<CaptionScreenProps> = ({route, navigation}) => { handleSuccess(); }; + const handleDone = async () => { + setLoading(true); + if (moment?.moment_id) { + const success = await patchMoment( + moment.moment_id, + caption, + formattedTags(), + ); + if (success) { + dispatch(loadUserMoments(userId)); + handleSuccess(); + } else { + handleFailed(); + } + } + }; + return ( <SearchBackground> {loading ? <TaggLoadingIndicator fullscreen /> : <Fragment />} @@ -145,20 +178,25 @@ const CaptionScreen: React.FC<CaptionScreenProps> = ({route, navigation}) => { <Button title="Cancel" buttonStyle={styles.button} - onPress={() => navigateToProfile()} + onPress={() => + moment ? navigation.goBack() : navigateToProfile() + } /> <Button - title="Share" + title={moment ? 'Done' : 'Share'} titleStyle={styles.shareButtonTitle} buttonStyle={styles.button} - onPress={handleShare} + onPress={moment ? handleDone : handleShare} /> </View> - <CaptionScreenHeader style={styles.header} {...{title: title}} /> + <CaptionScreenHeader + style={styles.header} + {...{title: moment ? moment.moment_category : title}} + /> {/* this is the image we want to center our tags' initial location within */} <Image style={styles.image} - source={{uri: image.path}} + source={{uri: moment ? moment.moment_url : image?.path}} resizeMode={'cover'} /> <MentionInput @@ -172,8 +210,11 @@ const CaptionScreen: React.FC<CaptionScreenProps> = ({route, navigation}) => { <TouchableOpacity onPress={() => navigation.navigate('TagFriendsScreen', { - image: image, - screenType: screenType, + imagePath: moment + ? moment.moment_url + : image + ? image.path + : '', selectedTags: tags, }) } diff --git a/src/screens/profile/EditProfile.tsx b/src/screens/profile/EditProfile.tsx index 26802e45..20a62b19 100644 --- a/src/screens/profile/EditProfile.tsx +++ b/src/screens/profile/EditProfile.tsx @@ -305,14 +305,13 @@ const EditProfile: React.FC<EditProfileProps> = ({route, navigation}) => { type: 'image/jpg', }); } - if (form.website) { - if (form.isValidWebsite) { - request.append('website', form.website); - } else { - setForm({...form, attemptedSubmit: false}); - setTimeout(() => setForm({...form, attemptedSubmit: true})); - invalidFields = true; - } + + if (form.isValidWebsite) { + request.append('website', form.website); + } else { + setForm({...form, attemptedSubmit: false}); + setTimeout(() => setForm({...form, attemptedSubmit: true})); + invalidFields = true; } if (form.bio) { diff --git a/src/screens/profile/InviteFriendsScreen.tsx b/src/screens/profile/InviteFriendsScreen.tsx index 89f2e62f..9ee6fb1c 100644 --- a/src/screens/profile/InviteFriendsScreen.tsx +++ b/src/screens/profile/InviteFriendsScreen.tsx @@ -21,7 +21,6 @@ import { getRemainingInviteCount, usersFromContactsService, } from '../../services/UserFriendsService'; -import {ProfilePreviewType} from '../../types'; import { extractContacts, HeaderHeight, @@ -45,11 +44,10 @@ export type SearchResultType = { const InviteFriendsScreen: React.FC = () => { const navigation = useNavigation(); - const [usersFromContacts, setUsersFromContacts] = useState< - ProfilePreviewType[] + const [nonUsersFromContacts, setNonUsersFromContacts] = useState< + InviteContactType[] >([]); - const [nonUsersFromContacts, setNonUsersFromContacts] = useState<[]>([]); - const [pendingUsers] = useState<[]>([]); + const [pendingUsers, setPendingUsers] = useState<InviteContactType[]>([]); const [results, setResults] = useState<SearchResultType>({ nonUsersFromContacts: nonUsersFromContacts, pendingUsers: pendingUsers, @@ -80,8 +78,8 @@ const InviteFriendsScreen: React.FC = () => { const permission = await checkPermission(); if (permission === 'authorized') { let response = await usersFromContactsService(retrievedContacts); - await setUsersFromContacts(response.existing_tagg_users); await setNonUsersFromContacts(response.invite_from_contacts); + await setPendingUsers(response.pending_users); setResults({ nonUsersFromContacts: response.invite_from_contacts, pendingUsers: response.pending_users, @@ -100,30 +98,32 @@ const InviteFriendsScreen: React.FC = () => { useEffect(() => { const search = async () => { if (query.length > 0) { - const searchResultsUsers = usersFromContacts.filter( - (item: ProfilePreviewType) => - (item.first_name + ' ' + item.last_name) - .toLowerCase() - .startsWith(query) || - item.username.toLowerCase().startsWith(query) || - item.last_name.toLowerCase().startsWith(query), - ); - const searchResultsNonUsers = nonUsersFromContacts.filter( - (item: InviteContactType) => - (item.firstName + ' ' + item.lastName) - .toLowerCase() - .startsWith(query) || - item.lastName.toLowerCase().startsWith(query), - ); - const sanitizedResult = { - usersFromContacts: searchResultsUsers, + const searchResultsPendingUsers = pendingUsers + ? pendingUsers.filter( + (item: InviteContactType) => + (item.firstName + ' ' + item.lastName) + .toLowerCase() + .startsWith(query) || + item.lastName.toLowerCase().startsWith(query), + ) + : []; + const searchResultsNonUsers = nonUsersFromContacts + ? nonUsersFromContacts.filter( + (item: InviteContactType) => + (item.firstName + ' ' + item.lastName) + .toLowerCase() + .startsWith(query) || + item.lastName.toLowerCase().startsWith(query), + ) + : []; + setResults({ nonUsersFromContacts: searchResultsNonUsers, - }; - setResults(sanitizedResult); + pendingUsers: searchResultsPendingUsers, + }); } else { setResults({ - nonUsersFromContacts: nonUsersFromContacts, - pendingUsers: pendingUsers, + nonUsersFromContacts: nonUsersFromContacts || [], + pendingUsers: pendingUsers || [], }); } }; @@ -203,7 +203,7 @@ const InviteFriendsScreen: React.FC = () => { styles.subheader, { height: - 72 * + 75 * (results.pendingUsers ? results.pendingUsers.length : 1), }, ]}> diff --git a/src/screens/suggestedPeople/SuggestedPeopleScreen.tsx b/src/screens/suggestedPeople/SuggestedPeopleScreen.tsx index c65d2012..39d98bcc 100644 --- a/src/screens/suggestedPeople/SuggestedPeopleScreen.tsx +++ b/src/screens/suggestedPeople/SuggestedPeopleScreen.tsx @@ -3,6 +3,7 @@ import {useFocusEffect, useNavigation} from '@react-navigation/native'; import React, {useCallback, useEffect, useRef, useState} from 'react'; import {FlatList, RefreshControl, StatusBar, ViewToken} from 'react-native'; import {useDispatch, useSelector, useStore} from 'react-redux'; +import {checkPermission} from 'react-native-contacts'; import {Background, TabsGradient, TaggLoadingIndicator} from '../../components'; import {SP_PAGE_SIZE} from '../../constants'; import {getSuggestedPeople} from '../../services/SuggestedPeopleService'; @@ -59,11 +60,17 @@ const SuggestedPeopleScreen: React.FC = () => { const stausBarRef = useRef(hideStatusBar); useEffect(() => { - AsyncStorage.getItem('respondedToAccessContacts').then((value) => { - if (value === null) { + const handlePageChange = async () => { + const checkAsync = await AsyncStorage.getItem( + 'respondedToAccessContacts', + ); + const permission = await checkPermission(); + if (checkAsync === null && permission !== 'authorized') { navigation.navigate('RequestContactsAccess'); } - }); + }; + + handlePageChange(); }, []); // loads data and append it to users based on current page diff --git a/src/services/MomentService.ts b/src/services/MomentService.ts index e4db95b5..b837585a 100644 --- a/src/services/MomentService.ts +++ b/src/services/MomentService.ts @@ -54,6 +54,38 @@ export const postMoment = async ( return undefined; }; +export const patchMoment = async ( + momentId: string, + caption: string, + tags: { + x: number; + y: number; + z: number; + user_id: string; + }[], +) => { + try { + const request = new FormData(); + request.append('moment_id', momentId); + request.append('captions', JSON.stringify({[momentId]: caption})); + request.append('tags', JSON.stringify(tags)); + const token = await AsyncStorage.getItem('token'); + let response = await fetch(MOMENTS_ENDPOINT, { + method: 'PATCH', + headers: { + 'Content-Type': 'multipart/form-data', + Authorization: 'Token ' + token, + }, + body: request, + }); + let statusCode = response.status; + return statusCode === 200 || statusCode === 201; + } catch (err) { + console.log(err); + } + return false; +}; + export const loadMoments = async (userId: string, token: string) => { try { const response = await fetch(MOMENTS_ENDPOINT + '?user_id=' + userId, { diff --git a/src/services/NotificationService.ts b/src/services/NotificationService.ts index c5c843f5..ccaa9135 100644 --- a/src/services/NotificationService.ts +++ b/src/services/NotificationService.ts @@ -1,5 +1,9 @@ import AsyncStorage from '@react-native-community/async-storage'; -import {NOTIFICATIONS_ENDPOINT} from '../constants'; +import { + NOTIFICATIONS_ENDPOINT, + NOTIFICATIONS_COUNT_ENDPOINT, + NOTIFICATIONS_DATE, +} from '../constants'; import {NotificationType} from '../types'; export const getNotificationsData: () => Promise<NotificationType[]> = @@ -29,3 +33,60 @@ export const getNotificationsData: () => Promise<NotificationType[]> = return []; } }; + +export const getNotificationsUnreadCount = async () => { + try { + const token = await AsyncStorage.getItem('token'); + const response = await fetch(NOTIFICATIONS_COUNT_ENDPOINT, { + method: 'GET', + headers: { + Authorization: 'Token ' + token, + }, + }); + if (response.status === 200) { + const data: any = await response.json(); + const typedData: { + CMT?: number; + FRD_REQ?: number; + P_VIEW?: number; + MOM_TAG?: number; + } = {}; + if (data.CMT) { + typedData.CMT = data.CMT; + } + if (data.FRD_REQ && data.FRD_REQ > 0) { + typedData.FRD_REQ = data.FRD_REQ; + } + if (data.P_VIEW && data.P_VIEW > 0) { + typedData.P_VIEW = data.P_VIEW; + } + if (data.MOM_TAG && data.MOM_TAG > 0) { + typedData.MOM_TAG = data.MOM_TAG; + } + return typedData; + } + } catch (error) { + console.log('Unable to fetch notifications'); + } + return undefined; +}; + +export const setNotificationsReadDate: () => Promise<boolean> = async () => { + try { + const token = await AsyncStorage.getItem('token'); + const response = await fetch(NOTIFICATIONS_DATE, { + method: 'POST', + headers: { + Authorization: 'Token ' + token, + }, + }); + if (response.status === 204) { + return true; + } else { + return false; + } + } catch (error) { + console.log('Unable to fetch notifications'); + return false; + } +}; diff --git a/src/types/types.ts b/src/types/types.ts index 3ac7ec2f..171a9ff3 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -264,7 +264,9 @@ export type TypeOfNotification = // notification_object is MomentType | 'MOM_TAG' // notification_object is undefined - | 'SYSTEM_MSG'; + | 'SYSTEM_MSG' + // notification_object is undefined + | 'P_VIEW'; export type UniversityBadge = { id: number; diff --git a/src/utils/common.ts b/src/utils/common.ts index cfd9244a..1956e811 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -233,3 +233,8 @@ export const badgesToDisplayBadges = ( img: badgeToImgMap[b.category + b.name], })); }; + +// Documentation: https://stackoverflow.com/questions/2901102/how-to-print-a-number-with-commas-as-thousands-separators-in-javascript +export const numberWithCommas = (digits: number) => { + return digits.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); +}; |