diff options
Diffstat (limited to 'src/view/com/auth')
24 files changed, 1973 insertions, 1426 deletions
diff --git a/src/view/com/auth/LoggedOut.tsx b/src/view/com/auth/LoggedOut.tsx index 3e2c9c1bf..030ae68b1 100644 --- a/src/view/com/auth/LoggedOut.tsx +++ b/src/view/com/auth/LoggedOut.tsx @@ -1,15 +1,19 @@ import React from 'react' -import {SafeAreaView} from 'react-native' -import {observer} from 'mobx-react-lite' +import {View, Pressable} from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' + +import {isIOS} from 'platform/detection' import {Login} from 'view/com/auth/login/Login' import {CreateAccount} from 'view/com/auth/create/CreateAccount' import {ErrorBoundary} from 'view/com/util/ErrorBoundary' import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' -import {useStores} from 'state/index' import {useAnalytics} from 'lib/analytics/analytics' import {SplashScreen} from './SplashScreen' import {useSetMinimalShellMode} from '#/state/shell/minimal-mode' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' enum ScreenState { S_LoginOrCreateAccount, @@ -17,35 +21,66 @@ enum ScreenState { S_CreateAccount, } -export const LoggedOut = observer(function LoggedOutImpl() { +export function LoggedOut({onDismiss}: {onDismiss?: () => void}) { + const {_} = useLingui() const pal = usePalette('default') - const store = useStores() const setMinimalShellMode = useSetMinimalShellMode() const {screen} = useAnalytics() const [screenState, setScreenState] = React.useState<ScreenState>( ScreenState.S_LoginOrCreateAccount, ) + const {isMobile} = useWebMediaQueries() React.useEffect(() => { screen('Login') setMinimalShellMode(true) }, [screen, setMinimalShellMode]) - if ( - store.session.isResumingSession || - screenState === ScreenState.S_LoginOrCreateAccount - ) { - return ( - <SplashScreen - onPressSignin={() => setScreenState(ScreenState.S_Login)} - onPressCreateAccount={() => setScreenState(ScreenState.S_CreateAccount)} - /> - ) - } - return ( - <SafeAreaView testID="noSessionView" style={[s.hContentRegion, pal.view]}> + <View + testID="noSessionView" + style={[ + s.hContentRegion, + pal.view, + { + // only needed if dismiss button is present + paddingTop: onDismiss && isMobile ? 40 : 0, + }, + ]}> <ErrorBoundary> + {onDismiss && ( + <Pressable + accessibilityHint={_(msg`Go back`)} + accessibilityLabel={_(msg`Go back`)} + accessibilityRole="button" + style={{ + position: 'absolute', + top: isIOS ? 0 : 20, + right: 20, + padding: 10, + zIndex: 100, + backgroundColor: pal.text.color, + borderRadius: 100, + }} + onPress={onDismiss}> + <FontAwesomeIcon + icon="x" + size={12} + style={{ + color: String(pal.textInverted.color), + }} + /> + </Pressable> + )} + + {screenState === ScreenState.S_LoginOrCreateAccount ? ( + <SplashScreen + onPressSignin={() => setScreenState(ScreenState.S_Login)} + onPressCreateAccount={() => + setScreenState(ScreenState.S_CreateAccount) + } + /> + ) : undefined} {screenState === ScreenState.S_Login ? ( <Login onPressBack={() => @@ -61,6 +96,6 @@ export const LoggedOut = observer(function LoggedOutImpl() { /> ) : undefined} </ErrorBoundary> - </SafeAreaView> + </View> ) -}) +} diff --git a/src/view/com/auth/Onboarding.tsx b/src/view/com/auth/Onboarding.tsx index bec1dc236..bdb7f27c8 100644 --- a/src/view/com/auth/Onboarding.tsx +++ b/src/view/com/auth/Onboarding.tsx @@ -1,40 +1,51 @@ import React from 'react' -import {SafeAreaView} from 'react-native' -import {observer} from 'mobx-react-lite' +import {SafeAreaView, Platform} from 'react-native' import {ErrorBoundary} from 'view/com/util/ErrorBoundary' import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' -import {useStores} from 'state/index' import {Welcome} from './onboarding/Welcome' import {RecommendedFeeds} from './onboarding/RecommendedFeeds' import {RecommendedFollows} from './onboarding/RecommendedFollows' import {useSetMinimalShellMode} from '#/state/shell/minimal-mode' +import {useOnboardingState, useOnboardingDispatch} from '#/state/shell' -export const Onboarding = observer(function OnboardingImpl() { +export function Onboarding() { const pal = usePalette('default') - const store = useStores() const setMinimalShellMode = useSetMinimalShellMode() + const onboardingState = useOnboardingState() + const onboardingDispatch = useOnboardingDispatch() React.useEffect(() => { setMinimalShellMode(true) }, [setMinimalShellMode]) - const next = () => store.onboarding.next() - const skip = () => store.onboarding.skip() + const next = () => onboardingDispatch({type: 'next'}) + const skip = () => onboardingDispatch({type: 'skip'}) return ( - <SafeAreaView testID="onboardingView" style={[s.hContentRegion, pal.view]}> + <SafeAreaView + testID="onboardingView" + style={[ + s.hContentRegion, + pal.view, + // @ts-ignore web only -esb + Platform.select({ + web: { + height: '100vh', + }, + }), + ]}> <ErrorBoundary> - {store.onboarding.step === 'Welcome' && ( + {onboardingState.step === 'Welcome' && ( <Welcome skip={skip} next={next} /> )} - {store.onboarding.step === 'RecommendedFeeds' && ( + {onboardingState.step === 'RecommendedFeeds' && ( <RecommendedFeeds next={next} /> )} - {store.onboarding.step === 'RecommendedFollows' && ( + {onboardingState.step === 'RecommendedFollows' && ( <RecommendedFollows next={next} /> )} </ErrorBoundary> </SafeAreaView> ) -}) +} diff --git a/src/view/com/auth/SplashScreen.tsx b/src/view/com/auth/SplashScreen.tsx index 67453f111..d88627f65 100644 --- a/src/view/com/auth/SplashScreen.tsx +++ b/src/view/com/auth/SplashScreen.tsx @@ -1,10 +1,12 @@ import React from 'react' -import {SafeAreaView, StyleSheet, TouchableOpacity, View} from 'react-native' +import {StyleSheet, TouchableOpacity, View} from 'react-native' import {Text} from 'view/com/util/text/Text' import {ErrorBoundary} from 'view/com/util/ErrorBoundary' import {s, colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {CenteredView} from '../util/Views' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' export const SplashScreen = ({ onPressSignin, @@ -14,40 +16,44 @@ export const SplashScreen = ({ onPressCreateAccount: () => void }) => { const pal = usePalette('default') + const {_} = useLingui() + return ( <CenteredView style={[styles.container, pal.view]}> - <SafeAreaView testID="noSessionView" style={styles.container}> - <ErrorBoundary> - <View style={styles.hero}> - <Text style={[styles.title, pal.link]}>Bluesky</Text> - <Text style={[styles.subtitle, pal.textLight]}> - See what's next + <ErrorBoundary> + <View style={styles.hero}> + <Text style={[styles.title, pal.link]}> + <Trans>Bluesky</Trans> + </Text> + <Text style={[styles.subtitle, pal.textLight]}> + <Trans>See what's next</Trans> + </Text> + </View> + <View testID="signinOrCreateAccount" style={styles.btns}> + <TouchableOpacity + testID="createAccountButton" + style={[styles.btn, {backgroundColor: colors.blue3}]} + onPress={onPressCreateAccount} + accessibilityRole="button" + accessibilityLabel={_(msg`Create new account`)} + accessibilityHint="Opens flow to create a new Bluesky account"> + <Text style={[s.white, styles.btnLabel]}> + <Trans>Create a new account</Trans> + </Text> + </TouchableOpacity> + <TouchableOpacity + testID="signInButton" + style={[styles.btn, pal.btn]} + onPress={onPressSignin} + accessibilityRole="button" + accessibilityLabel={_(msg`Sign in`)} + accessibilityHint="Opens flow to sign into your existing Bluesky account"> + <Text style={[pal.text, styles.btnLabel]}> + <Trans>Sign In</Trans> </Text> - </View> - <View testID="signinOrCreateAccount" style={styles.btns}> - <TouchableOpacity - testID="createAccountButton" - style={[styles.btn, {backgroundColor: colors.blue3}]} - onPress={onPressCreateAccount} - accessibilityRole="button" - accessibilityLabel="Create new account" - accessibilityHint="Opens flow to create a new Bluesky account"> - <Text style={[s.white, styles.btnLabel]}> - Create a new account - </Text> - </TouchableOpacity> - <TouchableOpacity - testID="signInButton" - style={[styles.btn, pal.btn]} - onPress={onPressSignin} - accessibilityRole="button" - accessibilityLabel="Sign in" - accessibilityHint="Opens flow to sign into your existing Bluesky account"> - <Text style={[pal.text, styles.btnLabel]}>Sign In</Text> - </TouchableOpacity> - </View> - </ErrorBoundary> - </SafeAreaView> + </TouchableOpacity> + </View> + </ErrorBoundary> </CenteredView> ) } diff --git a/src/view/com/auth/SplashScreen.web.tsx b/src/view/com/auth/SplashScreen.web.tsx index cef9618ef..08cf701da 100644 --- a/src/view/com/auth/SplashScreen.web.tsx +++ b/src/view/com/auth/SplashScreen.web.tsx @@ -1,5 +1,6 @@ import React from 'react' -import {StyleSheet, TouchableOpacity, View} from 'react-native' +import {StyleSheet, TouchableOpacity, View, Pressable} from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Text} from 'view/com/util/text/Text' import {TextLink} from '../util/Link' import {ErrorBoundary} from 'view/com/util/ErrorBoundary' @@ -8,11 +9,14 @@ import {usePalette} from 'lib/hooks/usePalette' import {CenteredView} from '../util/Views' import {isWeb} from 'platform/detection' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {Trans} from '@lingui/macro' export const SplashScreen = ({ + onDismiss, onPressSignin, onPressCreateAccount, }: { + onDismiss?: () => void onPressSignin: () => void onPressCreateAccount: () => void }) => { @@ -22,45 +26,70 @@ export const SplashScreen = ({ const isMobileWeb = isWeb && isTabletOrMobile return ( - <CenteredView style={[styles.container, pal.view]}> - <View - testID="noSessionView" - style={[ - styles.containerInner, - isMobileWeb && styles.containerInnerMobile, - pal.border, - ]}> - <ErrorBoundary> - <Text style={isMobileWeb ? styles.titleMobile : styles.title}> - Bluesky - </Text> - <Text style={isMobileWeb ? styles.subtitleMobile : styles.subtitle}> - See what's next - </Text> - <View testID="signinOrCreateAccount" style={styles.btns}> - <TouchableOpacity - testID="createAccountButton" - style={[styles.btn, {backgroundColor: colors.blue3}]} - onPress={onPressCreateAccount} - // TODO: web accessibility - accessibilityRole="button"> - <Text style={[s.white, styles.btnLabel]}> - Create a new account - </Text> - </TouchableOpacity> - <TouchableOpacity - testID="signInButton" - style={[styles.btn, pal.btn]} - onPress={onPressSignin} - // TODO: web accessibility - accessibilityRole="button"> - <Text style={[pal.text, styles.btnLabel]}>Sign In</Text> - </TouchableOpacity> - </View> - </ErrorBoundary> - </View> - <Footer styles={styles} /> - </CenteredView> + <> + {onDismiss && ( + <Pressable + accessibilityRole="button" + style={{ + position: 'absolute', + top: 20, + right: 20, + padding: 20, + zIndex: 100, + }} + onPress={onDismiss}> + <FontAwesomeIcon + icon="x" + size={24} + style={{ + color: String(pal.text.color), + }} + /> + </Pressable> + )} + + <CenteredView style={[styles.container, pal.view]}> + <View + testID="noSessionView" + style={[ + styles.containerInner, + isMobileWeb && styles.containerInnerMobile, + pal.border, + ]}> + <ErrorBoundary> + <Text style={isMobileWeb ? styles.titleMobile : styles.title}> + Bluesky + </Text> + <Text style={isMobileWeb ? styles.subtitleMobile : styles.subtitle}> + See what's next + </Text> + <View testID="signinOrCreateAccount" style={styles.btns}> + <TouchableOpacity + testID="createAccountButton" + style={[styles.btn, {backgroundColor: colors.blue3}]} + onPress={onPressCreateAccount} + // TODO: web accessibility + accessibilityRole="button"> + <Text style={[s.white, styles.btnLabel]}> + Create a new account + </Text> + </TouchableOpacity> + <TouchableOpacity + testID="signInButton" + style={[styles.btn, pal.btn]} + onPress={onPressSignin} + // TODO: web accessibility + accessibilityRole="button"> + <Text style={[pal.text, styles.btnLabel]}> + <Trans>Sign In</Trans> + </Text> + </TouchableOpacity> + </View> + </ErrorBoundary> + </View> + <Footer styles={styles} /> + </CenteredView> + </> ) } diff --git a/src/view/com/auth/create/CreateAccount.tsx b/src/view/com/auth/create/CreateAccount.tsx index 1d64cc067..ab6d34584 100644 --- a/src/view/com/auth/create/CreateAccount.tsx +++ b/src/view/com/auth/create/CreateAccount.tsx @@ -7,78 +7,134 @@ import { TouchableOpacity, View, } from 'react-native' -import {observer} from 'mobx-react-lite' import {useAnalytics} from 'lib/analytics/analytics' import {Text} from '../../util/text/Text' import {LoggedOutLayout} from 'view/com/util/layouts/LoggedOutLayout' import {s} from 'lib/styles' -import {useStores} from 'state/index' -import {CreateAccountModel} from 'state/models/ui/create-account' import {usePalette} from 'lib/hooks/usePalette' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useOnboardingDispatch} from '#/state/shell' +import {useSessionApi} from '#/state/session' +import {useCreateAccount, submit} from './state' +import {useServiceQuery} from '#/state/queries/service' +import { + usePreferencesSetBirthDateMutation, + useSetSaveFeedsMutation, + DEFAULT_PROD_FEEDS, +} from '#/state/queries/preferences' +import {IS_PROD} from '#/lib/constants' import {Step1} from './Step1' import {Step2} from './Step2' import {Step3} from './Step3' -export const CreateAccount = observer(function CreateAccountImpl({ - onPressBack, -}: { - onPressBack: () => void -}) { +export function CreateAccount({onPressBack}: {onPressBack: () => void}) { const {track, screen} = useAnalytics() const pal = usePalette('default') - const store = useStores() - const model = React.useMemo(() => new CreateAccountModel(store), [store]) + const {_} = useLingui() + const [uiState, uiDispatch] = useCreateAccount() + const onboardingDispatch = useOnboardingDispatch() + const {createAccount} = useSessionApi() + const {mutate: setBirthDate} = usePreferencesSetBirthDateMutation() + const {mutate: setSavedFeeds} = useSetSaveFeedsMutation() React.useEffect(() => { screen('CreateAccount') }, [screen]) + // fetch service info + // = + + const { + data: serviceInfo, + isFetching: serviceInfoIsFetching, + error: serviceInfoError, + refetch: refetchServiceInfo, + } = useServiceQuery(uiState.serviceUrl) + React.useEffect(() => { - model.fetchServiceDescription() - }, [model]) + if (serviceInfo) { + uiDispatch({type: 'set-service-description', value: serviceInfo}) + uiDispatch({type: 'set-error', value: ''}) + } else if (serviceInfoError) { + uiDispatch({ + type: 'set-error', + value: _( + msg`Unable to contact your service. Please check your Internet connection.`, + ), + }) + } + }, [_, uiDispatch, serviceInfo, serviceInfoError]) - const onPressRetryConnect = React.useCallback( - () => model.fetchServiceDescription(), - [model], - ) + // event handlers + // = const onPressBackInner = React.useCallback(() => { - if (model.canBack) { - model.back() + if (uiState.canBack) { + uiDispatch({type: 'back'}) } else { onPressBack() } - }, [model, onPressBack]) + }, [uiState, uiDispatch, onPressBack]) const onPressNext = React.useCallback(async () => { - if (!model.canNext) { + if (!uiState.canNext) { return } - if (model.step < 3) { - model.next() + if (uiState.step < 3) { + uiDispatch({type: 'next'}) } else { try { - await model.submit() + await submit({ + onboardingDispatch, + createAccount, + uiState, + uiDispatch, + _, + }) + track('Create Account') + setBirthDate({birthDate: uiState.birthDate}) + if (IS_PROD(uiState.serviceUrl)) { + setSavedFeeds(DEFAULT_PROD_FEEDS) + } } catch { // dont need to handle here } finally { track('Try Create Account') } } - }, [model, track]) + }, [ + uiState, + uiDispatch, + track, + onboardingDispatch, + createAccount, + setBirthDate, + setSavedFeeds, + _, + ]) + + // rendering + // = return ( <LoggedOutLayout - leadin={`Step ${model.step}`} - title="Create Account" - description="We're so excited to have you join us!"> + leadin={`Step ${uiState.step}`} + title={_(msg`Create Account`)} + description={_(msg`We're so excited to have you join us!`)}> <ScrollView testID="createAccount" style={pal.view}> <KeyboardAvoidingView behavior="padding"> <View style={styles.stepContainer}> - {model.step === 1 && <Step1 model={model} />} - {model.step === 2 && <Step2 model={model} />} - {model.step === 3 && <Step3 model={model} />} + {uiState.step === 1 && ( + <Step1 uiState={uiState} uiDispatch={uiDispatch} /> + )} + {uiState.step === 2 && ( + <Step2 uiState={uiState} uiDispatch={uiDispatch} /> + )} + {uiState.step === 3 && ( + <Step3 uiState={uiState} uiDispatch={uiDispatch} /> + )} </View> <View style={[s.flexRow, s.pl20, s.pr20]}> <TouchableOpacity @@ -86,40 +142,40 @@ export const CreateAccount = observer(function CreateAccountImpl({ testID="backBtn" accessibilityRole="button"> <Text type="xl" style={pal.link}> - Back + <Trans>Back</Trans> </Text> </TouchableOpacity> <View style={s.flex1} /> - {model.canNext ? ( + {uiState.canNext ? ( <TouchableOpacity testID="nextBtn" onPress={onPressNext} accessibilityRole="button"> - {model.isProcessing ? ( + {uiState.isProcessing ? ( <ActivityIndicator /> ) : ( <Text type="xl-bold" style={[pal.link, s.pr5]}> - Next + <Trans>Next</Trans> </Text> )} </TouchableOpacity> - ) : model.didServiceDescriptionFetchFail ? ( + ) : serviceInfoError ? ( <TouchableOpacity testID="retryConnectBtn" - onPress={onPressRetryConnect} + onPress={() => refetchServiceInfo()} accessibilityRole="button" - accessibilityLabel="Retry" - accessibilityHint="Retries account creation" + accessibilityLabel={_(msg`Retry`)} + accessibilityHint="" accessibilityLiveRegion="polite"> <Text type="xl-bold" style={[pal.link, s.pr5]}> - Retry + <Trans>Retry</Trans> </Text> </TouchableOpacity> - ) : model.isFetchingServiceDescription ? ( + ) : serviceInfoIsFetching ? ( <> <ActivityIndicator color="#fff" /> <Text type="xl" style={[pal.text, s.pr5]}> - Connecting... + <Trans>Connecting...</Trans> </Text> </> ) : undefined} @@ -129,7 +185,7 @@ export const CreateAccount = observer(function CreateAccountImpl({ </ScrollView> </LoggedOutLayout> ) -}) +} const styles = StyleSheet.create({ stepContainer: { diff --git a/src/view/com/auth/create/Policies.tsx b/src/view/com/auth/create/Policies.tsx index 8eb669bcf..a52f07531 100644 --- a/src/view/com/auth/create/Policies.tsx +++ b/src/view/com/auth/create/Policies.tsx @@ -4,12 +4,14 @@ import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' +import {ComAtprotoServerDescribeServer} from '@atproto/api' import {TextLink} from '../../util/Link' import {Text} from '../../util/text/Text' import {s, colors} from 'lib/styles' -import {ServiceDescription} from 'state/models/session' import {usePalette} from 'lib/hooks/usePalette' +type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema + export const Policies = ({ serviceDescription, needsGuardian, @@ -93,7 +95,7 @@ function validWebLink(url?: string): string | undefined { const styles = StyleSheet.create({ policies: { - flexDirection: 'row', + flexDirection: 'column', gap: 8, }, errorIcon: { diff --git a/src/view/com/auth/create/Step1.tsx b/src/view/com/auth/create/Step1.tsx index cdd5cb21d..c9d19e868 100644 --- a/src/view/com/auth/create/Step1.tsx +++ b/src/view/com/auth/create/Step1.tsx @@ -1,10 +1,8 @@ import React from 'react' import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native' -import {observer} from 'mobx-react-lite' -import debounce from 'lodash.debounce' import {Text} from 'view/com/util/text/Text' import {StepHeader} from './StepHeader' -import {CreateAccountModel} from 'state/models/ui/create-account' +import {CreateAccountState, CreateAccountDispatch} from './state' import {useTheme} from 'lib/ThemeContext' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' @@ -12,60 +10,49 @@ import {HelpTip} from '../util/HelpTip' import {TextInput} from '../util/TextInput' import {Button} from 'view/com/util/forms/Button' import {ErrorMessage} from 'view/com/util/error/ErrorMessage' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' -import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'state/index' +import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'lib/constants' import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags' /** STEP 1: Your hosting provider * @field Bluesky (default) * @field Other (staging, local dev, your own PDS, etc.) */ -export const Step1 = observer(function Step1Impl({ - model, +export function Step1({ + uiState, + uiDispatch, }: { - model: CreateAccountModel + uiState: CreateAccountState + uiDispatch: CreateAccountDispatch }) { const pal = usePalette('default') const [isDefaultSelected, setIsDefaultSelected] = React.useState(true) + const {_} = useLingui() const onPressDefault = React.useCallback(() => { setIsDefaultSelected(true) - model.setServiceUrl(PROD_SERVICE) - model.fetchServiceDescription() - }, [setIsDefaultSelected, model]) + uiDispatch({type: 'set-service-url', value: PROD_SERVICE}) + }, [setIsDefaultSelected, uiDispatch]) const onPressOther = React.useCallback(() => { setIsDefaultSelected(false) - model.setServiceUrl('https://') - model.setServiceDescription(undefined) - }, [setIsDefaultSelected, model]) - - const fetchServiceDescription = React.useMemo( - () => debounce(() => model.fetchServiceDescription(), 1e3), // debouce for 1 second (1e3 = 1000ms) - [model], - ) + uiDispatch({type: 'set-service-url', value: 'https://'}) + }, [setIsDefaultSelected, uiDispatch]) const onChangeServiceUrl = React.useCallback( (v: string) => { - model.setServiceUrl(v) - fetchServiceDescription() - }, - [model, fetchServiceDescription], - ) - - const onDebugChangeServiceUrl = React.useCallback( - (v: string) => { - model.setServiceUrl(v) - model.fetchServiceDescription() + uiDispatch({type: 'set-service-url', value: v}) }, - [model], + [uiDispatch], ) return ( <View> - <StepHeader step="1" title="Your hosting provider" /> + <StepHeader step="1" title={_(msg`Your hosting provider`)} /> <Text style={[pal.text, s.mb10]}> - This is the service that keeps you online. + <Trans>This is the service that keeps you online.</Trans> </Text> <Option testID="blueskyServerBtn" @@ -81,17 +68,17 @@ export const Step1 = observer(function Step1Impl({ onPress={onPressOther}> <View style={styles.otherForm}> <Text nativeID="addressProvider" style={[pal.text, s.mb5]}> - Enter the address of your provider: + <Trans>Enter the address of your provider:</Trans> </Text> <TextInput testID="customServerInput" icon="globe" - placeholder="Hosting provider address" - value={model.serviceUrl} + placeholder={_(msg`Hosting provider address`)} + value={uiState.serviceUrl} editable onChange={onChangeServiceUrl} accessibilityHint="Input hosting provider address" - accessibilityLabel="Hosting provider address" + accessibilityLabel={_(msg`Hosting provider address`)} accessibilityLabelledBy="addressProvider" /> {LOGIN_INCLUDE_DEV_SERVERS && ( @@ -100,27 +87,27 @@ export const Step1 = observer(function Step1Impl({ testID="stagingServerBtn" type="default" style={s.mr5} - label="Staging" - onPress={() => onDebugChangeServiceUrl(STAGING_SERVICE)} + label={_(msg`Staging`)} + onPress={() => onChangeServiceUrl(STAGING_SERVICE)} /> <Button testID="localDevServerBtn" type="default" - label="Dev Server" - onPress={() => onDebugChangeServiceUrl(LOCAL_DEV_SERVICE)} + label={_(msg`Dev Server`)} + onPress={() => onChangeServiceUrl(LOCAL_DEV_SERVICE)} /> </View> )} </View> </Option> - {model.error ? ( - <ErrorMessage message={model.error} style={styles.error} /> + {uiState.error ? ( + <ErrorMessage message={uiState.error} style={styles.error} /> ) : ( - <HelpTip text="You can change hosting providers at any time." /> + <HelpTip text={_(msg`You can change hosting providers at any time.`)} /> )} </View> ) -}) +} function Option({ children, diff --git a/src/view/com/auth/create/Step2.tsx b/src/view/com/auth/create/Step2.tsx index 60e197564..89fd070ad 100644 --- a/src/view/com/auth/create/Step2.tsx +++ b/src/view/com/auth/create/Step2.tsx @@ -1,7 +1,6 @@ import React from 'react' import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native' -import {observer} from 'mobx-react-lite' -import {CreateAccountModel} from 'state/models/ui/create-account' +import {CreateAccountState, CreateAccountDispatch, is18} from './state' import {Text} from 'view/com/util/text/Text' import {DateInput} from 'view/com/util/forms/DateInput' import {StepHeader} from './StepHeader' @@ -10,8 +9,10 @@ import {usePalette} from 'lib/hooks/usePalette' import {TextInput} from '../util/TextInput' import {Policies} from './Policies' import {ErrorMessage} from 'view/com/util/error/ErrorMessage' -import {useStores} from 'state/index' import {isWeb} from 'platform/detection' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' /** STEP 2: Your account * @field Invite code or waitlist @@ -22,23 +23,26 @@ import {isWeb} from 'platform/detection' * @field Birth date * @readonly Terms of service & privacy policy */ -export const Step2 = observer(function Step2Impl({ - model, +export function Step2({ + uiState, + uiDispatch, }: { - model: CreateAccountModel + uiState: CreateAccountState + uiDispatch: CreateAccountDispatch }) { const pal = usePalette('default') - const store = useStores() + const {_} = useLingui() + const {openModal} = useModalControls() const onPressWaitlist = React.useCallback(() => { - store.shell.openModal({name: 'waitlist'}) - }, [store]) + openModal({name: 'waitlist'}) + }, [openModal]) return ( <View> - <StepHeader step="2" title="Your account" /> + <StepHeader step="2" title={_(msg`Your account`)} /> - {model.isInviteCodeRequired && ( + {uiState.isInviteCodeRequired && ( <View style={s.pb20}> <Text type="md-medium" style={[pal.text, s.mb2]}> Invite code @@ -46,25 +50,27 @@ export const Step2 = observer(function Step2Impl({ <TextInput testID="inviteCodeInput" icon="ticket" - placeholder="Required for this provider" - value={model.inviteCode} + placeholder={_(msg`Required for this provider`)} + value={uiState.inviteCode} editable - onChange={model.setInviteCode} - accessibilityLabel="Invite code" + onChange={value => uiDispatch({type: 'set-invite-code', value})} + accessibilityLabel={_(msg`Invite code`)} accessibilityHint="Input invite code to proceed" /> </View> )} - {!model.inviteCode && model.isInviteCodeRequired ? ( + {!uiState.inviteCode && uiState.isInviteCodeRequired ? ( <Text style={[s.alignBaseline, pal.text]}> Don't have an invite code?{' '} <TouchableWithoutFeedback onPress={onPressWaitlist} - accessibilityLabel="Join the waitlist." + accessibilityLabel={_(msg`Join the waitlist.`)} accessibilityHint=""> <View style={styles.touchable}> - <Text style={pal.link}>Join the waitlist.</Text> + <Text style={pal.link}> + <Trans>Join the waitlist.</Trans> + </Text> </View> </TouchableWithoutFeedback> </Text> @@ -72,16 +78,16 @@ export const Step2 = observer(function Step2Impl({ <> <View style={s.pb20}> <Text type="md-medium" style={[pal.text, s.mb2]} nativeID="email"> - Email address + <Trans>Email address</Trans> </Text> <TextInput testID="emailInput" icon="envelope" - placeholder="Enter your email address" - value={model.email} + placeholder={_(msg`Enter your email address`)} + value={uiState.email} editable - onChange={model.setEmail} - accessibilityLabel="Email" + onChange={value => uiDispatch({type: 'set-email', value})} + accessibilityLabel={_(msg`Email`)} accessibilityHint="Input email for Bluesky waitlist" accessibilityLabelledBy="email" /> @@ -92,17 +98,17 @@ export const Step2 = observer(function Step2Impl({ type="md-medium" style={[pal.text, s.mb2]} nativeID="password"> - Password + <Trans>Password</Trans> </Text> <TextInput testID="passwordInput" icon="lock" - placeholder="Choose your password" - value={model.password} + placeholder={_(msg`Choose your password`)} + value={uiState.password} editable secureTextEntry - onChange={model.setPassword} - accessibilityLabel="Password" + onChange={value => uiDispatch({type: 'set-password', value})} + accessibilityLabel={_(msg`Password`)} accessibilityHint="Set password" accessibilityLabelledBy="password" /> @@ -113,35 +119,35 @@ export const Step2 = observer(function Step2Impl({ type="md-medium" style={[pal.text, s.mb2]} nativeID="birthDate"> - Your birth date + <Trans>Your birth date</Trans> </Text> <DateInput testID="birthdayInput" - value={model.birthDate} - onChange={model.setBirthDate} + value={uiState.birthDate} + onChange={value => uiDispatch({type: 'set-birth-date', value})} buttonType="default-light" buttonStyle={[pal.border, styles.dateInputButton]} buttonLabelType="lg" - accessibilityLabel="Birthday" + accessibilityLabel={_(msg`Birthday`)} accessibilityHint="Enter your birth date" accessibilityLabelledBy="birthDate" /> </View> - {model.serviceDescription && ( + {uiState.serviceDescription && ( <Policies - serviceDescription={model.serviceDescription} - needsGuardian={!model.isAge18} + serviceDescription={uiState.serviceDescription} + needsGuardian={!is18(uiState)} /> )} </> )} - {model.error ? ( - <ErrorMessage message={model.error} style={styles.error} /> + {uiState.error ? ( + <ErrorMessage message={uiState.error} style={styles.error} /> ) : undefined} </View> ) -}) +} const styles = StyleSheet.create({ error: { diff --git a/src/view/com/auth/create/Step3.tsx b/src/view/com/auth/create/Step3.tsx index beb756ac1..3b628b6b6 100644 --- a/src/view/com/auth/create/Step3.tsx +++ b/src/view/com/auth/create/Step3.tsx @@ -1,7 +1,6 @@ import React from 'react' import {StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' -import {CreateAccountModel} from 'state/models/ui/create-account' +import {CreateAccountState, CreateAccountDispatch} from './state' import {Text} from 'view/com/util/text/Text' import {StepHeader} from './StepHeader' import {s} from 'lib/styles' @@ -9,44 +8,49 @@ import {TextInput} from '../util/TextInput' import {createFullHandle} from 'lib/strings/handles' import {usePalette} from 'lib/hooks/usePalette' import {ErrorMessage} from 'view/com/util/error/ErrorMessage' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' /** STEP 3: Your user handle * @field User handle */ -export const Step3 = observer(function Step3Impl({ - model, +export function Step3({ + uiState, + uiDispatch, }: { - model: CreateAccountModel + uiState: CreateAccountState + uiDispatch: CreateAccountDispatch }) { const pal = usePalette('default') + const {_} = useLingui() return ( <View> - <StepHeader step="3" title="Your user handle" /> + <StepHeader step="3" title={_(msg`Your user handle`)} /> <View style={s.pb10}> <TextInput testID="handleInput" icon="at" placeholder="e.g. alice" - value={model.handle} + value={uiState.handle} editable - onChange={model.setHandle} + onChange={value => uiDispatch({type: 'set-handle', value})} // TODO: Add explicit text label - accessibilityLabel="User handle" + accessibilityLabel={_(msg`User handle`)} accessibilityHint="Input your user handle" /> <Text type="lg" style={[pal.text, s.pl5, s.pt10]}> - Your full handle will be{' '} - <Text type="lg-bold" style={pal.text}> - @{createFullHandle(model.handle, model.userDomain)} + <Trans>Your full handle will be</Trans> + <Text type="lg-bold" style={[pal.text, s.ml5]}> + @{createFullHandle(uiState.handle, uiState.userDomain)} </Text> </Text> </View> - {model.error ? ( - <ErrorMessage message={model.error} style={styles.error} /> + {uiState.error ? ( + <ErrorMessage message={uiState.error} style={styles.error} /> ) : undefined} </View> ) -}) +} const styles = StyleSheet.create({ error: { diff --git a/src/view/com/auth/create/state.ts b/src/view/com/auth/create/state.ts new file mode 100644 index 000000000..4df82f8fc --- /dev/null +++ b/src/view/com/auth/create/state.ts @@ -0,0 +1,242 @@ +import {useReducer} from 'react' +import { + ComAtprotoServerDescribeServer, + ComAtprotoServerCreateAccount, +} from '@atproto/api' +import {I18nContext, useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' +import * as EmailValidator from 'email-validator' +import {getAge} from 'lib/strings/time' +import {logger} from '#/logger' +import {createFullHandle} from '#/lib/strings/handles' +import {cleanError} from '#/lib/strings/errors' +import {DispatchContext as OnboardingDispatchContext} from '#/state/shell/onboarding' +import {ApiContext as SessionApiContext} from '#/state/session' +import {DEFAULT_SERVICE} from '#/lib/constants' + +export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema +const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago + +export type CreateAccountAction = + | {type: 'set-step'; value: number} + | {type: 'set-error'; value: string | undefined} + | {type: 'set-processing'; value: boolean} + | {type: 'set-service-url'; value: string} + | {type: 'set-service-description'; value: ServiceDescription | undefined} + | {type: 'set-user-domain'; value: string} + | {type: 'set-invite-code'; value: string} + | {type: 'set-email'; value: string} + | {type: 'set-password'; value: string} + | {type: 'set-handle'; value: string} + | {type: 'set-birth-date'; value: Date} + | {type: 'next'} + | {type: 'back'} + +export interface CreateAccountState { + // state + step: number + error: string | undefined + isProcessing: boolean + serviceUrl: string + serviceDescription: ServiceDescription | undefined + userDomain: string + inviteCode: string + email: string + password: string + handle: string + birthDate: Date + + // computed + canBack: boolean + canNext: boolean + isInviteCodeRequired: boolean +} + +export type CreateAccountDispatch = (action: CreateAccountAction) => void + +export function useCreateAccount() { + const {_} = useLingui() + return useReducer(createReducer({_}), { + step: 1, + error: undefined, + isProcessing: false, + serviceUrl: DEFAULT_SERVICE, + serviceDescription: undefined, + userDomain: '', + inviteCode: '', + email: '', + password: '', + handle: '', + birthDate: DEFAULT_DATE, + + canBack: false, + canNext: false, + isInviteCodeRequired: false, + }) +} + +export async function submit({ + createAccount, + onboardingDispatch, + uiState, + uiDispatch, + _, +}: { + createAccount: SessionApiContext['createAccount'] + onboardingDispatch: OnboardingDispatchContext + uiState: CreateAccountState + uiDispatch: CreateAccountDispatch + _: I18nContext['_'] +}) { + if (!uiState.email) { + uiDispatch({type: 'set-step', value: 2}) + return uiDispatch({ + type: 'set-error', + value: _(msg`Please enter your email.`), + }) + } + if (!EmailValidator.validate(uiState.email)) { + uiDispatch({type: 'set-step', value: 2}) + return uiDispatch({ + type: 'set-error', + value: _(msg`Your email appears to be invalid.`), + }) + } + if (!uiState.password) { + uiDispatch({type: 'set-step', value: 2}) + return uiDispatch({ + type: 'set-error', + value: _(msg`Please choose your password.`), + }) + } + if (!uiState.handle) { + uiDispatch({type: 'set-step', value: 3}) + return uiDispatch({ + type: 'set-error', + value: _(msg`Please choose your handle.`), + }) + } + uiDispatch({type: 'set-error', value: ''}) + uiDispatch({type: 'set-processing', value: true}) + + try { + onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view + await createAccount({ + service: uiState.serviceUrl, + email: uiState.email, + handle: createFullHandle(uiState.handle, uiState.userDomain), + password: uiState.password, + inviteCode: uiState.inviteCode.trim(), + }) + } catch (e: any) { + onboardingDispatch({type: 'skip'}) // undo starting the onboard + let errMsg = e.toString() + if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) { + errMsg = _( + msg`Invite code not accepted. Check that you input it correctly and try again.`, + ) + } + logger.error('Failed to create account', {error: e}) + uiDispatch({type: 'set-processing', value: false}) + uiDispatch({type: 'set-error', value: cleanError(errMsg)}) + throw e + } +} + +export function is13(state: CreateAccountState) { + return getAge(state.birthDate) >= 18 +} + +export function is18(state: CreateAccountState) { + return getAge(state.birthDate) >= 18 +} + +function createReducer({_}: {_: I18nContext['_']}) { + return function reducer( + state: CreateAccountState, + action: CreateAccountAction, + ): CreateAccountState { + switch (action.type) { + case 'set-step': { + return compute({...state, step: action.value}) + } + case 'set-error': { + return compute({...state, error: action.value}) + } + case 'set-processing': { + return compute({...state, isProcessing: action.value}) + } + case 'set-service-url': { + return compute({ + ...state, + serviceUrl: action.value, + serviceDescription: + state.serviceUrl !== action.value + ? undefined + : state.serviceDescription, + }) + } + case 'set-service-description': { + return compute({ + ...state, + serviceDescription: action.value, + userDomain: action.value?.availableUserDomains[0] || '', + }) + } + case 'set-user-domain': { + return compute({...state, userDomain: action.value}) + } + case 'set-invite-code': { + return compute({...state, inviteCode: action.value}) + } + case 'set-email': { + return compute({...state, email: action.value}) + } + case 'set-password': { + return compute({...state, password: action.value}) + } + case 'set-handle': { + return compute({...state, handle: action.value}) + } + case 'set-birth-date': { + return compute({...state, birthDate: action.value}) + } + case 'next': { + if (state.step === 2) { + if (!is13(state)) { + return compute({ + ...state, + error: _( + msg`Unfortunately, you do not meet the requirements to create an account.`, + ), + }) + } + } + return compute({...state, error: '', step: state.step + 1}) + } + case 'back': { + return compute({...state, error: '', step: state.step - 1}) + } + } + } +} + +function compute(state: CreateAccountState): CreateAccountState { + let canNext = true + if (state.step === 1) { + canNext = !!state.serviceDescription + } else if (state.step === 2) { + canNext = + (!state.isInviteCodeRequired || !!state.inviteCode) && + !!state.email && + !!state.password + } else if (state.step === 3) { + canNext = !!state.handle + } + return { + ...state, + canBack: state.step > 1, + canNext, + isInviteCodeRequired: !!state.serviceDescription?.inviteCodeRequired, + } +} diff --git a/src/view/com/auth/login/ChooseAccountForm.tsx b/src/view/com/auth/login/ChooseAccountForm.tsx new file mode 100644 index 000000000..73ddfc9d6 --- /dev/null +++ b/src/view/com/auth/login/ChooseAccountForm.tsx @@ -0,0 +1,158 @@ +import React from 'react' +import {ScrollView, TouchableOpacity, View} from 'react-native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {useAnalytics} from 'lib/analytics/analytics' +import {Text} from '../../util/text/Text' +import {UserAvatar} from '../../util/UserAvatar' +import {s, colors} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {styles} from './styles' +import {useSession, useSessionApi, SessionAccount} from '#/state/session' +import {useProfileQuery} from '#/state/queries/profile' +import {useLoggedOutViewControls} from '#/state/shell/logged-out' +import * as Toast from '#/view/com/util/Toast' + +function AccountItem({ + account, + onSelect, + isCurrentAccount, +}: { + account: SessionAccount + onSelect: (account: SessionAccount) => void + isCurrentAccount: boolean +}) { + const pal = usePalette('default') + const {_} = useLingui() + const {data: profile} = useProfileQuery({did: account.did}) + + const onPress = React.useCallback(() => { + onSelect(account) + }, [account, onSelect]) + + return ( + <TouchableOpacity + testID={`chooseAccountBtn-${account.handle}`} + key={account.did} + style={[pal.view, pal.border, styles.account]} + onPress={onPress} + accessibilityRole="button" + accessibilityLabel={_(msg`Sign in as ${account.handle}`)} + accessibilityHint="Double tap to sign in"> + <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> + <View style={s.p10}> + <UserAvatar avatar={profile?.avatar} size={30} /> + </View> + <Text style={styles.accountText}> + <Text type="lg-bold" style={pal.text}> + {profile?.displayName || account.handle}{' '} + </Text> + <Text type="lg" style={[pal.textLight]}> + {account.handle} + </Text> + </Text> + {isCurrentAccount ? ( + <FontAwesomeIcon + icon="check" + size={16} + style={[{color: colors.green3} as FontAwesomeIconStyle, s.mr10]} + /> + ) : ( + <FontAwesomeIcon + icon="angle-right" + size={16} + style={[pal.text, s.mr10]} + /> + )} + </View> + </TouchableOpacity> + ) +} +export const ChooseAccountForm = ({ + onSelectAccount, + onPressBack, +}: { + onSelectAccount: (account?: SessionAccount) => void + onPressBack: () => void +}) => { + const {track, screen} = useAnalytics() + const pal = usePalette('default') + const {_} = useLingui() + const {accounts, currentAccount} = useSession() + const {initSession} = useSessionApi() + const {setShowLoggedOut} = useLoggedOutViewControls() + + React.useEffect(() => { + screen('Choose Account') + }, [screen]) + + const onSelect = React.useCallback( + async (account: SessionAccount) => { + if (account.accessJwt) { + if (account.did === currentAccount?.did) { + setShowLoggedOut(false) + Toast.show(`Already signed in as @${account.handle}`) + } else { + await initSession(account) + track('Sign In', {resumedSession: true}) + setTimeout(() => { + Toast.show(`Signed in as @${account.handle}`) + }, 100) + } + } else { + onSelectAccount(account) + } + }, + [currentAccount, track, initSession, onSelectAccount, setShowLoggedOut], + ) + + return ( + <ScrollView testID="chooseAccountForm" style={styles.maxHeight}> + <Text + type="2xl-medium" + style={[pal.text, styles.groupLabel, s.mt5, s.mb10]}> + <Trans>Sign in as...</Trans> + </Text> + {accounts.map(account => ( + <AccountItem + key={account.did} + account={account} + onSelect={onSelect} + isCurrentAccount={account.did === currentAccount?.did} + /> + ))} + <TouchableOpacity + testID="chooseNewAccountBtn" + style={[pal.view, pal.border, styles.account, styles.accountLast]} + onPress={() => onSelectAccount(undefined)} + accessibilityRole="button" + accessibilityLabel={_(msg`Login to account that is not listed`)} + accessibilityHint=""> + <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> + <Text style={[styles.accountText, styles.accountTextOther]}> + <Text type="lg" style={pal.text}> + <Trans>Other account</Trans> + </Text> + </Text> + <FontAwesomeIcon + icon="angle-right" + size={16} + style={[pal.text, s.mr10]} + /> + </View> + </TouchableOpacity> + <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> + <TouchableOpacity onPress={onPressBack} accessibilityRole="button"> + <Text type="xl" style={[pal.link, s.pl5]}> + <Trans>Back</Trans> + </Text> + </TouchableOpacity> + <View style={s.flex1} /> + </View> + </ScrollView> + ) +} diff --git a/src/view/com/auth/login/ForgotPasswordForm.tsx b/src/view/com/auth/login/ForgotPasswordForm.tsx new file mode 100644 index 000000000..215c393d9 --- /dev/null +++ b/src/view/com/auth/login/ForgotPasswordForm.tsx @@ -0,0 +1,197 @@ +import React, {useState, useEffect} from 'react' +import { + ActivityIndicator, + TextInput, + TouchableOpacity, + View, +} from 'react-native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {ComAtprotoServerDescribeServer} from '@atproto/api' +import * as EmailValidator from 'email-validator' +import {BskyAgent} from '@atproto/api' +import {useAnalytics} from 'lib/analytics/analytics' +import {Text} from '../../util/text/Text' +import {s} from 'lib/styles' +import {toNiceDomain} from 'lib/strings/url-helpers' +import {isNetworkError} from 'lib/strings/errors' +import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' +import {cleanError} from 'lib/strings/errors' +import {logger} from '#/logger' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {styles} from './styles' +import {useModalControls} from '#/state/modals' + +type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema + +export const ForgotPasswordForm = ({ + error, + serviceUrl, + serviceDescription, + setError, + setServiceUrl, + onPressBack, + onEmailSent, +}: { + error: string + serviceUrl: string + serviceDescription: ServiceDescription | undefined + setError: (v: string) => void + setServiceUrl: (v: string) => void + onPressBack: () => void + onEmailSent: () => void +}) => { + const pal = usePalette('default') + const theme = useTheme() + const [isProcessing, setIsProcessing] = useState<boolean>(false) + const [email, setEmail] = useState<string>('') + const {screen} = useAnalytics() + const {_} = useLingui() + const {openModal} = useModalControls() + + useEffect(() => { + screen('Signin:ForgotPassword') + }, [screen]) + + const onPressSelectService = () => { + openModal({ + name: 'server-input', + initialService: serviceUrl, + onSelect: setServiceUrl, + }) + } + + const onPressNext = async () => { + if (!EmailValidator.validate(email)) { + return setError('Your email appears to be invalid.') + } + + setError('') + setIsProcessing(true) + + try { + const agent = new BskyAgent({service: serviceUrl}) + await agent.com.atproto.server.requestPasswordReset({email}) + onEmailSent() + } catch (e: any) { + const errMsg = e.toString() + logger.warn('Failed to request password reset', {error: e}) + setIsProcessing(false) + if (isNetworkError(e)) { + setError( + 'Unable to contact your service. Please check your Internet connection.', + ) + } else { + setError(cleanError(errMsg)) + } + } + } + + return ( + <> + <View> + <Text type="title-lg" style={[pal.text, styles.screenTitle]}> + <Trans>Reset password</Trans> + </Text> + <Text type="md" style={[pal.text, styles.instructions]}> + <Trans> + Enter the email you used to create your account. We'll send you a + "reset code" so you can set a new password. + </Trans> + </Text> + <View + testID="forgotPasswordView" + style={[pal.borderDark, pal.view, styles.group]}> + <TouchableOpacity + testID="forgotPasswordSelectServiceButton" + style={[pal.borderDark, styles.groupContent, styles.noTopBorder]} + onPress={onPressSelectService} + accessibilityRole="button" + accessibilityLabel={_(msg`Hosting provider`)} + accessibilityHint="Sets hosting provider for password reset"> + <FontAwesomeIcon + icon="globe" + style={[pal.textLight, styles.groupContentIcon]} + /> + <Text style={[pal.text, styles.textInput]} numberOfLines={1}> + {toNiceDomain(serviceUrl)} + </Text> + <View style={[pal.btn, styles.textBtnFakeInnerBtn]}> + <FontAwesomeIcon + icon="pen" + size={12} + style={pal.text as FontAwesomeIconStyle} + /> + </View> + </TouchableOpacity> + <View style={[pal.borderDark, styles.groupContent]}> + <FontAwesomeIcon + icon="envelope" + style={[pal.textLight, styles.groupContentIcon]} + /> + <TextInput + testID="forgotPasswordEmail" + style={[pal.text, styles.textInput]} + placeholder="Email address" + placeholderTextColor={pal.colors.textLight} + autoCapitalize="none" + autoFocus + autoCorrect={false} + keyboardAppearance={theme.colorScheme} + value={email} + onChangeText={setEmail} + editable={!isProcessing} + accessibilityLabel={_(msg`Email`)} + accessibilityHint="Sets email for password reset" + /> + </View> + </View> + {error ? ( + <View style={styles.error}> + <View style={styles.errorIcon}> + <FontAwesomeIcon icon="exclamation" style={s.white} size={10} /> + </View> + <View style={s.flex1}> + <Text style={[s.white, s.bold]}>{error}</Text> + </View> + </View> + ) : undefined} + <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> + <TouchableOpacity onPress={onPressBack} accessibilityRole="button"> + <Text type="xl" style={[pal.link, s.pl5]}> + <Trans>Back</Trans> + </Text> + </TouchableOpacity> + <View style={s.flex1} /> + {!serviceDescription || isProcessing ? ( + <ActivityIndicator /> + ) : !email ? ( + <Text type="xl-bold" style={[pal.link, s.pr5, styles.dimmed]}> + <Trans>Next</Trans> + </Text> + ) : ( + <TouchableOpacity + testID="newPasswordButton" + onPress={onPressNext} + accessibilityRole="button" + accessibilityLabel={_(msg`Go to next`)} + accessibilityHint="Navigates to the next screen"> + <Text type="xl-bold" style={[pal.link, s.pr5]}> + <Trans>Next</Trans> + </Text> + </TouchableOpacity> + )} + {!serviceDescription || isProcessing ? ( + <Text type="xl" style={[pal.textLight, s.pl10]}> + <Trans>Processing...</Trans> + </Text> + ) : undefined} + </View> + </View> + </> + ) +} diff --git a/src/view/com/auth/login/Login.tsx b/src/view/com/auth/login/Login.tsx index acc05b6ca..67d0afdf1 100644 --- a/src/view/com/auth/login/Login.tsx +++ b/src/view/com/auth/login/Login.tsx @@ -1,36 +1,19 @@ -import React, {useState, useEffect, useRef} from 'react' -import { - ActivityIndicator, - Keyboard, - KeyboardAvoidingView, - ScrollView, - StyleSheet, - TextInput, - TouchableOpacity, - View, -} from 'react-native' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import * as EmailValidator from 'email-validator' -import {BskyAgent} from '@atproto/api' +import React, {useState, useEffect} from 'react' +import {KeyboardAvoidingView} from 'react-native' import {useAnalytics} from 'lib/analytics/analytics' -import {Text} from '../../util/text/Text' -import {UserAvatar} from '../../util/UserAvatar' import {LoggedOutLayout} from 'view/com/util/layouts/LoggedOutLayout' -import {s, colors} from 'lib/styles' -import {createFullHandle} from 'lib/strings/handles' -import {toNiceDomain} from 'lib/strings/url-helpers' -import {useStores, RootStoreModel, DEFAULT_SERVICE} from 'state/index' -import {ServiceDescription} from 'state/models/session' -import {AccountData} from 'state/models/session' -import {isNetworkError} from 'lib/strings/errors' +import {DEFAULT_SERVICE} from '#/lib/constants' import {usePalette} from 'lib/hooks/usePalette' -import {useTheme} from 'lib/ThemeContext' -import {cleanError} from 'lib/strings/errors' -import {isWeb} from 'platform/detection' import {logger} from '#/logger' +import {ChooseAccountForm} from './ChooseAccountForm' +import {LoginForm} from './LoginForm' +import {ForgotPasswordForm} from './ForgotPasswordForm' +import {SetNewPasswordForm} from './SetNewPasswordForm' +import {PasswordUpdatedForm} from './PasswordUpdatedForm' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' +import {useSession, SessionAccount} from '#/state/session' +import {useServiceQuery} from '#/state/queries/service' enum Forms { Login, @@ -42,20 +25,22 @@ enum Forms { export const Login = ({onPressBack}: {onPressBack: () => void}) => { const pal = usePalette('default') - const store = useStores() + const {accounts} = useSession() const {track} = useAnalytics() + const {_} = useLingui() const [error, setError] = useState<string>('') - const [retryDescribeTrigger, setRetryDescribeTrigger] = useState<any>({}) const [serviceUrl, setServiceUrl] = useState<string>(DEFAULT_SERVICE) - const [serviceDescription, setServiceDescription] = useState< - ServiceDescription | undefined - >(undefined) const [initialHandle, setInitialHandle] = useState<string>('') const [currentForm, setCurrentForm] = useState<Forms>( - store.session.hasAccounts ? Forms.ChooseAccount : Forms.Login, + accounts.length ? Forms.ChooseAccount : Forms.Login, ) + const { + data: serviceDescription, + error: serviceError, + refetch: refetchService, + } = useServiceQuery(serviceUrl) - const onSelectAccount = (account?: AccountData) => { + const onSelectAccount = (account?: SessionAccount) => { if (account?.service) { setServiceUrl(account.service) } @@ -69,33 +54,21 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { } useEffect(() => { - let aborted = false - setError('') - store.session.describeService(serviceUrl).then( - desc => { - if (aborted) { - return - } - setServiceDescription(desc) - }, - err => { - if (aborted) { - return - } - logger.warn(`Failed to fetch service description for ${serviceUrl}`, { - error: err, - }) - setError( - 'Unable to contact your service. Please check your Internet connection.', - ) - }, - ) - return () => { - aborted = true + if (serviceError) { + setError( + _( + msg`Unable to contact your service. Please check your Internet connection.`, + ), + ) + logger.warn(`Failed to fetch service description for ${serviceUrl}`, { + error: String(serviceError), + }) + } else { + setError('') } - }, [store.session, serviceUrl, retryDescribeTrigger]) + }, [serviceError, serviceUrl, _]) - const onPressRetryConnect = () => setRetryDescribeTrigger({}) + const onPressRetryConnect = () => refetchService() const onPressForgotPassword = () => { track('Signin:PressedForgotPassword') setCurrentForm(Forms.ForgotPassword) @@ -106,10 +79,9 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { {currentForm === Forms.Login ? ( <LoggedOutLayout leadin="" - title="Sign in" - description="Enter your username and password"> + title={_(msg`Sign in`)} + description={_(msg`Enter your username and password`)}> <LoginForm - store={store} error={error} serviceUrl={serviceUrl} serviceDescription={serviceDescription} @@ -125,10 +97,9 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { {currentForm === Forms.ChooseAccount ? ( <LoggedOutLayout leadin="" - title="Sign in as..." - description="Select from an existing account"> + title={_(msg`Sign in as...`)} + description={_(msg`Select from an existing account`)}> <ChooseAccountForm - store={store} onSelectAccount={onSelectAccount} onPressBack={onPressBack} /> @@ -137,10 +108,9 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { {currentForm === Forms.ForgotPassword ? ( <LoggedOutLayout leadin="" - title="Forgot Password" - description="Let's get your password reset!"> + title={_(msg`Forgot Password`)} + description={_(msg`Let's get your password reset!`)}> <ForgotPasswordForm - store={store} error={error} serviceUrl={serviceUrl} serviceDescription={serviceDescription} @@ -154,10 +124,9 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { {currentForm === Forms.SetNewPassword ? ( <LoggedOutLayout leadin="" - title="Forgot Password" - description="Let's get your password reset!"> + title={_(msg`Forgot Password`)} + description={_(msg`Let's get your password reset!`)}> <SetNewPasswordForm - store={store} error={error} serviceUrl={serviceUrl} setError={setError} @@ -167,834 +136,13 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { </LoggedOutLayout> ) : undefined} {currentForm === Forms.PasswordUpdated ? ( - <PasswordUpdatedForm onPressNext={gotoForm(Forms.Login)} /> + <LoggedOutLayout + leadin="" + title={_(msg`Password updated`)} + description={_(msg`You can now sign in with your new password.`)}> + <PasswordUpdatedForm onPressNext={gotoForm(Forms.Login)} /> + </LoggedOutLayout> ) : undefined} </KeyboardAvoidingView> ) } - -const ChooseAccountForm = ({ - store, - onSelectAccount, - onPressBack, -}: { - store: RootStoreModel - onSelectAccount: (account?: AccountData) => void - onPressBack: () => void -}) => { - const {track, screen} = useAnalytics() - const pal = usePalette('default') - const [isProcessing, setIsProcessing] = React.useState(false) - - React.useEffect(() => { - screen('Choose Account') - }, [screen]) - - const onTryAccount = async (account: AccountData) => { - if (account.accessJwt && account.refreshJwt) { - setIsProcessing(true) - if (await store.session.resumeSession(account)) { - track('Sign In', {resumedSession: true}) - setIsProcessing(false) - return - } - setIsProcessing(false) - } - onSelectAccount(account) - } - - return ( - <ScrollView testID="chooseAccountForm" style={styles.maxHeight}> - <Text - type="2xl-medium" - style={[pal.text, styles.groupLabel, s.mt5, s.mb10]}> - Sign in as... - </Text> - {store.session.accounts.map(account => ( - <TouchableOpacity - testID={`chooseAccountBtn-${account.handle}`} - key={account.did} - style={[pal.view, pal.border, styles.account]} - onPress={() => onTryAccount(account)} - accessibilityRole="button" - accessibilityLabel={`Sign in as ${account.handle}`} - accessibilityHint="Double tap to sign in"> - <View - style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> - <View style={s.p10}> - <UserAvatar avatar={account.aviUrl} size={30} /> - </View> - <Text style={styles.accountText}> - <Text type="lg-bold" style={pal.text}> - {account.displayName || account.handle}{' '} - </Text> - <Text type="lg" style={[pal.textLight]}> - {account.handle} - </Text> - </Text> - <FontAwesomeIcon - icon="angle-right" - size={16} - style={[pal.text, s.mr10]} - /> - </View> - </TouchableOpacity> - ))} - <TouchableOpacity - testID="chooseNewAccountBtn" - style={[pal.view, pal.border, styles.account, styles.accountLast]} - onPress={() => onSelectAccount(undefined)} - accessibilityRole="button" - accessibilityLabel="Login to account that is not listed" - accessibilityHint=""> - <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> - <Text style={[styles.accountText, styles.accountTextOther]}> - <Text type="lg" style={pal.text}> - Other account - </Text> - </Text> - <FontAwesomeIcon - icon="angle-right" - size={16} - style={[pal.text, s.mr10]} - /> - </View> - </TouchableOpacity> - <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> - <TouchableOpacity onPress={onPressBack} accessibilityRole="button"> - <Text type="xl" style={[pal.link, s.pl5]}> - Back - </Text> - </TouchableOpacity> - <View style={s.flex1} /> - {isProcessing && <ActivityIndicator />} - </View> - </ScrollView> - ) -} - -const LoginForm = ({ - store, - error, - serviceUrl, - serviceDescription, - initialHandle, - setError, - setServiceUrl, - onPressRetryConnect, - onPressBack, - onPressForgotPassword, -}: { - store: RootStoreModel - error: string - serviceUrl: string - serviceDescription: ServiceDescription | undefined - initialHandle: string - setError: (v: string) => void - setServiceUrl: (v: string) => void - onPressRetryConnect: () => void - onPressBack: () => void - onPressForgotPassword: () => void -}) => { - const {track} = useAnalytics() - const pal = usePalette('default') - const theme = useTheme() - const [isProcessing, setIsProcessing] = useState<boolean>(false) - const [identifier, setIdentifier] = useState<string>(initialHandle) - const [password, setPassword] = useState<string>('') - const passwordInputRef = useRef<TextInput>(null) - - const onPressSelectService = () => { - store.shell.openModal({ - name: 'server-input', - initialService: serviceUrl, - onSelect: setServiceUrl, - }) - Keyboard.dismiss() - track('Signin:PressedSelectService') - } - - const onPressNext = async () => { - Keyboard.dismiss() - setError('') - setIsProcessing(true) - - try { - // try to guess the handle if the user just gave their own username - let fullIdent = identifier - if ( - !identifier.includes('@') && // not an email - !identifier.includes('.') && // not a domain - serviceDescription && - serviceDescription.availableUserDomains.length > 0 - ) { - let matched = false - for (const domain of serviceDescription.availableUserDomains) { - if (fullIdent.endsWith(domain)) { - matched = true - } - } - if (!matched) { - fullIdent = createFullHandle( - identifier, - serviceDescription.availableUserDomains[0], - ) - } - } - - await store.session.login({ - service: serviceUrl, - identifier: fullIdent, - password, - }) - } catch (e: any) { - const errMsg = e.toString() - logger.warn('Failed to login', {error: e}) - setIsProcessing(false) - if (errMsg.includes('Authentication Required')) { - setError('Invalid username or password') - } else if (isNetworkError(e)) { - setError( - 'Unable to contact your service. Please check your Internet connection.', - ) - } else { - setError(cleanError(errMsg)) - } - } finally { - track('Sign In', {resumedSession: false}) - } - } - - const isReady = !!serviceDescription && !!identifier && !!password - return ( - <View testID="loginForm"> - <Text type="sm-bold" style={[pal.text, styles.groupLabel]}> - Sign into - </Text> - <View style={[pal.borderDark, styles.group]}> - <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> - <FontAwesomeIcon - icon="globe" - style={[pal.textLight, styles.groupContentIcon]} - /> - <TouchableOpacity - testID="loginSelectServiceButton" - style={styles.textBtn} - onPress={onPressSelectService} - accessibilityRole="button" - accessibilityLabel="Select service" - accessibilityHint="Sets server for the Bluesky client"> - <Text type="xl" style={[pal.text, styles.textBtnLabel]}> - {toNiceDomain(serviceUrl)} - </Text> - <View style={[pal.btn, styles.textBtnFakeInnerBtn]}> - <FontAwesomeIcon - icon="pen" - size={12} - style={pal.textLight as FontAwesomeIconStyle} - /> - </View> - </TouchableOpacity> - </View> - </View> - <Text type="sm-bold" style={[pal.text, styles.groupLabel]}> - Account - </Text> - <View style={[pal.borderDark, styles.group]}> - <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> - <FontAwesomeIcon - icon="at" - style={[pal.textLight, styles.groupContentIcon]} - /> - <TextInput - testID="loginUsernameInput" - style={[pal.text, styles.textInput]} - placeholder="Username or email address" - placeholderTextColor={pal.colors.textLight} - autoCapitalize="none" - autoFocus - autoCorrect={false} - autoComplete="username" - returnKeyType="next" - onSubmitEditing={() => { - passwordInputRef.current?.focus() - }} - blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field - keyboardAppearance={theme.colorScheme} - value={identifier} - onChangeText={str => - setIdentifier((str || '').toLowerCase().trim()) - } - editable={!isProcessing} - accessibilityLabel="Username or email address" - accessibilityHint="Input the username or email address you used at signup" - /> - </View> - <View style={[pal.borderDark, styles.groupContent]}> - <FontAwesomeIcon - icon="lock" - style={[pal.textLight, styles.groupContentIcon]} - /> - <TextInput - testID="loginPasswordInput" - ref={passwordInputRef} - style={[pal.text, styles.textInput]} - placeholder="Password" - placeholderTextColor={pal.colors.textLight} - autoCapitalize="none" - autoCorrect={false} - autoComplete="password" - returnKeyType="done" - enablesReturnKeyAutomatically={true} - keyboardAppearance={theme.colorScheme} - secureTextEntry={true} - textContentType="password" - clearButtonMode="while-editing" - value={password} - onChangeText={setPassword} - onSubmitEditing={onPressNext} - blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing - editable={!isProcessing} - accessibilityLabel="Password" - accessibilityHint={ - identifier === '' - ? 'Input your password' - : `Input the password tied to ${identifier}` - } - /> - <TouchableOpacity - testID="forgotPasswordButton" - style={styles.textInputInnerBtn} - onPress={onPressForgotPassword} - accessibilityRole="button" - accessibilityLabel="Forgot password" - accessibilityHint="Opens password reset form"> - <Text style={pal.link}>Forgot</Text> - </TouchableOpacity> - </View> - </View> - {error ? ( - <View style={styles.error}> - <View style={styles.errorIcon}> - <FontAwesomeIcon icon="exclamation" style={s.white} size={10} /> - </View> - <View style={s.flex1}> - <Text style={[s.white, s.bold]}>{error}</Text> - </View> - </View> - ) : undefined} - <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> - <TouchableOpacity onPress={onPressBack} accessibilityRole="button"> - <Text type="xl" style={[pal.link, s.pl5]}> - Back - </Text> - </TouchableOpacity> - <View style={s.flex1} /> - {!serviceDescription && error ? ( - <TouchableOpacity - testID="loginRetryButton" - onPress={onPressRetryConnect} - accessibilityRole="button" - accessibilityLabel="Retry" - accessibilityHint="Retries login"> - <Text type="xl-bold" style={[pal.link, s.pr5]}> - Retry - </Text> - </TouchableOpacity> - ) : !serviceDescription ? ( - <> - <ActivityIndicator /> - <Text type="xl" style={[pal.textLight, s.pl10]}> - Connecting... - </Text> - </> - ) : isProcessing ? ( - <ActivityIndicator /> - ) : isReady ? ( - <TouchableOpacity - testID="loginNextButton" - onPress={onPressNext} - accessibilityRole="button" - accessibilityLabel="Go to next" - accessibilityHint="Navigates to the next screen"> - <Text type="xl-bold" style={[pal.link, s.pr5]}> - Next - </Text> - </TouchableOpacity> - ) : undefined} - </View> - </View> - ) -} - -const ForgotPasswordForm = ({ - store, - error, - serviceUrl, - serviceDescription, - setError, - setServiceUrl, - onPressBack, - onEmailSent, -}: { - store: RootStoreModel - error: string - serviceUrl: string - serviceDescription: ServiceDescription | undefined - setError: (v: string) => void - setServiceUrl: (v: string) => void - onPressBack: () => void - onEmailSent: () => void -}) => { - const pal = usePalette('default') - const theme = useTheme() - const [isProcessing, setIsProcessing] = useState<boolean>(false) - const [email, setEmail] = useState<string>('') - const {screen} = useAnalytics() - - useEffect(() => { - screen('Signin:ForgotPassword') - }, [screen]) - - const onPressSelectService = () => { - store.shell.openModal({ - name: 'server-input', - initialService: serviceUrl, - onSelect: setServiceUrl, - }) - } - - const onPressNext = async () => { - if (!EmailValidator.validate(email)) { - return setError('Your email appears to be invalid.') - } - - setError('') - setIsProcessing(true) - - try { - const agent = new BskyAgent({service: serviceUrl}) - await agent.com.atproto.server.requestPasswordReset({email}) - onEmailSent() - } catch (e: any) { - const errMsg = e.toString() - logger.warn('Failed to request password reset', {error: e}) - setIsProcessing(false) - if (isNetworkError(e)) { - setError( - 'Unable to contact your service. Please check your Internet connection.', - ) - } else { - setError(cleanError(errMsg)) - } - } - } - - return ( - <> - <View> - <Text type="title-lg" style={[pal.text, styles.screenTitle]}> - Reset password - </Text> - <Text type="md" style={[pal.text, styles.instructions]}> - Enter the email you used to create your account. We'll send you a - "reset code" so you can set a new password. - </Text> - <View - testID="forgotPasswordView" - style={[pal.borderDark, pal.view, styles.group]}> - <TouchableOpacity - testID="forgotPasswordSelectServiceButton" - style={[pal.borderDark, styles.groupContent, styles.noTopBorder]} - onPress={onPressSelectService} - accessibilityRole="button" - accessibilityLabel="Hosting provider" - accessibilityHint="Sets hosting provider for password reset"> - <FontAwesomeIcon - icon="globe" - style={[pal.textLight, styles.groupContentIcon]} - /> - <Text style={[pal.text, styles.textInput]} numberOfLines={1}> - {toNiceDomain(serviceUrl)} - </Text> - <View style={[pal.btn, styles.textBtnFakeInnerBtn]}> - <FontAwesomeIcon - icon="pen" - size={12} - style={pal.text as FontAwesomeIconStyle} - /> - </View> - </TouchableOpacity> - <View style={[pal.borderDark, styles.groupContent]}> - <FontAwesomeIcon - icon="envelope" - style={[pal.textLight, styles.groupContentIcon]} - /> - <TextInput - testID="forgotPasswordEmail" - style={[pal.text, styles.textInput]} - placeholder="Email address" - placeholderTextColor={pal.colors.textLight} - autoCapitalize="none" - autoFocus - autoCorrect={false} - keyboardAppearance={theme.colorScheme} - value={email} - onChangeText={setEmail} - editable={!isProcessing} - accessibilityLabel="Email" - accessibilityHint="Sets email for password reset" - /> - </View> - </View> - {error ? ( - <View style={styles.error}> - <View style={styles.errorIcon}> - <FontAwesomeIcon icon="exclamation" style={s.white} size={10} /> - </View> - <View style={s.flex1}> - <Text style={[s.white, s.bold]}>{error}</Text> - </View> - </View> - ) : undefined} - <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> - <TouchableOpacity onPress={onPressBack} accessibilityRole="button"> - <Text type="xl" style={[pal.link, s.pl5]}> - Back - </Text> - </TouchableOpacity> - <View style={s.flex1} /> - {!serviceDescription || isProcessing ? ( - <ActivityIndicator /> - ) : !email ? ( - <Text type="xl-bold" style={[pal.link, s.pr5, styles.dimmed]}> - Next - </Text> - ) : ( - <TouchableOpacity - testID="newPasswordButton" - onPress={onPressNext} - accessibilityRole="button" - accessibilityLabel="Go to next" - accessibilityHint="Navigates to the next screen"> - <Text type="xl-bold" style={[pal.link, s.pr5]}> - Next - </Text> - </TouchableOpacity> - )} - {!serviceDescription || isProcessing ? ( - <Text type="xl" style={[pal.textLight, s.pl10]}> - Processing... - </Text> - ) : undefined} - </View> - </View> - </> - ) -} - -const SetNewPasswordForm = ({ - error, - serviceUrl, - setError, - onPressBack, - onPasswordSet, -}: { - store: RootStoreModel - error: string - serviceUrl: string - setError: (v: string) => void - onPressBack: () => void - onPasswordSet: () => void -}) => { - const pal = usePalette('default') - const theme = useTheme() - const {screen} = useAnalytics() - - useEffect(() => { - screen('Signin:SetNewPasswordForm') - }, [screen]) - - const [isProcessing, setIsProcessing] = useState<boolean>(false) - const [resetCode, setResetCode] = useState<string>('') - const [password, setPassword] = useState<string>('') - - const onPressNext = async () => { - setError('') - setIsProcessing(true) - - try { - const agent = new BskyAgent({service: serviceUrl}) - const token = resetCode.replace(/\s/g, '') - await agent.com.atproto.server.resetPassword({ - token, - password, - }) - onPasswordSet() - } catch (e: any) { - const errMsg = e.toString() - logger.warn('Failed to set new password', {error: e}) - setIsProcessing(false) - if (isNetworkError(e)) { - setError( - 'Unable to contact your service. Please check your Internet connection.', - ) - } else { - setError(cleanError(errMsg)) - } - } - } - - return ( - <> - <View> - <Text type="title-lg" style={[pal.text, styles.screenTitle]}> - Set new password - </Text> - <Text type="lg" style={[pal.text, styles.instructions]}> - You will receive an email with a "reset code." Enter that code here, - then enter your new password. - </Text> - <View - testID="newPasswordView" - style={[pal.view, pal.borderDark, styles.group]}> - <View - style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> - <FontAwesomeIcon - icon="ticket" - style={[pal.textLight, styles.groupContentIcon]} - /> - <TextInput - testID="resetCodeInput" - style={[pal.text, styles.textInput]} - placeholder="Reset code" - placeholderTextColor={pal.colors.textLight} - autoCapitalize="none" - autoCorrect={false} - keyboardAppearance={theme.colorScheme} - autoFocus - value={resetCode} - onChangeText={setResetCode} - editable={!isProcessing} - accessible={true} - accessibilityLabel="Reset code" - accessibilityHint="Input code sent to your email for password reset" - /> - </View> - <View style={[pal.borderDark, styles.groupContent]}> - <FontAwesomeIcon - icon="lock" - style={[pal.textLight, styles.groupContentIcon]} - /> - <TextInput - testID="newPasswordInput" - style={[pal.text, styles.textInput]} - placeholder="New password" - placeholderTextColor={pal.colors.textLight} - autoCapitalize="none" - autoCorrect={false} - keyboardAppearance={theme.colorScheme} - secureTextEntry - value={password} - onChangeText={setPassword} - editable={!isProcessing} - accessible={true} - accessibilityLabel="Password" - accessibilityHint="Input new password" - /> - </View> - </View> - {error ? ( - <View style={styles.error}> - <View style={styles.errorIcon}> - <FontAwesomeIcon icon="exclamation" style={s.white} size={10} /> - </View> - <View style={s.flex1}> - <Text style={[s.white, s.bold]}>{error}</Text> - </View> - </View> - ) : undefined} - <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> - <TouchableOpacity onPress={onPressBack} accessibilityRole="button"> - <Text type="xl" style={[pal.link, s.pl5]}> - Back - </Text> - </TouchableOpacity> - <View style={s.flex1} /> - {isProcessing ? ( - <ActivityIndicator /> - ) : !resetCode || !password ? ( - <Text type="xl-bold" style={[pal.link, s.pr5, styles.dimmed]}> - Next - </Text> - ) : ( - <TouchableOpacity - testID="setNewPasswordButton" - onPress={onPressNext} - accessibilityRole="button" - accessibilityLabel="Go to next" - accessibilityHint="Navigates to the next screen"> - <Text type="xl-bold" style={[pal.link, s.pr5]}> - Next - </Text> - </TouchableOpacity> - )} - {isProcessing ? ( - <Text type="xl" style={[pal.textLight, s.pl10]}> - Updating... - </Text> - ) : undefined} - </View> - </View> - </> - ) -} - -const PasswordUpdatedForm = ({onPressNext}: {onPressNext: () => void}) => { - const {screen} = useAnalytics() - - useEffect(() => { - screen('Signin:PasswordUpdatedForm') - }, [screen]) - - const pal = usePalette('default') - return ( - <> - <View> - <Text type="title-lg" style={[pal.text, styles.screenTitle]}> - Password updated! - </Text> - <Text type="lg" style={[pal.text, styles.instructions]}> - You can now sign in with your new password. - </Text> - <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> - <View style={s.flex1} /> - <TouchableOpacity - onPress={onPressNext} - accessibilityRole="button" - accessibilityLabel="Close alert" - accessibilityHint="Closes password update alert"> - <Text type="xl-bold" style={[pal.link, s.pr5]}> - Okay - </Text> - </TouchableOpacity> - </View> - </View> - </> - ) -} - -const styles = StyleSheet.create({ - screenTitle: { - marginBottom: 10, - marginHorizontal: 20, - }, - instructions: { - marginBottom: 20, - marginHorizontal: 20, - }, - group: { - borderWidth: 1, - borderRadius: 10, - marginBottom: 20, - marginHorizontal: 20, - }, - groupLabel: { - paddingHorizontal: 20, - paddingBottom: 5, - }, - groupContent: { - borderTopWidth: 1, - flexDirection: 'row', - alignItems: 'center', - }, - noTopBorder: { - borderTopWidth: 0, - }, - groupContentIcon: { - marginLeft: 10, - }, - account: { - borderTopWidth: 1, - paddingHorizontal: 20, - paddingVertical: 4, - }, - accountLast: { - borderBottomWidth: 1, - marginBottom: 20, - paddingVertical: 8, - }, - textInput: { - flex: 1, - width: '100%', - paddingVertical: 10, - paddingHorizontal: 12, - fontSize: 17, - letterSpacing: 0.25, - fontWeight: '400', - borderRadius: 10, - }, - textInputInnerBtn: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: 6, - paddingHorizontal: 8, - marginHorizontal: 6, - }, - textBtn: { - flexDirection: 'row', - flex: 1, - alignItems: 'center', - }, - textBtnLabel: { - flex: 1, - paddingVertical: 10, - paddingHorizontal: 12, - }, - textBtnFakeInnerBtn: { - flexDirection: 'row', - alignItems: 'center', - borderRadius: 6, - paddingVertical: 6, - paddingHorizontal: 8, - marginHorizontal: 6, - }, - accountText: { - flex: 1, - flexDirection: 'row', - alignItems: 'baseline', - paddingVertical: 10, - }, - accountTextOther: { - paddingLeft: 12, - }, - error: { - backgroundColor: colors.red4, - flexDirection: 'row', - alignItems: 'center', - marginTop: -5, - marginHorizontal: 20, - marginBottom: 15, - borderRadius: 8, - paddingHorizontal: 8, - paddingVertical: 8, - }, - errorIcon: { - borderWidth: 1, - borderColor: colors.white, - color: colors.white, - borderRadius: 30, - width: 16, - height: 16, - alignItems: 'center', - justifyContent: 'center', - marginRight: 5, - }, - dimmed: {opacity: 0.5}, - - maxHeight: { - // @ts-ignore web only -prf - maxHeight: isWeb ? '100vh' : undefined, - height: !isWeb ? '100%' : undefined, - }, -}) diff --git a/src/view/com/auth/login/LoginForm.tsx b/src/view/com/auth/login/LoginForm.tsx new file mode 100644 index 000000000..365f2e253 --- /dev/null +++ b/src/view/com/auth/login/LoginForm.tsx @@ -0,0 +1,290 @@ +import React, {useState, useRef} from 'react' +import { + ActivityIndicator, + Keyboard, + TextInput, + TouchableOpacity, + View, +} from 'react-native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {ComAtprotoServerDescribeServer} from '@atproto/api' +import {useAnalytics} from 'lib/analytics/analytics' +import {Text} from '../../util/text/Text' +import {s} from 'lib/styles' +import {createFullHandle} from 'lib/strings/handles' +import {toNiceDomain} from 'lib/strings/url-helpers' +import {isNetworkError} from 'lib/strings/errors' +import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' +import {useSessionApi} from '#/state/session' +import {cleanError} from 'lib/strings/errors' +import {logger} from '#/logger' +import {Trans, msg} from '@lingui/macro' +import {styles} from './styles' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' + +type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema + +export const LoginForm = ({ + error, + serviceUrl, + serviceDescription, + initialHandle, + setError, + setServiceUrl, + onPressRetryConnect, + onPressBack, + onPressForgotPassword, +}: { + error: string + serviceUrl: string + serviceDescription: ServiceDescription | undefined + initialHandle: string + setError: (v: string) => void + setServiceUrl: (v: string) => void + onPressRetryConnect: () => void + onPressBack: () => void + onPressForgotPassword: () => void +}) => { + const {track} = useAnalytics() + const pal = usePalette('default') + const theme = useTheme() + const [isProcessing, setIsProcessing] = useState<boolean>(false) + const [identifier, setIdentifier] = useState<string>(initialHandle) + const [password, setPassword] = useState<string>('') + const passwordInputRef = useRef<TextInput>(null) + const {_} = useLingui() + const {openModal} = useModalControls() + const {login} = useSessionApi() + + const onPressSelectService = () => { + openModal({ + name: 'server-input', + initialService: serviceUrl, + onSelect: setServiceUrl, + }) + Keyboard.dismiss() + track('Signin:PressedSelectService') + } + + const onPressNext = async () => { + Keyboard.dismiss() + setError('') + setIsProcessing(true) + + try { + // try to guess the handle if the user just gave their own username + let fullIdent = identifier + if ( + !identifier.includes('@') && // not an email + !identifier.includes('.') && // not a domain + serviceDescription && + serviceDescription.availableUserDomains.length > 0 + ) { + let matched = false + for (const domain of serviceDescription.availableUserDomains) { + if (fullIdent.endsWith(domain)) { + matched = true + } + } + if (!matched) { + fullIdent = createFullHandle( + identifier, + serviceDescription.availableUserDomains[0], + ) + } + } + + // TODO remove double login + await login({ + service: serviceUrl, + identifier: fullIdent, + password, + }) + } catch (e: any) { + const errMsg = e.toString() + logger.warn('Failed to login', {error: e}) + setIsProcessing(false) + if (errMsg.includes('Authentication Required')) { + setError(_(msg`Invalid username or password`)) + } else if (isNetworkError(e)) { + setError( + _( + msg`Unable to contact your service. Please check your Internet connection.`, + ), + ) + } else { + setError(cleanError(errMsg)) + } + } finally { + track('Sign In', {resumedSession: false}) + } + } + + const isReady = !!serviceDescription && !!identifier && !!password + return ( + <View testID="loginForm"> + <Text type="sm-bold" style={[pal.text, styles.groupLabel]}> + <Trans>Sign into</Trans> + </Text> + <View style={[pal.borderDark, styles.group]}> + <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> + <FontAwesomeIcon + icon="globe" + style={[pal.textLight, styles.groupContentIcon]} + /> + <TouchableOpacity + testID="loginSelectServiceButton" + style={styles.textBtn} + onPress={onPressSelectService} + accessibilityRole="button" + accessibilityLabel={_(msg`Select service`)} + accessibilityHint="Sets server for the Bluesky client"> + <Text type="xl" style={[pal.text, styles.textBtnLabel]}> + {toNiceDomain(serviceUrl)} + </Text> + <View style={[pal.btn, styles.textBtnFakeInnerBtn]}> + <FontAwesomeIcon + icon="pen" + size={12} + style={pal.textLight as FontAwesomeIconStyle} + /> + </View> + </TouchableOpacity> + </View> + </View> + <Text type="sm-bold" style={[pal.text, styles.groupLabel]}> + <Trans>Account</Trans> + </Text> + <View style={[pal.borderDark, styles.group]}> + <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> + <FontAwesomeIcon + icon="at" + style={[pal.textLight, styles.groupContentIcon]} + /> + <TextInput + testID="loginUsernameInput" + style={[pal.text, styles.textInput]} + placeholder={_(msg`Username or email address`)} + placeholderTextColor={pal.colors.textLight} + autoCapitalize="none" + autoFocus + autoCorrect={false} + autoComplete="username" + returnKeyType="next" + onSubmitEditing={() => { + passwordInputRef.current?.focus() + }} + blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field + keyboardAppearance={theme.colorScheme} + value={identifier} + onChangeText={str => + setIdentifier((str || '').toLowerCase().trim()) + } + editable={!isProcessing} + accessibilityLabel={_(msg`Username or email address`)} + accessibilityHint="Input the username or email address you used at signup" + /> + </View> + <View style={[pal.borderDark, styles.groupContent]}> + <FontAwesomeIcon + icon="lock" + style={[pal.textLight, styles.groupContentIcon]} + /> + <TextInput + testID="loginPasswordInput" + ref={passwordInputRef} + style={[pal.text, styles.textInput]} + placeholder="Password" + placeholderTextColor={pal.colors.textLight} + autoCapitalize="none" + autoCorrect={false} + autoComplete="password" + returnKeyType="done" + enablesReturnKeyAutomatically={true} + keyboardAppearance={theme.colorScheme} + secureTextEntry={true} + textContentType="password" + clearButtonMode="while-editing" + value={password} + onChangeText={setPassword} + onSubmitEditing={onPressNext} + blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing + editable={!isProcessing} + accessibilityLabel={_(msg`Password`)} + accessibilityHint={ + identifier === '' + ? 'Input your password' + : `Input the password tied to ${identifier}` + } + /> + <TouchableOpacity + testID="forgotPasswordButton" + style={styles.textInputInnerBtn} + onPress={onPressForgotPassword} + accessibilityRole="button" + accessibilityLabel={_(msg`Forgot password`)} + accessibilityHint="Opens password reset form"> + <Text style={pal.link}> + <Trans>Forgot</Trans> + </Text> + </TouchableOpacity> + </View> + </View> + {error ? ( + <View style={styles.error}> + <View style={styles.errorIcon}> + <FontAwesomeIcon icon="exclamation" style={s.white} size={10} /> + </View> + <View style={s.flex1}> + <Text style={[s.white, s.bold]}>{error}</Text> + </View> + </View> + ) : undefined} + <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> + <TouchableOpacity onPress={onPressBack} accessibilityRole="button"> + <Text type="xl" style={[pal.link, s.pl5]}> + <Trans>Back</Trans> + </Text> + </TouchableOpacity> + <View style={s.flex1} /> + {!serviceDescription && error ? ( + <TouchableOpacity + testID="loginRetryButton" + onPress={onPressRetryConnect} + accessibilityRole="button" + accessibilityLabel={_(msg`Retry`)} + accessibilityHint="Retries login"> + <Text type="xl-bold" style={[pal.link, s.pr5]}> + <Trans>Retry</Trans> + </Text> + </TouchableOpacity> + ) : !serviceDescription ? ( + <> + <ActivityIndicator /> + <Text type="xl" style={[pal.textLight, s.pl10]}> + <Trans>Connecting...</Trans> + </Text> + </> + ) : isProcessing ? ( + <ActivityIndicator /> + ) : isReady ? ( + <TouchableOpacity + testID="loginNextButton" + onPress={onPressNext} + accessibilityRole="button" + accessibilityLabel={_(msg`Go to next`)} + accessibilityHint="Navigates to the next screen"> + <Text type="xl-bold" style={[pal.link, s.pr5]}> + <Trans>Next</Trans> + </Text> + </TouchableOpacity> + ) : undefined} + </View> + </View> + ) +} diff --git a/src/view/com/auth/login/PasswordUpdatedForm.tsx b/src/view/com/auth/login/PasswordUpdatedForm.tsx new file mode 100644 index 000000000..1e07588a9 --- /dev/null +++ b/src/view/com/auth/login/PasswordUpdatedForm.tsx @@ -0,0 +1,48 @@ +import React, {useEffect} from 'react' +import {TouchableOpacity, View} from 'react-native' +import {useAnalytics} from 'lib/analytics/analytics' +import {Text} from '../../util/text/Text' +import {s} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {styles} from './styles' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +export const PasswordUpdatedForm = ({ + onPressNext, +}: { + onPressNext: () => void +}) => { + const {screen} = useAnalytics() + const pal = usePalette('default') + const {_} = useLingui() + + useEffect(() => { + screen('Signin:PasswordUpdatedForm') + }, [screen]) + + return ( + <> + <View> + <Text type="title-lg" style={[pal.text, styles.screenTitle]}> + <Trans>Password updated!</Trans> + </Text> + <Text type="lg" style={[pal.text, styles.instructions]}> + <Trans>You can now sign in with your new password.</Trans> + </Text> + <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> + <View style={s.flex1} /> + <TouchableOpacity + onPress={onPressNext} + accessibilityRole="button" + accessibilityLabel={_(msg`Close alert`)} + accessibilityHint="Closes password update alert"> + <Text type="xl-bold" style={[pal.link, s.pr5]}> + <Trans>Okay</Trans> + </Text> + </TouchableOpacity> + </View> + </View> + </> + ) +} diff --git a/src/view/com/auth/login/SetNewPasswordForm.tsx b/src/view/com/auth/login/SetNewPasswordForm.tsx new file mode 100644 index 000000000..2bb614df2 --- /dev/null +++ b/src/view/com/auth/login/SetNewPasswordForm.tsx @@ -0,0 +1,179 @@ +import React, {useState, useEffect} from 'react' +import { + ActivityIndicator, + TextInput, + TouchableOpacity, + View, +} from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {BskyAgent} from '@atproto/api' +import {useAnalytics} from 'lib/analytics/analytics' +import {Text} from '../../util/text/Text' +import {s} from 'lib/styles' +import {isNetworkError} from 'lib/strings/errors' +import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' +import {cleanError} from 'lib/strings/errors' +import {logger} from '#/logger' +import {styles} from './styles' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +export const SetNewPasswordForm = ({ + error, + serviceUrl, + setError, + onPressBack, + onPasswordSet, +}: { + error: string + serviceUrl: string + setError: (v: string) => void + onPressBack: () => void + onPasswordSet: () => void +}) => { + const pal = usePalette('default') + const theme = useTheme() + const {screen} = useAnalytics() + const {_} = useLingui() + + useEffect(() => { + screen('Signin:SetNewPasswordForm') + }, [screen]) + + const [isProcessing, setIsProcessing] = useState<boolean>(false) + const [resetCode, setResetCode] = useState<string>('') + const [password, setPassword] = useState<string>('') + + const onPressNext = async () => { + setError('') + setIsProcessing(true) + + try { + const agent = new BskyAgent({service: serviceUrl}) + const token = resetCode.replace(/\s/g, '') + await agent.com.atproto.server.resetPassword({ + token, + password, + }) + onPasswordSet() + } catch (e: any) { + const errMsg = e.toString() + logger.warn('Failed to set new password', {error: e}) + setIsProcessing(false) + if (isNetworkError(e)) { + setError( + 'Unable to contact your service. Please check your Internet connection.', + ) + } else { + setError(cleanError(errMsg)) + } + } + } + + return ( + <> + <View> + <Text type="title-lg" style={[pal.text, styles.screenTitle]}> + <Trans>Set new password</Trans> + </Text> + <Text type="lg" style={[pal.text, styles.instructions]}> + <Trans> + You will receive an email with a "reset code." Enter that code here, + then enter your new password. + </Trans> + </Text> + <View + testID="newPasswordView" + style={[pal.view, pal.borderDark, styles.group]}> + <View + style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> + <FontAwesomeIcon + icon="ticket" + style={[pal.textLight, styles.groupContentIcon]} + /> + <TextInput + testID="resetCodeInput" + style={[pal.text, styles.textInput]} + placeholder="Reset code" + placeholderTextColor={pal.colors.textLight} + autoCapitalize="none" + autoCorrect={false} + keyboardAppearance={theme.colorScheme} + autoFocus + value={resetCode} + onChangeText={setResetCode} + editable={!isProcessing} + accessible={true} + accessibilityLabel={_(msg`Reset code`)} + accessibilityHint="Input code sent to your email for password reset" + /> + </View> + <View style={[pal.borderDark, styles.groupContent]}> + <FontAwesomeIcon + icon="lock" + style={[pal.textLight, styles.groupContentIcon]} + /> + <TextInput + testID="newPasswordInput" + style={[pal.text, styles.textInput]} + placeholder="New password" + placeholderTextColor={pal.colors.textLight} + autoCapitalize="none" + autoCorrect={false} + keyboardAppearance={theme.colorScheme} + secureTextEntry + value={password} + onChangeText={setPassword} + editable={!isProcessing} + accessible={true} + accessibilityLabel={_(msg`Password`)} + accessibilityHint="Input new password" + /> + </View> + </View> + {error ? ( + <View style={styles.error}> + <View style={styles.errorIcon}> + <FontAwesomeIcon icon="exclamation" style={s.white} size={10} /> + </View> + <View style={s.flex1}> + <Text style={[s.white, s.bold]}>{error}</Text> + </View> + </View> + ) : undefined} + <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> + <TouchableOpacity onPress={onPressBack} accessibilityRole="button"> + <Text type="xl" style={[pal.link, s.pl5]}> + <Trans>Back</Trans> + </Text> + </TouchableOpacity> + <View style={s.flex1} /> + {isProcessing ? ( + <ActivityIndicator /> + ) : !resetCode || !password ? ( + <Text type="xl-bold" style={[pal.link, s.pr5, styles.dimmed]}> + <Trans>Next</Trans> + </Text> + ) : ( + <TouchableOpacity + testID="setNewPasswordButton" + onPress={onPressNext} + accessibilityRole="button" + accessibilityLabel={_(msg`Go to next`)} + accessibilityHint="Navigates to the next screen"> + <Text type="xl-bold" style={[pal.link, s.pr5]}> + <Trans>Next</Trans> + </Text> + </TouchableOpacity> + )} + {isProcessing ? ( + <Text type="xl" style={[pal.textLight, s.pl10]}> + <Trans>Updating...</Trans> + </Text> + ) : undefined} + </View> + </View> + </> + ) +} diff --git a/src/view/com/auth/login/styles.ts b/src/view/com/auth/login/styles.ts new file mode 100644 index 000000000..9dccc2803 --- /dev/null +++ b/src/view/com/auth/login/styles.ts @@ -0,0 +1,118 @@ +import {StyleSheet} from 'react-native' +import {colors} from 'lib/styles' +import {isWeb} from '#/platform/detection' + +export const styles = StyleSheet.create({ + screenTitle: { + marginBottom: 10, + marginHorizontal: 20, + }, + instructions: { + marginBottom: 20, + marginHorizontal: 20, + }, + group: { + borderWidth: 1, + borderRadius: 10, + marginBottom: 20, + marginHorizontal: 20, + }, + groupLabel: { + paddingHorizontal: 20, + paddingBottom: 5, + }, + groupContent: { + borderTopWidth: 1, + flexDirection: 'row', + alignItems: 'center', + }, + noTopBorder: { + borderTopWidth: 0, + }, + groupContentIcon: { + marginLeft: 10, + }, + account: { + borderTopWidth: 1, + paddingHorizontal: 20, + paddingVertical: 4, + }, + accountLast: { + borderBottomWidth: 1, + marginBottom: 20, + paddingVertical: 8, + }, + textInput: { + flex: 1, + width: '100%', + paddingVertical: 10, + paddingHorizontal: 12, + fontSize: 17, + letterSpacing: 0.25, + fontWeight: '400', + borderRadius: 10, + }, + textInputInnerBtn: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 6, + paddingHorizontal: 8, + marginHorizontal: 6, + }, + textBtn: { + flexDirection: 'row', + flex: 1, + alignItems: 'center', + }, + textBtnLabel: { + flex: 1, + paddingVertical: 10, + paddingHorizontal: 12, + }, + textBtnFakeInnerBtn: { + flexDirection: 'row', + alignItems: 'center', + borderRadius: 6, + paddingVertical: 6, + paddingHorizontal: 8, + marginHorizontal: 6, + }, + accountText: { + flex: 1, + flexDirection: 'row', + alignItems: 'baseline', + paddingVertical: 10, + }, + accountTextOther: { + paddingLeft: 12, + }, + error: { + backgroundColor: colors.red4, + flexDirection: 'row', + alignItems: 'center', + marginTop: -5, + marginHorizontal: 20, + marginBottom: 15, + borderRadius: 8, + paddingHorizontal: 8, + paddingVertical: 8, + }, + errorIcon: { + borderWidth: 1, + borderColor: colors.white, + color: colors.white, + borderRadius: 30, + width: 16, + height: 16, + alignItems: 'center', + justifyContent: 'center', + marginRight: 5, + }, + dimmed: {opacity: 0.5}, + + maxHeight: { + // @ts-ignore web only -prf + maxHeight: isWeb ? '100vh' : undefined, + height: !isWeb ? '100%' : undefined, + }, +}) diff --git a/src/view/com/auth/onboarding/RecommendedFeeds.tsx b/src/view/com/auth/onboarding/RecommendedFeeds.tsx index 400b836d0..d3318bffd 100644 --- a/src/view/com/auth/onboarding/RecommendedFeeds.tsx +++ b/src/view/com/auth/onboarding/RecommendedFeeds.tsx @@ -1,6 +1,5 @@ import React from 'react' import {ActivityIndicator, FlatList, StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {TabletOrDesktop, Mobile} from 'view/com/util/layouts/Breakpoints' import {Text} from 'view/com/util/text/Text' @@ -10,76 +9,55 @@ import {Button} from 'view/com/util/forms/Button' import {RecommendedFeedsItem} from './RecommendedFeedsItem' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {usePalette} from 'lib/hooks/usePalette' -import {useQuery} from '@tanstack/react-query' -import {useStores} from 'state/index' -import {FeedSourceModel} from 'state/models/content/feed-source' import {ErrorMessage} from 'view/com/util/error/ErrorMessage' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useSuggestedFeedsQuery} from '#/state/queries/suggested-feeds' type Props = { next: () => void } -export const RecommendedFeeds = observer(function RecommendedFeedsImpl({ - next, -}: Props) { - const store = useStores() +export function RecommendedFeeds({next}: Props) { const pal = usePalette('default') + const {_} = useLingui() const {isTabletOrMobile} = useWebMediaQueries() - const {isLoading, data: recommendedFeeds} = useQuery({ - staleTime: Infinity, // fixed list rn, never refetch - queryKey: ['onboarding', 'recommended_feeds'], - async queryFn() { - try { - const { - data: {feeds}, - success, - } = await store.agent.app.bsky.feed.getSuggestedFeeds() + const {isLoading, data} = useSuggestedFeedsQuery() - if (!success) { - return [] - } - - return (feeds.length ? feeds : []).map(feed => { - const model = new FeedSourceModel(store, feed.uri) - model.hydrateFeedGenerator(feed) - return model - }) - } catch (e) { - return [] - } - }, - }) - - const hasFeeds = recommendedFeeds && recommendedFeeds.length + const hasFeeds = data && data.pages[0].feeds.length const title = ( <> - <Text - style={[ - pal.textLight, - tdStyles.title1, - isTabletOrMobile && tdStyles.title1Small, - ]}> - Choose your - </Text> - <Text - style={[ - pal.link, - tdStyles.title2, - isTabletOrMobile && tdStyles.title2Small, - ]}> - Recommended - </Text> - <Text - style={[ - pal.link, - tdStyles.title2, - isTabletOrMobile && tdStyles.title2Small, - ]}> - Feeds - </Text> + <Trans> + <Text + style={[ + pal.textLight, + tdStyles.title1, + isTabletOrMobile && tdStyles.title1Small, + ]}> + Choose your + </Text> + <Text + style={[ + pal.link, + tdStyles.title2, + isTabletOrMobile && tdStyles.title2Small, + ]}> + Recommended + </Text> + <Text + style={[ + pal.link, + tdStyles.title2, + isTabletOrMobile && tdStyles.title2Small, + ]}> + Feeds + </Text> + </Trans> <Text type="2xl-medium" style={[pal.textLight, tdStyles.description]}> - Feeds are created by users to curate content. Choose some feeds that you - find interesting. + <Trans> + Feeds are created by users to curate content. Choose some feeds that + you find interesting. + </Trans> </Text> <View style={{ @@ -98,7 +76,7 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({ <Text type="2xl-medium" style={{color: '#fff', position: 'relative', top: -1}}> - Next + <Trans>Next</Trans> </Text> <FontAwesomeIcon icon="angle-right" color="#fff" size={14} /> </View> @@ -118,7 +96,7 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({ contentStyle={{paddingHorizontal: 0}}> {hasFeeds ? ( <FlatList - data={recommendedFeeds} + data={data.pages[0].feeds} renderItem={({item}) => <RecommendedFeedsItem item={item} />} keyExtractor={item => item.uri} style={{flex: 1}} @@ -128,25 +106,27 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({ <ActivityIndicator size="large" /> </View> ) : ( - <ErrorMessage message="Failed to load recommended feeds" /> + <ErrorMessage message={_(msg`Failed to load recommended feeds`)} /> )} </TitleColumnLayout> </TabletOrDesktop> <Mobile> <View style={[mStyles.container]} testID="recommendedFeedsOnboarding"> <ViewHeader - title="Recommended Feeds" + title={_(msg`Recommended Feeds`)} showBackButton={false} showOnDesktop /> <Text type="lg-medium" style={[pal.text, mStyles.header]}> - Check out some recommended feeds. Tap + to add them to your list of - pinned feeds. + <Trans> + Check out some recommended feeds. Tap + to add them to your list + of pinned feeds. + </Trans> </Text> {hasFeeds ? ( <FlatList - data={recommendedFeeds} + data={data.pages[0].feeds} renderItem={({item}) => <RecommendedFeedsItem item={item} />} keyExtractor={item => item.uri} style={{flex: 1}} @@ -157,13 +137,15 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({ </View> ) : ( <View style={{flex: 1}}> - <ErrorMessage message="Failed to load recommended feeds" /> + <ErrorMessage + message={_(msg`Failed to load recommended feeds`)} + /> </View> )} <Button onPress={next} - label="Continue" + label={_(msg`Continue`)} testID="continueBtn" style={mStyles.button} labelStyle={mStyles.buttonText} @@ -172,7 +154,7 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({ </Mobile> </> ) -}) +} const tdStyles = StyleSheet.create({ container: { diff --git a/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx b/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx index bee23c953..7417e5b06 100644 --- a/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx +++ b/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx @@ -1,7 +1,7 @@ import React from 'react' import {View} from 'react-native' -import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {AppBskyFeedDefs, RichText as BskRichText} from '@atproto/api' import {Text} from 'view/com/util/text/Text' import {RichText} from 'view/com/util/text/RichText' import {Button} from 'view/com/util/forms/Button' @@ -11,33 +11,58 @@ import {HeartIcon} from 'lib/icons' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {sanitizeHandle} from 'lib/strings/handles' -import {FeedSourceModel} from 'state/models/content/feed-source' +import { + usePreferencesQuery, + usePinFeedMutation, + useRemoveFeedMutation, +} from '#/state/queries/preferences' +import {logger} from '#/logger' -export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({ +export function RecommendedFeedsItem({ item, }: { - item: FeedSourceModel + item: AppBskyFeedDefs.GeneratorView }) { const {isMobile} = useWebMediaQueries() const pal = usePalette('default') - if (!item) return null + const {data: preferences} = usePreferencesQuery() + const { + mutateAsync: pinFeed, + variables: pinnedFeed, + reset: resetPinFeed, + } = usePinFeedMutation() + const { + mutateAsync: removeFeed, + variables: removedFeed, + reset: resetRemoveFeed, + } = useRemoveFeedMutation() + + if (!item || !preferences) return null + + const isPinned = + !removedFeed?.uri && + (pinnedFeed?.uri || preferences.feeds.saved.includes(item.uri)) + const onToggle = async () => { - if (item.isSaved) { + if (isPinned) { try { - await item.unsave() + await removeFeed({uri: item.uri}) + resetRemoveFeed() } catch (e) { Toast.show('There was an issue contacting your server') - console.error('Failed to unsave feed', {e}) + logger.error('Failed to unsave feed', {error: e}) } } else { try { - await item.pin() + await pinFeed({uri: item.uri}) + resetPinFeed() } catch (e) { Toast.show('There was an issue contacting your server') - console.error('Failed to pin feed', {e}) + logger.error('Failed to pin feed', {error: e}) } } } + return ( <View testID={`feed-${item.displayName}`}> <View @@ -66,10 +91,10 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({ </Text> <Text style={[pal.textLight, {marginBottom: 8}]} numberOfLines={1}> - by {sanitizeHandle(item.creatorHandle, '@')} + by {sanitizeHandle(item.creator.handle, '@')} </Text> - {item.descriptionRT ? ( + {item.description ? ( <RichText type="xl" style={[ @@ -80,7 +105,7 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({ marginBottom: 18, }, ]} - richText={item.descriptionRT} + richText={new BskRichText({text: item.description || ''})} numberOfLines={6} /> ) : null} @@ -97,7 +122,7 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({ paddingRight: 2, gap: 6, }}> - {item.isSaved ? ( + {isPinned ? ( <> <FontAwesomeIcon icon="check" @@ -138,4 +163,4 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({ </View> </View> ) -}) +} diff --git a/src/view/com/auth/onboarding/RecommendedFollows.tsx b/src/view/com/auth/onboarding/RecommendedFollows.tsx index f2710d2ac..372bbec6a 100644 --- a/src/view/com/auth/onboarding/RecommendedFollows.tsx +++ b/src/view/com/auth/onboarding/RecommendedFollows.tsx @@ -1,7 +1,7 @@ import React from 'react' import {ActivityIndicator, FlatList, StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {AppBskyActorDefs, moderateProfile} from '@atproto/api' import {TabletOrDesktop, Mobile} from 'view/com/util/layouts/Breakpoints' import {Text} from 'view/com/util/text/Text' import {ViewHeader} from 'view/com/util/ViewHeader' @@ -9,59 +9,62 @@ import {TitleColumnLayout} from 'view/com/util/layouts/TitleColumnLayout' import {Button} from 'view/com/util/forms/Button' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {usePalette} from 'lib/hooks/usePalette' -import {useStores} from 'state/index' import {RecommendedFollowsItem} from './RecommendedFollowsItem' +import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows' +import {useGetSuggestedFollowersByActor} from '#/state/queries/suggested-follows' +import {useModerationOpts} from '#/state/queries/preferences' +import {logger} from '#/logger' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' type Props = { next: () => void } -export const RecommendedFollows = observer(function RecommendedFollowsImpl({ - next, -}: Props) { - const store = useStores() +export function RecommendedFollows({next}: Props) { const pal = usePalette('default') + const {_} = useLingui() const {isTabletOrMobile} = useWebMediaQueries() - - React.useEffect(() => { - // Load suggested actors if not already loaded - // prefetch should happen in the onboarding model - if ( - !store.onboarding.suggestedActors.hasLoaded || - store.onboarding.suggestedActors.isEmpty - ) { - store.onboarding.suggestedActors.loadMore(true) - } - }, [store]) + const {data: suggestedFollows} = useSuggestedFollowsQuery() + const getSuggestedFollowsByActor = useGetSuggestedFollowersByActor() + const [additionalSuggestions, setAdditionalSuggestions] = React.useState<{ + [did: string]: AppBskyActorDefs.ProfileView[] + }>({}) + const existingDids = React.useRef<string[]>([]) + const moderationOpts = useModerationOpts() const title = ( <> - <Text - style={[ - pal.textLight, - tdStyles.title1, - isTabletOrMobile && tdStyles.title1Small, - ]}> - Follow some - </Text> - <Text - style={[ - pal.link, - tdStyles.title2, - isTabletOrMobile && tdStyles.title2Small, - ]}> - Recommended - </Text> - <Text - style={[ - pal.link, - tdStyles.title2, - isTabletOrMobile && tdStyles.title2Small, - ]}> - Users - </Text> + <Trans> + <Text + style={[ + pal.textLight, + tdStyles.title1, + isTabletOrMobile && tdStyles.title1Small, + ]}> + Follow some + </Text> + <Text + style={[ + pal.link, + tdStyles.title2, + isTabletOrMobile && tdStyles.title2Small, + ]}> + Recommended + </Text> + <Text + style={[ + pal.link, + tdStyles.title2, + isTabletOrMobile && tdStyles.title2Small, + ]}> + Users + </Text> + </Trans> <Text type="2xl-medium" style={[pal.textLight, tdStyles.description]}> - Follow some users to get started. We can recommend you more users based - on who you find interesting. + <Trans> + Follow some users to get started. We can recommend you more users + based on who you find interesting. + </Trans> </Text> <View style={{ @@ -80,7 +83,7 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({ <Text type="2xl-medium" style={{color: '#fff', position: 'relative', top: -1}}> - Done + <Trans>Done</Trans> </Text> <FontAwesomeIcon icon="angle-right" color="#fff" size={14} /> </View> @@ -89,6 +92,59 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({ </> ) + const suggestions = React.useMemo(() => { + if (!suggestedFollows) return [] + + const additional = Object.entries(additionalSuggestions) + const items = suggestedFollows.pages.flatMap(page => page.actors) + + outer: while (additional.length) { + const additionalAccount = additional.shift() + + if (!additionalAccount) break + + const [followedUser, relatedAccounts] = additionalAccount + + for (let i = 0; i < items.length; i++) { + if (items[i].did === followedUser) { + items.splice(i + 1, 0, ...relatedAccounts) + continue outer + } + } + } + + existingDids.current = items.map(i => i.did) + + return items + }, [suggestedFollows, additionalSuggestions]) + + const onFollowStateChange = React.useCallback( + async ({following, did}: {following: boolean; did: string}) => { + if (following) { + try { + const {suggestions: results} = await getSuggestedFollowsByActor(did) + + if (results.length) { + const deduped = results.filter( + r => !existingDids.current.find(did => did === r.did), + ) + setAdditionalSuggestions(s => ({ + ...s, + [did]: deduped.slice(0, 3), + })) + } + } catch (e) { + logger.error('RecommendedFollows: failed to get suggestions', { + error: e, + }) + } + } + + // not handling the unfollow case + }, + [existingDids, getSuggestedFollowsByActor, setAdditionalSuggestions], + ) + return ( <> <TabletOrDesktop> @@ -98,15 +154,19 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({ horizontal titleStyle={isTabletOrMobile ? undefined : {minWidth: 470}} contentStyle={{paddingHorizontal: 0}}> - {store.onboarding.suggestedActors.isLoading ? ( + {!suggestedFollows || !moderationOpts ? ( <ActivityIndicator size="large" /> ) : ( <FlatList - data={store.onboarding.suggestedActors.suggestions} - renderItem={({item, index}) => ( - <RecommendedFollowsItem item={item} index={index} /> + data={suggestions} + renderItem={({item}) => ( + <RecommendedFollowsItem + profile={item} + onFollowStateChange={onFollowStateChange} + moderation={moderateProfile(item, moderationOpts)} + /> )} - keyExtractor={(item, index) => item.did + index.toString()} + keyExtractor={item => item.did} style={{flex: 1}} /> )} @@ -117,30 +177,36 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({ <View style={[mStyles.container]} testID="recommendedFollowsOnboarding"> <View> <ViewHeader - title="Recommended Follows" + title={_(msg`Recommended Users`)} showBackButton={false} showOnDesktop /> <Text type="lg-medium" style={[pal.text, mStyles.header]}> - Check out some recommended users. Follow them to see similar - users. + <Trans> + Check out some recommended users. Follow them to see similar + users. + </Trans> </Text> </View> - {store.onboarding.suggestedActors.isLoading ? ( + {!suggestedFollows || !moderationOpts ? ( <ActivityIndicator size="large" /> ) : ( <FlatList - data={store.onboarding.suggestedActors.suggestions} - renderItem={({item, index}) => ( - <RecommendedFollowsItem item={item} index={index} /> + data={suggestions} + renderItem={({item}) => ( + <RecommendedFollowsItem + profile={item} + onFollowStateChange={onFollowStateChange} + moderation={moderateProfile(item, moderationOpts)} + /> )} - keyExtractor={(item, index) => item.did + index.toString()} + keyExtractor={item => item.did} style={{flex: 1}} /> )} <Button onPress={next} - label="Continue" + label={_(msg`Continue`)} testID="continueBtn" style={mStyles.button} labelStyle={mStyles.buttonText} @@ -149,7 +215,7 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({ </Mobile> </> ) -}) +} const tdStyles = StyleSheet.create({ container: { diff --git a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx index 2b26918d0..93c515f38 100644 --- a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx +++ b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx @@ -1,11 +1,8 @@ -import React, {useMemo} from 'react' +import React from 'react' import {View, StyleSheet, ActivityIndicator} from 'react-native' -import {AppBskyActorDefs, moderateProfile} from '@atproto/api' -import {observer} from 'mobx-react-lite' -import {useStores} from 'state/index' -import {FollowButton} from 'view/com/profile/FollowButton' +import {ProfileModeration, AppBskyActorDefs} from '@atproto/api' +import {Button} from '#/view/com/util/forms/Button' import {usePalette} from 'lib/hooks/usePalette' -import {SuggestedActor} from 'state/models/discovery/suggested-actors' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' import {s} from 'lib/styles' @@ -14,26 +11,32 @@ import {Text} from 'view/com/util/text/Text' import Animated, {FadeInRight} from 'react-native-reanimated' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useAnalytics} from 'lib/analytics/analytics' +import {Trans} from '@lingui/macro' +import {Shadow, useProfileShadow} from '#/state/cache/profile-shadow' +import {useProfileFollowMutationQueue} from '#/state/queries/profile' +import {logger} from '#/logger' type Props = { - item: SuggestedActor - index: number + profile: AppBskyActorDefs.ProfileViewBasic + moderation: ProfileModeration + onFollowStateChange: (props: { + did: string + following: boolean + }) => Promise<void> } -export const RecommendedFollowsItem: React.FC<Props> = ({item, index}) => { + +export function RecommendedFollowsItem({ + profile, + moderation, + onFollowStateChange, +}: React.PropsWithChildren<Props>) { const pal = usePalette('default') - const store = useStores() const {isMobile} = useWebMediaQueries() - const delay = useMemo(() => { - return ( - 50 * - (Math.abs(store.onboarding.suggestedActors.lastInsertedAtIndex - index) % - 5) - ) - }, [index, store.onboarding.suggestedActors.lastInsertedAtIndex]) + const shadowedProfile = useProfileShadow(profile) return ( <Animated.View - entering={FadeInRight.delay(delay).springify()} + entering={FadeInRight} style={[ styles.cardContainer, pal.view, @@ -43,24 +46,62 @@ export const RecommendedFollowsItem: React.FC<Props> = ({item, index}) => { borderRightWidth: isMobile ? undefined : 1, }, ]}> - <ProfileCard key={item.did} profile={item} index={index} /> + <ProfileCard + key={profile.did} + profile={shadowedProfile} + onFollowStateChange={onFollowStateChange} + moderation={moderation} + /> </Animated.View> ) } -export const ProfileCard = observer(function ProfileCardImpl({ +export function ProfileCard({ profile, - index, + onFollowStateChange, + moderation, }: { - profile: AppBskyActorDefs.ProfileViewBasic - index: number + profile: Shadow<AppBskyActorDefs.ProfileViewBasic> + moderation: ProfileModeration + onFollowStateChange: (props: { + did: string + following: boolean + }) => Promise<void> }) { const {track} = useAnalytics() - const store = useStores() const pal = usePalette('default') - const moderation = moderateProfile(profile, store.preferences.moderationOpts) const [addingMoreSuggestions, setAddingMoreSuggestions] = React.useState(false) + const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile) + + const onToggleFollow = React.useCallback(async () => { + try { + if (profile.viewer?.following) { + await queueUnfollow() + } else { + setAddingMoreSuggestions(true) + await queueFollow() + await onFollowStateChange({did: profile.did, following: true}) + setAddingMoreSuggestions(false) + track('Onboarding:SuggestedFollowFollowed') + } + } catch (e: any) { + if (e?.name !== 'AbortError') { + logger.error('RecommendedFollows: failed to toggle following', { + error: e, + }) + } + } finally { + setAddingMoreSuggestions(false) + } + }, [ + profile, + queueFollow, + queueUnfollow, + setAddingMoreSuggestions, + track, + onFollowStateChange, + ]) return ( <View style={styles.card}> @@ -88,20 +129,11 @@ export const ProfileCard = observer(function ProfileCardImpl({ </Text> </View> - <FollowButton - profile={profile} + <Button + type={profile.viewer?.following ? 'default' : 'inverted'} labelStyle={styles.followButton} - onToggleFollow={async isFollow => { - if (isFollow) { - setAddingMoreSuggestions(true) - await store.onboarding.suggestedActors.insertSuggestionsByActor( - profile.did, - index, - ) - setAddingMoreSuggestions(false) - track('Onboarding:SuggestedFollowFollowed') - } - }} + onPress={onToggleFollow} + label={profile.viewer?.following ? 'Unfollow' : 'Follow'} /> </View> {profile.description ? ( @@ -114,12 +146,14 @@ export const ProfileCard = observer(function ProfileCardImpl({ {addingMoreSuggestions ? ( <View style={styles.addingMoreContainer}> <ActivityIndicator size="small" color={pal.colors.text} /> - <Text style={[pal.text]}>Finding similar accounts...</Text> + <Text style={[pal.text]}> + <Trans>Finding similar accounts...</Trans> + </Text> </View> ) : null} </View> ) -}) +} const styles = StyleSheet.create({ cardContainer: { diff --git a/src/view/com/auth/onboarding/WelcomeDesktop.tsx b/src/view/com/auth/onboarding/WelcomeDesktop.tsx index c066e9bd5..1a30c17f9 100644 --- a/src/view/com/auth/onboarding/WelcomeDesktop.tsx +++ b/src/view/com/auth/onboarding/WelcomeDesktop.tsx @@ -7,16 +7,13 @@ import {usePalette} from 'lib/hooks/usePalette' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {TitleColumnLayout} from 'view/com/util/layouts/TitleColumnLayout' import {Button} from 'view/com/util/forms/Button' -import {observer} from 'mobx-react-lite' type Props = { next: () => void skip: () => void } -export const WelcomeDesktop = observer(function WelcomeDesktopImpl({ - next, -}: Props) { +export function WelcomeDesktop({next}: Props) { const pal = usePalette('default') const horizontal = useMediaQuery({minWidth: 1300}) const title = ( @@ -105,7 +102,7 @@ export const WelcomeDesktop = observer(function WelcomeDesktopImpl({ </View> </TitleColumnLayout> ) -}) +} const styles = StyleSheet.create({ row: { diff --git a/src/view/com/auth/onboarding/WelcomeMobile.tsx b/src/view/com/auth/onboarding/WelcomeMobile.tsx index 1f0a64370..5de1a7817 100644 --- a/src/view/com/auth/onboarding/WelcomeMobile.tsx +++ b/src/view/com/auth/onboarding/WelcomeMobile.tsx @@ -5,18 +5,15 @@ import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Button} from 'view/com/util/forms/Button' -import {observer} from 'mobx-react-lite' import {ViewHeader} from 'view/com/util/ViewHeader' +import {Trans} from '@lingui/macro' type Props = { next: () => void skip: () => void } -export const WelcomeMobile = observer(function WelcomeMobileImpl({ - next, - skip, -}: Props) { +export function WelcomeMobile({next, skip}: Props) { const pal = usePalette('default') return ( @@ -32,7 +29,9 @@ export const WelcomeMobile = observer(function WelcomeMobileImpl({ accessibilityRole="button" style={[s.flexRow, s.alignCenter]} onPress={skip}> - <Text style={[pal.link]}>Skip</Text> + <Text style={[pal.link]}> + <Trans>Skip</Trans> + </Text> <FontAwesomeIcon icon={'chevron-right'} size={14} @@ -44,18 +43,22 @@ export const WelcomeMobile = observer(function WelcomeMobileImpl({ /> <View> <Text style={[pal.text, styles.title]}> - Welcome to{' '} - <Text style={[pal.text, pal.link, styles.title]}>Bluesky</Text> + <Trans> + Welcome to{' '} + <Text style={[pal.text, pal.link, styles.title]}>Bluesky</Text> + </Trans> </Text> <View style={styles.spacer} /> <View style={[styles.row]}> <FontAwesomeIcon icon={'globe'} size={36} color={pal.colors.link} /> <View style={[styles.rowText]}> <Text type="lg-bold" style={[pal.text]}> - Bluesky is public. + <Trans>Bluesky is public.</Trans> </Text> <Text type="lg-thin" style={[pal.text, s.pt2]}> - Your posts, likes, and blocks are public. Mutes are private. + <Trans> + Your posts, likes, and blocks are public. Mutes are private. + </Trans> </Text> </View> </View> @@ -63,10 +66,10 @@ export const WelcomeMobile = observer(function WelcomeMobileImpl({ <FontAwesomeIcon icon={'at'} size={36} color={pal.colors.link} /> <View style={[styles.rowText]}> <Text type="lg-bold" style={[pal.text]}> - Bluesky is open. + <Trans>Bluesky is open.</Trans> </Text> <Text type="lg-thin" style={[pal.text, s.pt2]}> - Never lose access to your followers and data. + <Trans>Never lose access to your followers and data.</Trans> </Text> </View> </View> @@ -74,11 +77,13 @@ export const WelcomeMobile = observer(function WelcomeMobileImpl({ <FontAwesomeIcon icon={'gear'} size={36} color={pal.colors.link} /> <View style={[styles.rowText]}> <Text type="lg-bold" style={[pal.text]}> - Bluesky is flexible. + <Trans>Bluesky is flexible.</Trans> </Text> <Text type="lg-thin" style={[pal.text, s.pt2]}> - Choose the algorithms that power your experience with custom - feeds. + <Trans> + Choose the algorithms that power your experience with custom + feeds. + </Trans> </Text> </View> </View> @@ -93,7 +98,7 @@ export const WelcomeMobile = observer(function WelcomeMobileImpl({ /> </View> ) -}) +} const styles = StyleSheet.create({ container: { diff --git a/src/view/com/auth/withAuthRequired.tsx b/src/view/com/auth/withAuthRequired.tsx deleted file mode 100644 index 25d12165f..000000000 --- a/src/view/com/auth/withAuthRequired.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import React from 'react' -import { - ActivityIndicator, - Linking, - StyleSheet, - TouchableOpacity, -} from 'react-native' -import {observer} from 'mobx-react-lite' -import {useStores} from 'state/index' -import {CenteredView} from '../util/Views' -import {LoggedOut} from './LoggedOut' -import {Onboarding} from './Onboarding' -import {Text} from '../util/text/Text' -import {usePalette} from 'lib/hooks/usePalette' -import {STATUS_PAGE_URL} from 'lib/constants' - -export const withAuthRequired = <P extends object>( - Component: React.ComponentType<P>, -): React.FC<P> => - observer(function AuthRequired(props: P) { - const store = useStores() - if (store.session.isResumingSession) { - return <Loading /> - } - if (!store.session.hasSession) { - return <LoggedOut /> - } - if (store.onboarding.isActive) { - return <Onboarding /> - } - return <Component {...props} /> - }) - -function Loading() { - const pal = usePalette('default') - - const [isTakingTooLong, setIsTakingTooLong] = React.useState(false) - React.useEffect(() => { - const t = setTimeout(() => setIsTakingTooLong(true), 15e3) // 15 seconds - return () => clearTimeout(t) - }, [setIsTakingTooLong]) - - return ( - <CenteredView style={[styles.loading, pal.view]}> - <ActivityIndicator size="large" /> - <Text type="2xl" style={[styles.loadingText, pal.textLight]}> - {isTakingTooLong - ? "This is taking too long. There may be a problem with your internet or with the service, but we're going to try a couple more times..." - : 'Connecting...'} - </Text> - {isTakingTooLong ? ( - <TouchableOpacity - onPress={() => { - Linking.openURL(STATUS_PAGE_URL) - }} - accessibilityRole="button"> - <Text type="2xl" style={[styles.loadingText, pal.link]}> - Check Bluesky status page - </Text> - </TouchableOpacity> - ) : null} - </CenteredView> - ) -} - -const styles = StyleSheet.create({ - loading: { - height: '100%', - alignContent: 'center', - justifyContent: 'center', - paddingBottom: 100, - }, - loadingText: { - paddingVertical: 20, - paddingHorizontal: 20, - textAlign: 'center', - }, -}) |