diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/assets/icons/purple_ring+.svg | 1 | ||||
-rw-r--r-- | src/assets/icons/purple_ring.svg | 1 | ||||
-rw-r--r-- | src/assets/icons/ring+.svg | 1 | ||||
-rw-r--r-- | src/assets/icons/ring.svg | 1 | ||||
-rw-r--r-- | src/components/common/SocialIcon.tsx | 3 | ||||
-rw-r--r-- | src/components/common/SocialLinkModal.tsx | 118 | ||||
-rw-r--r-- | src/components/common/index.ts | 1 | ||||
-rw-r--r-- | src/components/onboarding/SocialMediaLinker.tsx | 112 | ||||
-rw-r--r-- | src/components/taggs/Tagg.tsx | 146 | ||||
-rw-r--r-- | src/components/taggs/TaggsBar.tsx | 101 | ||||
-rw-r--r-- | src/constants/api.ts | 7 | ||||
-rw-r--r-- | src/constants/constants.ts | 20 | ||||
-rw-r--r-- | src/routes/authentication/AuthProvider.tsx | 60 | ||||
-rw-r--r-- | src/routes/viewProfile/ProfileProvider.tsx | 49 | ||||
-rw-r--r-- | src/screens/profile/SocialMediaTaggs.tsx | 33 | ||||
-rw-r--r-- | src/services/SocialLinkingService.ts | 184 | ||||
-rw-r--r-- | src/services/UserProfileService.ts | 40 | ||||
-rw-r--r-- | src/services/index.ts | 1 |
18 files changed, 607 insertions, 272 deletions
diff --git a/src/assets/icons/purple_ring+.svg b/src/assets/icons/purple_ring+.svg new file mode 100644 index 00000000..f86252ce --- /dev/null +++ b/src/assets/icons/purple_ring+.svg @@ -0,0 +1 @@ +<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 216 216"><defs><style>.cls-1{fill:#8f01ff;}.cls-2{fill:url(#linear-gradient);}.cls-3{fill:#fff;}</style><linearGradient id="linear-gradient" x1="213.07" y1="207.1" x2="164.63" y2="162.74" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#6ee7e7"/><stop offset="1" stop-color="#8f01ff"/></linearGradient></defs><path class="cls-1" d="M106.17,1.83A106.17,106.17,0,1,0,212.34,108,106.16,106.16,0,0,0,106.17,1.83Zm0,198.58A92.41,92.41,0,1,1,198.58,108,92.39,92.39,0,0,1,106.17,200.41Z"/><circle class="cls-2" cx="183.16" cy="179.71" r="32.84"/><rect class="cls-3" x="180.47" y="160.8" width="5.38" height="37.82" rx="2.45"/><rect class="cls-3" x="180.47" y="160.8" width="5.38" height="37.82" rx="2.45" transform="translate(3.45 362.87) rotate(-90)"/></svg>
\ No newline at end of file diff --git a/src/assets/icons/purple_ring.svg b/src/assets/icons/purple_ring.svg new file mode 100644 index 00000000..2eef0d28 --- /dev/null +++ b/src/assets/icons/purple_ring.svg @@ -0,0 +1 @@ +<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 216 216"><defs><style>.cls-1{fill:#8f01ff;}</style></defs><path class="cls-1" d="M108,1.83A106.17,106.17,0,1,0,214.17,108,106.16,106.16,0,0,0,108,1.83Zm0,198.58A92.41,92.41,0,1,1,200.41,108,92.39,92.39,0,0,1,108,200.41Z"/></svg>
\ No newline at end of file diff --git a/src/assets/icons/ring+.svg b/src/assets/icons/ring+.svg new file mode 100644 index 00000000..6ff79515 --- /dev/null +++ b/src/assets/icons/ring+.svg @@ -0,0 +1 @@ +<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 216 216"><defs><style>.cls-1{fill:url(#linear-gradient);}.cls-2{fill:url(#linear-gradient-2);}.cls-3{fill:#fff;}</style><linearGradient id="linear-gradient" x1="163.49" y1="206.28" x2="56.49" y2="22.83" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#6ee7e7"/><stop offset="1" stop-color="#8f01ff"/></linearGradient><linearGradient id="linear-gradient-2" x1="213.07" y1="207.1" x2="164.63" y2="162.74" xlink:href="#linear-gradient"/></defs><path class="cls-1" d="M106.17,1.83A106.17,106.17,0,1,0,212.34,108,106.16,106.16,0,0,0,106.17,1.83Zm0,198.58A92.41,92.41,0,1,1,198.58,108,92.39,92.39,0,0,1,106.17,200.41Z"/><circle class="cls-2" cx="183.16" cy="179.71" r="32.84"/><rect class="cls-3" x="180.47" y="160.8" width="5.38" height="37.82" rx="2.45"/><rect class="cls-3" x="180.47" y="160.8" width="5.38" height="37.82" rx="2.45" transform="translate(3.45 362.87) rotate(-90)"/></svg>
\ No newline at end of file diff --git a/src/assets/icons/ring.svg b/src/assets/icons/ring.svg new file mode 100644 index 00000000..4b7448a2 --- /dev/null +++ b/src/assets/icons/ring.svg @@ -0,0 +1 @@ +<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 216 216"><defs><style>.cls-1{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" x1="165.32" y1="206.28" x2="58.33" y2="22.83" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#6ee7e7"/><stop offset="1" stop-color="#8f01ff"/></linearGradient></defs><path class="cls-1" d="M108,1.83A106.17,106.17,0,1,0,214.17,108,106.16,106.16,0,0,0,108,1.83Zm0,198.58A92.41,92.41,0,1,1,200.41,108,92.39,92.39,0,0,1,108,200.41Z"/></svg>
\ No newline at end of file diff --git a/src/components/common/SocialIcon.tsx b/src/components/common/SocialIcon.tsx index a46b1445..84da1ca7 100644 --- a/src/components/common/SocialIcon.tsx +++ b/src/components/common/SocialIcon.tsx @@ -22,6 +22,9 @@ const SocialIcon: React.FC<SocialIconProps> = ({ case 'Twitter': var icon = require('../../assets/images/twitter-icon.png'); break; + case 'Tiktok': + var icon = require('../../assets/images/tiktok-icon.png'); + break; case 'Twitch': var icon = require('../../assets/images/twitch-icon.png'); break; diff --git a/src/components/common/SocialLinkModal.tsx b/src/components/common/SocialLinkModal.tsx new file mode 100644 index 00000000..3cea2567 --- /dev/null +++ b/src/components/common/SocialLinkModal.tsx @@ -0,0 +1,118 @@ +import React from 'react'; +import {Modal, StyleSheet, Text, TouchableHighlight, View} from 'react-native'; +import {TextInput} from 'react-native-gesture-handler'; +import {SCREEN_WIDTH} from '../../utils'; + +interface SocialLinkModalProps { + modalVisible: boolean; + setModalVisible: (_: boolean) => void; + completionCallback: (username: string) => void; +} + +const SocialLinkModal: React.FC<SocialLinkModalProps> = ({ + modalVisible, + setModalVisible, + completionCallback, +}) => { + const [username, setUsername] = React.useState(''); + return ( + <> + <View style={styles.centeredView}> + <Modal + animationType="slide" + transparent={true} + visible={modalVisible} + onRequestClose={() => {}}> + <View style={styles.centeredView}> + <View style={styles.modalView}> + <TextInput + autoCapitalize={'none'} + autoCorrect={false} + textAlign={'center'} + placeholder={'Your username'} + style={styles.textInput} + onChangeText={setUsername} + value={username} + /> + {/* link button */} + <TouchableHighlight + style={styles.openButton} + onPress={() => { + setModalVisible(!modalVisible); + setUsername(''); + completionCallback(username); + }}> + <Text style={styles.textStyle}>Link</Text> + </TouchableHighlight> + {/* cancel button */} + <Text + onPress={() => { + setUsername(''); + setModalVisible(!modalVisible); + }} + style={styles.cancelStyle}> + Cancel + </Text> + </View> + </View> + </Modal> + </View> + </> + ); +}; + +const styles = StyleSheet.create({ + centeredView: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + marginTop: 22, + }, + modalView: { + width: (SCREEN_WIDTH * 2) / 3, + margin: 20, + backgroundColor: 'white', + borderRadius: 20, + padding: 35, + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.25, + shadowRadius: 3.84, + elevation: 5, + }, + openButton: { + borderRadius: 20, + padding: 10, + elevation: 2, + backgroundColor: '#2196F3', + }, + textStyle: { + color: 'white', + fontWeight: 'bold', + textAlign: 'center', + }, + cancelStyle: { + position: 'relative', + height: 17, + top: 17, + fontStyle: 'normal', + fontWeight: '500', + fontSize: 14, + /* identical to box height */ + textAlign: 'center', + color: '#698DD3', + }, + textInput: { + height: 20, + width: '75%', + borderBottomWidth: 0.4, + borderBottomColor: '#C4C4C4', + marginBottom: 20, + }, +}); + +export default SocialLinkModal; diff --git a/src/components/common/index.ts b/src/components/common/index.ts index cd72a70b..61d826bd 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -9,4 +9,5 @@ export {default as TabsGradient} from './TabsGradient'; export {default as RecentSearches} from '../search/RecentSearches'; export {default as LoadingIndicator} from './LoadingIndicator'; export {default as DateLabel} from './DateLabel'; +export {default as SocialLinkModal} from './SocialLinkModal'; export * from './post'; diff --git a/src/components/onboarding/SocialMediaLinker.tsx b/src/components/onboarding/SocialMediaLinker.tsx index 15afb731..da637f99 100644 --- a/src/components/onboarding/SocialMediaLinker.tsx +++ b/src/components/onboarding/SocialMediaLinker.tsx @@ -1,24 +1,14 @@ -import AsyncStorage from '@react-native-community/async-storage'; import React from 'react'; import { - Alert, Image, StyleSheet, Text, TouchableOpacity, TouchableOpacityProps, } from 'react-native'; -import InAppBrowser from 'react-native-inappbrowser-reborn'; import {LinkerType} from 'src/types'; -import { - LINK_FB_ENDPOINT, - LINK_FB_OAUTH, - LINK_IG_ENDPOINT, - LINK_IG_OAUTH, - LINK_TWITTER_ENDPOINT, - LINK_TWITTER_OAUTH, -} from '../../constants'; import {SOCIAL_FONT_COLORS} from '../../constants/constants'; +import {handlePressForAuthBrowser} from '../../services'; import SocialIcon from '../common/SocialIcon'; interface SocialMediaLinkerProps extends TouchableOpacityProps { @@ -29,102 +19,14 @@ const SocialMediaLinker: React.FC<SocialMediaLinkerProps> = ({ social: {label}, }) => { const [state, setState] = React.useState({ - authenticated: false, + socialLinked: false, }); - const integrated_endpoints: {[label: string]: [string, string]} = { - Instagram: [LINK_IG_OAUTH, LINK_IG_ENDPOINT], - Facebook: [LINK_FB_OAUTH, LINK_FB_ENDPOINT], - Twitter: [LINK_TWITTER_OAUTH, LINK_TWITTER_ENDPOINT], - }; - - const registerSocialLink: (token: string) => Promise<boolean> = async ( - callback_url, - ) => { - if (!(label in integrated_endpoints)) { - // This error is already handled earlier, more of a safety check here - return false; - } - const user_token = await AsyncStorage.getItem('token'); - const response = await fetch(integrated_endpoints[label][1], { - method: 'POST', - headers: { - Authorization: `Token ${user_token}`, - }, - body: JSON.stringify({ - callback_url: callback_url, - }), - }); - if (!(response.status === 201)) { - console.log(await response.json()); - } - return response.status === 201; - }; - const handlePress = async () => { - try { - const isAvailable = await InAppBrowser.isAvailable(); - if (!(label in integrated_endpoints)) { - // TODO handle non-integrated social links with a modal - // TODO remove the alert below - Alert.alert('Coming soon!'); - return; - } - let url = integrated_endpoints[label][0]; - - // We will need to do an extra step for twitter sign-in - if (label === 'Twitter') { - const user_token = await AsyncStorage.getItem('token'); - const response = await fetch(url, { - method: 'GET', - headers: { - Authorization: `Token ${user_token}`, - }, - }); - url = response.url; - } - - if (isAvailable) { - InAppBrowser.openAuth(url, 'taggid://callback', { - ephemeralWebSession: true, - }) - .then(async (response) => { - console.log(response); - if (response.type === 'success' && response.url) { - const success = await registerSocialLink(response.url); - if (!success) { - throw new Error('Unable to register with backend'); - } - setState({ - ...state, - authenticated: true, - }); - Alert.alert(`Successfully linked ${label} 🎉`); - } else { - throw new Error(`Unable to link with ${label} API`); - } - }) - .catch((error) => { - console.log(error); - Alert.alert(`Something went wrong, we can't link with ${label} 😔`); - }); - } else { - // Okay... to open an external browser and have it link back to - // the app is a bit tricky, we will need to have navigation routes - // setup for this screen and have it hooked up. - // See https://github.com/proyecto26/react-native-inappbrowser#authentication-flow-using-deep-linking - // Though this isn't the end of the world, from the documentation, - // the in-app browser should be supported from iOS 11, which - // is about 98.5% of all iOS devices in the world. - // See https://support.apple.com/en-gb/HT209574 - Alert.alert( - 'Sorry! Your device was unable to open a browser to let you sign-in! 😔', - ); - } - } catch (error) { - console.log(error); - Alert.alert(`Something went wrong, we can't link with ${label} 😔`); - } + setState({ + ...state, + socialLinked: await handlePressForAuthBrowser(label), + }); }; switch (label) { @@ -166,7 +68,7 @@ const SocialMediaLinker: React.FC<SocialMediaLinkerProps> = ({ style={styles.container}> <SocialIcon social={label} style={styles.icon} /> <Text style={[styles.label, {color: font_color}]}>{label}</Text> - {state.authenticated && ( + {state.socialLinked && ( <Image source={require('../../assets/images/link-tick.png')} style={styles.tick} diff --git a/src/components/taggs/Tagg.tsx b/src/components/taggs/Tagg.tsx index 9274e0eb..c64da5ef 100644 --- a/src/components/taggs/Tagg.tsx +++ b/src/components/taggs/Tagg.tsx @@ -1,51 +1,141 @@ import {useNavigation} from '@react-navigation/native'; -import React from 'react'; -import {StyleSheet, TouchableOpacity, View} from 'react-native'; -import LinearGradient from 'react-native-linear-gradient'; -import {TAGGS_GRADIENT} from '../../constants'; +import React, {Fragment, useState} from 'react'; +import {Alert, Linking, StyleSheet, TouchableOpacity, View} from 'react-native'; +import PurpleRingPlus from '../../assets/icons/purple_ring+.svg'; +import PurpleRing from '../../assets/icons/purple_ring.svg'; +import RingPlus from '../../assets/icons/ring+.svg'; +import Ring from '../../assets/icons/ring.svg'; +import {INTEGRATED_SOCIAL_LIST, TAGG_ICON_DIM} from '../../constants'; +import { + handlePressForAuthBrowser, + registerNonIntegratedSocialLink, +} from '../../services'; +import {SocialIcon, SocialLinkModal} from '../common'; interface TaggProps { - style: object; social: string; isProfileView: boolean; + isLinked: boolean; + isIntegrated: boolean; + setTaggsNeedUpdate: (_: boolean) => void; + setSocialDataNeedUpdate: (_: string[]) => void; } -const Tagg: React.FC<TaggProps> = ({style, social, isProfileView}) => { +const Tagg: React.FC<TaggProps> = ({ + social, + isProfileView, + isLinked, + isIntegrated, + setTaggsNeedUpdate, + setSocialDataNeedUpdate, +}) => { const navigation = useNavigation(); + const [modalVisible, setModalVisible] = useState(false); + const youMayPass = isLinked || isProfileView; - return ( - <TouchableOpacity - onPress={() => + /* + case isProfileView: + case linked: + show normal ring, navigate to taggs view + case !linked: + don't show tagg + case !isProfileView: + case linked: + show normal ring, navigate to taggs view + case !linked: + show ring+, then... + case integrated_social: + show auth browser + case !integrated_social: + show modal + Tagg's "Tagg" will use the Ring instead of PurpleRing + */ + + const modalOrAuthBrowserOrPass = async () => { + if (youMayPass) { + if (INTEGRATED_SOCIAL_LIST.indexOf(social) !== -1) { navigation.navigate('SocialMediaTaggs', { socialMediaType: social, isProfileView: isProfileView, - }) - }> - <LinearGradient - colors={[TAGGS_GRADIENT.start, TAGGS_GRADIENT.end]} - useAngle={true} - angle={154.72} - angleCenter={{x: 0.5, y: 0.5}} - style={[styles.gradient, style]}> - <View style={styles.image} /> - </LinearGradient> - </TouchableOpacity> + }); + } else { + // TODO: we don't know what the link is...? + Linking.openURL( + `http://google.com/search?q=take+me+to+${social}+profile+page`, + ); + } + } else { + if (isIntegrated) { + handlePressForAuthBrowser(social).then((success) => { + setTaggsNeedUpdate(success); + setSocialDataNeedUpdate(success ? [social] : []); + }); + } else { + setModalVisible(true); + } + } + }; + + const pickTheRightRingHere = () => { + if (youMayPass) { + if (social === 'Tagg') { + return <Ring width={TAGG_ICON_DIM} height={TAGG_ICON_DIM} />; + } else { + return <PurpleRing width={TAGG_ICON_DIM} height={TAGG_ICON_DIM} />; + } + } else { + if (social === 'Tagg') { + return <RingPlus width={TAGG_ICON_DIM} height={TAGG_ICON_DIM} />; + } else { + return <PurpleRingPlus width={TAGG_ICON_DIM} height={TAGG_ICON_DIM} />; + } + } + }; + + const linkNonIntegratedSocial = async (username: string) => { + if (await registerNonIntegratedSocialLink(social, username)) { + Alert.alert(`Successfully linked ${social} 🎉`); + setTaggsNeedUpdate(true); + } else { + // If we display too fast the alert will get dismissed with the modal + setTimeout(() => { + Alert.alert(`Something went wrong, we can't link with ${social} 😔`); + }, 500); + } + }; + + return ( + <> + {isProfileView && !isLinked ? ( + <Fragment /> + ) : ( + <TouchableOpacity onPress={modalOrAuthBrowserOrPass}> + <SocialLinkModal + modalVisible={modalVisible} + setModalVisible={setModalVisible} + completionCallback={linkNonIntegratedSocial} + /> + <View style={styles.container}> + <SocialIcon style={styles.image} social={social} /> + {pickTheRightRingHere()} + </View> + </TouchableOpacity> + )} + </> ); }; const styles = StyleSheet.create({ - gradient: { - width: 80, - height: 80, - borderRadius: 40, + container: { justifyContent: 'center', alignItems: 'center', + marginHorizontal: 5, }, image: { - width: 72, - height: 72, - borderRadius: 37.5, - backgroundColor: 'pink', + width: TAGG_ICON_DIM, + height: TAGG_ICON_DIM, + borderRadius: TAGG_ICON_DIM / 2, + position: 'absolute', }, }); diff --git a/src/components/taggs/TaggsBar.tsx b/src/components/taggs/TaggsBar.tsx index 88f670b5..520cc266 100644 --- a/src/components/taggs/TaggsBar.tsx +++ b/src/components/taggs/TaggsBar.tsx @@ -1,10 +1,16 @@ // @refresh react -import React from 'react'; +import React, {useEffect, useState} from 'react'; import {StyleSheet} from 'react-native'; import Animated from 'react-native-reanimated'; -import Tagg from './Tagg'; -import {PROFILE_CUTOUT_BOTTOM_Y} from '../../constants'; +import { + INTEGRATED_SOCIAL_LIST, + PROFILE_CUTOUT_BOTTOM_Y, + SOCIAL_LIST, +} from '../../constants'; +import {AuthContext, ProfileContext} from '../../routes'; +import {getLinkedSocials} from '../../services'; import {StatusBarHeight} from '../../utils'; +import Tagg from './Tagg'; const {View, ScrollView, interpolate, Extrapolate} = Animated; interface TaggsBarProps { @@ -17,43 +23,59 @@ const TaggsBar: React.FC<TaggsBarProps> = ({ profileBodyHeight, isProfileView, }) => { - const taggs: Array<JSX.Element> = []; + let [taggs, setTaggs] = useState<Object[]>([]); + let [taggsNeedUpdate, setTaggsNeedUpdate] = useState(true); + const context = isProfileView + ? React.useContext(ProfileContext) + : React.useContext(AuthContext); + const {user, socialsNeedUpdate} = context; - taggs.push( - <Tagg - key={0} - style={styles.tagg} - social={'Instagram'} - isProfileView={isProfileView} - />, - ); - taggs.push( - <Tagg - key={1} - style={styles.tagg} - social={'Facebook'} - isProfileView={isProfileView} - />, - ); - taggs.push( - <Tagg - key={2} - style={styles.tagg} - social={'Twitter'} - isProfileView={isProfileView} - />, - ); + useEffect(() => { + const loadData = async () => { + getLinkedSocials(user.userId).then((linkedSocials) => { + const unlinkedSocials = SOCIAL_LIST.filter( + (s) => linkedSocials.indexOf(s) === -1, + ); + let new_taggs = []; + let i = 0; + for (let social of linkedSocials) { + new_taggs.push( + <Tagg + key={i} + social={social} + isProfileView={isProfileView} + isLinked={true} + isIntegrated={INTEGRATED_SOCIAL_LIST.indexOf(social) !== -1} + setTaggsNeedUpdate={setTaggsNeedUpdate} + setSocialDataNeedUpdate={socialsNeedUpdate} + />, + ); + i++; + } + for (let social of unlinkedSocials) { + new_taggs.push( + <Tagg + key={i} + social={social} + isProfileView={isProfileView} + isLinked={false} + isIntegrated={INTEGRATED_SOCIAL_LIST.indexOf(social) !== -1} + setTaggsNeedUpdate={setTaggsNeedUpdate} + setSocialDataNeedUpdate={socialsNeedUpdate} + />, + ); + i++; + } + setTaggs(new_taggs); + setTaggsNeedUpdate(false); + }); + }; + + if (taggsNeedUpdate) { + loadData(); + } + }, [isProfileView, taggsNeedUpdate, user.userId]); - for (let i = 3; i < 10; i++) { - taggs.push( - <Tagg - key={i} - style={styles.tagg} - social={'Instagram'} - isProfileView={isProfileView} - />, - ); - } const shadowOpacity: Animated.Node<number> = interpolate(y, { inputRange: [ PROFILE_CUTOUT_BOTTOM_Y + profileBodyHeight, @@ -105,9 +127,6 @@ const styles = StyleSheet.create({ alignItems: 'center', paddingHorizontal: 15, }, - tagg: { - marginHorizontal: 14, - }, }); export default TaggsBar; diff --git a/src/constants/api.ts b/src/constants/api.ts index d9e199d2..ce9b24f4 100644 --- a/src/constants/api.ts +++ b/src/constants/api.ts @@ -21,7 +21,12 @@ export const FOLLOW_USER_ENDPOINT: string = API_URL + 'follow/'; export const UNFOLLOW_USER_ENDPOINT: string = API_URL + 'unfollow/'; export const FOLLOWERS_ENDPOINT: string = API_URL + 'followers/'; -// Social Link +// Register Social Link (Non-integrated) +export const LINK_SNAPCHAT_ENDPOINT: string = API_URL + 'link-sc/'; +export const LINK_TIKTOK_ENDPOINT: string = API_URL + 'link-tt/'; + +// Register Social Link (Integrated) +export const LINKED_SOCIALS_ENDPOINT: string = API_URL + 'linked-socials/'; export const LINK_IG_ENDPOINT: string = API_URL + 'link-ig/'; export const LINK_FB_ENDPOINT: string = API_URL + 'link-fb/'; export const LINK_TWITTER_ENDPOINT: string = API_URL + 'link-twitter/'; diff --git a/src/constants/constants.ts b/src/constants/constants.ts index 5f341f41..6434f6e4 100644 --- a/src/constants/constants.ts +++ b/src/constants/constants.ts @@ -12,17 +12,32 @@ export const COVER_HEIGHT = SCREEN_WIDTH * (7 / 5); export const AVATAR_DIM = 44; export const AVATAR_GRADIENT_DIM = 50; -export const SOCIAL_LIST: Array<string> = [ +export const TAGG_ICON_DIM = 72; + +export const INTEGRATED_SOCIAL_LIST: string[] = [ + 'Instagram', + 'Facebook', + 'Twitter', +]; + +export const SOCIAL_LIST: string[] = [ 'Instagram', 'Facebook', 'Twitter', - 'TikTok', 'Snapchat', + 'Tiktok', + // TODO: we don't have endpoints to support these yet... + // 'Twitch', + // 'Pinterest', + // 'Whatsapp', + // 'Linkedin', + // 'Youtube', ]; export const INSTAGRAM_FONT_COLOR: string = '#FF97DE'; export const FACEBOOK_FONT_COLOR: string = '#6697FD'; export const TWITTER_FONT_COLOR: string = '#74C9FD'; +export const TIKTOK_FONT_COLOR: string = '#78B5FD'; export const TWITCH_FONT_COLOR: string = '#CB93FF'; export const PINTEREST_FONT_COLOR: string = '#FF7584'; export const WHATSAPP_FONT_COLOR: string = '#4AC959'; @@ -44,6 +59,7 @@ export const SOCIAL_FONT_COLORS = { INSTAGRAM: INSTAGRAM_FONT_COLOR, FACEBOOK: FACEBOOK_FONT_COLOR, TWITTER: TWITTER_FONT_COLOR, + TIKTOK: TIKTOK_FONT_COLOR, TWITCH: TWITCH_FONT_COLOR, PINTEREST: PINTEREST_FONT_COLOR, WHATSAPP: WHATSAPP_FONT_COLOR, diff --git a/src/routes/authentication/AuthProvider.tsx b/src/routes/authentication/AuthProvider.tsx index a705f074..5bd4278d 100644 --- a/src/routes/authentication/AuthProvider.tsx +++ b/src/routes/authentication/AuthProvider.tsx @@ -1,10 +1,6 @@ import AsyncStorage from '@react-native-community/async-storage'; import React, {createContext, useEffect, useState} from 'react'; -import { - GET_FB_POSTS_ENDPOINT, - GET_IG_POSTS_ENDPOINT, - GET_TWITTER_POSTS_ENDPOINT, -} from '../../constants'; +import {INTEGRATED_SOCIAL_LIST} from '../../constants'; import { loadAvatar, loadCover, @@ -30,6 +26,7 @@ interface AuthContextProps { recentSearches: Array<ProfilePreviewType>; newMomentsAvailable: boolean; updateMoments: (value: boolean) => void; + socialsNeedUpdate: (_: string[]) => void; } const NO_USER: UserType = { @@ -43,11 +40,10 @@ const NO_PROFILE: ProfileType = { name: '', }; -// Not necessary, but safer, in case SocialAccountType object is undefined const NO_SOCIAL_ACCOUNTS: Record<string, SocialAccountType> = { - Instagram: {}, - Facebook: {}, - Twitter: {}, + Instagram: {posts: []}, + Facebook: {posts: []}, + Twitter: {posts: []}, }; export const AuthContext = createContext<AuthContextProps>({ @@ -57,10 +53,11 @@ export const AuthContext = createContext<AuthContextProps>({ logout: () => {}, avatar: null, cover: null, - socialAccounts: NO_SOCIAL_ACCOUNTS, recentSearches: [], newMomentsAvailable: true, updateMoments: () => {}, + socialAccounts: NO_SOCIAL_ACCOUNTS, + socialsNeedUpdate: () => {}, }); /** @@ -78,6 +75,10 @@ const AuthProvider: React.FC = ({children}) => { Array<ProfilePreviewType> >([]); const [newMomentsAvailable, setNewMomentsAvailable] = useState<boolean>(true); + // Default update all integrated social lists on start + const [socialsNeedUpdate, setSocialsNeedUpdate] = useState<string[]>([ + ...INTEGRATED_SOCIAL_LIST, + ]); const {userId} = user; useEffect(() => { if (!userId) { @@ -95,33 +96,25 @@ const AuthProvider: React.FC = ({children}) => { loadAvatar(token, userId, setAvatar); loadCover(token, userId, setCover); loadRecentlySearchedUsers(setRecentSearches); - loadSocialPosts( - token, - userId, - 'Instagram', - GET_IG_POSTS_ENDPOINT, - socialAccounts, - ).then((newSocialAccounts) => setSocialAccounts(newSocialAccounts)); - loadSocialPosts( - token, - userId, - 'Facebook', - GET_FB_POSTS_ENDPOINT, - socialAccounts, - ).then((newSocialAccounts) => setSocialAccounts(newSocialAccounts)); - loadSocialPosts( - token, - userId, - 'Twitter', - GET_TWITTER_POSTS_ENDPOINT, - socialAccounts, - ).then((newSocialAccounts) => setSocialAccounts(newSocialAccounts)); } catch (err) { console.log(err); } }; loadData(); - }, [socialAccounts, userId]); + }, [userId]); + + useEffect(() => { + if (socialsNeedUpdate.length > 0 && userId) { + for (let social of socialsNeedUpdate) { + loadSocialPosts(userId, social).then((accountData) => { + socialAccounts[social] = accountData; + setSocialAccounts(socialAccounts); + console.log('Updated posts data', social); + }); + } + setSocialsNeedUpdate([]); + } + }, [socialAccounts, socialsNeedUpdate, userId]); return ( <AuthContext.Provider @@ -150,6 +143,9 @@ const AuthProvider: React.FC = ({children}) => { updateMoments: (value) => { setNewMomentsAvailable(value); }, + socialsNeedUpdate: (socials: string[]) => { + setSocialsNeedUpdate(socials); + }, }}> {children} </AuthContext.Provider> diff --git a/src/routes/viewProfile/ProfileProvider.tsx b/src/routes/viewProfile/ProfileProvider.tsx index a4b6cb14..c4942ea0 100644 --- a/src/routes/viewProfile/ProfileProvider.tsx +++ b/src/routes/viewProfile/ProfileProvider.tsx @@ -1,9 +1,6 @@ import AsyncStorage from '@react-native-community/async-storage'; import React, {createContext, useEffect, useState} from 'react'; -import { - GET_IG_POSTS_ENDPOINT, - GET_TWITTER_POSTS_ENDPOINT, -} from '../../constants'; +import {INTEGRATED_SOCIAL_LIST} from '../../constants'; import { loadAvatar, loadCover, @@ -21,6 +18,7 @@ interface ProfileContextProps { newMomentsAvailable: boolean; updateMoments: (value: boolean) => void; socialAccounts: Record<string, SocialAccountType>; + socialsNeedUpdate: (_: string[]) => void; } const NO_USER: UserType = { userId: '', @@ -32,11 +30,10 @@ const NO_PROFILE: ProfileType = { name: '', }; -// Not necessary, but safer, in case SocialAccountType object is undefined const NO_SOCIAL_ACCOUNTS: Record<string, SocialAccountType> = { - Instagram: {}, - Facebook: {}, - Twitter: {}, + Instagram: {posts: []}, + Facebook: {posts: []}, + Twitter: {posts: []}, }; export const ProfileContext = createContext<ProfileContextProps>({ @@ -48,6 +45,7 @@ export const ProfileContext = createContext<ProfileContextProps>({ newMomentsAvailable: true, updateMoments: () => {}, socialAccounts: NO_SOCIAL_ACCOUNTS, + socialsNeedUpdate: () => {}, }); /** @@ -59,10 +57,14 @@ const ProfileProvider: React.FC = ({children}) => { const [avatar, setAvatar] = useState<string | null>(null); const [cover, setCover] = useState<string | null>(null); const [newMomentsAvailable, setNewMomentsAvailable] = useState<boolean>(true); - const [socialAccounts, setSocialAccounts] = useState< Record<string, SocialAccountType> >(NO_SOCIAL_ACCOUNTS); + // Default update all integrated social lists on start + const [socialsNeedUpdate, setSocialsNeedUpdate] = useState<string[]>([ + ...INTEGRATED_SOCIAL_LIST, + ]); + const {userId} = user; useEffect(() => { if (!userId) { @@ -79,26 +81,25 @@ const ProfileProvider: React.FC = ({children}) => { loadProfileInfo(token, userId, setProfile); loadAvatar(token, userId, setAvatar); loadCover(token, userId, setCover); - loadSocialPosts( - token, - userId, - 'Instagram', - GET_IG_POSTS_ENDPOINT, - socialAccounts, - ).then((newSocialAccounts) => setSocialAccounts(newSocialAccounts)); - loadSocialPosts( - token, - userId, - 'Twitter', - GET_TWITTER_POSTS_ENDPOINT, - socialAccounts, - ).then((newSocialAccounts) => setSocialAccounts(newSocialAccounts)); } catch (err) { console.log(err); } }; loadData(); - }, [socialAccounts, userId]); + }, [userId]); + + useEffect(() => { + if (socialsNeedUpdate.length > 0 && userId) { + for (let social of socialsNeedUpdate) { + loadSocialPosts(userId, social).then((accountData) => { + socialAccounts[social] = accountData; + setSocialAccounts(socialAccounts); + console.log('Updated posts data', social); + }); + } + setSocialsNeedUpdate([]); + } + }, [socialAccounts, socialsNeedUpdate, userId]); return ( <ProfileContext.Provider diff --git a/src/screens/profile/SocialMediaTaggs.tsx b/src/screens/profile/SocialMediaTaggs.tsx index 0ac6d1ef..2a319326 100644 --- a/src/screens/profile/SocialMediaTaggs.tsx +++ b/src/screens/profile/SocialMediaTaggs.tsx @@ -1,17 +1,18 @@ import {RouteProp} from '@react-navigation/native'; -import React from 'react'; -import {Alert, ScrollView, StatusBar, StyleSheet, View} from 'react-native'; +import React, {useEffect, useState} from 'react'; +import {ScrollView, StatusBar, StyleSheet, View} from 'react-native'; import LinearGradient from 'react-native-linear-gradient'; -import {AVATAR_GRADIENT} from '../../constants'; import { SocialMediaInfo, TabsGradient, TaggPost, TwitterTaggPost, } from '../../components'; -import {AuthContext, ProfileStackParams, ProfileContext} from '../../routes'; +import {AVATAR_GRADIENT} from '../../constants'; +import {AuthContext, ProfileContext, ProfileStackParams} from '../../routes'; +import {loadSocialPosts} from '../../services'; +import {SimplePostType, SocialAccountType, TwitterPostType} from '../../types'; import {headerBarHeightWithImage, SCREEN_HEIGHT} from '../../utils'; -import {SimplePostType, TwitterPostType} from '../../types'; type SocialMediaTaggsRouteProp = RouteProp< ProfileStackParams, @@ -22,17 +23,6 @@ interface SocialMediaTaggsProps { route: SocialMediaTaggsRouteProp; } -/** - * Social media taggs screen for a user's social media - * includes: - * + tagg profile pic - * + username from social media - * + post - * + caption - * + sharebutton + number of shares - * + date posted - * + dark background - */ const SocialMediaTaggs: React.FC<SocialMediaTaggsProps> = ({route}) => { const {socialMediaType, isProfileView} = route.params; const context = isProfileView @@ -42,11 +32,8 @@ const SocialMediaTaggs: React.FC<SocialMediaTaggsProps> = ({route}) => { profile: {name}, socialAccounts, } = context; - - - const handle = socialAccounts[socialMediaType].handle; - const posts = socialAccounts[socialMediaType].posts || []; const headerHeight = headerBarHeightWithImage(); + let accountData = socialAccounts[socialMediaType]; return ( <LinearGradient @@ -65,15 +52,15 @@ const SocialMediaTaggs: React.FC<SocialMediaTaggsProps> = ({route}) => { <SocialMediaInfo fullname={name} type={socialMediaType} - handle={handle} + handle={accountData.handle} /> - {(posts as Array< + {(accountData.posts as Array< SimplePostType | TwitterPostType >).map((post, index) => socialMediaType === 'Twitter' ? ( <TwitterTaggPost key={index} - ownerHandle={handle || '_'} + ownerHandle={accountData.handle || '_'} post={post as TwitterPostType} /> ) : ( diff --git a/src/services/SocialLinkingService.ts b/src/services/SocialLinkingService.ts new file mode 100644 index 00000000..8d67d90e --- /dev/null +++ b/src/services/SocialLinkingService.ts @@ -0,0 +1,184 @@ +import AsyncStorage from '@react-native-community/async-storage'; +import {Alert} from 'react-native'; +import InAppBrowser from 'react-native-inappbrowser-reborn'; +import { + LINKED_SOCIALS_ENDPOINT, + LINK_FB_ENDPOINT, + LINK_FB_OAUTH, + LINK_IG_ENDPOINT, + LINK_IG_OAUTH, + LINK_SNAPCHAT_ENDPOINT, + LINK_TIKTOK_ENDPOINT, + LINK_TWITTER_ENDPOINT, + LINK_TWITTER_OAUTH, +} from '../constants'; + +// A list of endpoint strings for all the integrated socials +export const integratedEndpoints: {[social: string]: [string, string]} = { + Instagram: [LINK_IG_OAUTH, LINK_IG_ENDPOINT], + Facebook: [LINK_FB_OAUTH, LINK_FB_ENDPOINT], + Twitter: [LINK_TWITTER_OAUTH, LINK_TWITTER_ENDPOINT], +}; + +export const nonIntegratedEndponits: {[social: string]: string} = { + Snapchat: LINK_SNAPCHAT_ENDPOINT, + TikTok: LINK_TIKTOK_ENDPOINT, +}; + +export const registerNonIntegratedSocialLink: ( + socialType: string, + username: string, +) => Promise<boolean> = async (socialType, username) => { + if (!(socialType in nonIntegratedEndponits)) { + return false; + } + try { + const user_token = await AsyncStorage.getItem('token'); + const response = await fetch(nonIntegratedEndponits[socialType], { + method: 'POST', + headers: { + Authorization: `Token ${user_token}`, + }, + body: JSON.stringify({ + username: username, + }), + }); + return response.status === 200; + } catch (error) { + console.log(error); + return false; + } +}; + +// We have already received the short-lived token (callback_data), sending it +// to backend to exchange for and store the long-lived token. +export const registerIntegratedSocialLink: ( + callback_data: string, + user_token: string, + socialType: string, +) => Promise<boolean> = async (callback_data, user_token, socialType) => { + if (!(socialType in integratedEndpoints)) { + return false; + } + const response = await fetch(integratedEndpoints[socialType][1], { + method: 'POST', + headers: { + Authorization: `Token ${user_token}`, + }, + body: JSON.stringify({ + callback_url: callback_data, + }), + }); + if (!(response.status === 201)) { + console.log(await response.json()); + } + return response.status === 201; +}; + +// Twitter is a special case since they use OAuth1, we will need to request +// for a request_token before we can begin browser signin. +export const getTwitterRequestToken: ( + user_token: string, +) => Promise<string> = async (user_token) => { + const response = await fetch(integratedEndpoints.Twitter[0], { + method: 'GET', + headers: { + Authorization: `Token ${user_token}`, + }, + }); + return response.url; +}; + +// one stop shop for handling all browser sign-in social linkings +export const handlePressForAuthBrowser: ( + socialType: string, +) => Promise<boolean> = async (socialType: string) => { + try { + if (!(socialType in integratedEndpoints)) { + Alert.alert('Coming soon!'); + return false; + } + + if (!(await InAppBrowser.isAvailable())) { + // Okay... to open an external browser and have it link back to + // the app is a bit tricky, we will need to have navigation routes + // setup for this screen and have it hooked up. + // See https://github.com/proyecto26/react-native-inappbrowser#authentication-flow-using-deep-linking + // Though this isn't the end of the world, from the documentation, + // the in-app browser should be supported from iOS 11, which + // is about 98.5% of all iOS devices in the world. + // See https://support.apple.com/en-gb/HT209574 + Alert.alert( + 'Sorry! Your device was unable to open a browser to let you sign-in! 😔', + ); + return false; + } + + let url = integratedEndpoints[socialType][0]; + const user_token = await AsyncStorage.getItem('token'); + + if (!user_token) { + throw 'Unable to get user token'; + } + + // We will need to do an extra step for twitter sign-in + if (socialType === 'Twitter') { + url = await getTwitterRequestToken(user_token); + } + + return await InAppBrowser.openAuth(url, 'taggid://callback', { + ephemeralWebSession: true, + }) + .then(async (response) => { + if (response.type === 'success' && response.url) { + const success = await registerIntegratedSocialLink( + response.url, + user_token, + socialType, + ); + if (!success) { + throw 'Unable to register with backend'; + } + Alert.alert(`Successfully linked ${socialType} 🎉`); + return true; + } else { + throw 'Error from Oauth API'; + } + }) + .catch((error) => { + console.log(error); + Alert.alert( + `Something went wrong, we can't link with ${socialType} 😔`, + ); + return false; + }); + } catch (error) { + console.log(error); + Alert.alert(`Something went wrong, we can't link with ${socialType} 😔`); + } + return false; +}; + +// get all the linked socials from backend as an array +export const getLinkedSocials: (user_id: string) => Promise<string[]> = async ( + user_id: string, +) => { + try { + const user_token = await AsyncStorage.getItem('token'); + const response = await fetch(`${LINKED_SOCIALS_ENDPOINT}${user_id}/`, { + method: 'GET', + headers: { + Authorization: 'Token ' + user_token, + }, + }); + const body = await response.json(); + if (response.status !== 200) { + console.log(body); + throw 'Unable to fetch from server'; + } + return body.linked_socials || []; + } catch (error) { + console.log(error); + return []; + } +}; diff --git a/src/services/UserProfileService.ts b/src/services/UserProfileService.ts index f5523be4..31383f67 100644 --- a/src/services/UserProfileService.ts +++ b/src/services/UserProfileService.ts @@ -1,16 +1,18 @@ //Abstracted common profile api calls out here +import AsyncStorage from '@react-native-community/async-storage'; import {Alert} from 'react-native'; +import RNFetchBlob from 'rn-fetch-blob'; +import {SocialAccountType} from 'src/types'; import { - PROFILE_INFO_ENDPOINT, AVATAR_PHOTO_ENDPOINT, COVER_PHOTO_ENDPOINT, + GET_FB_POSTS_ENDPOINT, + GET_IG_POSTS_ENDPOINT, + GET_TWITTER_POSTS_ENDPOINT, + PROFILE_INFO_ENDPOINT, } from '../constants'; -import AsyncStorage from '@react-native-community/async-storage'; -import RNFetchBlob from 'rn-fetch-blob'; -import {SocialAccountType} from 'src/types'; - export const loadProfileInfo = async ( token: string, userId: string, @@ -83,13 +85,19 @@ export const loadCover = async ( } }; -export const loadSocialPosts = async ( - token: string, +const integratedSocialPostsEndpoints: {[social: string]: string} = { + Facebook: GET_FB_POSTS_ENDPOINT, + Instagram: GET_IG_POSTS_ENDPOINT, + Twitter: GET_TWITTER_POSTS_ENDPOINT, +}; + +export const loadSocialPosts: ( userId: string, socialType: string, - endpoint: string, - socialAccounts: Record<string, SocialAccountType>, -) => { +) => Promise<SocialAccountType> = async (userId, socialType) => { + const token = await AsyncStorage.getItem('token'); + const endpoint = integratedSocialPostsEndpoints[socialType]; + const accountData: SocialAccountType = {}; try { const response = await fetch(endpoint + `${userId}/`, { method: 'GET', @@ -99,16 +107,16 @@ export const loadSocialPosts = async ( }); if (response.status === 200) { const body = await response.json(); - socialAccounts[socialType].handle = body.handle; - socialAccounts[socialType].posts = body.posts; - socialAccounts[socialType].profile_pic = body.profile_pic; + accountData.handle = body.handle; + accountData.posts = body.posts; + accountData.profile_pic = body.profile_pic; } else { - throw new Error(await response.json()); + throw 'Unable to fetch posts data from ' + socialType; } } catch (error) { - console.log(error); + console.warn(error); } - return socialAccounts; + return accountData; }; export const loadRecentlySearchedUsers = async (callback: Function) => { diff --git a/src/services/index.ts b/src/services/index.ts index aa13dbe3..6d0f4314 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -1,3 +1,4 @@ export * from './UserProfileService'; +export * from './SocialLinkingService'; export * from './MomentServices'; export * from './UserFollowServices'; |