diff options
29 files changed, 530 insertions, 101 deletions
diff --git a/src/components/notifications/Notification.tsx b/src/components/notifications/Notification.tsx new file mode 100644 index 00000000..f533e42d --- /dev/null +++ b/src/components/notifications/Notification.tsx @@ -0,0 +1,150 @@ +import {useNavigation} from '@react-navigation/native'; +import React, {useEffect, useState} from 'react'; +import {Image, StyleSheet, Text, View} from 'react-native'; +import {TouchableWithoutFeedback} from 'react-native-gesture-handler'; +import {useDispatch, useStore} from 'react-redux'; +import {loadAvatar} from '../../services'; +import {RootState} from '../../store/rootReducer'; +import {NotificationType, ScreenType} from '../../types'; +import {fetchUserX, SCREEN_HEIGHT, userXInStore} from '../../utils'; + +interface NotificationProps { + item: NotificationType; + screenType: ScreenType; +} + +const Notification: React.FC<NotificationProps> = (props) => { + const { + item: { + actor: {id, username, first_name, last_name}, + verbage, + notification_type, + notification_object, + unread, + }, + screenType, + } = props; + const navigation = useNavigation(); + const state: RootState = useStore().getState(); + const dispatch = useDispatch(); + + const [avatarURI, setAvatarURI] = useState<string | undefined>(undefined); + const [momentURI, setMomentURI] = useState<string | undefined>(undefined); + const backgroundColor = unread ? '#DCF1F1' : 'rgba(0,0,0,0)'; + + useEffect(() => { + let mounted = true; + const loadAvatarImage = async (user_id: string) => { + const response = await loadAvatar(user_id, true); + if (mounted) { + setAvatarURI(response); + } + }; + loadAvatarImage(id); + return () => { + mounted = false; + }; + }, [id]); + + // TODO: this should be moment thumbnail, waiting for that to complete + // useEffect(() => { + // let mounted = true; + // const loadMomentImage = async (user_id: string) => { + // const response = await loadAvatar(user_id, true); + // if (mounted) { + // setMomentURI(response); + // } + // }; + // loadMomentImage(id); + // return () => { + // mounted = false; + // }; + // }, [id, notification_object]); + + const onNotificationTap = async () => { + switch (notification_type) { + case 'FLO': + if (!userXInStore(state, screenType, id)) { + await fetchUserX( + dispatch, + {userId: id, username: username}, + screenType, + ); + } + navigation.push('Profile', { + userXId: id, + screenType, + }); + break; + default: + break; + } + }; + + return ( + <TouchableWithoutFeedback + style={[styles.container, {backgroundColor}]} + onPress={onNotificationTap}> + <View style={styles.avatarContainer}> + <Image + style={styles.avatar} + source={ + avatarURI + ? {uri: avatarURI, cache: 'only-if-cached'} + : require('../../assets/images/avatar-placeholder.png') + } + /> + </View> + <View style={styles.contentContainer}> + <Text style={styles.actorName}> + {first_name} {last_name} + </Text> + <Text>{verbage}</Text> + </View> + {/* TODO: Still WIP */} + {/* {notification_type === 'CMT' && notification_object && ( + <Image + style={styles.moment} + source={{uri: momentURI, cache: 'only-if-cached'}} + /> + )} */} + </TouchableWithoutFeedback> + ); +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + height: SCREEN_HEIGHT / 12, + flex: 1, + alignItems: 'center', + }, + avatarContainer: { + marginLeft: '5%', + flex: 1, + justifyContent: 'center', + }, + avatar: { + height: 42, + width: 42, + borderRadius: 20, + }, + contentContainer: { + flex: 5, + marginLeft: '5%', + height: '80%', + flexDirection: 'column', + justifyContent: 'space-around', + }, + actorName: { + fontWeight: 'bold', + }, + moment: { + position: 'absolute', + height: 42, + width: 42, + right: '5%', + }, +}); + +export default Notification; diff --git a/src/components/notifications/index.ts b/src/components/notifications/index.ts new file mode 100644 index 00000000..0260ce24 --- /dev/null +++ b/src/components/notifications/index.ts @@ -0,0 +1 @@ +export {default as Notification} from './Notification'; diff --git a/src/components/profile/ProfilePreview.tsx b/src/components/profile/ProfilePreview.tsx index cc18e457..49c79e2d 100644 --- a/src/components/profile/ProfilePreview.tsx +++ b/src/components/profile/ProfilePreview.tsx @@ -14,12 +14,14 @@ import RNFetchBlob from 'rn-fetch-blob'; import AsyncStorage from '@react-native-community/async-storage'; import {PROFILE_PHOTO_THUMBNAIL_ENDPOINT} from '../../constants'; import {UserType, PreviewType} from '../../types'; -import {isUserBlocked} from '../../services'; +import {isUserBlocked, loadAvatar} from '../../services'; import {useSelector, useDispatch, useStore} from 'react-redux'; import {RootState} from '../../store/rootreducer'; import {logout} from '../../store/actions'; import {fetchUserX, userXInStore} from '../../utils'; +import {SearchResultsBackground} from '../search'; import NavigationBar from 'src/routes/tabs'; + const NO_USER: UserType = { userId: '', username: '', @@ -52,34 +54,13 @@ const ProfilePreview: React.FC<ProfilePreviewProps> = ({ const dispatch = useDispatch(); useEffect(() => { let mounted = true; - const loadAvatar = async () => { - try { - const token = await AsyncStorage.getItem('token'); - if (!token) { - setUser(NO_USER); - return; - } - const response = await RNFetchBlob.config({ - fileCache: true, - appendExt: 'jpg', - }).fetch('GET', PROFILE_PHOTO_THUMBNAIL_ENDPOINT + `${id}/`, { - Authorization: 'Token ' + token, - }); - const status = response.info().status; - if (status === 200) { - if (mounted) { - setAvatarURI(response.path()); - } - return; - } - if (mounted) { - setAvatarURI(''); - } - } catch (error) { - console.log(error); + const loadAvatarImage = async () => { + const response = await loadAvatar(id, true); + if (mounted) { + setAvatarURI(response); } }; - loadAvatar(); + loadAvatarImage(); return () => { mounted = false; }; diff --git a/src/constants/api.ts b/src/constants/api.ts index e1658993..2118492d 100644 --- a/src/constants/api.ts +++ b/src/constants/api.ts @@ -11,7 +11,8 @@ export const VERIFY_OTP_ENDPOINT: string = API_URL + 'verify-otp/'; export const PROFILE_INFO_ENDPOINT: string = API_URL + 'user-profile-info/'; export const HEADER_PHOTO_ENDPOINT: string = API_URL + 'header-pic/'; export const PROFILE_PHOTO_ENDPOINT: string = API_URL + 'profile-pic/'; -export const PROFILE_PHOTO_THUMBNAIL_ENDPOINT: string = API_URL + 'profile-thumbnail/'; +export const PROFILE_PHOTO_THUMBNAIL_ENDPOINT: string = + API_URL + 'profile-thumbnail/'; export const GET_IG_POSTS_ENDPOINT: string = API_URL + 'posts-ig/'; export const GET_FB_POSTS_ENDPOINT: string = API_URL + 'posts-fb/'; export const GET_TWITTER_POSTS_ENDPOINT: string = API_URL + 'posts-twitter/'; @@ -28,6 +29,7 @@ export const REPORT_ISSUE_ENDPOINT: string = API_URL + 'report/'; 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/'; // Register as FCM device export const FCM_ENDPOINT: string = API_URL + 'fcm/'; diff --git a/src/routes/index.ts b/src/routes/index.ts index 3b74e130..ed61d92f 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,3 +1,3 @@ export * from './onboarding'; -export * from './profile'; +export * from './main'; export {default} from './Routes'; diff --git a/src/routes/profile/ProfileStackNavigator.tsx b/src/routes/main/MainStackNavigator.tsx index bc0a9560..c156c725 100644 --- a/src/routes/profile/ProfileStackNavigator.tsx +++ b/src/routes/main/MainStackNavigator.tsx @@ -4,7 +4,7 @@ import {createStackNavigator} from '@react-navigation/stack'; import {CategorySelectionScreenType, MomentType, ScreenType} from '../../types'; -export type ProfileStackParams = { +export type MainStackParams = { Search: { screenType: ScreenType; }; @@ -45,6 +45,9 @@ export type ProfileStackParams = { categories: Array<string>; screenType: CategorySelectionScreenType; }; + Notifications: { + screenType: ScreenType; + }; }; -export const ProfileStack = createStackNavigator<ProfileStackParams>(); +export const MainStack = createStackNavigator<MainStackParams>(); diff --git a/src/routes/profile/ProfileStackScreen.tsx b/src/routes/main/MainStackScreen.tsx index 4fc9f0c7..cd053bde 100644 --- a/src/routes/profile/ProfileStackScreen.tsx +++ b/src/routes/main/MainStackScreen.tsx @@ -9,12 +9,14 @@ import { FollowersListScreen, EditProfile, CategorySelection, + NotificationsScreen, } from '../../screens'; -import {ProfileStack, ProfileStackParams} from './ProfileStackNavigator'; +import {MainStack, MainStackParams} from './MainStackNavigator'; import {RouteProp} from '@react-navigation/native'; import {ScreenType} from '../../types'; import {AvatarHeaderHeight} from '../../utils'; import {StackNavigationOptions} from '@react-navigation/stack'; +import {Screen} from 'react-native-screens'; /** * Trying to explain the purpose of each route on the stack (ACTUALLY A STACK) @@ -27,16 +29,29 @@ import {StackNavigationOptions} from '@react-navigation/stack'; * EditProfile : To edit logged in user's information. */ -type ProfileStackRouteProps = RouteProp<ProfileStackParams, 'Profile'>; +type MainStackRouteProps = RouteProp<MainStackParams, 'Profile'>; -interface ProfileStackProps { - route: ProfileStackRouteProps; +interface MainStackProps { + route: MainStackRouteProps; } -const ProfileStackScreen: React.FC<ProfileStackProps> = ({route}) => { +const MainStackScreen: React.FC<MainStackProps> = ({route}) => { const {screenType} = route.params; - const isProfileStack = screenType === ScreenType.Profile; + // const isProfileTab = screenType === ScreenType.Profile; + const isSearchTab = screenType === ScreenType.Search; + const isNotificationsTab = screenType === ScreenType.Notifications; + + const initialRouteName = (() => { + switch (screenType) { + case ScreenType.Profile: + return 'Profile'; + case ScreenType.Search: + return 'Search'; + case ScreenType.Notifications: + return 'Notifications'; + } + })(); const modalStyle: StackNavigationOptions = { cardStyle: {backgroundColor: 'transparent'}, @@ -61,13 +76,13 @@ const ProfileStackScreen: React.FC<ProfileStackProps> = ({route}) => { }; return ( - <ProfileStack.Navigator + <MainStack.Navigator screenOptions={{ headerShown: false, }} mode="card" - initialRouteName={isProfileStack ? 'Profile' : 'Search'}> - <ProfileStack.Screen + initialRouteName={initialRouteName}> + <MainStack.Screen name="Profile" component={ProfileScreen} options={{ @@ -82,16 +97,26 @@ const ProfileStackScreen: React.FC<ProfileStackProps> = ({route}) => { screenType, }} /> - {!isProfileStack ? ( - <ProfileStack.Screen + {isSearchTab && ( + <MainStack.Screen name="Search" component={SearchScreen} initialParams={{screenType}} /> - ) : ( - <React.Fragment /> )} - <ProfileStack.Screen + {isNotificationsTab && ( + <MainStack.Screen + name="Notifications" + component={NotificationsScreen} + initialParams={{screenType}} + /> + )} + <MainStack.Screen + name="CaptionScreen" + component={CaptionScreen} + options={{...modalStyle, gestureEnabled: false}} + /> + <MainStack.Screen name="SocialMediaTaggs" component={SocialMediaTaggs} options={{ @@ -104,7 +129,7 @@ const ProfileStackScreen: React.FC<ProfileStackProps> = ({route}) => { }} initialParams={{screenType}} /> - <ProfileStack.Screen + <MainStack.Screen name="CategorySelection" component={CategorySelection} options={{ @@ -115,16 +140,7 @@ const ProfileStackScreen: React.FC<ProfileStackProps> = ({route}) => { headerTitle: '', }} /> - {isProfileStack ? ( - <ProfileStack.Screen - name="CaptionScreen" - component={CaptionScreen} - options={{...modalStyle, gestureEnabled: false}} - /> - ) : ( - <React.Fragment /> - )} - <ProfileStack.Screen + <MainStack.Screen name="IndividualMoment" component={IndividualMoment} options={{ @@ -132,7 +148,7 @@ const ProfileStackScreen: React.FC<ProfileStackProps> = ({route}) => { }} initialParams={{screenType}} /> - <ProfileStack.Screen + <MainStack.Screen name="MomentCommentsScreen" component={MomentCommentsScreen} options={{ @@ -140,7 +156,7 @@ const ProfileStackScreen: React.FC<ProfileStackProps> = ({route}) => { }} initialParams={{screenType}} /> - <ProfileStack.Screen + <MainStack.Screen name="FollowersListScreen" component={FollowersListScreen} options={{ @@ -148,7 +164,7 @@ const ProfileStackScreen: React.FC<ProfileStackProps> = ({route}) => { }} initialParams={{screenType}} /> - <ProfileStack.Screen + <MainStack.Screen name="EditProfile" component={EditProfile} options={{ @@ -159,8 +175,8 @@ const ProfileStackScreen: React.FC<ProfileStackProps> = ({route}) => { headerTintColor: 'white', }} /> - </ProfileStack.Navigator> + </MainStack.Navigator> ); }; -export default ProfileStackScreen; +export default MainStackScreen; diff --git a/src/routes/main/index.ts b/src/routes/main/index.ts new file mode 100644 index 00000000..945c3fb0 --- /dev/null +++ b/src/routes/main/index.ts @@ -0,0 +1,2 @@ +export * from './MainStackNavigator'; +export * from './MainStackScreen'; diff --git a/src/routes/profile/index.ts b/src/routes/profile/index.ts deleted file mode 100644 index 05a6b24a..00000000 --- a/src/routes/profile/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './ProfileStackNavigator'; -export * from './ProfileStackScreen'; diff --git a/src/routes/tabs/NavigationBar.tsx b/src/routes/tabs/NavigationBar.tsx index f3043696..9d7d4b12 100644 --- a/src/routes/tabs/NavigationBar.tsx +++ b/src/routes/tabs/NavigationBar.tsx @@ -2,7 +2,7 @@ import {createBottomTabNavigator} from '@react-navigation/bottom-tabs'; import React, {Fragment} from 'react'; import {NavigationIcon} from '../../components'; import {ScreenType} from '../../types'; -import Profile from '../profile/ProfileStackScreen'; +import MainStackScreen from '../main/MainStackScreen'; const Tabs = createBottomTabNavigator(); @@ -39,18 +39,19 @@ const NavigationBar: React.FC = () => { bottom: '1%', }, }}> - {/* Removed for Alpha for now */} - {/* <Tabs.Screen name="Home" component={Home} /> - <Tabs.Screen name="Notifications" component={Notifications} /> - <Tabs.Screen name="Upload" component={Upload} /> */} + <Tabs.Screen + name="Notifications" + component={MainStackScreen} + initialParams={{screenType: ScreenType.Notifications}} + /> <Tabs.Screen name="Search" - component={Profile} + component={MainStackScreen} initialParams={{screenType: ScreenType.Search}} /> <Tabs.Screen name="Profile" - component={Profile} + component={MainStackScreen} initialParams={{screenType: ScreenType.Profile}} /> </Tabs.Navigator> diff --git a/src/screens/main/Notifications.tsx b/src/screens/main/Notifications.tsx deleted file mode 100644 index ca8c41c3..00000000 --- a/src/screens/main/Notifications.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react'; -import {ComingSoon} from '../../components'; - -/** - * Navigation Screen for displaying other users' - * actions on the logged in user's posts - */ - -const Notifications: React.FC = () => { - return <ComingSoon />; -}; - -export default Notifications; diff --git a/src/screens/main/NotificationsScreen.tsx b/src/screens/main/NotificationsScreen.tsx new file mode 100644 index 00000000..2343215f --- /dev/null +++ b/src/screens/main/NotificationsScreen.tsx @@ -0,0 +1,167 @@ +import AsyncStorage from '@react-native-community/async-storage'; +import moment from 'moment'; +import React, {useCallback, useEffect, useState} from 'react'; +import { + RefreshControl, + SectionList, + StyleSheet, + Text, + View, +} from 'react-native'; +import {SafeAreaView} from 'react-native-safe-area-context'; +import {useDispatch, useSelector} from 'react-redux'; +import {Notification} from '../../components/notifications'; +import {loadUserNotifications} from '../../store/actions'; +import {RootState} from '../../store/rootReducer'; +import {NotificationType, ScreenType} from '../../types'; +import {getDateAge, SCREEN_HEIGHT} from '../../utils'; + +const NotificationsScreen: React.FC = () => { + const [refreshing, setRefreshing] = useState(false); + // used for figuring out which ones are unread + const [lastViewed, setLastViewed] = useState<moment.Moment | undefined>( + undefined, + ); + const {notifications} = useSelector( + (state: RootState) => state.notifications, + ); + const [sectionedNotifications, setSectionedNotifications] = useState< + {title: 'Today' | 'Yesterday' | 'This Week'; data: NotificationType[]}[] + >([]); + + const dispatch = useDispatch(); + + const onRefresh = useCallback(() => { + const refrestState = async () => { + dispatch(loadUserNotifications()); + }; + setRefreshing(true); + refrestState().then(() => { + setRefreshing(false); + }); + }, [dispatch]); + + // handles storing and fetching the "previously viewed" information + useEffect(() => { + const getAndUpdateLastViewed = async () => { + const key = 'notificationLastViewed'; + const previousLastViewed = await AsyncStorage.getItem(key); + setLastViewed( + previousLastViewed == null + ? moment.unix(0) + : moment(previousLastViewed), + ); + await AsyncStorage.setItem(key, moment().toString()); + }; + getAndUpdateLastViewed(); + }, [notifications]); + + // handles sectioning notifications to "date age" + // mark notifications as read or unread + useEffect(() => { + const sortedNotifications = (notifications ?? []) + .slice() + .sort((a, b) => (a.timestamp < b.timestamp ? 1 : -1)); + let todays = []; + let yesterdays = []; + let thisWeeks = []; + for (const n of sortedNotifications) { + const notificationDate = moment(n.timestamp); + const dateAge = getDateAge(notificationDate); + if (dateAge === 'unknown') { + continue; + } + const unread = lastViewed ? lastViewed.diff(notificationDate) < 0 : false; + const newN = {...n, unread}; + switch (dateAge) { + case 'today': + todays.push(newN); + continue; + case 'yesterday': + yesterdays.push(newN); + continue; + case 'thisWeek': + thisWeeks.push(newN); + continue; + default: + continue; + } + } + setSectionedNotifications([ + { + title: 'Today', + data: todays, + }, + { + title: 'Yesterday', + data: yesterdays, + }, + { + title: 'This Week', + data: thisWeeks, + }, + ]); + }, [lastViewed, notifications]); + + const renderNotification = ({item}: {item: NotificationType}) => ( + <Notification item={item} screenType={ScreenType.Notifications} /> + ); + + const renderSectionHeader = ({section: {title}}) => ( + <View style={styles.sectionHeaderContainer}> + <Text style={styles.sectionHeader}>{title}</Text> + </View> + ); + + return ( + <SafeAreaView> + <View style={styles.header}> + <Text style={styles.headerText}>Notifications</Text> + <View style={styles.underline} /> + </View> + <SectionList + contentContainerStyle={styles.container} + sections={sectionedNotifications} + keyExtractor={(item, index) => index.toString()} + renderItem={renderNotification} + renderSectionHeader={renderSectionHeader} + refreshControl={ + <RefreshControl refreshing={refreshing} onRefresh={onRefresh} /> + } + /> + </SafeAreaView> + ); +}; + +const styles = StyleSheet.create({ + header: { + marginLeft: '5%', + marginTop: '5%', + alignSelf: 'flex-start', + flexDirection: 'column', + }, + headerText: { + fontWeight: 'bold', + fontSize: 16, + }, + underline: { + borderWidth: 2, + borderColor: '#8F01FF', + }, + container: { + paddingBottom: '20%', + minHeight: (SCREEN_HEIGHT * 8) / 10, + }, + sectionHeaderContainer: { + width: '100%', + backgroundColor: '#f3f2f2', + }, + sectionHeader: { + marginLeft: '5%', + marginTop: '5%', + marginBottom: '2%', + fontSize: 16, + }, +}); + +export default NotificationsScreen; diff --git a/src/screens/main/index.ts b/src/screens/main/index.ts index b15f76da..a5a723d7 100644 --- a/src/screens/main/index.ts +++ b/src/screens/main/index.ts @@ -1,3 +1,3 @@ export {default as Home} from './Home'; -export {default as Notifications} from './Notifications'; +export {default as NotificationsScreen} from './NotificationsScreen'; export {default as Upload} from './Upload'; diff --git a/src/screens/profile/IndividualMoment.tsx b/src/screens/profile/IndividualMoment.tsx index 469c648e..91742324 100644 --- a/src/screens/profile/IndividualMoment.tsx +++ b/src/screens/profile/IndividualMoment.tsx @@ -4,7 +4,7 @@ import {StackNavigationProp} from '@react-navigation/stack'; import React from 'react'; import {FlatList, StyleSheet, View} from 'react-native'; import {useSelector} from 'react-redux'; -import {ProfileStackParams} from 'src/routes/profile/ProfileStack'; +import {ProfileStackParams} from 'src/routes/main/ProfileStack'; import { IndividualMomentTitleBar, MomentPostContent, diff --git a/src/screens/profile/MomentCommentsScreen.tsx b/src/screens/profile/MomentCommentsScreen.tsx index 2cc809a3..ebe4da28 100644 --- a/src/screens/profile/MomentCommentsScreen.tsx +++ b/src/screens/profile/MomentCommentsScreen.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import {RouteProp, useNavigation} from '@react-navigation/native'; -import {ProfileStackParams} from '../../routes/profile'; +import {ProfileStackParams} from '../../routes/main'; import {CenteredView, CommentTile} from '../../components'; import {CommentType} from '../../types'; import {ScrollView, StyleSheet, Text, View} from 'react-native'; diff --git a/src/services/NotificationService.ts b/src/services/NotificationService.ts new file mode 100644 index 00000000..a62b0df9 --- /dev/null +++ b/src/services/NotificationService.ts @@ -0,0 +1,32 @@ +import AsyncStorage from '@react-native-community/async-storage'; +import {NOTIFICATIONS_ENDPOINT} from '../constants'; +import {NotificationType} from '../types'; + +export const getNotificationsData: () => Promise< + NotificationType[] +> = async () => { + try { + const token = await AsyncStorage.getItem('token'); + const response = await fetch(NOTIFICATIONS_ENDPOINT, { + method: 'GET', + headers: { + Authorization: 'Token ' + token, + }, + }); + if (response.status === 200) { + const data: any[] = await response.json(); + let typedData: NotificationType[] = []; + for (const o of data) { + typedData.push({ + ...o.notification, + unread: false, + }); + } + return typedData; + } + return []; + } catch (error) { + console.log('Unable to fetch notifications'); + return []; + } +}; diff --git a/src/services/UserProfileService.ts b/src/services/UserProfileService.ts index c67174f9..8c88f0ab 100644 --- a/src/services/UserProfileService.ts +++ b/src/services/UserProfileService.ts @@ -1,3 +1,4 @@ +import {transformFromAstAsync} from '@babel/core'; import AsyncStorage from '@react-native-community/async-storage'; import moment from 'moment'; import {Alert} from 'react-native'; @@ -14,6 +15,7 @@ import { TAGG_CUSTOMER_SUPPORT, VERIFY_OTP_ENDPOINT, SEND_OTP_ENDPOINT, + PROFILE_PHOTO_THUMBNAIL_ENDPOINT, } from '../constants'; export const loadProfileInfo = async (token: string, userId: string) => { @@ -41,12 +43,16 @@ export const loadProfileInfo = async (token: string, userId: string) => { } }; -export const loadAvatar = async (token: string, userId: string) => { +export const loadAvatar = async (userId: string, thumbnail: boolean) => { try { + const token = await AsyncStorage.getItem('token'); + const url = thumbnail + ? PROFILE_PHOTO_THUMBNAIL_ENDPOINT + : PROFILE_PHOTO_ENDPOINT; const response = await RNFetchBlob.config({ fileCache: true, appendExt: 'jpg', - }).fetch('GET', PROFILE_PHOTO_ENDPOINT + `${userId}/`, { + }).fetch('GET', url + `${userId}/`, { Authorization: 'Token ' + token, }); const status = response.info().status; @@ -57,6 +63,7 @@ export const loadAvatar = async (token: string, userId: string) => { } } catch (error) { console.log(error); + return ''; } }; @@ -209,7 +216,7 @@ export const handlePasswordCodeVerification = async ( export const handlePasswordReset = async (value: string, password: string) => { try { const token = await AsyncStorage.getItem('token'); - const response = await fetch(PASSWORD_RESET_ENDPOINT + `reset/`, { + const response = await fetch(PASSWORD_RESET_ENDPOINT + 'reset/', { method: 'POST', headers: { Authorization: 'Token ' + token, diff --git a/src/services/index.ts b/src/services/index.ts index 81a09b92..7e6b836a 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -6,4 +6,5 @@ export * from './UserFollowServices'; export * from './ReportingService'; export * from './BlockUserService'; export * from './MomentCategoryService'; +export * from './NotificationService'; export * from './FCMService'; diff --git a/src/store/actions/index.ts b/src/store/actions/index.ts index f9fd5e9c..285ca4de 100644 --- a/src/store/actions/index.ts +++ b/src/store/actions/index.ts @@ -6,3 +6,4 @@ export * from './socials'; export * from './taggUsers'; export * from './userBlock'; export * from './userX'; +export * from './notifications'; diff --git a/src/store/actions/notifications.ts b/src/store/actions/notifications.ts new file mode 100644 index 00000000..bace1776 --- /dev/null +++ b/src/store/actions/notifications.ts @@ -0,0 +1,21 @@ +import {Action, ThunkAction} from '@reduxjs/toolkit'; +import {getNotificationsData} from '../../services'; +import {userNotificationsFetched} from '../reducers'; +import {RootState} from '../rootReducer'; + +export const loadUserNotifications = (): ThunkAction< + Promise<void>, + RootState, + unknown, + Action<string> +> => async (dispatch) => { + try { + const notifications = await getNotificationsData(); + dispatch({ + type: userNotificationsFetched.type, + payload: notifications, + }); + } catch (error) { + console.log(error); + } +}; diff --git a/src/store/actions/user.ts b/src/store/actions/user.ts index e77b8513..eee5fcde 100644 --- a/src/store/actions/user.ts +++ b/src/store/actions/user.ts @@ -26,7 +26,7 @@ export const loadUserData = ( const token = await getTokenOrLogout(dispatch); const [profile, avatar, cover] = await Promise.all([ loadProfileInfo(token, user.userId), - loadAvatar(token, user.userId), + loadAvatar(user.userId, false), loadCover(token, user.userId), ]); dispatch({ diff --git a/src/store/actions/userX.ts b/src/store/actions/userX.ts index 87162eb1..e313546e 100644 --- a/src/store/actions/userX.ts +++ b/src/store/actions/userX.ts @@ -52,7 +52,7 @@ export const loadUserX = ( payload: {screenType, userId, data}, }), ); - loadAvatar(token, userId).then((data) => + loadAvatar(userId, false).then((data) => dispatch({ type: userXAvatarFetched.type, payload: {screenType, userId, data}, diff --git a/src/store/initialStates.ts b/src/store/initialStates.ts index 8f4a2e84..b75569d6 100644 --- a/src/store/initialStates.ts +++ b/src/store/initialStates.ts @@ -1,11 +1,13 @@ -import {MomentCategoryType, MomentType} from '../types'; import { - ProfileType, - SocialAccountType, + MomentCategoryType, + MomentType, + NotificationType, ProfilePreviewType, + ProfileType, ScreenType, - UserXType, + SocialAccountType, UserType, + UserXType, } from '../types'; export const NO_PROFILE: ProfileType = { @@ -20,6 +22,8 @@ export const NO_PROFILE: ProfileType = { export const EMPTY_MOMENTS_LIST = <MomentType[]>[]; +export const EMPTY_NOTIFICATIONS_LIST = <NotificationType[]>[]; + export const NO_USER: UserType = { userId: '', username: '', @@ -34,6 +38,10 @@ export const NO_USER_DATA = { cover: <string | null>'', }; +export const NO_NOTIFICATIONS = { + notifications: EMPTY_NOTIFICATIONS_LIST, +}; + export const NO_FOLLOW_DATA = { followers: EMPTY_PROFILE_PREVIEW_LIST, following: EMPTY_PROFILE_PREVIEW_LIST, @@ -113,6 +121,7 @@ export const EMPTY_SCREEN_TO_USERS_LIST: Record< > = { [ScreenType.Profile]: EMPTY_USERX_LIST, [ScreenType.Search]: EMPTY_USERX_LIST, + [ScreenType.Notifications]: EMPTY_USERX_LIST, }; export const INITIAL_CATEGORIES_STATE = { diff --git a/src/store/reducers/index.ts b/src/store/reducers/index.ts index e09b41ee..f525eb81 100644 --- a/src/store/reducers/index.ts +++ b/src/store/reducers/index.ts @@ -6,3 +6,4 @@ export * from './taggUsersReducer'; export * from './userBlockReducer'; export * from './userXReducer'; export * from './momentCategoryReducer'; +export * from './userNotificationsReducer'; diff --git a/src/store/reducers/userNotificationsReducer.ts b/src/store/reducers/userNotificationsReducer.ts new file mode 100644 index 00000000..4fc196ca --- /dev/null +++ b/src/store/reducers/userNotificationsReducer.ts @@ -0,0 +1,15 @@ +import {createSlice} from '@reduxjs/toolkit'; +import {NO_NOTIFICATIONS} from '../initialStates'; + +const userNotificationsSlice = createSlice({ + name: 'userNotifications', + initialState: NO_NOTIFICATIONS, + reducers: { + userNotificationsFetched: (state, action) => { + state.notifications = action.payload; + }, + }, +}); + +export const {userNotificationsFetched} = userNotificationsSlice.actions; +export const userNotificationsReducer = userNotificationsSlice.reducer; diff --git a/src/store/rootReducer.ts b/src/store/rootReducer.ts index 8f002de0..7940b1fe 100644 --- a/src/store/rootReducer.ts +++ b/src/store/rootReducer.ts @@ -8,6 +8,7 @@ import { userBlockReducer, userXReducer, momentCategoriesReducer, + userNotificationsReducer, } from './reducers'; /** @@ -18,6 +19,7 @@ const rootReducer = combineReducers({ user: userDataReducer, follow: userFollowReducer, moments: userMomentsReducer, + notifications: userNotificationsReducer, socialAccounts: userSocialsReducer, taggUsers: taggUsersReducer, blocked: userBlockReducer, diff --git a/src/types/types.ts b/src/types/types.ts index bda43190..fc0af522 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -84,6 +84,7 @@ export interface CommentType { comment_id: string; comment: string; date_time: string; + moment_id: string; commenter: ProfilePreviewType; } @@ -97,6 +98,7 @@ export type PreviewType = export enum ScreenType { Profile, Search, + Notifications, } /** @@ -170,3 +172,12 @@ export type TaggPopupType = { messageBody: string; next?: TaggPopupType; }; + +export type NotificationType = { + actor: ProfilePreviewType; + verbage: string; + notification_type: 'DFT' | 'FLO' | 'CMT'; + notification_object: CommentType | undefined; + timestamp: string; + unread: boolean; +}; diff --git a/src/utils/common.ts b/src/utils/common.ts index ae83ad9c..27411149 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -1,3 +1,4 @@ +import moment from 'moment'; import {Linking} from 'react-native'; import {BROWSABLE_SOCIAL_URLS, TOGGLE_BUTTON_TYPE} from '../constants'; @@ -23,3 +24,21 @@ export const handleOpenSocialUrlOnBrowser = ( Linking.openURL(BROWSABLE_SOCIAL_URLS[social] + `${handle}/`); } }; + +export const getDateAge: ( + date: moment.Moment, +) => 'today' | 'yesterday' | 'thisWeek' | 'unknown' = (date: moment.Moment) => { + const today = moment().startOf('day'); + const yesterday = moment().subtract(1, 'days').startOf('day'); + const weekOld = moment().subtract(7, 'days').startOf('day'); + if (date.isSame(today, 'd')) { + return 'today'; + } else if (date.isSame(yesterday, 'd')) { + return 'yesterday'; + } else if (date.isAfter(weekOld)) { + return 'thisWeek'; + } else { + // this can be longer than a week or in the future + return 'unknown'; + } +}; diff --git a/src/utils/users.ts b/src/utils/users.ts index 4f93347d..be92d184 100644 --- a/src/utils/users.ts +++ b/src/utils/users.ts @@ -1,10 +1,6 @@ -import {loadUserMomentCategories} from './../store/actions/momentCategories'; -import {loadUserX} from './../store/actions/userX'; -import {RootState} from './../store/rootReducer'; import AsyncStorage from '@react-native-community/async-storage'; -import {AppDispatch} from './../store/configureStore'; -import {UserType, ScreenType} from './../types/types'; import {INTEGRATED_SOCIAL_LIST} from '../constants'; +import {loadSocialPosts} from '../services'; import { loadAllSocials, loadBlockedList, @@ -12,10 +8,15 @@ import { loadRecentlySearched, loadUserData, loadUserMoments, + loadUserNotifications, } from '../store/actions'; import {NO_SOCIAL_ACCOUNTS} from '../store/initialStates'; -import {loadSocialPosts} from '../services'; import {userLoggedIn} from '../store/reducers'; +import {loadUserMomentCategories} from './../store/actions/momentCategories'; +import {loadUserX} from './../store/actions/userX'; +import {AppDispatch} from './../store/configureStore'; +import {RootState} from './../store/rootReducer'; +import {ScreenType, UserType} from './../types/types'; const loadData = async (dispatch: AppDispatch, user: UserType) => { await Promise.all([ @@ -23,6 +24,7 @@ const loadData = async (dispatch: AppDispatch, user: UserType) => { dispatch(loadFollowData(user.userId)), dispatch(loadUserMomentCategories(user.userId)), dispatch(loadUserMoments(user.userId)), + dispatch(loadUserNotifications()), dispatch(loadAllSocials(user.userId)), dispatch(loadBlockedList(user.userId)), dispatch(loadRecentlySearched()), |