Internationalization & localization (#1822)
* install and setup lingui * setup dynamic locale activation and async loading * first pass of automated replacement of text messages * add some more documentaton * fix nits * add `es` and `hi`locales for testing purposes * make accessibilityLabel localized * compile and extract new messages * fix merge conflicts * fix eslint warning * change instructions from sending email to opening PR * fix comments
This commit is contained in:
parent
82059b7ee1
commit
4c7850f8c4
108 changed files with 10334 additions and 1365 deletions
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
|
|
119
src/view/com/auth/login/ChooseAccountForm.tsx
Normal file
119
src/view/com/auth/login/ChooseAccountForm.tsx
Normal file
|
@ -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>
|
||||
)
|
||||
}
|
197
src/view/com/auth/login/ForgotPasswordForm.tsx
Normal file
197
src/view/com/auth/login/ForgotPasswordForm.tsx
Normal file
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
|
288
src/view/com/auth/login/LoginForm.tsx
Normal file
288
src/view/com/auth/login/LoginForm.tsx
Normal file
|
@ -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>
|
||||
)
|
||||
}
|
48
src/view/com/auth/login/PasswordUpdatedForm.tsx
Normal file
48
src/view/com/auth/login/PasswordUpdatedForm.tsx
Normal file
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
181
src/view/com/auth/login/SetNewPasswordForm.tsx
Normal file
181
src/view/com/auth/login/SetNewPasswordForm.tsx
Normal file
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
118
src/view/com/auth/login/styles.ts
Normal file
118
src/view/com/auth/login/styles.ts
Normal file
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue