diff options
Diffstat (limited to 'src/view/com/auth')
-rw-r--r-- | src/view/com/auth/SplashScreen.tsx | 20 | ||||
-rw-r--r-- | src/view/com/auth/SplashScreen.web.tsx | 5 | ||||
-rw-r--r-- | src/view/com/auth/create/CreateAccount.tsx | 17 | ||||
-rw-r--r-- | src/view/com/auth/create/Step1.tsx | 19 | ||||
-rw-r--r-- | src/view/com/auth/create/Step2.tsx | 31 | ||||
-rw-r--r-- | src/view/com/auth/create/Step3.tsx | 11 | ||||
-rw-r--r-- | src/view/com/auth/login/ChooseAccountForm.tsx | 119 | ||||
-rw-r--r-- | src/view/com/auth/login/ForgotPasswordForm.tsx | 197 | ||||
-rw-r--r-- | src/view/com/auth/login/Login.tsx | 888 | ||||
-rw-r--r-- | src/view/com/auth/login/LoginForm.tsx | 288 | ||||
-rw-r--r-- | src/view/com/auth/login/PasswordUpdatedForm.tsx | 48 | ||||
-rw-r--r-- | src/view/com/auth/login/SetNewPasswordForm.tsx | 181 | ||||
-rw-r--r-- | src/view/com/auth/login/styles.ts | 118 | ||||
-rw-r--r-- | src/view/com/auth/onboarding/RecommendedFollowsItem.tsx | 5 | ||||
-rw-r--r-- | src/view/com/auth/onboarding/WelcomeMobile.tsx | 27 |
15 files changed, 1060 insertions, 914 deletions
diff --git a/src/view/com/auth/SplashScreen.tsx b/src/view/com/auth/SplashScreen.tsx index 67453f111..05e72a2e6 100644 --- a/src/view/com/auth/SplashScreen.tsx +++ b/src/view/com/auth/SplashScreen.tsx @@ -5,6 +5,8 @@ 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,14 +16,18 @@ 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.title, pal.link]}> + <Trans>Bluesky</Trans> + </Text> <Text style={[styles.subtitle, pal.textLight]}> - See what's next + <Trans>See what's next</Trans> </Text> </View> <View testID="signinOrCreateAccount" style={styles.btns}> @@ -30,10 +36,10 @@ export const SplashScreen = ({ style={[styles.btn, {backgroundColor: colors.blue3}]} onPress={onPressCreateAccount} accessibilityRole="button" - accessibilityLabel="Create new account" + accessibilityLabel={_(msg`Create new account`)} accessibilityHint="Opens flow to create a new Bluesky account"> <Text style={[s.white, styles.btnLabel]}> - Create a new account + <Trans>Create a new account</Trans> </Text> </TouchableOpacity> <TouchableOpacity @@ -41,9 +47,11 @@ export const SplashScreen = ({ style={[styles.btn, pal.btn]} onPress={onPressSignin} accessibilityRole="button" - accessibilityLabel="Sign in" + accessibilityLabel={_(msg`Sign in`)} accessibilityHint="Opens flow to sign into your existing Bluesky account"> - <Text style={[pal.text, styles.btnLabel]}>Sign In</Text> + <Text style={[pal.text, styles.btnLabel]}> + <Trans>Sign In</Trans> + </Text> </TouchableOpacity> </View> </ErrorBoundary> diff --git a/src/view/com/auth/SplashScreen.web.tsx b/src/view/com/auth/SplashScreen.web.tsx index cef9618ef..f10dc4f98 100644 --- a/src/view/com/auth/SplashScreen.web.tsx +++ b/src/view/com/auth/SplashScreen.web.tsx @@ -8,6 +8,7 @@ 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 = ({ onPressSignin, @@ -54,7 +55,9 @@ export const SplashScreen = ({ onPress={onPressSignin} // TODO: web accessibility accessibilityRole="button"> - <Text style={[pal.text, styles.btnLabel]}>Sign In</Text> + <Text style={[pal.text, styles.btnLabel]}> + <Trans>Sign In</Trans> + </Text> </TouchableOpacity> </View> </ErrorBoundary> diff --git a/src/view/com/auth/create/CreateAccount.tsx b/src/view/com/auth/create/CreateAccount.tsx index c3cfb3ad3..8e2bbed85 100644 --- a/src/view/com/auth/create/CreateAccount.tsx +++ b/src/view/com/auth/create/CreateAccount.tsx @@ -15,6 +15,8 @@ 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 {Step1} from './Step1' @@ -30,6 +32,7 @@ export const CreateAccount = observer(function CreateAccountImpl({ const pal = usePalette('default') const store = useStores() const model = React.useMemo(() => new CreateAccountModel(store), [store]) + const {_} = useLingui() const onboardingDispatch = useOnboardingDispatch() React.useEffect(() => { @@ -73,8 +76,8 @@ export const CreateAccount = observer(function CreateAccountImpl({ return ( <LoggedOutLayout leadin={`Step ${model.step}`} - title="Create Account" - description="We're so excited to have you join us!"> + 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}> @@ -88,7 +91,7 @@ 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} /> @@ -101,7 +104,7 @@ export const CreateAccount = observer(function CreateAccountImpl({ <ActivityIndicator /> ) : ( <Text type="xl-bold" style={[pal.link, s.pr5]}> - Next + <Trans>Next</Trans> </Text> )} </TouchableOpacity> @@ -110,18 +113,18 @@ export const CreateAccount = observer(function CreateAccountImpl({ testID="retryConnectBtn" onPress={onPressRetryConnect} accessibilityRole="button" - accessibilityLabel="Retry" + accessibilityLabel={_(msg`Retry`)} accessibilityHint="Retries account creation" accessibilityLiveRegion="polite"> <Text type="xl-bold" style={[pal.link, s.pr5]}> - Retry + <Trans>Retry</Trans> </Text> </TouchableOpacity> ) : model.isFetchingServiceDescription ? ( <> <ActivityIndicator color="#fff" /> <Text type="xl" style={[pal.text, s.pr5]}> - Connecting... + <Trans>Connecting...</Trans> </Text> </> ) : undefined} diff --git a/src/view/com/auth/create/Step1.tsx b/src/view/com/auth/create/Step1.tsx index cdd5cb21d..7e3ea062d 100644 --- a/src/view/com/auth/create/Step1.tsx +++ b/src/view/com/auth/create/Step1.tsx @@ -12,6 +12,8 @@ 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 {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags' @@ -27,6 +29,7 @@ export const Step1 = observer(function Step1Impl({ }) { const pal = usePalette('default') const [isDefaultSelected, setIsDefaultSelected] = React.useState(true) + const {_} = useLingui() const onPressDefault = React.useCallback(() => { setIsDefaultSelected(true) @@ -63,9 +66,9 @@ export const Step1 = observer(function Step1Impl({ 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 +84,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" + placeholder={_(msg`Hosting provider address`)} value={model.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,13 +103,13 @@ export const Step1 = observer(function Step1Impl({ testID="stagingServerBtn" type="default" style={s.mr5} - label="Staging" + label={_(msg`Staging`)} onPress={() => onDebugChangeServiceUrl(STAGING_SERVICE)} /> <Button testID="localDevServerBtn" type="default" - label="Dev Server" + label={_(msg`Dev Server`)} onPress={() => onDebugChangeServiceUrl(LOCAL_DEV_SERVICE)} /> </View> @@ -116,7 +119,7 @@ export const Step1 = observer(function Step1Impl({ {model.error ? ( <ErrorMessage message={model.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> ) diff --git a/src/view/com/auth/create/Step2.tsx b/src/view/com/auth/create/Step2.tsx index b2054150b..3cc8ae934 100644 --- a/src/view/com/auth/create/Step2.tsx +++ b/src/view/com/auth/create/Step2.tsx @@ -11,6 +11,8 @@ import {TextInput} from '../util/TextInput' import {Policies} from './Policies' import {ErrorMessage} from 'view/com/util/error/ErrorMessage' 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 @@ -28,6 +30,7 @@ export const Step2 = observer(function Step2Impl({ model: CreateAccountModel }) { const pal = usePalette('default') + const {_} = useLingui() const {openModal} = useModalControls() const onPressWaitlist = React.useCallback(() => { @@ -36,7 +39,7 @@ export const Step2 = observer(function Step2Impl({ return ( <View> - <StepHeader step="2" title="Your account" /> + <StepHeader step="2" title={_(msg`Your account`)} /> {model.isInviteCodeRequired && ( <View style={s.pb20}> @@ -46,11 +49,11 @@ export const Step2 = observer(function Step2Impl({ <TextInput testID="inviteCodeInput" icon="ticket" - placeholder="Required for this provider" + placeholder={_(msg`Required for this provider`)} value={model.inviteCode} editable onChange={model.setInviteCode} - accessibilityLabel="Invite code" + accessibilityLabel={_(msg`Invite code`)} accessibilityHint="Input invite code to proceed" /> </View> @@ -61,10 +64,12 @@ export const Step2 = observer(function Step2Impl({ 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 +77,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" + placeholder={_(msg`Enter your email address`)} value={model.email} editable onChange={model.setEmail} - accessibilityLabel="Email" + accessibilityLabel={_(msg`Email`)} accessibilityHint="Input email for Bluesky waitlist" accessibilityLabelledBy="email" /> @@ -92,17 +97,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" + placeholder={_(msg`Choose your password`)} value={model.password} editable secureTextEntry onChange={model.setPassword} - accessibilityLabel="Password" + accessibilityLabel={_(msg`Password`)} accessibilityHint="Set password" accessibilityLabelledBy="password" /> @@ -113,7 +118,7 @@ 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" @@ -122,7 +127,7 @@ export const Step2 = observer(function Step2Impl({ buttonType="default-light" buttonStyle={[pal.border, styles.dateInputButton]} buttonLabelType="lg" - accessibilityLabel="Birthday" + accessibilityLabel={_(msg`Birthday`)} accessibilityHint="Enter your birth date" accessibilityLabelledBy="birthDate" /> diff --git a/src/view/com/auth/create/Step3.tsx b/src/view/com/auth/create/Step3.tsx index beb756ac1..09fba0714 100644 --- a/src/view/com/auth/create/Step3.tsx +++ b/src/view/com/auth/create/Step3.tsx @@ -9,6 +9,8 @@ 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 @@ -19,9 +21,10 @@ export const Step3 = observer(function Step3Impl({ model: CreateAccountModel }) { 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" @@ -31,12 +34,12 @@ export const Step3 = observer(function Step3Impl({ editable onChange={model.setHandle} // 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}> + <Trans>Your full handle will be</Trans> + <Text type="lg-bold" style={[pal.text, s.ml5]}> @{createFullHandle(model.handle, model.userDomain)} </Text> </Text> diff --git a/src/view/com/auth/login/ChooseAccountForm.tsx b/src/view/com/auth/login/ChooseAccountForm.tsx new file mode 100644 index 000000000..596a8e411 --- /dev/null +++ b/src/view/com/auth/login/ChooseAccountForm.tsx @@ -0,0 +1,119 @@ +import React from 'react' +import { + ActivityIndicator, + ScrollView, + TouchableOpacity, + View, +} from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {useAnalytics} from 'lib/analytics/analytics' +import {Text} from '../../util/text/Text' +import {UserAvatar} from '../../util/UserAvatar' +import {s} from 'lib/styles' +import {RootStoreModel} from 'state/index' +import {AccountData} from 'state/models/session' +import {usePalette} from 'lib/hooks/usePalette' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {styles} from './styles' + +export 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) + const {_} = useLingui() + + 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]}> + <Trans>Sign in as...</Trans> + </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={_(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={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={_(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} /> + {isProcessing && <ActivityIndicator />} + </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..9bfab18b5 --- /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 * 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 {RootStoreModel} from 'state/index' +import {ServiceDescription} from 'state/models/session' +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' + +export const ForgotPasswordForm = ({ + 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() + 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 24a657c66..401b7d980 100644 --- a/src/view/com/auth/login/Login.tsx +++ b/src/view/com/auth/login/Login.tsx @@ -1,37 +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 {useStores, DEFAULT_SERVICE} from 'state/index' import {ServiceDescription} from 'state/models/session' import {AccountData} from 'state/models/session' -import {isNetworkError} from 'lib/strings/errors' 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 {useModalControls} from '#/state/modals' +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' enum Forms { Login, @@ -45,6 +27,7 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { const pal = usePalette('default') const store = useStores() const {track} = useAnalytics() + const {_} = useLingui() const [error, setError] = useState<string>('') const [retryDescribeTrigger, setRetryDescribeTrigger] = useState<any>({}) const [serviceUrl, setServiceUrl] = useState<string>(DEFAULT_SERVICE) @@ -87,14 +70,16 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { error: err, }) setError( - 'Unable to contact your service. Please check your Internet connection.', + _( + msg`Unable to contact your service. Please check your Internet connection.`, + ), ) }, ) return () => { aborted = true } - }, [store.session, serviceUrl, retryDescribeTrigger]) + }, [store.session, serviceUrl, retryDescribeTrigger, _]) const onPressRetryConnect = () => setRetryDescribeTrigger({}) const onPressForgotPassword = () => { @@ -107,8 +92,8 @@ 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} @@ -126,8 +111,8 @@ 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} @@ -138,8 +123,8 @@ 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} @@ -155,8 +140,8 @@ 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} @@ -173,830 +158,3 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { </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 {openModal} = useModalControls() - - 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], - ) - } - } - - 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 = ({ - 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() - 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]}> - 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..9779b939a --- /dev/null +++ b/src/view/com/auth/login/LoginForm.tsx @@ -0,0 +1,288 @@ +import React, {useState, useRef} from 'react' +import { + ActivityIndicator, + Keyboard, + TextInput, + 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 {s} from 'lib/styles' +import {createFullHandle} from 'lib/strings/handles' +import {toNiceDomain} from 'lib/strings/url-helpers' +import {RootStoreModel} from 'state/index' +import {ServiceDescription} from 'state/models/session' +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 {styles} from './styles' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' + +export 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 {_} = useLingui() + const {openModal} = useModalControls() + + 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], + ) + } + } + + 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(_(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..04eaa2842 --- /dev/null +++ b/src/view/com/auth/login/SetNewPasswordForm.tsx @@ -0,0 +1,181 @@ +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 {RootStoreModel} from 'state/index' +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, +}: { + store: RootStoreModel + 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/RecommendedFollowsItem.tsx b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx index f672372b8..7ec78bd7f 100644 --- a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx +++ b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx @@ -14,6 +14,7 @@ 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' type Props = { item: SuggestedActor @@ -115,7 +116,9 @@ 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> diff --git a/src/view/com/auth/onboarding/WelcomeMobile.tsx b/src/view/com/auth/onboarding/WelcomeMobile.tsx index 1f0a64370..ef70a1fe3 100644 --- a/src/view/com/auth/onboarding/WelcomeMobile.tsx +++ b/src/view/com/auth/onboarding/WelcomeMobile.tsx @@ -7,6 +7,7 @@ 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 @@ -32,7 +33,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} @@ -45,17 +48,21 @@ 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> + <Text style={[pal.text, pal.link, styles.title]}> + <Trans>Bluesky</Trans> + </Text> </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 +70,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 +81,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> |