From 5263e032b4115824951d8c7da2c85f850ea764f0 Mon Sep 17 00:00:00 2001 From: Omar Mihilmy Date: Sat, 23 Feb 2019 21:38:40 +0000 Subject: [PATCH] UPS11-Accept/Decline Connection Requests (pull request #7) --- .gitignore | 1 + ios/Tagfer/Info.plist | 2 +- .../navigators/ContactsTabNavigator.js | 1 + src/components/new/TagferAvatar.js | 6 +- src/components/new/UserListItem.js | 5 +- src/config/apiEndpoints.js | 18 +- src/config/router.js | 49 ---- src/realm/actions/AuthActions.js | 9 +- src/screens/contacts/RequestsScreen.js | 254 +++++++++++++++++- .../contacts/SuggestedContactsScreen.js | 90 ++++++- src/utils/fetch.js | 19 ++ 11 files changed, 377 insertions(+), 77 deletions(-) delete mode 100644 src/config/router.js diff --git a/.gitignore b/.gitignore index 849b401..7c77f69 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,4 @@ storybook test.js ios/Tagfer.xcodeproj/project.pbxproj package-lock.json +src/screens/contacts/data.js diff --git a/ios/Tagfer/Info.plist b/ios/Tagfer/Info.plist index 4d9b63d..67049b8 100644 --- a/ios/Tagfer/Info.plist +++ b/ios/Tagfer/Info.plist @@ -21,7 +21,7 @@ CFBundleSignature ???? CFBundleVersion - 2 + 3 LSRequiresIPhoneOS NSAppTransportSecurity diff --git a/src/components/navigators/ContactsTabNavigator.js b/src/components/navigators/ContactsTabNavigator.js index 304eee5..4b6d69c 100644 --- a/src/components/navigators/ContactsTabNavigator.js +++ b/src/components/navigators/ContactsTabNavigator.js @@ -8,6 +8,7 @@ const contactsTabBarOptions = { upperCaseLabel: false, activeTintColor: 'white', inactiveTintColor: '#53ACF0', + lazy: true, labelStyle: { margin: 1, fontSize: 13, diff --git a/src/components/new/TagferAvatar.js b/src/components/new/TagferAvatar.js index a11e3b9..b2c6e02 100644 --- a/src/components/new/TagferAvatar.js +++ b/src/components/new/TagferAvatar.js @@ -1,11 +1,9 @@ import React from 'react'; import { Avatar } from 'react-native-elements'; -import { isEmpty } from '../../utils/aux'; - const TagferAvatar = ({ size, photoURL, style, color }) => { - const uri = !isEmpty(photoURL) ? { uri: photoURL } : null; - const icon = isEmpty(photoURL) ? { name: 'user', type: 'entypo' } : null; + const uri = photoURL ? { uri: photoURL } : null; + const icon = !photoURL ? { name: 'user', type: 'entypo' } : null; return ( { return ( } + avatar={} title={fullName} subtitle={subtitle} subtitleNumberOfLines={3} @@ -24,5 +24,4 @@ const UserListItem = ({ profile, selected, onPress }) => { />); }; -export default UserListItem -; +export default UserListItem; diff --git a/src/config/apiEndpoints.js b/src/config/apiEndpoints.js index 66d0455..98a84e5 100644 --- a/src/config/apiEndpoints.js +++ b/src/config/apiEndpoints.js @@ -1,4 +1,4 @@ -const baseurl = 'https://us-central1-tagfer-inc.cloudfunctions.net/api'; +const baseurl = 'http://localhost:3000'; const endpoints = { login: { method: 'POST', @@ -39,6 +39,22 @@ const endpoints = { signup: { method: 'PUT', url: `${baseurl}/auth/signup` + }, + getConnectionRequests: { + method: 'GET', + url: `${baseurl}/connections/me/requests` + }, + sendConnectionRequest: (profileN) => ({ + method: 'PUT', + url: `${baseurl}/connections/me/${profileN}` + }), + acceptConnectionRequest: (profileN) => ({ + method: 'POST', + url: `${baseurl}/connections/me/${profileN}` + }), + removeConnectionRequest: { + method: 'DELETE', + url: `${baseurl}/connections/me` } }; diff --git a/src/config/router.js b/src/config/router.js deleted file mode 100644 index eb191ea..0000000 --- a/src/config/router.js +++ /dev/null @@ -1,49 +0,0 @@ -import { createStackNavigator, createAppContainer } from 'react-navigation'; -import WelcomeScreen from '../screens/onboaring/WelcomeScreen'; -// AUTH IMPORTS -import LoginScreen from '../screens/auth/LoginScreen'; -import SignupScreenOne from '../screens/auth/SignupScreen1'; -import SignupScreenTwo from '../screens/auth/SignupScreen2'; -import SignupScreenThreeA from '../screens/auth/SignupScreen3a'; -import SignupScreenThreeB from '../screens/auth/SignupScreen3b'; -import SignupScreenFour from '../screens/auth/SignupScreen4'; -import SignupScreenFive from '../screens/auth/SignupScreen5'; -import SignupScreenSix from '../screens/auth/SignupScreen6'; -import TwitterWebView from '../screens/auth/TwitterWebView'; -import LinkedInWebView from '../screens/auth/LinkedInWebView'; -import ForgotPasswordScreen from '../screens/auth/ForgotPasswordScreen'; -// PROFILE IMPORTS -import ProfileQRCodeScanner from '../screens/profile/ProfileQRCodeScanner'; -import ProfileSearchScreen from '../screens/profile/ProfileSearchScreen'; - -const MainNavigator = createStackNavigator({ - welcome: WelcomeScreen, - login: LoginScreen, - forgotPassword: ForgotPasswordScreen, - signup1: SignupScreenOne, - signup2: SignupScreenTwo, - signup3a: SignupScreenThreeA, - signup3b: SignupScreenThreeB, - signup4: SignupScreenFour, - signup5: SignupScreenFive, - signup6: SignupScreenSix, - profileQRScan: ProfileQRCodeScanner, - profileSearch: ProfileSearchScreen -}); - -const RootNavigator = createStackNavigator( -{ - Main: { - screen: MainNavigator, - navigationOptions: { header: null } - }, - twitterWebView: TwitterWebView, - linkedInWebView: LinkedInWebView -}, -{ - mode: 'modal' -}); - -const AppNavigator = createAppContainer(RootNavigator); - -export default AppNavigator; diff --git a/src/realm/actions/AuthActions.js b/src/realm/actions/AuthActions.js index 1a34a02..9989e40 100644 --- a/src/realm/actions/AuthActions.js +++ b/src/realm/actions/AuthActions.js @@ -6,6 +6,7 @@ import { UserSchema, ProfileSchema, InvitesSchema, ContactsSchema, SignupConfig, var authRealm; var signupRealm; +var sessionId; // Loads the signup state; a `then` should be chained at the component level to save the state export async function loadSignupState(screen) { @@ -37,9 +38,15 @@ export async function saveAuthState(sessionId) { } export async function getAuthState() { + if (!!sessionId) { + return sessionId; + } + try { const realm = await getRealm(0); - return realm.objectForPrimaryKey(SessionSchema.name, primaryKey); + const auth = realm.objectForPrimaryKey(SessionSchema.name, primaryKey); + sessionId = !!auth ? auth.sessionId : undefined; + return sessionId; } catch (error) { console.log(error); } diff --git a/src/screens/contacts/RequestsScreen.js b/src/screens/contacts/RequestsScreen.js index a7a0156..c21af3d 100644 --- a/src/screens/contacts/RequestsScreen.js +++ b/src/screens/contacts/RequestsScreen.js @@ -1,21 +1,265 @@ import React from 'react'; -import { Text, View } from 'react-native'; +import { View, Text, TouchableOpacity, FlatList } from 'react-native'; +import { ListItem, Icon } from 'react-native-elements'; + +import { fetchTagferApi } from '../../utils/fetch'; + +import TagferAvatar from '../../components/new/TagferAvatar'; +import colors from '../../config/colors.json'; +import endpoints from '../../config/apiEndpoints'; +import { showFlashErrorMessage } from '../../utils/errorHandler'; + +export default class RequestsScreen extends React.Component { + static navigationOptions = ({ navigation }) => { + const title = navigation.getParam('title', 'Unconfirmed'); + return { title }; + } + + constructor(props) { + super(props); + this.state = { + filter: 'rcvd', + currRequests: [], + loading: false + }; + + this.rcvdRequests = []; + this.sentRequests = []; + this.onRcvdPress = this.onRcvdPress.bind(this); + this.onSentPress = this.onSentPress.bind(this); + } + + componentDidMount() { + this.onLoad(); + } + + onRcvdPress() { + this.setState({ filter: 'rcvd', currRequests: this.rcvdRequests }); + } + + onSentPress() { + this.setState({ filter: 'sent', currRequests: this.sentRequests }); + } + + onAccept(index) { + const fromTagferId = this.rcvdRequests[index].tagferId; + const fromProfileN = this.rcvdRequests[index].profileN; + const response = fetchTagferApi(endpoints.acceptConnectionRequest(1), { fromTagferId, fromProfileN }); + + if (response.error) { + return showFlashErrorMessage(response.error); + } + + this.rcvdRequests.splice(index, 1); + this.setState({ currRequests: this.rcvdRequests }); + this.updateTitle(); + } + + async onReject(index) { + const { filter } = this.state; + const fromTagferId = filter === 'rcvd' ? this.rcvdRequests[index].tagferId : undefined; + const toTagferId = filter === 'sent' ? this.sentRequests[index].tagferId : undefined; + const response = await fetchTagferApi(endpoints.removeConnectionRequest, { fromTagferId, toTagferId }); + + if (response.error) { + return showFlashErrorMessage(response.error); + } + + if (this.state.filter === 'rcvd') { + this.rcvdRequests.splice(index, 1); + this.setState({ currRequests: this.rcvdRequests }); + } else { + this.sentRequests.splice(index, 1); + this.setState({ currRequests: this.sentRequests }); + } + + this.updateTitle(); + } + + async onLoad() { + this.setState({ loading: true }); + const response = await fetchTagferApi(endpoints.getConnectionRequests); + + if (response.error) { + this.setState({ loading: false }); + return showFlashErrorMessage(response.error); + } + + this.rcvdRequests = response.received; + this.sentRequests = response.sent; + this.updateTitle(); + + const currRequests = this.state.filter === 'rcvd' ? this.rcvdRequests : this.sentRequests; + this.setState({ currRequests, loading: false }); + } + + updateTitle() { + let numOfRequests = this.rcvdRequests.length + this.sentRequests.length; + if (numOfRequests > 100) { numOfRequests = '99+'; } + + this.props.navigation.setParams({ title: `Unconfirmed(${numOfRequests})` }); + } + + // Render a single entry in the list + renderItem(profile, index) { + return ( + this.onAccept(index)} + onReject={() => this.onReject(index)} + /> + ); + } -export default class UnconfirmedConnetcionsScreen extends React.Component { - render() { return ( - Unconfirmed Connections + + this.renderItem(item, index)} + keyExtractor={(item) => item.tagferId} + style={{ flex: 1, marginHorizontal: 10, marginTop: 10, borderRadius: 5 }} + refreshing={this.state.loading} + onRefresh={() => this.onLoad()} + /> ); } } +const FilterButtons = ({ filter, rcvdCount = 0, sentCount = 0, onRcvdPress, onSentPress }) => { + let rcvdButtonStyle; + let rcvdButtonText; + let sentButtonStyle; + let sentButtonText; + + if (filter === 'sent') { + rcvdButtonStyle = styles.rcvdButtonNotSelected; + sentButtonStyle = styles.sentButtonSelected; + rcvdButtonText = styles.notSelectedText; + sentButtonText = styles.selectedText; + } else { + rcvdButtonStyle = styles.rcvdButtonSelected; + sentButtonStyle = styles.sentButtonNotSelected; + rcvdButtonText = styles.selectedText; + sentButtonText = styles.notSelectedText; + } + + return ( + + + Requests received ({rcvdCount}) + + + + Requests sent ({sentCount}) + + + ); +}; + +const RequestListItem = ({ type, profile, onAccept, onReject }) => { + const { tagferId, fullName, jobTitle, companyName, photoURL } = profile; + const subtitle = `@${tagferId}\n${jobTitle} at ${companyName}`; + + return ( + } + title={fullName} + subtitle={subtitle} + subtitleNumberOfLines={3} + subtitleStyle={{ fontSize: 12, fontWeight: 'normal' }} + titleStyle={{ fontSize: 16, color: 'black' }} + rightIcon={} + containerStyle={{ backgroundColor: 'white' }} + /> + ); +}; + +const ActionButtons = ({ type, onAccept, onReject }) => { + const sentActionButtons = ( + + ); + + const rcvdActionButtons = ( + + + + + ); + + return type === 'sent' ? sentActionButtons : rcvdActionButtons; +}; + const styles = { container: { flex: 1, + backgroundColor: colors.offWhite + }, + rcvdButtonSelected: { + marginLeft: 40, + height: 30, + paddingHorizontal: 15, justifyContent: 'center', - alignItems: 'center' + borderRadius: 15, + backgroundColor: '#AAAFC4' + }, + rcvdButtonNotSelected: { + marginLeft: 40, + height: 30, + paddingHorizontal: 15, + justifyContent: 'center', + borderRadius: 15 + }, + sentButtonSelected: { + marginRight: 40, + height: 30, + paddingHorizontal: 15, + justifyContent: 'center', + borderRadius: 15, + backgroundColor: '#AAAFC4' + }, + sentButtonNotSelected: { + marginRight: 40, + height: 30, + paddingHorizontal: 15, + justifyContent: 'center', + borderRadius: 15 + }, + selectedText: { + fontSize: 13, + fontWeight: 'bold', + color: 'white' + }, + notSelectedText: { + fontSize: 14, + fontWeight: 'normal', + color: '#6A6A6A' } }; diff --git a/src/screens/contacts/SuggestedContactsScreen.js b/src/screens/contacts/SuggestedContactsScreen.js index 5b73c48..cf5fa64 100644 --- a/src/screens/contacts/SuggestedContactsScreen.js +++ b/src/screens/contacts/SuggestedContactsScreen.js @@ -1,21 +1,85 @@ import React from 'react'; -import { Text, View } from 'react-native'; +import { View, FlatList } from 'react-native'; + +import { fetchAuth, fetchTagferApi } from '../../utils/fetch'; +import { showFlashErrorMessage } from '../../utils/errorHandler'; + +import colors from '../../config/colors.json'; +import endpoints from '../../config/apiEndpoints'; +import UserListItem from '../../components/new/UserListItem'; + +export default class SuggestedContactsScreen extends React.Component { + static navigationOptions = ({ navigation }) => { + const title = navigation.getParam('title', 'Suggested'); + return { title }; + } + + constructor(props) { + super(props); + this.state = { + users: [], + loading: true + }; + } + + componentDidMount() { + this.onLoad(); + } + + // Load suggested contacts + async onLoad() { + const resp = await fetchAuth(endpoints.suggestProfiles); + this.setState({ loading: false, users: resp.profiles || [] }); + + if (resp.error) { + return showFlashErrorMessage(resp.error); + } + + this.updateTitle(); + } + + // WARN: this might break as we are mutating state + async onConnect(index) { + const response = await fetchTagferApi(endpoints.sendConnectionRequest(1), { toTagferId: this.state.users[index].tagferId }); + if (response.error) { + return showFlashErrorMessage(response.error); + } + + this.state.users.splice(index, 1); + this.updateTitle(); + + if (this.state.users.length === 0) { + this.onLoad(); + } + } + + // Update count in title + updateTitle() { + let count = this.state.users.length; + if (count > 100) { count = '99+'; } + + this.props.navigation.setParams({ title: `Suggested(${count})` }); + } + + // Render a single entry in the list + renderItem(profile, index) { + return this.onConnect(index)} />; + } -export default class SuggestedConnectionsScreen extends React.Component { - render() { return ( - - Suggested Connections + + {/* SUGGESTED CONTACTS */} + this.renderItem(item, index)} + keyExtractor={(item) => item.tagferId} + style={{ flex: 1, marginHorizontal: 10, marginTop: 10, borderRadius: 2 }} + refreshing={this.state.loading} + onRefresh={() => this.onLoad()} + /> ); } } - -const styles = { - container: { - flex: 1, - justifyContent: 'center', - alignItems: 'center' - } -}; diff --git a/src/utils/fetch.js b/src/utils/fetch.js index f70b57c..5b49320 100644 --- a/src/utils/fetch.js +++ b/src/utils/fetch.js @@ -1,6 +1,8 @@ import appConfig from '../config/appConfig.json'; import errors from '../config/errors.js'; +import { getAuthState } from '../realm/actions/AuthActions'; + export function fetchAuth(endpoint, body) { const request = { method: endpoint.method, @@ -13,3 +15,20 @@ export function fetchAuth(endpoint, body) { return fetch(endpoint.url, request).then(res => res.json()).catch(() => ({ error: errors.APP_NETWORK_ERROR })); } + + +// Fetch wrapper for authorized calls with valid session id. Returns either a JSON object documented in the wiki or an error. +export async function fetchTagferApi(endpoint, body) { + const sessionId = await getAuthState(); + + const request = { + method: endpoint.method, + headers: { + Authorization: sessionId, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(body) + }; + + return fetch(endpoint.url, request).then(res => res.json()).catch(() => ({ error: errors.APP_NETWORK_ERROR })); +}