diff --git a/assets/icons/calendar_stroke2_corner0_rounded.svg b/assets/icons/calendar_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..703f389dba
--- /dev/null
+++ b/assets/icons/calendar_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/icons/envelope_stroke2_corner0_rounded.svg b/assets/icons/envelope_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..c3ab45980b
--- /dev/null
+++ b/assets/icons/envelope_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/icons/lock_stroke2_corner0_rounded.svg b/assets/icons/lock_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..8b094ba5eb
--- /dev/null
+++ b/assets/icons/lock_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/icons/pencilLine_stroke2_corner0_rounded.svg b/assets/icons/pencilLine_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..c58bef9fa2
--- /dev/null
+++ b/assets/icons/pencilLine_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/ticket_stroke2_corner0_rounded.svg b/assets/icons/ticket_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..a45a90ae5f
--- /dev/null
+++ b/assets/icons/ticket_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/package.json b/package.json
index 0858dc38a3..eaf242a5f4 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
- "version": "1.73.0",
+ "version": "1.74.0",
"private": true,
"engines": {
"node": ">=18"
diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts
index 0b473ba903..45ab72ca61 100644
--- a/src/alf/atoms.ts
+++ b/src/alf/atoms.ts
@@ -1,6 +1,7 @@
import {Platform} from 'react-native'
-import {web, native} from '#/alf/util/platform'
+
import * as tokens from '#/alf/tokens'
+import {native, web} from '#/alf/util/platform'
export const atoms = {
/*
@@ -157,6 +158,12 @@ export const atoms = {
align_end: {
alignItems: 'flex-end',
},
+ align_baseline: {
+ alignItems: 'baseline',
+ },
+ align_stretch: {
+ alignItems: 'stretch',
+ },
self_auto: {
alignSelf: 'auto',
},
@@ -247,10 +254,10 @@ export const atoms = {
fontWeight: tokens.fontWeight.normal,
},
font_semibold: {
- fontWeight: '500',
+ fontWeight: tokens.fontWeight.semibold,
},
font_bold: {
- fontWeight: tokens.fontWeight.semibold,
+ fontWeight: tokens.fontWeight.bold,
},
italic: {
fontStyle: 'italic',
@@ -300,6 +307,9 @@ export const atoms = {
/*
* Padding
*/
+ p_0: {
+ padding: 0,
+ },
p_2xs: {
padding: tokens.space._2xs,
},
@@ -330,6 +340,10 @@ export const atoms = {
p_5xl: {
padding: tokens.space._5xl,
},
+ px_0: {
+ paddingLeft: 0,
+ paddingRight: 0,
+ },
px_2xs: {
paddingLeft: tokens.space._2xs,
paddingRight: tokens.space._2xs,
@@ -370,6 +384,10 @@ export const atoms = {
paddingLeft: tokens.space._5xl,
paddingRight: tokens.space._5xl,
},
+ py_0: {
+ paddingTop: 0,
+ paddingBottom: 0,
+ },
py_2xs: {
paddingTop: tokens.space._2xs,
paddingBottom: tokens.space._2xs,
@@ -410,6 +428,9 @@ export const atoms = {
paddingTop: tokens.space._5xl,
paddingBottom: tokens.space._5xl,
},
+ pt_0: {
+ paddingTop: 0,
+ },
pt_2xs: {
paddingTop: tokens.space._2xs,
},
@@ -440,6 +461,9 @@ export const atoms = {
pt_5xl: {
paddingTop: tokens.space._5xl,
},
+ pb_0: {
+ paddingBottom: 0,
+ },
pb_2xs: {
paddingBottom: tokens.space._2xs,
},
@@ -470,6 +494,9 @@ export const atoms = {
pb_5xl: {
paddingBottom: tokens.space._5xl,
},
+ pl_0: {
+ paddingLeft: 0,
+ },
pl_2xs: {
paddingLeft: tokens.space._2xs,
},
@@ -500,6 +527,9 @@ export const atoms = {
pl_5xl: {
paddingLeft: tokens.space._5xl,
},
+ pr_0: {
+ paddingRight: 0,
+ },
pr_2xs: {
paddingRight: tokens.space._2xs,
},
@@ -534,9 +564,8 @@ export const atoms = {
/*
* Margin
*/
- mx_auto: {
- marginLeft: 'auto',
- marginRight: 'auto',
+ m_0: {
+ margin: 0,
},
m_2xs: {
margin: tokens.space._2xs,
@@ -568,6 +597,13 @@ export const atoms = {
m_5xl: {
margin: tokens.space._5xl,
},
+ m_auto: {
+ margin: 'auto',
+ },
+ mx_0: {
+ marginLeft: 0,
+ marginRight: 0,
+ },
mx_2xs: {
marginLeft: tokens.space._2xs,
marginRight: tokens.space._2xs,
@@ -608,6 +644,14 @@ export const atoms = {
marginLeft: tokens.space._5xl,
marginRight: tokens.space._5xl,
},
+ mx_auto: {
+ marginLeft: 'auto',
+ marginRight: 'auto',
+ },
+ my_0: {
+ marginTop: 0,
+ marginBottom: 0,
+ },
my_2xs: {
marginTop: tokens.space._2xs,
marginBottom: tokens.space._2xs,
@@ -648,6 +692,13 @@ export const atoms = {
marginTop: tokens.space._5xl,
marginBottom: tokens.space._5xl,
},
+ my_auto: {
+ marginTop: 'auto',
+ marginBottom: 'auto',
+ },
+ mt_0: {
+ marginTop: 0,
+ },
mt_2xs: {
marginTop: tokens.space._2xs,
},
@@ -678,6 +729,12 @@ export const atoms = {
mt_5xl: {
marginTop: tokens.space._5xl,
},
+ mt_auto: {
+ marginTop: 'auto',
+ },
+ mb_0: {
+ marginBottom: 0,
+ },
mb_2xs: {
marginBottom: tokens.space._2xs,
},
@@ -708,6 +765,12 @@ export const atoms = {
mb_5xl: {
marginBottom: tokens.space._5xl,
},
+ mb_auto: {
+ marginBottom: 'auto',
+ },
+ ml_0: {
+ marginLeft: 0,
+ },
ml_2xs: {
marginLeft: tokens.space._2xs,
},
@@ -738,6 +801,12 @@ export const atoms = {
ml_5xl: {
marginLeft: tokens.space._5xl,
},
+ ml_auto: {
+ marginLeft: 'auto',
+ },
+ mr_0: {
+ marginRight: 0,
+ },
mr_2xs: {
marginRight: tokens.space._2xs,
},
@@ -768,4 +837,7 @@ export const atoms = {
mr_5xl: {
marginRight: tokens.space._5xl,
},
+ mr_auto: {
+ marginRight: 'auto',
+ },
} as const
diff --git a/src/alf/tokens.ts b/src/alf/tokens.ts
index 4045c831c5..1bddd95d43 100644
--- a/src/alf/tokens.ts
+++ b/src/alf/tokens.ts
@@ -1,8 +1,8 @@
import {
BLUE_HUE,
- RED_HUE,
- GREEN_HUE,
generateScale,
+ GREEN_HUE,
+ RED_HUE,
} from '#/alf/util/colorGeneration'
export const scale = generateScale(6, 100)
@@ -116,8 +116,8 @@ export const borderRadius = {
export const fontWeight = {
normal: '400',
- semibold: '600',
- bold: '900',
+ semibold: '500',
+ bold: '600',
} as const
export const gradients = {
diff --git a/src/components/forms/DateField/index.android.tsx b/src/components/forms/DateField/index.android.tsx
index 451810a5ea..700d15e6d6 100644
--- a/src/components/forms/DateField/index.android.tsx
+++ b/src/components/forms/DateField/index.android.tsx
@@ -1,19 +1,11 @@
import React from 'react'
-import {View, Pressable} from 'react-native'
-
-import {useTheme, atoms} from '#/alf'
-import {Text} from '#/components/Typography'
-import {useInteractionState} from '#/components/hooks/useInteractionState'
-import * as TextField from '#/components/forms/TextField'
-import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays'
-
-import {DateFieldProps} from '#/components/forms/DateField/types'
-import {
- localizeDate,
- toSimpleDateString,
-} from '#/components/forms/DateField/utils'
import DatePicker from 'react-native-date-picker'
-import {isAndroid} from 'platform/detection'
+
+import {useTheme} from '#/alf'
+import {DateFieldProps} from '#/components/forms/DateField/types'
+import {toSimpleDateString} from '#/components/forms/DateField/utils'
+import * as TextField from '#/components/forms/TextField'
+import {DateFieldButton} from './index.shared'
export * as utils from '#/components/forms/DateField/utils'
export const Label = TextField.Label
@@ -24,18 +16,10 @@ export function DateField({
label,
isInvalid,
testID,
+ accessibilityHint,
}: DateFieldProps) {
const t = useTheme()
const [open, setOpen] = React.useState(false)
- const {
- state: pressed,
- onIn: onPressIn,
- onOut: onPressOut,
- } = useInteractionState()
- const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
-
- const {chromeFocus, chromeError, chromeErrorHover} =
- TextField.useSharedInputStyles()
const onChangeInternal = React.useCallback(
(date: Date) => {
@@ -47,50 +31,29 @@ export function DateField({
[onChangeDate, setOpen],
)
+ const onPress = React.useCallback(() => {
+ setOpen(true)
+ }, [])
+
const onCancel = React.useCallback(() => {
setOpen(false)
}, [])
return (
-
- setOpen(true)}
- onPressIn={onPressIn}
- onPressOut={onPressOut}
- onFocus={onFocus}
- onBlur={onBlur}
- style={[
- {
- paddingTop: 16,
- paddingBottom: 16,
- borderColor: 'transparent',
- borderWidth: 2,
- },
- atoms.flex_row,
- atoms.flex_1,
- atoms.w_full,
- atoms.px_lg,
- atoms.rounded_sm,
- t.atoms.bg_contrast_50,
- focused || pressed ? chromeFocus : {},
- isInvalid ? chromeError : {},
- isInvalid && (focused || pressed) ? chromeErrorHover : {},
- ]}>
-
-
-
- {localizeDate(value)}
-
-
+ <>
+
{open && (
)}
-
+ >
)
}
diff --git a/src/components/forms/DateField/index.shared.tsx b/src/components/forms/DateField/index.shared.tsx
new file mode 100644
index 0000000000..1f54bdc8be
--- /dev/null
+++ b/src/components/forms/DateField/index.shared.tsx
@@ -0,0 +1,99 @@
+import React from 'react'
+import {Pressable, View} from 'react-native'
+
+import {android, atoms as a, useTheme, web} from '#/alf'
+import * as TextField from '#/components/forms/TextField'
+import {useInteractionState} from '#/components/hooks/useInteractionState'
+import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays'
+import {Text} from '#/components/Typography'
+import {localizeDate} from './utils'
+
+// looks like a TextField.Input, but is just a button. It'll do something different on each platform on press
+// iOS: open a dialog with an inline date picker
+// Android: open the date picker modal
+
+export function DateFieldButton({
+ label,
+ value,
+ onPress,
+ isInvalid,
+ accessibilityHint,
+}: {
+ label: string
+ value: string
+ onPress: () => void
+ isInvalid?: boolean
+ accessibilityHint?: string
+}) {
+ const t = useTheme()
+
+ const {
+ state: pressed,
+ onIn: onPressIn,
+ onOut: onPressOut,
+ } = useInteractionState()
+ const {
+ state: hovered,
+ onIn: onHoverIn,
+ onOut: onHoverOut,
+ } = useInteractionState()
+ const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
+
+ const {chromeHover, chromeFocus, chromeError, chromeErrorHover} =
+ TextField.useSharedInputStyles()
+
+ return (
+
+
+
+
+ {localizeDate(value)}
+
+
+
+ )
+}
diff --git a/src/components/forms/DateField/index.tsx b/src/components/forms/DateField/index.tsx
index 49e47a01e3..5662bb5941 100644
--- a/src/components/forms/DateField/index.tsx
+++ b/src/components/forms/DateField/index.tsx
@@ -1,11 +1,16 @@
import React from 'react'
import {View} from 'react-native'
-
-import {useTheme, atoms} from '#/alf'
-import * as TextField from '#/components/forms/TextField'
-import {toSimpleDateString} from '#/components/forms/DateField/utils'
-import {DateFieldProps} from '#/components/forms/DateField/types'
import DatePicker from 'react-native-date-picker'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {DateFieldProps} from '#/components/forms/DateField/types'
+import {toSimpleDateString} from '#/components/forms/DateField/utils'
+import * as TextField from '#/components/forms/TextField'
+import {DateFieldButton} from './index.shared'
export * as utils from '#/components/forms/DateField/utils'
export const Label = TextField.Label
@@ -22,8 +27,12 @@ export function DateField({
onChangeDate,
testID,
label,
+ isInvalid,
+ accessibilityHint,
}: DateFieldProps) {
+ const {_} = useLingui()
const t = useTheme()
+ const control = Dialog.useDialogControl()
const onChangeInternal = React.useCallback(
(date: Date | undefined) => {
@@ -36,17 +45,44 @@ export function DateField({
)
return (
-
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+ >
)
}
diff --git a/src/components/forms/DateField/index.web.tsx b/src/components/forms/DateField/index.web.tsx
index 32f38a5d16..982d32711a 100644
--- a/src/components/forms/DateField/index.web.tsx
+++ b/src/components/forms/DateField/index.web.tsx
@@ -1,11 +1,12 @@
import React from 'react'
-import {TextInput, TextInputProps, StyleSheet} from 'react-native'
+import {StyleSheet, TextInput, TextInputProps} from 'react-native'
// @ts-ignore
import {unstable_createElement} from 'react-native-web'
-import * as TextField from '#/components/forms/TextField'
-import {toSimpleDateString} from '#/components/forms/DateField/utils'
import {DateFieldProps} from '#/components/forms/DateField/types'
+import {toSimpleDateString} from '#/components/forms/DateField/utils'
+import * as TextField from '#/components/forms/TextField'
+import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays'
export * as utils from '#/components/forms/DateField/utils'
export const Label = TextField.Label
@@ -37,6 +38,7 @@ export function DateField({
label,
isInvalid,
testID,
+ accessibilityHint,
}: DateFieldProps) {
const handleOnChange = React.useCallback(
(e: any) => {
@@ -52,12 +54,14 @@ export function DateField({
return (
+
{}}
testID={testID}
+ accessibilityHint={accessibilityHint}
/>
)
diff --git a/src/components/forms/DateField/types.ts b/src/components/forms/DateField/types.ts
index 129f5672d4..5400cf9037 100644
--- a/src/components/forms/DateField/types.ts
+++ b/src/components/forms/DateField/types.ts
@@ -4,4 +4,5 @@ export type DateFieldProps = {
label: string
isInvalid?: boolean
testID?: string
+ accessibilityHint?: string
}
diff --git a/src/components/forms/FormError.tsx b/src/components/forms/FormError.tsx
new file mode 100644
index 0000000000..9e72df8790
--- /dev/null
+++ b/src/components/forms/FormError.tsx
@@ -0,0 +1,30 @@
+import React from 'react'
+import {View} from 'react-native'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
+import {Text} from '#/components/Typography'
+
+export function FormError({error}: {error?: string}) {
+ const t = useTheme()
+
+ if (!error) return null
+
+ return (
+
+
+
+
+ {error}
+
+
+
+ )
+}
diff --git a/src/components/forms/HostingProvider.tsx b/src/components/forms/HostingProvider.tsx
new file mode 100644
index 0000000000..f2d11062a5
--- /dev/null
+++ b/src/components/forms/HostingProvider.tsx
@@ -0,0 +1,95 @@
+import React from 'react'
+import {Keyboard, View} from 'react-native'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {toNiceDomain} from '#/lib/strings/url-helpers'
+import {isAndroid} from '#/platform/detection'
+import {ServerInputDialog} from '#/view/com/auth/server-input'
+import {atoms as a, useTheme} from '#/alf'
+import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
+import {PencilLine_Stroke2_Corner0_Rounded as Pencil} from '#/components/icons/Pencil'
+import {Button} from '../Button'
+import {useDialogControl} from '../Dialog'
+import {Text} from '../Typography'
+
+export function HostingProvider({
+ serviceUrl,
+ onSelectServiceUrl,
+ onOpenDialog,
+}: {
+ serviceUrl: string
+ onSelectServiceUrl: (provider: string) => void
+ onOpenDialog?: () => void
+}) {
+ const serverInputControl = useDialogControl()
+ const t = useTheme()
+ const {_} = useLingui()
+
+ const onPressSelectService = React.useCallback(() => {
+ Keyboard.dismiss()
+ serverInputControl.open()
+ if (onOpenDialog) {
+ onOpenDialog()
+ }
+ }, [onOpenDialog, serverInputControl])
+
+ return (
+ <>
+
+
+ >
+ )
+}
diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx
index b37f4bfae9..376883c9dd 100644
--- a/src/components/forms/TextField.tsx
+++ b/src/components/forms/TextField.tsx
@@ -14,6 +14,7 @@ import {useTheme, atoms as a, web, android} from '#/alf'
import {Text} from '#/components/Typography'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Props as SVGIconProps} from '#/components/icons/common'
+import {mergeRefs} from '#/lib/merge-refs'
const Context = React.createContext<{
inputRef: React.RefObject | null
@@ -125,9 +126,10 @@ export function useSharedInputStyles() {
export type InputProps = Omit & {
label: string
- value: string
- onChangeText: (value: string) => void
+ value?: string
+ onChangeText?: (value: string) => void
isInvalid?: boolean
+ inputRef?: React.RefObject
}
export function createInput(Component: typeof TextInput) {
@@ -137,6 +139,7 @@ export function createInput(Component: typeof TextInput) {
value,
onChangeText,
isInvalid,
+ inputRef,
...rest
}: InputProps) {
const t = useTheme()
@@ -161,19 +164,22 @@ export function createInput(Component: typeof TextInput) {
)
}
+ const refs = mergeRefs([ctx.inputRef, inputRef!].filter(Boolean))
+
return (
<>
}) {
= 3,
totalLength: fullHandle.length <= 253,
}
diff --git a/src/screens/Login/ChooseAccountForm.tsx b/src/screens/Login/ChooseAccountForm.tsx
new file mode 100644
index 0000000000..d0d4c784d0
--- /dev/null
+++ b/src/screens/Login/ChooseAccountForm.tsx
@@ -0,0 +1,188 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useAnalytics} from '#/lib/analytics/analytics'
+import {logEvent} from '#/lib/statsig/statsig'
+import {colors} from '#/lib/styles'
+import {useProfileQuery} from '#/state/queries/profile'
+import {SessionAccount, useSession, useSessionApi} from '#/state/session'
+import {useLoggedOutViewControls} from '#/state/shell/logged-out'
+import * as Toast from '#/view/com/util/Toast'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {atoms as a, useTheme} from '#/alf'
+import {Button} from '#/components/Button'
+import * as TextField from '#/components/forms/TextField'
+import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
+import {ChevronRight_Stroke2_Corner0_Rounded as Chevron} from '#/components/icons/Chevron'
+import {Text} from '#/components/Typography'
+import {FormContainer} from './FormContainer'
+
+function AccountItem({
+ account,
+ onSelect,
+ isCurrentAccount,
+}: {
+ account: SessionAccount
+ onSelect: (account: SessionAccount) => void
+ isCurrentAccount: boolean
+}) {
+ const t = useTheme()
+ const {_} = useLingui()
+ const {data: profile} = useProfileQuery({did: account.did})
+
+ const onPress = React.useCallback(() => {
+ onSelect(account)
+ }, [account, onSelect])
+
+ return (
+
+ )
+}
+export const ChooseAccountForm = ({
+ onSelectAccount,
+ onPressBack,
+}: {
+ onSelectAccount: (account?: SessionAccount) => void
+ onPressBack: () => void
+}) => {
+ const {track, screen} = useAnalytics()
+ const {_} = useLingui()
+ const t = useTheme()
+ const {accounts, currentAccount} = useSession()
+ const {initSession} = useSessionApi()
+ const {setShowLoggedOut} = useLoggedOutViewControls()
+
+ React.useEffect(() => {
+ screen('Choose Account')
+ }, [screen])
+
+ const onSelect = React.useCallback(
+ async (account: SessionAccount) => {
+ if (account.accessJwt) {
+ if (account.did === currentAccount?.did) {
+ setShowLoggedOut(false)
+ Toast.show(_(msg`Already signed in as @${account.handle}`))
+ } else {
+ await initSession(account)
+ logEvent('account:loggedIn', {
+ logContext: 'ChooseAccountForm',
+ withPassword: false,
+ })
+ track('Sign In', {resumedSession: true})
+ setTimeout(() => {
+ Toast.show(_(msg`Signed in as @${account.handle}`))
+ }, 100)
+ }
+ } else {
+ onSelectAccount(account)
+ }
+ },
+ [currentAccount, track, initSession, onSelectAccount, setShowLoggedOut, _],
+ )
+
+ return (
+ Select account}>
+
+
+ Sign in as...
+
+
+ {accounts.map(account => (
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/screens/Login/ForgotPasswordForm.tsx b/src/screens/Login/ForgotPasswordForm.tsx
new file mode 100644
index 0000000000..580452e75b
--- /dev/null
+++ b/src/screens/Login/ForgotPasswordForm.tsx
@@ -0,0 +1,184 @@
+import React, {useEffect, useState} from 'react'
+import {ActivityIndicator, Keyboard, View} from 'react-native'
+import {ComAtprotoServerDescribeServer} from '@atproto/api'
+import {BskyAgent} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import * as EmailValidator from 'email-validator'
+
+import {useAnalytics} from '#/lib/analytics/analytics'
+import {isNetworkError} from '#/lib/strings/errors'
+import {cleanError} from '#/lib/strings/errors'
+import {logger} from '#/logger'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import {FormError} from '#/components/forms/FormError'
+import {HostingProvider} from '#/components/forms/HostingProvider'
+import * as TextField from '#/components/forms/TextField'
+import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
+import {Text} from '#/components/Typography'
+import {FormContainer} from './FormContainer'
+
+type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
+
+export const ForgotPasswordForm = ({
+ error,
+ serviceUrl,
+ serviceDescription,
+ setError,
+ setServiceUrl,
+ onPressBack,
+ onEmailSent,
+}: {
+ error: string
+ serviceUrl: string
+ serviceDescription: ServiceDescription | undefined
+ setError: (v: string) => void
+ setServiceUrl: (v: string) => void
+ onPressBack: () => void
+ onEmailSent: () => void
+}) => {
+ const t = useTheme()
+ const [isProcessing, setIsProcessing] = useState(false)
+ const [email, setEmail] = useState('')
+ const {screen} = useAnalytics()
+ const {_} = useLingui()
+
+ useEffect(() => {
+ screen('Signin:ForgotPassword')
+ }, [screen])
+
+ const onPressSelectService = React.useCallback(() => {
+ Keyboard.dismiss()
+ }, [])
+
+ const onPressNext = async () => {
+ if (!EmailValidator.validate(email)) {
+ return setError(_(msg`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(
+ _(
+ msg`Unable to contact your service. Please check your Internet connection.`,
+ ),
+ )
+ } else {
+ setError(cleanError(errMsg))
+ }
+ }
+ }
+
+ return (
+ Reset password}>
+
+
+ Hosting provider
+
+
+
+
+
+ Email address
+
+
+
+
+
+
+
+
+
+ Enter the email you used to create your account. We'll send you a
+ "reset code" so you can set a new password.
+
+
+
+
+
+
+
+
+ {!serviceDescription || isProcessing ? (
+
+ ) : (
+
+ )}
+ {!serviceDescription || isProcessing ? (
+
+ Processing...
+
+ ) : undefined}
+
+
+
+
+
+ )
+}
diff --git a/src/screens/Login/FormContainer.tsx b/src/screens/Login/FormContainer.tsx
new file mode 100644
index 0000000000..e28b48b402
--- /dev/null
+++ b/src/screens/Login/FormContainer.tsx
@@ -0,0 +1,53 @@
+import React from 'react'
+import {
+ ScrollView,
+ type StyleProp,
+ StyleSheet,
+ View,
+ type ViewStyle,
+} from 'react-native'
+
+import {isWeb} from '#/platform/detection'
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {Text} from '#/components/Typography'
+
+export function FormContainer({
+ testID,
+ title,
+ children,
+ style,
+ contentContainerStyle,
+}: {
+ testID?: string
+ title?: React.ReactNode
+ children: React.ReactNode
+ style?: StyleProp
+ contentContainerStyle?: StyleProp
+}) {
+ const {gtMobile} = useBreakpoints()
+ const t = useTheme()
+ return (
+
+
+ {title && !gtMobile && (
+
+ {title}
+
+ )}
+ {children}
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ maxHeight: {
+ // @ts-ignore web only -prf
+ maxHeight: isWeb ? '100vh' : undefined,
+ height: !isWeb ? '100%' : undefined,
+ },
+})
diff --git a/src/screens/Login/LoginForm.tsx b/src/screens/Login/LoginForm.tsx
new file mode 100644
index 0000000000..6bf215ee56
--- /dev/null
+++ b/src/screens/Login/LoginForm.tsx
@@ -0,0 +1,266 @@
+import React, {useRef, useState} from 'react'
+import {
+ ActivityIndicator,
+ Keyboard,
+ LayoutAnimation,
+ TextInput,
+ View,
+} from 'react-native'
+import {ComAtprotoServerDescribeServer} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useAnalytics} from '#/lib/analytics/analytics'
+import {isNetworkError} from '#/lib/strings/errors'
+import {cleanError} from '#/lib/strings/errors'
+import {createFullHandle} from '#/lib/strings/handles'
+import {logger} from '#/logger'
+import {useSessionApi} from '#/state/session'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {FormError} from '#/components/forms/FormError'
+import {HostingProvider} from '#/components/forms/HostingProvider'
+import * as TextField from '#/components/forms/TextField'
+import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
+import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+import {FormContainer} from './FormContainer'
+
+type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
+
+export const LoginForm = ({
+ error,
+ serviceUrl,
+ serviceDescription,
+ initialHandle,
+ setError,
+ setServiceUrl,
+ onPressRetryConnect,
+ onPressBack,
+ onPressForgotPassword,
+}: {
+ error: string
+ serviceUrl: string
+ serviceDescription: ServiceDescription | undefined
+ initialHandle: string
+ setError: (v: string) => void
+ setServiceUrl: (v: string) => void
+ onPressRetryConnect: () => void
+ onPressBack: () => void
+ onPressForgotPassword: () => void
+}) => {
+ const {track} = useAnalytics()
+ const t = useTheme()
+ const [isProcessing, setIsProcessing] = useState(false)
+ const [identifier, setIdentifier] = useState(initialHandle)
+ const [password, setPassword] = useState('')
+ const passwordInputRef = useRef(null)
+ const {_} = useLingui()
+ const {login} = useSessionApi()
+
+ const onPressSelectService = React.useCallback(() => {
+ Keyboard.dismiss()
+ track('Signin:PressedSelectService')
+ }, [track])
+
+ const onPressNext = async () => {
+ if (isProcessing) return
+ Keyboard.dismiss()
+ LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
+ setError('')
+ setIsProcessing(true)
+
+ try {
+ // try to guess the handle if the user just gave their own username
+ let fullIdent = identifier
+ if (
+ !identifier.includes('@') && // not an email
+ !identifier.includes('.') && // not a domain
+ serviceDescription &&
+ serviceDescription.availableUserDomains.length > 0
+ ) {
+ let matched = false
+ for (const domain of serviceDescription.availableUserDomains) {
+ if (fullIdent.endsWith(domain)) {
+ matched = true
+ }
+ }
+ if (!matched) {
+ fullIdent = createFullHandle(
+ identifier,
+ serviceDescription.availableUserDomains[0],
+ )
+ }
+ }
+
+ // TODO remove double login
+ await login(
+ {
+ service: serviceUrl,
+ identifier: fullIdent,
+ password,
+ },
+ 'LoginForm',
+ )
+ } catch (e: any) {
+ const errMsg = e.toString()
+ LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
+ setIsProcessing(false)
+ if (errMsg.includes('Authentication Required')) {
+ logger.debug('Failed to login due to invalid credentials', {
+ error: errMsg,
+ })
+ setError(_(msg`Invalid username or password`))
+ } else if (isNetworkError(e)) {
+ logger.warn('Failed to login due to network error', {error: errMsg})
+ setError(
+ _(
+ msg`Unable to contact your service. Please check your Internet connection.`,
+ ),
+ )
+ } else {
+ logger.warn('Failed to login', {error: errMsg})
+ setError(cleanError(errMsg))
+ }
+ }
+ }
+
+ const isReady = !!serviceDescription && !!identifier && !!password
+ return (
+ Sign in}>
+
+
+ Hosting provider
+
+
+
+
+
+ Account
+
+
+
+
+ {
+ passwordInputRef.current?.focus()
+ }}
+ blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field
+ value={identifier}
+ onChangeText={str =>
+ setIdentifier((str || '').toLowerCase().trim())
+ }
+ editable={!isProcessing}
+ accessibilityHint={_(
+ msg`Input the username or email address you used at signup`,
+ )}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {!serviceDescription && error ? (
+
+ ) : !serviceDescription ? (
+ <>
+
+
+ Connecting...
+
+ >
+ ) : isReady ? (
+
+ ) : undefined}
+
+
+ )
+}
diff --git a/src/screens/Login/PasswordUpdatedForm.tsx b/src/screens/Login/PasswordUpdatedForm.tsx
new file mode 100644
index 0000000000..5407f3f1e3
--- /dev/null
+++ b/src/screens/Login/PasswordUpdatedForm.tsx
@@ -0,0 +1,50 @@
+import React, {useEffect} from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useAnalytics} from '#/lib/analytics/analytics'
+import {atoms as a, useBreakpoints} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import {Text} from '#/components/Typography'
+import {FormContainer} from './FormContainer'
+
+export const PasswordUpdatedForm = ({
+ onPressNext,
+}: {
+ onPressNext: () => void
+}) => {
+ const {screen} = useAnalytics()
+ const {_} = useLingui()
+ const {gtMobile} = useBreakpoints()
+
+ useEffect(() => {
+ screen('Signin:PasswordUpdatedForm')
+ }, [screen])
+
+ return (
+
+
+ Password updated!
+
+
+ You can now sign in with your new password.
+
+
+
+
+
+ )
+}
diff --git a/src/screens/Login/ScreenTransition.tsx b/src/screens/Login/ScreenTransition.tsx
new file mode 100644
index 0000000000..ab0a223678
--- /dev/null
+++ b/src/screens/Login/ScreenTransition.tsx
@@ -0,0 +1,10 @@
+import React from 'react'
+import Animated, {FadeInRight, FadeOutLeft} from 'react-native-reanimated'
+
+export function ScreenTransition({children}: {children: React.ReactNode}) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/screens/Login/ScreenTransition.web.tsx b/src/screens/Login/ScreenTransition.web.tsx
new file mode 100644
index 0000000000..4583720aa8
--- /dev/null
+++ b/src/screens/Login/ScreenTransition.web.tsx
@@ -0,0 +1 @@
+export {Fragment as ScreenTransition} from 'react'
diff --git a/src/screens/Login/SetNewPasswordForm.tsx b/src/screens/Login/SetNewPasswordForm.tsx
new file mode 100644
index 0000000000..e7b4886550
--- /dev/null
+++ b/src/screens/Login/SetNewPasswordForm.tsx
@@ -0,0 +1,192 @@
+import React, {useEffect, useState} from 'react'
+import {ActivityIndicator, View} from 'react-native'
+import {BskyAgent} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useAnalytics} from '#/lib/analytics/analytics'
+import {isNetworkError} from '#/lib/strings/errors'
+import {cleanError} from '#/lib/strings/errors'
+import {checkAndFormatResetCode} from '#/lib/strings/password'
+import {logger} from '#/logger'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import {FormError} from '#/components/forms/FormError'
+import * as TextField from '#/components/forms/TextField'
+import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock'
+import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket'
+import {Text} from '#/components/Typography'
+import {FormContainer} from './FormContainer'
+
+export const SetNewPasswordForm = ({
+ error,
+ serviceUrl,
+ setError,
+ onPressBack,
+ onPasswordSet,
+}: {
+ error: string
+ serviceUrl: string
+ setError: (v: string) => void
+ onPressBack: () => void
+ onPasswordSet: () => void
+}) => {
+ const {screen} = useAnalytics()
+ const {_} = useLingui()
+ const t = useTheme()
+
+ useEffect(() => {
+ screen('Signin:SetNewPasswordForm')
+ }, [screen])
+
+ const [isProcessing, setIsProcessing] = useState(false)
+ const [resetCode, setResetCode] = useState('')
+ const [password, setPassword] = useState('')
+
+ const onPressNext = async () => {
+ // Check that the code is correct. We do this again just incase the user enters the code after their pw and we
+ // don't get to call onBlur first
+ const formattedCode = checkAndFormatResetCode(resetCode)
+ // TODO Better password strength check
+ if (!formattedCode || !password) {
+ setError(
+ _(
+ msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`,
+ ),
+ )
+ return
+ }
+
+ setError('')
+ setIsProcessing(true)
+
+ try {
+ const agent = new BskyAgent({service: serviceUrl})
+ await agent.com.atproto.server.resetPassword({
+ token: formattedCode,
+ password,
+ })
+ onPasswordSet()
+ } catch (e: any) {
+ const errMsg = e.toString()
+ logger.warn('Failed to set new password', {error: e})
+ setIsProcessing(false)
+ if (isNetworkError(e)) {
+ setError(
+ _(
+ msg`Unable to contact your service. Please check your Internet connection.`,
+ ),
+ )
+ } else {
+ setError(cleanError(errMsg))
+ }
+ }
+ }
+
+ const onBlur = () => {
+ const formattedCode = checkAndFormatResetCode(resetCode)
+ if (!formattedCode) {
+ setError(
+ _(
+ msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`,
+ ),
+ )
+ return
+ }
+ setResetCode(formattedCode)
+ }
+
+ return (
+ Set new password}>
+
+
+ You will receive an email with a "reset code." Enter that code here,
+ then enter your new password.
+
+
+
+
+ Reset code
+
+
+ setError('')}
+ onBlur={onBlur}
+ editable={!isProcessing}
+ accessibilityHint={_(
+ msg`Input code sent to your email for password reset`,
+ )}
+ />
+
+
+
+
+ New password
+
+
+
+
+
+
+
+
+
+
+
+ {isProcessing ? (
+
+ ) : (
+
+ )}
+ {isProcessing ? (
+
+ Updating...
+
+ ) : undefined}
+
+
+ )
+}
diff --git a/src/screens/Login/index.tsx b/src/screens/Login/index.tsx
new file mode 100644
index 0000000000..49f7518b0d
--- /dev/null
+++ b/src/screens/Login/index.tsx
@@ -0,0 +1,174 @@
+import React from 'react'
+import {KeyboardAvoidingView} from 'react-native'
+import {LayoutAnimationConfig} from 'react-native-reanimated'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useAnalytics} from '#/lib/analytics/analytics'
+import {DEFAULT_SERVICE} from '#/lib/constants'
+import {logger} from '#/logger'
+import {useServiceQuery} from '#/state/queries/service'
+import {SessionAccount, useSession} from '#/state/session'
+import {useLoggedOutView} from '#/state/shell/logged-out'
+import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout'
+import {ForgotPasswordForm} from '#/screens/Login/ForgotPasswordForm'
+import {LoginForm} from '#/screens/Login/LoginForm'
+import {PasswordUpdatedForm} from '#/screens/Login/PasswordUpdatedForm'
+import {SetNewPasswordForm} from '#/screens/Login/SetNewPasswordForm'
+import {atoms as a} from '#/alf'
+import {ChooseAccountForm} from './ChooseAccountForm'
+import {ScreenTransition} from './ScreenTransition'
+
+enum Forms {
+ Login,
+ ChooseAccount,
+ ForgotPassword,
+ SetNewPassword,
+ PasswordUpdated,
+}
+
+export const Login = ({onPressBack}: {onPressBack: () => void}) => {
+ const {_} = useLingui()
+
+ const {accounts} = useSession()
+ const {track} = useAnalytics()
+ const {requestedAccountSwitchTo} = useLoggedOutView()
+ const requestedAccount = accounts.find(
+ acc => acc.did === requestedAccountSwitchTo,
+ )
+
+ const [error, setError] = React.useState('')
+ const [serviceUrl, setServiceUrl] = React.useState(
+ requestedAccount?.service || DEFAULT_SERVICE,
+ )
+ const [initialHandle, setInitialHandle] = React.useState(
+ requestedAccount?.handle || '',
+ )
+ const [currentForm, setCurrentForm] = React.useState(
+ requestedAccount
+ ? Forms.Login
+ : accounts.length
+ ? Forms.ChooseAccount
+ : Forms.Login,
+ )
+
+ const {
+ data: serviceDescription,
+ error: serviceError,
+ refetch: refetchService,
+ } = useServiceQuery(serviceUrl)
+
+ const onSelectAccount = (account?: SessionAccount) => {
+ if (account?.service) {
+ setServiceUrl(account.service)
+ }
+ setInitialHandle(account?.handle || '')
+ setCurrentForm(Forms.Login)
+ }
+
+ const gotoForm = (form: Forms) => {
+ setError('')
+ setCurrentForm(form)
+ }
+
+ React.useEffect(() => {
+ if (serviceError) {
+ setError(
+ _(
+ msg`Unable to contact your service. Please check your Internet connection.`,
+ ),
+ )
+ logger.warn(`Failed to fetch service description for ${serviceUrl}`, {
+ error: String(serviceError),
+ })
+ } else {
+ setError('')
+ }
+ }, [serviceError, serviceUrl, _])
+
+ const onPressForgotPassword = () => {
+ track('Signin:PressedForgotPassword')
+ setCurrentForm(Forms.ForgotPassword)
+ }
+
+ let content = null
+ let title = ''
+ let description = ''
+
+ switch (currentForm) {
+ case Forms.Login:
+ title = _(msg`Sign in`)
+ description = _(msg`Enter your username and password`)
+ content = (
+
+ accounts.length ? gotoForm(Forms.ChooseAccount) : onPressBack()
+ }
+ onPressForgotPassword={onPressForgotPassword}
+ onPressRetryConnect={refetchService}
+ />
+ )
+ break
+ case Forms.ChooseAccount:
+ title = _(msg`Sign in`)
+ description = _(msg`Select from an existing account`)
+ content = (
+
+ )
+ break
+ case Forms.ForgotPassword:
+ title = _(msg`Forgot Password`)
+ description = _(msg`Let's get your password reset!`)
+ content = (
+ gotoForm(Forms.Login)}
+ onEmailSent={() => gotoForm(Forms.SetNewPassword)}
+ />
+ )
+ break
+ case Forms.SetNewPassword:
+ title = _(msg`Forgot Password`)
+ description = _(msg`Let's get your password reset!`)
+ content = (
+ gotoForm(Forms.ForgotPassword)}
+ onPasswordSet={() => gotoForm(Forms.PasswordUpdated)}
+ />
+ )
+ break
+ case Forms.PasswordUpdated:
+ title = _(msg`Password updated`)
+ description = _(msg`You can now sign in with your new password.`)
+ content = (
+ gotoForm(Forms.Login)} />
+ )
+ break
+ }
+
+ return (
+
+
+
+ {content}
+
+
+
+ )
+}
diff --git a/src/view/com/auth/create/CaptchaWebView.tsx b/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx
similarity index 86%
rename from src/view/com/auth/create/CaptchaWebView.tsx
rename to src/screens/Signup/StepCaptcha/CaptchaWebView.tsx
index b0de8b4a4b..50918c4ce6 100644
--- a/src/view/com/auth/create/CaptchaWebView.tsx
+++ b/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx
@@ -1,8 +1,9 @@
import React from 'react'
+import {StyleSheet} from 'react-native'
import {WebView, WebViewNavigation} from 'react-native-webview'
import {ShouldStartLoadRequest} from 'react-native-webview/lib/WebViewTypes'
-import {StyleSheet} from 'react-native'
-import {CreateAccountState} from 'view/com/auth/create/state'
+
+import {SignupState} from '#/screens/Signup/state'
const ALLOWED_HOSTS = [
'bsky.social',
@@ -17,24 +18,24 @@ const ALLOWED_HOSTS = [
export function CaptchaWebView({
url,
stateParam,
- uiState,
+ state,
onSuccess,
onError,
}: {
url: string
stateParam: string
- uiState?: CreateAccountState
+ state?: SignupState
onSuccess: (code: string) => void
onError: () => void
}) {
const redirectHost = React.useMemo(() => {
- if (!uiState?.serviceUrl) return 'bsky.app'
+ if (!state?.serviceUrl) return 'bsky.app'
- return uiState?.serviceUrl &&
- new URL(uiState?.serviceUrl).host === 'staging.bsky.dev'
+ return state?.serviceUrl &&
+ new URL(state?.serviceUrl).host === 'staging.bsky.dev'
? 'staging.bsky.app'
: 'bsky.app'
- }, [uiState?.serviceUrl])
+ }, [state?.serviceUrl])
const wasSuccessful = React.useRef(false)
diff --git a/src/view/com/auth/create/CaptchaWebView.web.tsx b/src/screens/Signup/StepCaptcha/CaptchaWebView.web.tsx
similarity index 100%
rename from src/view/com/auth/create/CaptchaWebView.web.tsx
rename to src/screens/Signup/StepCaptcha/CaptchaWebView.web.tsx
diff --git a/src/screens/Signup/StepCaptcha/index.tsx b/src/screens/Signup/StepCaptcha/index.tsx
new file mode 100644
index 0000000000..311c697e7b
--- /dev/null
+++ b/src/screens/Signup/StepCaptcha/index.tsx
@@ -0,0 +1,95 @@
+import React from 'react'
+import {ActivityIndicator, StyleSheet, View} from 'react-native'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {nanoid} from 'nanoid/non-secure'
+
+import {createFullHandle} from '#/lib/strings/handles'
+import {isWeb} from '#/platform/detection'
+import {ScreenTransition} from '#/screens/Login/ScreenTransition'
+import {useSignupContext, useSubmitSignup} from '#/screens/Signup/state'
+import {CaptchaWebView} from '#/screens/Signup/StepCaptcha/CaptchaWebView'
+import {atoms as a, useTheme} from '#/alf'
+import {FormError} from '#/components/forms/FormError'
+
+const CAPTCHA_PATH = '/gate/signup'
+
+export function StepCaptcha() {
+ const {_} = useLingui()
+ const theme = useTheme()
+ const {state, dispatch} = useSignupContext()
+ const submit = useSubmitSignup({state, dispatch})
+
+ const [completed, setCompleted] = React.useState(false)
+
+ const stateParam = React.useMemo(() => nanoid(15), [])
+ const url = React.useMemo(() => {
+ const newUrl = new URL(state.serviceUrl)
+ newUrl.pathname = CAPTCHA_PATH
+ newUrl.searchParams.set(
+ 'handle',
+ createFullHandle(state.handle, state.userDomain),
+ )
+ newUrl.searchParams.set('state', stateParam)
+ newUrl.searchParams.set('colorScheme', theme.name)
+
+ return newUrl.href
+ }, [state.serviceUrl, state.handle, state.userDomain, stateParam, theme.name])
+
+ const onSuccess = React.useCallback(
+ (code: string) => {
+ setCompleted(true)
+ submit(code)
+ },
+ [submit],
+ )
+
+ const onError = React.useCallback(() => {
+ dispatch({
+ type: 'setError',
+ value: _(msg`Error receiving captcha response.`),
+ })
+ }, [_, dispatch])
+
+ return (
+
+
+
+ {!completed ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ error: {
+ borderRadius: 6,
+ marginTop: 10,
+ },
+ // @ts-expect-error: Suppressing error due to incomplete `ViewStyle` type definition in react-native-web, missing `cursor` prop as discussed in https://github.com/necolas/react-native-web/issues/832.
+ touchable: {
+ ...(isWeb && {cursor: 'pointer'}),
+ },
+ container: {
+ minHeight: 500,
+ width: '100%',
+ paddingBottom: 20,
+ overflow: 'hidden',
+ },
+ center: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+})
diff --git a/src/screens/Signup/StepHandle.tsx b/src/screens/Signup/StepHandle.tsx
new file mode 100644
index 0000000000..44a33b8331
--- /dev/null
+++ b/src/screens/Signup/StepHandle.tsx
@@ -0,0 +1,134 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useFocusEffect} from '@react-navigation/native'
+
+import {
+ createFullHandle,
+ IsValidHandle,
+ validateHandle,
+} from '#/lib/strings/handles'
+import {ScreenTransition} from '#/screens/Login/ScreenTransition'
+import {useSignupContext} from '#/screens/Signup/state'
+import {atoms as a, useTheme} from '#/alf'
+import * as TextField from '#/components/forms/TextField'
+import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
+import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
+import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times'
+import {Text} from '#/components/Typography'
+
+export function StepHandle() {
+ const {_} = useLingui()
+ const t = useTheme()
+ const {state, dispatch} = useSignupContext()
+
+ const [validCheck, setValidCheck] = React.useState({
+ handleChars: false,
+ hyphenStartOrEnd: false,
+ frontLength: false,
+ totalLength: true,
+ overall: false,
+ })
+
+ useFocusEffect(
+ React.useCallback(() => {
+ setValidCheck(validateHandle(state.handle, state.userDomain))
+ }, [state.handle, state.userDomain]),
+ )
+
+ const onHandleChange = React.useCallback(
+ (value: string) => {
+ if (state.error) {
+ dispatch({type: 'setError', value: ''})
+ }
+
+ dispatch({
+ type: 'setHandle',
+ value,
+ })
+ },
+ [dispatch, state.error],
+ )
+
+ return (
+
+
+
+
+
+
+
+
+
+ Your full handle will be{' '}
+
+ @{createFullHandle(state.handle, state.userDomain)}
+
+
+
+
+ {state.error ? (
+
+
+ {state.error}
+
+ ) : undefined}
+ {validCheck.hyphenStartOrEnd ? (
+
+
+
+ Only contains letters, numbers, and hyphens
+
+
+ ) : (
+
+
+
+ Doesn't begin or end with a hyphen
+
+
+ )}
+
+
+ {!validCheck.totalLength ? (
+
+ No longer than 253 characters
+
+ ) : (
+
+ At least 3 characters
+
+ )}
+
+
+
+
+ )
+}
+
+function IsValidIcon({valid}: {valid: boolean}) {
+ const t = useTheme()
+ if (!valid) {
+ return
+ }
+ return
+}
diff --git a/src/screens/Signup/StepInfo/Policies.tsx b/src/screens/Signup/StepInfo/Policies.tsx
new file mode 100644
index 0000000000..8a656203fc
--- /dev/null
+++ b/src/screens/Signup/StepInfo/Policies.tsx
@@ -0,0 +1,97 @@
+import React from 'react'
+import {View} from 'react-native'
+import {ComAtprotoServerDescribeServer} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {atoms as a, useTheme} from '#/alf'
+import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
+import {InlineLink} from '#/components/Link'
+import {Text} from '#/components/Typography'
+
+export const Policies = ({
+ serviceDescription,
+ needsGuardian,
+ under13,
+}: {
+ serviceDescription: ComAtprotoServerDescribeServer.OutputSchema
+ needsGuardian: boolean
+ under13: boolean
+}) => {
+ const t = useTheme()
+ const {_} = useLingui()
+
+ if (!serviceDescription) {
+ return
+ }
+
+ const tos = validWebLink(serviceDescription.links?.termsOfService)
+ const pp = validWebLink(serviceDescription.links?.privacyPolicy)
+
+ if (!tos && !pp) {
+ return (
+
+
+
+
+
+ This service has not provided terms of service or a privacy policy.
+
+
+
+ )
+ }
+
+ const els = []
+ if (tos) {
+ els.push(
+
+ {_(msg`Terms of Service`)}
+ ,
+ )
+ }
+ if (pp) {
+ els.push(
+
+ {_(msg`Privacy Policy`)}
+ ,
+ )
+ }
+ if (els.length === 2) {
+ els.splice(
+ 1,
+ 0,
+
+ {' '}
+ and{' '}
+ ,
+ )
+ }
+
+ return (
+
+
+ By creating an account you agree to the {els}.
+
+
+ {under13 ? (
+
+ You must be 13 years of age or older to sign up.
+
+ ) : needsGuardian ? (
+
+
+ If you are not yet an adult according to the laws of your country,
+ your parent or legal guardian must read these Terms on your behalf.
+
+
+ ) : undefined}
+
+ )
+}
+
+function validWebLink(url?: string): string | undefined {
+ return url && (url.startsWith('http://') || url.startsWith('https://'))
+ ? url
+ : undefined
+}
diff --git a/src/screens/Signup/StepInfo/index.tsx b/src/screens/Signup/StepInfo/index.tsx
new file mode 100644
index 0000000000..136592a0b1
--- /dev/null
+++ b/src/screens/Signup/StepInfo/index.tsx
@@ -0,0 +1,146 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {logger} from '#/logger'
+import {ScreenTransition} from '#/screens/Login/ScreenTransition'
+import {is13, is18, useSignupContext} from '#/screens/Signup/state'
+import {Policies} from '#/screens/Signup/StepInfo/Policies'
+import {atoms as a} from '#/alf'
+import * as DateField from '#/components/forms/DateField'
+import {FormError} from '#/components/forms/FormError'
+import {HostingProvider} from '#/components/forms/HostingProvider'
+import * as TextField from '#/components/forms/TextField'
+import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope'
+import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock'
+import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket'
+import {Loader} from '#/components/Loader'
+
+function sanitizeDate(date: Date): Date {
+ if (!date || date.toString() === 'Invalid Date') {
+ logger.error(`Create account: handled invalid date for birthDate`, {
+ hasDate: !!date,
+ })
+ return new Date()
+ }
+ return date
+}
+
+export function StepInfo() {
+ const {_} = useLingui()
+ const {state, dispatch} = useSignupContext()
+
+ return (
+
+
+
+
+
+ Hosting provider
+
+
+ dispatch({type: 'setServiceUrl', value: v})
+ }
+ />
+
+ {state.isLoading ? (
+
+
+
+ ) : state.serviceDescription ? (
+ <>
+ {state.serviceDescription.inviteCodeRequired && (
+
+
+ Invite code
+
+
+
+ {
+ dispatch({
+ type: 'setInviteCode',
+ value: value.trim(),
+ })
+ }}
+ label={_(msg`Required for this provider`)}
+ defaultValue={state.inviteCode}
+ autoCapitalize="none"
+ autoComplete="email"
+ keyboardType="email-address"
+ />
+
+
+ )}
+
+
+ Email
+
+
+
+ {
+ dispatch({
+ type: 'setEmail',
+ value: value.trim(),
+ })
+ }}
+ label={_(msg`Enter your email address`)}
+ defaultValue={state.email}
+ autoCapitalize="none"
+ autoComplete="email"
+ keyboardType="email-address"
+ />
+
+
+
+
+ Password
+
+
+
+ {
+ dispatch({
+ type: 'setPassword',
+ value,
+ })
+ }}
+ label={_(msg`Choose your password`)}
+ defaultValue={state.password}
+ secureTextEntry
+ autoComplete="new-password"
+ />
+
+
+
+
+ Your birth date
+
+ {
+ dispatch({
+ type: 'setDateOfBirth',
+ value: sanitizeDate(new Date(date)),
+ })
+ }}
+ label={_(msg`Date of birth`)}
+ accessibilityHint={_(msg`Select your date of birth`)}
+ />
+
+
+ >
+ ) : undefined}
+
+
+ )
+}
diff --git a/src/screens/Signup/index.tsx b/src/screens/Signup/index.tsx
new file mode 100644
index 0000000000..f19823b4f4
--- /dev/null
+++ b/src/screens/Signup/index.tsx
@@ -0,0 +1,211 @@
+import React from 'react'
+import {ScrollView, View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useAnalytics} from '#/lib/analytics/analytics'
+import {FEEDBACK_FORM_URL} from '#/lib/constants'
+import {createFullHandle} from '#/lib/strings/handles'
+import {useServiceQuery} from '#/state/queries/service'
+import {getAgent} from '#/state/session'
+import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout'
+import {
+ initialState,
+ reducer,
+ SignupContext,
+ SignupStep,
+ useSubmitSignup,
+} from '#/screens/Signup/state'
+import {StepCaptcha} from '#/screens/Signup/StepCaptcha'
+import {StepHandle} from '#/screens/Signup/StepHandle'
+import {StepInfo} from '#/screens/Signup/StepInfo'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import {Divider} from '#/components/Divider'
+import {InlineLink} from '#/components/Link'
+import {Text} from '#/components/Typography'
+
+export function Signup({onPressBack}: {onPressBack: () => void}) {
+ const {_} = useLingui()
+ const t = useTheme()
+ const {screen} = useAnalytics()
+ const [state, dispatch] = React.useReducer(reducer, initialState)
+ const submit = useSubmitSignup({state, dispatch})
+
+ const {
+ data: serviceInfo,
+ isFetching,
+ isError,
+ refetch,
+ } = useServiceQuery(state.serviceUrl)
+
+ React.useEffect(() => {
+ screen('CreateAccount')
+ }, [screen])
+
+ React.useEffect(() => {
+ if (isFetching) {
+ dispatch({type: 'setIsLoading', value: true})
+ } else if (!isFetching) {
+ dispatch({type: 'setIsLoading', value: false})
+ }
+ }, [isFetching])
+
+ React.useEffect(() => {
+ if (isError) {
+ dispatch({type: 'setServiceDescription', value: undefined})
+ dispatch({
+ type: 'setError',
+ value: _(
+ msg`Unable to contact your service. Please check your Internet connection.`,
+ ),
+ })
+ } else if (serviceInfo) {
+ dispatch({type: 'setServiceDescription', value: serviceInfo})
+ dispatch({type: 'setError', value: ''})
+ }
+ }, [_, serviceInfo, isError])
+
+ const onNextPress = React.useCallback(async () => {
+ if (state.activeStep === SignupStep.HANDLE) {
+ try {
+ dispatch({type: 'setIsLoading', value: true})
+
+ const res = await getAgent().resolveHandle({
+ handle: createFullHandle(state.handle, state.userDomain),
+ })
+
+ if (res.data.did) {
+ dispatch({
+ type: 'setError',
+ value: _(msg`That handle is already taken.`),
+ })
+ return
+ }
+ } catch (e) {
+ // Don't have to handle
+ } finally {
+ dispatch({type: 'setIsLoading', value: false})
+ }
+ }
+
+ // phoneVerificationRequired is actually whether a captcha is required
+ if (
+ state.activeStep === SignupStep.HANDLE &&
+ !state.serviceDescription?.phoneVerificationRequired
+ ) {
+ submit()
+ return
+ }
+
+ dispatch({type: 'next'})
+ }, [
+ _,
+ state.activeStep,
+ state.handle,
+ state.serviceDescription?.phoneVerificationRequired,
+ state.userDomain,
+ submit,
+ ])
+
+ const onBackPress = React.useCallback(() => {
+ if (state.activeStep !== SignupStep.INFO) {
+ dispatch({type: 'prev'})
+ } else {
+ onPressBack()
+ }
+ }, [onPressBack, state.activeStep])
+
+ return (
+
+
+
+
+
+
+ Step {state.activeStep + 1} of{' '}
+ {state.serviceDescription &&
+ !state.serviceDescription.phoneVerificationRequired
+ ? '2'
+ : '3'}
+
+
+ {state.activeStep === SignupStep.INFO ? (
+ Your account
+ ) : state.activeStep === SignupStep.HANDLE ? (
+ Your user handle
+ ) : (
+ Complete the challenge
+ )}
+
+
+
+
+ {state.activeStep === SignupStep.INFO ? (
+
+ ) : state.activeStep === SignupStep.HANDLE ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {state.activeStep !== SignupStep.CAPTCHA && (
+ <>
+ {isError ? (
+
+ ) : (
+
+ )}
+ >
+ )}
+
+
+
+
+
+
+ Having trouble?{' '}
+
+ Contact support
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/screens/Signup/state.ts b/src/screens/Signup/state.ts
new file mode 100644
index 0000000000..f185e2d449
--- /dev/null
+++ b/src/screens/Signup/state.ts
@@ -0,0 +1,320 @@
+import React, {useCallback} from 'react'
+import {LayoutAnimation} from 'react-native'
+import {
+ ComAtprotoServerCreateAccount,
+ ComAtprotoServerDescribeServer,
+} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import * as EmailValidator from 'email-validator'
+
+import {DEFAULT_SERVICE, IS_PROD_SERVICE} from '#/lib/constants'
+import {cleanError} from '#/lib/strings/errors'
+import {createFullHandle, validateHandle} from '#/lib/strings/handles'
+import {getAge} from '#/lib/strings/time'
+import {logger} from '#/logger'
+import {
+ DEFAULT_PROD_FEEDS,
+ usePreferencesSetBirthDateMutation,
+ useSetSaveFeedsMutation,
+} from '#/state/queries/preferences'
+import {useSessionApi} from '#/state/session'
+import {useOnboardingDispatch} from '#/state/shell'
+
+export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
+
+const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago
+
+export enum SignupStep {
+ INFO,
+ HANDLE,
+ CAPTCHA,
+}
+
+export type SignupState = {
+ hasPrev: boolean
+ canNext: boolean
+ activeStep: SignupStep
+
+ serviceUrl: string
+ serviceDescription?: ServiceDescription
+ userDomain: string
+ dateOfBirth: Date
+ email: string
+ password: string
+ inviteCode: string
+ handle: string
+
+ error: string
+ isLoading: boolean
+}
+
+export type SignupAction =
+ | {type: 'prev'}
+ | {type: 'next'}
+ | {type: 'finish'}
+ | {type: 'setStep'; value: SignupStep}
+ | {type: 'setServiceUrl'; value: string}
+ | {type: 'setServiceDescription'; value: ServiceDescription | undefined}
+ | {type: 'setEmail'; value: string}
+ | {type: 'setPassword'; value: string}
+ | {type: 'setDateOfBirth'; value: Date}
+ | {type: 'setInviteCode'; value: string}
+ | {type: 'setHandle'; value: string}
+ | {type: 'setVerificationCode'; value: string}
+ | {type: 'setError'; value: string}
+ | {type: 'setCanNext'; value: boolean}
+ | {type: 'setIsLoading'; value: boolean}
+
+export const initialState: SignupState = {
+ hasPrev: false,
+ canNext: false,
+ activeStep: SignupStep.INFO,
+
+ serviceUrl: DEFAULT_SERVICE,
+ serviceDescription: undefined,
+ userDomain: '',
+ dateOfBirth: DEFAULT_DATE,
+ email: '',
+ password: '',
+ handle: '',
+ inviteCode: '',
+
+ error: '',
+ isLoading: false,
+}
+
+export function is13(date: Date) {
+ return getAge(date) >= 13
+}
+
+export function is18(date: Date) {
+ return getAge(date) >= 18
+}
+
+export function reducer(s: SignupState, a: SignupAction): SignupState {
+ let next = {...s}
+
+ switch (a.type) {
+ case 'prev': {
+ if (s.activeStep !== SignupStep.INFO) {
+ LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
+ next.activeStep--
+ next.error = ''
+ }
+ break
+ }
+ case 'next': {
+ if (s.activeStep !== SignupStep.CAPTCHA) {
+ LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
+ next.activeStep++
+ next.error = ''
+ }
+ break
+ }
+ case 'setStep': {
+ next.activeStep = a.value
+ break
+ }
+ case 'setServiceUrl': {
+ next.serviceUrl = a.value
+ break
+ }
+ case 'setServiceDescription': {
+ LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
+
+ next.serviceDescription = a.value
+ next.userDomain = a.value?.availableUserDomains[0] ?? ''
+ next.isLoading = false
+ break
+ }
+
+ case 'setEmail': {
+ next.email = a.value
+ break
+ }
+ case 'setPassword': {
+ next.password = a.value
+ break
+ }
+ case 'setDateOfBirth': {
+ next.dateOfBirth = a.value
+ break
+ }
+ case 'setInviteCode': {
+ next.inviteCode = a.value
+ break
+ }
+ case 'setHandle': {
+ next.handle = a.value
+ break
+ }
+ case 'setCanNext': {
+ next.canNext = a.value
+ break
+ }
+ case 'setIsLoading': {
+ next.isLoading = a.value
+ break
+ }
+ case 'setError': {
+ next.error = a.value
+ break
+ }
+ }
+
+ next.hasPrev = next.activeStep !== SignupStep.INFO
+
+ switch (next.activeStep) {
+ case SignupStep.INFO: {
+ const isValidEmail = EmailValidator.validate(next.email)
+ next.canNext =
+ !!(next.email && next.password && next.dateOfBirth) &&
+ (!next.serviceDescription?.inviteCodeRequired || !!next.inviteCode) &&
+ is13(next.dateOfBirth) &&
+ isValidEmail
+ break
+ }
+ case SignupStep.HANDLE: {
+ next.canNext =
+ !!next.handle && validateHandle(next.handle, next.userDomain).overall
+ break
+ }
+ }
+
+ logger.debug('signup', next)
+
+ if (s.activeStep !== next.activeStep) {
+ logger.debug('signup: step changed', {activeStep: next.activeStep})
+ }
+
+ return next
+}
+
+interface IContext {
+ state: SignupState
+ dispatch: React.Dispatch
+}
+export const SignupContext = React.createContext({} as IContext)
+export const useSignupContext = () => React.useContext(SignupContext)
+
+export function useSubmitSignup({
+ state,
+ dispatch,
+}: {
+ state: SignupState
+ dispatch: (action: SignupAction) => void
+}) {
+ const {_} = useLingui()
+ const {createAccount} = useSessionApi()
+ const {mutate: setBirthDate} = usePreferencesSetBirthDateMutation()
+ const {mutate: setSavedFeeds} = useSetSaveFeedsMutation()
+ const onboardingDispatch = useOnboardingDispatch()
+
+ return useCallback(
+ async (verificationCode?: string) => {
+ if (!state.email) {
+ dispatch({type: 'setStep', value: SignupStep.INFO})
+ return dispatch({
+ type: 'setError',
+ value: _(msg`Please enter your email.`),
+ })
+ }
+ if (!EmailValidator.validate(state.email)) {
+ dispatch({type: 'setStep', value: SignupStep.INFO})
+ return dispatch({
+ type: 'setError',
+ value: _(msg`Your email appears to be invalid.`),
+ })
+ }
+ if (!state.password) {
+ dispatch({type: 'setStep', value: SignupStep.INFO})
+ return dispatch({
+ type: 'setError',
+ value: _(msg`Please choose your password.`),
+ })
+ }
+ if (!state.handle) {
+ dispatch({type: 'setStep', value: SignupStep.HANDLE})
+ return dispatch({
+ type: 'setError',
+ value: _(msg`Please choose your handle.`),
+ })
+ }
+ if (
+ state.serviceDescription?.phoneVerificationRequired &&
+ !verificationCode
+ ) {
+ dispatch({type: 'setStep', value: SignupStep.CAPTCHA})
+ return dispatch({
+ type: 'setError',
+ value: _(msg`Please complete the verification captcha.`),
+ })
+ }
+ dispatch({type: 'setError', value: ''})
+ dispatch({type: 'setIsLoading', value: true})
+
+ try {
+ onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view
+ await createAccount({
+ service: state.serviceUrl,
+ email: state.email,
+ handle: createFullHandle(state.handle, state.userDomain),
+ password: state.password,
+ inviteCode: state.inviteCode.trim(),
+ verificationCode: verificationCode,
+ })
+ setBirthDate({birthDate: state.dateOfBirth})
+ if (IS_PROD_SERVICE(state.serviceUrl)) {
+ setSavedFeeds(DEFAULT_PROD_FEEDS)
+ }
+ } catch (e: any) {
+ onboardingDispatch({type: 'skip'}) // undo starting the onboard
+ let errMsg = e.toString()
+ if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) {
+ dispatch({
+ type: 'setError',
+ value: _(
+ msg`Invite code not accepted. Check that you input it correctly and try again.`,
+ ),
+ })
+ dispatch({type: 'setStep', value: SignupStep.INFO})
+ return
+ }
+
+ if ([400, 429].includes(e.status)) {
+ logger.warn('Failed to create account', {message: e})
+ } else {
+ logger.error(`Failed to create account (${e.status} status)`, {
+ message: e,
+ })
+ }
+
+ const error = cleanError(errMsg)
+ const isHandleError = error.toLowerCase().includes('handle')
+
+ dispatch({type: 'setIsLoading', value: false})
+ dispatch({type: 'setError', value: cleanError(errMsg)})
+ dispatch({type: 'setStep', value: isHandleError ? 2 : 1})
+ } finally {
+ dispatch({type: 'setIsLoading', value: false})
+ }
+ },
+ [
+ state.email,
+ state.password,
+ state.handle,
+ state.serviceDescription?.phoneVerificationRequired,
+ state.serviceUrl,
+ state.userDomain,
+ state.inviteCode,
+ state.dateOfBirth,
+ dispatch,
+ _,
+ onboardingDispatch,
+ createAccount,
+ setBirthDate,
+ setSavedFeeds,
+ ],
+ )
+}
diff --git a/src/view/com/auth/LoggedOut.tsx b/src/view/com/auth/LoggedOut.tsx
index 603abbab2d..b22bbb7fe4 100644
--- a/src/view/com/auth/LoggedOut.tsx
+++ b/src/view/com/auth/LoggedOut.tsx
@@ -5,16 +5,16 @@ import {useLingui} from '@lingui/react'
import {Trans, msg} from '@lingui/macro'
import {useNavigation} from '@react-navigation/native'
-import {isIOS, isNative} from 'platform/detection'
-import {Login} from 'view/com/auth/login/Login'
-import {CreateAccount} from 'view/com/auth/create/CreateAccount'
-import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
-import {s} from 'lib/styles'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useAnalytics} from 'lib/analytics/analytics'
+import {isIOS, isNative} from '#/platform/detection'
+import {Login} from '#/screens/Login'
+import {Signup} from '#/screens/Signup'
+import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
+import {s} from '#/lib/styles'
+import {usePalette} from '#/lib/hooks/usePalette'
+import {useAnalytics} from '#/lib/analytics/analytics'
import {SplashScreen} from './SplashScreen'
import {useSetMinimalShellMode} from '#/state/shell/minimal-mode'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {
useLoggedOutView,
useLoggedOutViewControls,
@@ -148,7 +148,7 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
/>
) : undefined}
{screenState === ScreenState.S_CreateAccount ? (
-
setScreenState(ScreenState.S_LoginOrCreateAccount)
}
diff --git a/src/view/com/auth/create/CreateAccount.tsx b/src/view/com/auth/create/CreateAccount.tsx
deleted file mode 100644
index d193802fe0..0000000000
--- a/src/view/com/auth/create/CreateAccount.tsx
+++ /dev/null
@@ -1,230 +0,0 @@
-import React from 'react'
-import {
- ActivityIndicator,
- ScrollView,
- StyleSheet,
- TouchableOpacity,
- View,
-} from 'react-native'
-import {useAnalytics} from 'lib/analytics/analytics'
-import {Text} from '../../util/text/Text'
-import {LoggedOutLayout} from 'view/com/util/layouts/LoggedOutLayout'
-import {s} from 'lib/styles'
-import {usePalette} from 'lib/hooks/usePalette'
-import {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {useCreateAccount, useSubmitCreateAccount} from './state'
-import {useServiceQuery} from '#/state/queries/service'
-import {FEEDBACK_FORM_URL, HITSLOP_10} from '#/lib/constants'
-
-import {Step1} from './Step1'
-import {Step2} from './Step2'
-import {Step3} from './Step3'
-import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
-import {TextLink} from '../../util/Link'
-import {getAgent} from 'state/session'
-import {createFullHandle, validateHandle} from 'lib/strings/handles'
-
-export function CreateAccount({onPressBack}: {onPressBack: () => void}) {
- const {screen} = useAnalytics()
- const pal = usePalette('default')
- const {_} = useLingui()
- const [uiState, uiDispatch] = useCreateAccount()
- const {isTabletOrDesktop} = useWebMediaQueries()
- const submit = useSubmitCreateAccount(uiState, uiDispatch)
-
- React.useEffect(() => {
- screen('CreateAccount')
- }, [screen])
-
- // fetch service info
- // =
-
- const {
- data: serviceInfo,
- isFetching: serviceInfoIsFetching,
- error: serviceInfoError,
- refetch: refetchServiceInfo,
- } = useServiceQuery(uiState.serviceUrl)
-
- React.useEffect(() => {
- if (serviceInfo) {
- uiDispatch({type: 'set-service-description', value: serviceInfo})
- uiDispatch({type: 'set-error', value: ''})
- } else if (serviceInfoError) {
- uiDispatch({
- type: 'set-error',
- value: _(
- msg`Unable to contact your service. Please check your Internet connection.`,
- ),
- })
- }
- }, [_, uiDispatch, serviceInfo, serviceInfoError])
-
- // event handlers
- // =
-
- const onPressBackInner = React.useCallback(() => {
- if (uiState.canBack) {
- uiDispatch({type: 'back'})
- } else {
- onPressBack()
- }
- }, [uiState, uiDispatch, onPressBack])
-
- const onPressNext = React.useCallback(async () => {
- if (!uiState.canNext) {
- return
- }
-
- if (uiState.step === 2) {
- if (!validateHandle(uiState.handle, uiState.userDomain).overall) {
- return
- }
-
- uiDispatch({type: 'set-processing', value: true})
- try {
- const res = await getAgent().resolveHandle({
- handle: createFullHandle(uiState.handle, uiState.userDomain),
- })
-
- if (res.data.did) {
- uiDispatch({
- type: 'set-error',
- value: _(msg`That handle is already taken.`),
- })
- return
- }
- } catch (e) {
- // Don't need to handle
- } finally {
- uiDispatch({type: 'set-processing', value: false})
- }
-
- if (!uiState.isCaptchaRequired) {
- try {
- await submit()
- } catch {
- // dont need to handle here
- }
- // We don't need to go to the next page if there wasn't a captcha required
- return
- }
- }
-
- uiDispatch({type: 'next'})
- }, [
- uiState.canNext,
- uiState.step,
- uiState.isCaptchaRequired,
- uiState.handle,
- uiState.userDomain,
- uiDispatch,
- _,
- submit,
- ])
-
- // rendering
- // =
-
- return (
-
-
-
- {uiState.step === 1 && (
-
- )}
- {uiState.step === 2 && (
-
- )}
- {uiState.step === 3 && (
-
- )}
-
-
-
-
- Back
-
-
-
- {uiState.canNext ? (
-
- {uiState.isProcessing ? (
-
- ) : (
-
- Next
-
- )}
-
- ) : serviceInfoError ? (
- refetchServiceInfo()}
- accessibilityRole="button"
- accessibilityLabel={_(msg`Retry`)}
- accessibilityHint=""
- accessibilityLiveRegion="polite"
- hitSlop={HITSLOP_10}>
-
- Retry
-
-
- ) : serviceInfoIsFetching ? (
- <>
-
-
- Connecting...
-
- >
- ) : undefined}
-
-
-
-
-
- Having trouble?{' '}
-
-
-
-
-
-
-
-
- )
-}
-
-const styles = StyleSheet.create({
- stepContainer: {
- paddingHorizontal: 20,
- paddingVertical: 20,
- },
-})
diff --git a/src/view/com/auth/create/Policies.tsx b/src/view/com/auth/create/Policies.tsx
deleted file mode 100644
index 803e2ad32b..0000000000
--- a/src/view/com/auth/create/Policies.tsx
+++ /dev/null
@@ -1,121 +0,0 @@
-import React from 'react'
-import {StyleSheet, View} from 'react-native'
-import {
- FontAwesomeIcon,
- FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
-import {ComAtprotoServerDescribeServer} from '@atproto/api'
-import {TextLink} from '../../util/Link'
-import {Text} from '../../util/text/Text'
-import {s, colors} from 'lib/styles'
-import {usePalette} from 'lib/hooks/usePalette'
-import {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
-
-export const Policies = ({
- serviceDescription,
- needsGuardian,
-}: {
- serviceDescription: ServiceDescription
- needsGuardian: boolean
-}) => {
- const pal = usePalette('default')
- const {_} = useLingui()
- if (!serviceDescription) {
- return
- }
- const tos = validWebLink(serviceDescription.links?.termsOfService)
- const pp = validWebLink(serviceDescription.links?.privacyPolicy)
- if (!tos && !pp) {
- return (
-
-
-
-
-
-
- This service has not provided terms of service or a privacy policy.
-
-
-
- )
- }
- const els = []
- if (tos) {
- els.push(
- ,
- )
- }
- if (pp) {
- els.push(
- ,
- )
- }
- if (els.length === 2) {
- els.splice(
- 1,
- 0,
-
- {' '}
- and{' '}
- ,
- )
- }
- return (
-
-
- By creating an account you agree to the {els}.
-
- {needsGuardian && (
-
-
- If you are not yet an adult according to the laws of your country,
- your parent or legal guardian must read these Terms on your behalf.
-
-
- )}
-
- )
-}
-
-function validWebLink(url?: string): string | undefined {
- return url && (url.startsWith('http://') || url.startsWith('https://'))
- ? url
- : undefined
-}
-
-const styles = StyleSheet.create({
- policies: {
- flexDirection: 'column',
- gap: 8,
- },
- errorIcon: {
- borderWidth: 1,
- borderColor: colors.white,
- borderRadius: 30,
- width: 16,
- height: 16,
- alignItems: 'center',
- justifyContent: 'center',
- },
-})
diff --git a/src/view/com/auth/create/Step1.tsx b/src/view/com/auth/create/Step1.tsx
deleted file mode 100644
index 1f6852f8cc..0000000000
--- a/src/view/com/auth/create/Step1.tsx
+++ /dev/null
@@ -1,261 +0,0 @@
-import React from 'react'
-import {
- ActivityIndicator,
- Keyboard,
- StyleSheet,
- TouchableOpacity,
- View,
-} from 'react-native'
-import {CreateAccountState, CreateAccountDispatch, is18} from './state'
-import {Text} from 'view/com/util/text/Text'
-import {DateInput} from 'view/com/util/forms/DateInput'
-import {StepHeader} from './StepHeader'
-import {s} from 'lib/styles'
-import {usePalette} from 'lib/hooks/usePalette'
-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 {logger} from '#/logger'
-import {
- FontAwesomeIcon,
- FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
-import {useDialogControl} from '#/components/Dialog'
-
-import {ServerInputDialog} from '../server-input'
-import {toNiceDomain} from '#/lib/strings/url-helpers'
-
-function sanitizeDate(date: Date): Date {
- if (!date || date.toString() === 'Invalid Date') {
- logger.error(`Create account: handled invalid date for birthDate`, {
- hasDate: !!date,
- })
- return new Date()
- }
- return date
-}
-
-export function Step1({
- uiState,
- uiDispatch,
-}: {
- uiState: CreateAccountState
- uiDispatch: CreateAccountDispatch
-}) {
- const pal = usePalette('default')
- const {_} = useLingui()
- const serverInputControl = useDialogControl()
-
- const onPressSelectService = React.useCallback(() => {
- serverInputControl.open()
- Keyboard.dismiss()
- }, [serverInputControl])
-
- const birthDate = React.useMemo(() => {
- return sanitizeDate(uiState.birthDate)
- }, [uiState.birthDate])
-
- return (
-
- uiDispatch({type: 'set-service-url', value: url})}
- />
-
-
- {uiState.error ? (
-
- ) : undefined}
-
-
-
- Hosting provider
-
-
-
-
-
-
- {toNiceDomain(uiState.serviceUrl)}
-
-
-
-
-
-
-
-
-
- {!uiState.serviceDescription ? (
-
- ) : (
- <>
- {uiState.isInviteCodeRequired && (
-
-
- Invite code
-
- uiDispatch({type: 'set-invite-code', value})}
- accessibilityLabel={_(msg`Invite code`)}
- accessibilityHint={_(msg`Input invite code to proceed`)}
- autoCapitalize="none"
- autoComplete="off"
- autoCorrect={false}
- autoFocus={true}
- />
-
- )}
-
- {!uiState.isInviteCodeRequired || uiState.inviteCode ? (
- <>
-
-
- Email address
-
- uiDispatch({type: 'set-email', value})}
- accessibilityLabel={_(msg`Email`)}
- accessibilityHint={_(msg`Input email for Bluesky account`)}
- accessibilityLabelledBy="email"
- autoCapitalize="none"
- autoComplete="email"
- autoCorrect={false}
- autoFocus={!uiState.isInviteCodeRequired}
- />
-
-
-
-
- Password
-
- uiDispatch({type: 'set-password', value})}
- accessibilityLabel={_(msg`Password`)}
- accessibilityHint={_(msg`Set password`)}
- accessibilityLabelledBy="password"
- autoCapitalize="none"
- autoComplete="new-password"
- autoCorrect={false}
- />
-
-
-
-
- Your birth date
-
-
- uiDispatch({type: 'set-birth-date', value})
- }
- buttonType="default-light"
- buttonStyle={[pal.border, styles.dateInputButton]}
- buttonLabelType="lg"
- accessibilityLabel={_(msg`Birthday`)}
- accessibilityHint={_(msg`Enter your birth date`)}
- accessibilityLabelledBy="birthDate"
- />
-
-
- {uiState.serviceDescription && (
-
- )}
- >
- ) : undefined}
- >
- )}
-
- )
-}
-
-const styles = StyleSheet.create({
- error: {
- borderRadius: 6,
- marginBottom: 10,
- },
- dateInputButton: {
- borderWidth: 1,
- borderRadius: 6,
- paddingVertical: 14,
- },
- // @ts-expect-error: Suppressing error due to incomplete `ViewStyle` type definition in react-native-web, missing `cursor` prop as discussed in https://github.com/necolas/react-native-web/issues/832.
- touchable: {
- ...(isWeb && {cursor: 'pointer'}),
- },
-})
diff --git a/src/view/com/auth/create/Step2.tsx b/src/view/com/auth/create/Step2.tsx
deleted file mode 100644
index 5c262977f1..0000000000
--- a/src/view/com/auth/create/Step2.tsx
+++ /dev/null
@@ -1,140 +0,0 @@
-import React from 'react'
-import {View} from 'react-native'
-import {CreateAccountState, CreateAccountDispatch} from './state'
-import {Text} from 'view/com/util/text/Text'
-import {StepHeader} from './StepHeader'
-import {s} from 'lib/styles'
-import {TextInput} from '../util/TextInput'
-import {
- createFullHandle,
- IsValidHandle,
- validateHandle,
-} from 'lib/strings/handles'
-import {usePalette} from 'lib/hooks/usePalette'
-import {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {atoms as a, useTheme} from '#/alf'
-import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
-import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times'
-import {useFocusEffect} from '@react-navigation/native'
-
-/** STEP 3: Your user handle
- * @field User handle
- */
-export function Step2({
- uiState,
- uiDispatch,
-}: {
- uiState: CreateAccountState
- uiDispatch: CreateAccountDispatch
-}) {
- const pal = usePalette('default')
- const {_} = useLingui()
- const t = useTheme()
-
- const [validCheck, setValidCheck] = React.useState({
- handleChars: false,
- frontLength: false,
- totalLength: true,
- overall: false,
- })
-
- useFocusEffect(
- React.useCallback(() => {
- setValidCheck(validateHandle(uiState.handle, uiState.userDomain))
-
- // Disabling this, because we only want to run this when we focus the screen
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []),
- )
-
- const onHandleChange = React.useCallback(
- (value: string) => {
- if (uiState.error) {
- uiDispatch({type: 'set-error', value: ''})
- }
-
- setValidCheck(validateHandle(value, uiState.userDomain))
- uiDispatch({type: 'set-handle', value})
- },
- [uiDispatch, uiState.error, uiState.userDomain],
- )
-
- return (
-
-
-
-
-
-
- Your full handle will be{' '}
-
- @{createFullHandle(uiState.handle, uiState.userDomain)}
-
-
-
-
- {uiState.error ? (
-
-
-
- {uiState.error}
-
-
- ) : undefined}
-
-
-
- May only contain letters and numbers
-
-
-
-
- {!validCheck.totalLength ? (
-
- May not be longer than 253 characters
-
- ) : (
-
- Must be at least 3 characters
-
- )}
-
-
-
-
- )
-}
-
-function IsValidIcon({valid}: {valid: boolean}) {
- const t = useTheme()
-
- if (!valid) {
- return
- }
-
- return
-}
diff --git a/src/view/com/auth/create/Step3.tsx b/src/view/com/auth/create/Step3.tsx
deleted file mode 100644
index 53fdfdde81..0000000000
--- a/src/view/com/auth/create/Step3.tsx
+++ /dev/null
@@ -1,114 +0,0 @@
-import React from 'react'
-import {ActivityIndicator, StyleSheet, View} from 'react-native'
-import {
- CreateAccountState,
- CreateAccountDispatch,
- useSubmitCreateAccount,
-} from './state'
-import {StepHeader} from './StepHeader'
-import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
-import {isWeb} from 'platform/detection'
-import {msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-import {nanoid} from 'nanoid/non-secure'
-import {CaptchaWebView} from 'view/com/auth/create/CaptchaWebView'
-import {useTheme} from 'lib/ThemeContext'
-import {createFullHandle} from 'lib/strings/handles'
-
-const CAPTCHA_PATH = '/gate/signup'
-
-export function Step3({
- uiState,
- uiDispatch,
-}: {
- uiState: CreateAccountState
- uiDispatch: CreateAccountDispatch
-}) {
- const {_} = useLingui()
- const theme = useTheme()
- const submit = useSubmitCreateAccount(uiState, uiDispatch)
-
- const [completed, setCompleted] = React.useState(false)
-
- const stateParam = React.useMemo(() => nanoid(15), [])
- const url = React.useMemo(() => {
- const newUrl = new URL(uiState.serviceUrl)
- newUrl.pathname = CAPTCHA_PATH
- newUrl.searchParams.set(
- 'handle',
- createFullHandle(uiState.handle, uiState.userDomain),
- )
- newUrl.searchParams.set('state', stateParam)
- newUrl.searchParams.set('colorScheme', theme.colorScheme)
-
- console.log(newUrl)
-
- return newUrl.href
- }, [
- uiState.serviceUrl,
- uiState.handle,
- uiState.userDomain,
- stateParam,
- theme.colorScheme,
- ])
-
- const onSuccess = React.useCallback(
- (code: string) => {
- setCompleted(true)
- submit(code)
- },
- [submit],
- )
-
- const onError = React.useCallback(() => {
- uiDispatch({
- type: 'set-error',
- value: _(msg`Error receiving captcha response.`),
- })
- }, [_, uiDispatch])
-
- return (
-
-
-
- {!completed ? (
-
- ) : (
-
- )}
-
-
- {uiState.error ? (
-
- ) : undefined}
-
- )
-}
-
-const styles = StyleSheet.create({
- error: {
- borderRadius: 6,
- marginTop: 10,
- },
- // @ts-expect-error: Suppressing error due to incomplete `ViewStyle` type definition in react-native-web, missing `cursor` prop as discussed in https://github.com/necolas/react-native-web/issues/832.
- touchable: {
- ...(isWeb && {cursor: 'pointer'}),
- },
- container: {
- minHeight: 500,
- width: '100%',
- paddingBottom: 20,
- overflow: 'hidden',
- },
- center: {
- alignItems: 'center',
- justifyContent: 'center',
- },
-})
diff --git a/src/view/com/auth/create/StepHeader.tsx b/src/view/com/auth/create/StepHeader.tsx
deleted file mode 100644
index a98b392d8d..0000000000
--- a/src/view/com/auth/create/StepHeader.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import React from 'react'
-import {StyleSheet, View} from 'react-native'
-import {Text} from 'view/com/util/text/Text'
-import {usePalette} from 'lib/hooks/usePalette'
-import {Trans} from '@lingui/macro'
-import {CreateAccountState} from './state'
-
-export function StepHeader({
- uiState,
- title,
- children,
-}: React.PropsWithChildren<{uiState: CreateAccountState; title: string}>) {
- const pal = usePalette('default')
- const numSteps = 3
- return (
-
-
-
- {uiState.step === 3 ? (
- Last step!
- ) : (
-
- Step {uiState.step} of {numSteps}
-
- )}
-
-
-
- {title}
-
-
- {children}
-
- )
-}
-
-const styles = StyleSheet.create({
- container: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- alignItems: 'center',
- marginBottom: 20,
- },
-})
diff --git a/src/view/com/auth/create/state.ts b/src/view/com/auth/create/state.ts
deleted file mode 100644
index 840084dcb7..0000000000
--- a/src/view/com/auth/create/state.ts
+++ /dev/null
@@ -1,298 +0,0 @@
-import {useCallback, useReducer} from 'react'
-import {
- ComAtprotoServerDescribeServer,
- ComAtprotoServerCreateAccount,
-} from '@atproto/api'
-import {I18nContext, useLingui} from '@lingui/react'
-import {msg} from '@lingui/macro'
-import * as EmailValidator from 'email-validator'
-import {getAge} from 'lib/strings/time'
-import {logger} from '#/logger'
-import {createFullHandle, validateHandle} from '#/lib/strings/handles'
-import {cleanError} from '#/lib/strings/errors'
-import {useOnboardingDispatch} from '#/state/shell/onboarding'
-import {useSessionApi} from '#/state/session'
-import {DEFAULT_SERVICE, IS_TEST_USER} from '#/lib/constants'
-import {
- DEFAULT_PROD_FEEDS,
- usePreferencesSetBirthDateMutation,
- useSetSaveFeedsMutation,
-} from 'state/queries/preferences'
-
-export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
-const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago
-
-export type CreateAccountAction =
- | {type: 'set-step'; value: number}
- | {type: 'set-error'; value: string | undefined}
- | {type: 'set-processing'; value: boolean}
- | {type: 'set-service-url'; value: string}
- | {type: 'set-service-description'; value: ServiceDescription | undefined}
- | {type: 'set-user-domain'; value: string}
- | {type: 'set-invite-code'; value: string}
- | {type: 'set-email'; value: string}
- | {type: 'set-password'; value: string}
- | {type: 'set-handle'; value: string}
- | {type: 'set-birth-date'; value: Date}
- | {type: 'next'}
- | {type: 'back'}
-
-export interface CreateAccountState {
- // state
- step: number
- error: string | undefined
- isProcessing: boolean
- serviceUrl: string
- serviceDescription: ServiceDescription | undefined
- userDomain: string
- inviteCode: string
- email: string
- password: string
- handle: string
- birthDate: Date
-
- // computed
- canBack: boolean
- canNext: boolean
- isInviteCodeRequired: boolean
- isCaptchaRequired: boolean
-}
-
-export type CreateAccountDispatch = (action: CreateAccountAction) => void
-
-export function useCreateAccount() {
- const {_} = useLingui()
-
- return useReducer(createReducer({_}), {
- step: 1,
- error: undefined,
- isProcessing: false,
- serviceUrl: DEFAULT_SERVICE,
- serviceDescription: undefined,
- userDomain: '',
- inviteCode: '',
- email: '',
- password: '',
- handle: '',
- birthDate: DEFAULT_DATE,
-
- canBack: false,
- canNext: false,
- isInviteCodeRequired: false,
- isCaptchaRequired: false,
- })
-}
-
-export function useSubmitCreateAccount(
- uiState: CreateAccountState,
- uiDispatch: CreateAccountDispatch,
-) {
- const {_} = useLingui()
- const {createAccount} = useSessionApi()
- const {mutate: setBirthDate} = usePreferencesSetBirthDateMutation()
- const {mutate: setSavedFeeds} = useSetSaveFeedsMutation()
- const onboardingDispatch = useOnboardingDispatch()
-
- return useCallback(
- async (verificationCode?: string) => {
- if (!uiState.email) {
- uiDispatch({type: 'set-step', value: 1})
- console.log('no email?')
- return uiDispatch({
- type: 'set-error',
- value: _(msg`Please enter your email.`),
- })
- }
- if (!EmailValidator.validate(uiState.email)) {
- uiDispatch({type: 'set-step', value: 1})
- return uiDispatch({
- type: 'set-error',
- value: _(msg`Your email appears to be invalid.`),
- })
- }
- if (!uiState.password) {
- uiDispatch({type: 'set-step', value: 1})
- return uiDispatch({
- type: 'set-error',
- value: _(msg`Please choose your password.`),
- })
- }
- if (!uiState.handle) {
- uiDispatch({type: 'set-step', value: 2})
- return uiDispatch({
- type: 'set-error',
- value: _(msg`Please choose your handle.`),
- })
- }
- if (uiState.isCaptchaRequired && !verificationCode) {
- uiDispatch({type: 'set-step', value: 3})
- return uiDispatch({
- type: 'set-error',
- value: _(msg`Please complete the verification captcha.`),
- })
- }
- uiDispatch({type: 'set-error', value: ''})
- uiDispatch({type: 'set-processing', value: true})
-
- try {
- onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view
- await createAccount({
- service: uiState.serviceUrl,
- email: uiState.email,
- handle: createFullHandle(uiState.handle, uiState.userDomain),
- password: uiState.password,
- inviteCode: uiState.inviteCode.trim(),
- verificationCode: uiState.isCaptchaRequired
- ? verificationCode
- : undefined,
- })
- setBirthDate({birthDate: uiState.birthDate})
- if (!IS_TEST_USER(uiState.handle)) {
- setSavedFeeds(DEFAULT_PROD_FEEDS)
- }
- } catch (e: any) {
- onboardingDispatch({type: 'skip'}) // undo starting the onboard
- let errMsg = e.toString()
- if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) {
- errMsg = _(
- msg`Invite code not accepted. Check that you input it correctly and try again.`,
- )
- uiDispatch({type: 'set-step', value: 1})
- }
-
- if ([400, 429].includes(e.status)) {
- logger.warn('Failed to create account', {message: e})
- } else {
- logger.error(`Failed to create account (${e.status} status)`, {
- message: e,
- })
- }
-
- const error = cleanError(errMsg)
- const isHandleError = error.toLowerCase().includes('handle')
-
- uiDispatch({type: 'set-processing', value: false})
- uiDispatch({type: 'set-error', value: cleanError(errMsg)})
- uiDispatch({type: 'set-step', value: isHandleError ? 2 : 1})
- }
- },
- [
- uiState.email,
- uiState.password,
- uiState.handle,
- uiState.isCaptchaRequired,
- uiState.serviceUrl,
- uiState.userDomain,
- uiState.inviteCode,
- uiState.birthDate,
- uiDispatch,
- _,
- onboardingDispatch,
- createAccount,
- setBirthDate,
- setSavedFeeds,
- ],
- )
-}
-
-export function is13(state: CreateAccountState) {
- return getAge(state.birthDate) >= 13
-}
-
-export function is18(state: CreateAccountState) {
- return getAge(state.birthDate) >= 18
-}
-
-function createReducer({_}: {_: I18nContext['_']}) {
- return function reducer(
- state: CreateAccountState,
- action: CreateAccountAction,
- ): CreateAccountState {
- switch (action.type) {
- case 'set-step': {
- return compute({...state, step: action.value})
- }
- case 'set-error': {
- return compute({...state, error: action.value})
- }
- case 'set-processing': {
- return compute({...state, isProcessing: action.value})
- }
- case 'set-service-url': {
- return compute({
- ...state,
- serviceUrl: action.value,
- serviceDescription:
- state.serviceUrl !== action.value
- ? undefined
- : state.serviceDescription,
- })
- }
- case 'set-service-description': {
- return compute({
- ...state,
- serviceDescription: action.value,
- userDomain: action.value?.availableUserDomains[0] || '',
- })
- }
- case 'set-user-domain': {
- return compute({...state, userDomain: action.value})
- }
- case 'set-invite-code': {
- return compute({...state, inviteCode: action.value})
- }
- case 'set-email': {
- return compute({...state, email: action.value})
- }
- case 'set-password': {
- return compute({...state, password: action.value})
- }
- case 'set-handle': {
- return compute({...state, handle: action.value})
- }
- case 'set-birth-date': {
- return compute({...state, birthDate: action.value})
- }
- case 'next': {
- if (state.step === 1) {
- if (!is13(state)) {
- return compute({
- ...state,
- error: _(
- msg`Unfortunately, you do not meet the requirements to create an account.`,
- ),
- })
- }
- }
- return compute({...state, error: '', step: state.step + 1})
- }
- case 'back': {
- return compute({...state, error: '', step: state.step - 1})
- }
- }
- }
-}
-
-function compute(state: CreateAccountState): CreateAccountState {
- let canNext = true
- if (state.step === 1) {
- canNext =
- !!state.serviceDescription &&
- (!state.isInviteCodeRequired || !!state.inviteCode) &&
- !!state.email &&
- !!state.password
- } else if (state.step === 2) {
- canNext =
- !!state.handle && validateHandle(state.handle, state.userDomain).overall
- } else if (state.step === 3) {
- // Step 3 will automatically redirect as soon as the captcha completes
- canNext = false
- }
- return {
- ...state,
- canBack: state.step > 1,
- canNext,
- isInviteCodeRequired: !!state.serviceDescription?.inviteCodeRequired,
- isCaptchaRequired: !!state.serviceDescription?.phoneVerificationRequired,
- }
-}
diff --git a/src/view/com/auth/login/ChooseAccountForm.tsx b/src/view/com/auth/login/ChooseAccountForm.tsx
deleted file mode 100644
index e754c84834..0000000000
--- a/src/view/com/auth/login/ChooseAccountForm.tsx
+++ /dev/null
@@ -1,167 +0,0 @@
-import React from 'react'
-import {ScrollView, TouchableOpacity, View} from 'react-native'
-import {
- FontAwesomeIcon,
- FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
-import {useAnalytics} from 'lib/analytics/analytics'
-import {Text} from '../../util/text/Text'
-import {UserAvatar} from '../../util/UserAvatar'
-import {s, colors} from 'lib/styles'
-import {usePalette} from 'lib/hooks/usePalette'
-import {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {styles} from './styles'
-import {useSession, useSessionApi, SessionAccount} from '#/state/session'
-import {useProfileQuery} from '#/state/queries/profile'
-import {useLoggedOutViewControls} from '#/state/shell/logged-out'
-import * as Toast from '#/view/com/util/Toast'
-import {logEvent} from '#/lib/statsig/statsig'
-
-function AccountItem({
- account,
- onSelect,
- isCurrentAccount,
-}: {
- account: SessionAccount
- onSelect: (account: SessionAccount) => void
- isCurrentAccount: boolean
-}) {
- const pal = usePalette('default')
- const {_} = useLingui()
- const {data: profile} = useProfileQuery({did: account.did})
-
- const onPress = React.useCallback(() => {
- onSelect(account)
- }, [account, onSelect])
-
- return (
-
-
-
-
-
-
-
- {profile?.displayName || account.handle}{' '}
-
-
- {account.handle}
-
-
- {isCurrentAccount ? (
-
- ) : (
-
- )}
-
-
- )
-}
-export const ChooseAccountForm = ({
- onSelectAccount,
- onPressBack,
-}: {
- onSelectAccount: (account?: SessionAccount) => void
- onPressBack: () => void
-}) => {
- const {track, screen} = useAnalytics()
- const pal = usePalette('default')
- const {_} = useLingui()
- const {accounts, currentAccount} = useSession()
- const {initSession} = useSessionApi()
- const {setShowLoggedOut} = useLoggedOutViewControls()
-
- React.useEffect(() => {
- screen('Choose Account')
- }, [screen])
-
- const onSelect = React.useCallback(
- async (account: SessionAccount) => {
- if (account.accessJwt) {
- if (account.did === currentAccount?.did) {
- setShowLoggedOut(false)
- Toast.show(_(msg`Already signed in as @${account.handle}`))
- } else {
- await initSession(account)
- logEvent('account:loggedIn', {
- logContext: 'ChooseAccountForm',
- withPassword: false,
- })
- track('Sign In', {resumedSession: true})
- setTimeout(() => {
- Toast.show(_(msg`Signed in as @${account.handle}`))
- }, 100)
- }
- } else {
- onSelectAccount(account)
- }
- },
- [currentAccount, track, initSession, onSelectAccount, setShowLoggedOut, _],
- )
-
- return (
-
-
- Sign in as...
-
- {accounts.map(account => (
-
- ))}
- onSelectAccount(undefined)}
- accessibilityRole="button"
- accessibilityLabel={_(msg`Login to account that is not listed`)}
- accessibilityHint="">
-
-
-
- Other account
-
-
-
-
-
-
-
-
- Back
-
-
-
-
-
- )
-}
diff --git a/src/view/com/auth/login/ForgotPasswordForm.tsx b/src/view/com/auth/login/ForgotPasswordForm.tsx
deleted file mode 100644
index 322da2b8fd..0000000000
--- a/src/view/com/auth/login/ForgotPasswordForm.tsx
+++ /dev/null
@@ -1,228 +0,0 @@
-import React, {useState, useEffect} from 'react'
-import {
- ActivityIndicator,
- Keyboard,
- TextInput,
- TouchableOpacity,
- View,
-} from 'react-native'
-import {
- FontAwesomeIcon,
- FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
-import {ComAtprotoServerDescribeServer} from '@atproto/api'
-import * as EmailValidator from 'email-validator'
-import {BskyAgent} from '@atproto/api'
-import {useAnalytics} from 'lib/analytics/analytics'
-import {Text} from '../../util/text/Text'
-import {s} from 'lib/styles'
-import {toNiceDomain} from 'lib/strings/url-helpers'
-import {isNetworkError} from 'lib/strings/errors'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useTheme} from 'lib/ThemeContext'
-import {cleanError} from 'lib/strings/errors'
-import {logger} from '#/logger'
-import {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {styles} from './styles'
-import {useDialogControl} from '#/components/Dialog'
-
-import {ServerInputDialog} from '../server-input'
-
-type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
-
-export const ForgotPasswordForm = ({
- error,
- serviceUrl,
- serviceDescription,
- setError,
- setServiceUrl,
- onPressBack,
- onEmailSent,
-}: {
- error: string
- serviceUrl: string
- serviceDescription: ServiceDescription | undefined
- setError: (v: string) => void
- setServiceUrl: (v: string) => void
- onPressBack: () => void
- onEmailSent: () => void
-}) => {
- const pal = usePalette('default')
- const theme = useTheme()
- const [isProcessing, setIsProcessing] = useState(false)
- const [email, setEmail] = useState('')
- const {screen} = useAnalytics()
- const {_} = useLingui()
- const serverInputControl = useDialogControl()
-
- useEffect(() => {
- screen('Signin:ForgotPassword')
- }, [screen])
-
- const onPressSelectService = React.useCallback(() => {
- serverInputControl.open()
- Keyboard.dismiss()
- }, [serverInputControl])
-
- const onPressNext = async () => {
- if (!EmailValidator.validate(email)) {
- return setError(_(msg`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(
- _(
- msg`Unable to contact your service. Please check your Internet connection.`,
- ),
- )
- } else {
- setError(cleanError(errMsg))
- }
- }
- }
-
- return (
- <>
-
-
-
- Reset password
-
-
-
- Enter the email you used to create your account. We'll send you a
- "reset code" so you can set a new password.
-
-
-
-
-
-
- {toNiceDomain(serviceUrl)}
-
-
-
-
-
-
-
-
-
-
- {error ? (
-
-
-
-
-
- {error}
-
-
- ) : undefined}
-
-
-
- Back
-
-
-
- {!serviceDescription || isProcessing ? (
-
- ) : !email ? (
-
- Next
-
- ) : (
-
-
- Next
-
-
- )}
- {!serviceDescription || isProcessing ? (
-
- Processing...
-
- ) : undefined}
-
-
-
-
-
- Already have a code?
-
-
-
-
- >
- )
-}
diff --git a/src/view/com/auth/login/Login.tsx b/src/view/com/auth/login/Login.tsx
deleted file mode 100644
index bc931ac04c..0000000000
--- a/src/view/com/auth/login/Login.tsx
+++ /dev/null
@@ -1,164 +0,0 @@
-import React, {useState, useEffect} from 'react'
-import {KeyboardAvoidingView} from 'react-native'
-import {useAnalytics} from 'lib/analytics/analytics'
-import {LoggedOutLayout} from 'view/com/util/layouts/LoggedOutLayout'
-import {DEFAULT_SERVICE} from '#/lib/constants'
-import {usePalette} from 'lib/hooks/usePalette'
-import {logger} from '#/logger'
-import {ChooseAccountForm} from './ChooseAccountForm'
-import {LoginForm} from './LoginForm'
-import {ForgotPasswordForm} from './ForgotPasswordForm'
-import {SetNewPasswordForm} from './SetNewPasswordForm'
-import {PasswordUpdatedForm} from './PasswordUpdatedForm'
-import {useLingui} from '@lingui/react'
-import {msg} from '@lingui/macro'
-import {useSession, SessionAccount} from '#/state/session'
-import {useServiceQuery} from '#/state/queries/service'
-import {useLoggedOutView} from '#/state/shell/logged-out'
-
-enum Forms {
- Login,
- ChooseAccount,
- ForgotPassword,
- SetNewPassword,
- PasswordUpdated,
-}
-
-export const Login = ({onPressBack}: {onPressBack: () => void}) => {
- const {_} = useLingui()
- const pal = usePalette('default')
-
- const {accounts} = useSession()
- const {track} = useAnalytics()
- const {requestedAccountSwitchTo} = useLoggedOutView()
- const requestedAccount = accounts.find(
- a => a.did === requestedAccountSwitchTo,
- )
-
- const [error, setError] = useState('')
- const [serviceUrl, setServiceUrl] = useState(
- requestedAccount?.service || DEFAULT_SERVICE,
- )
- const [initialHandle, setInitialHandle] = useState(
- requestedAccount?.handle || '',
- )
- const [currentForm, setCurrentForm] = useState(
- requestedAccount
- ? Forms.Login
- : accounts.length
- ? Forms.ChooseAccount
- : Forms.Login,
- )
-
- const {
- data: serviceDescription,
- error: serviceError,
- refetch: refetchService,
- } = useServiceQuery(serviceUrl)
-
- const onSelectAccount = (account?: SessionAccount) => {
- if (account?.service) {
- setServiceUrl(account.service)
- }
- setInitialHandle(account?.handle || '')
- setCurrentForm(Forms.Login)
- }
-
- const gotoForm = (form: Forms) => () => {
- setError('')
- setCurrentForm(form)
- }
-
- useEffect(() => {
- if (serviceError) {
- setError(
- _(
- msg`Unable to contact your service. Please check your Internet connection.`,
- ),
- )
- logger.warn(`Failed to fetch service description for ${serviceUrl}`, {
- error: String(serviceError),
- })
- } else {
- setError('')
- }
- }, [serviceError, serviceUrl, _])
-
- const onPressRetryConnect = () => refetchService()
- const onPressForgotPassword = () => {
- track('Signin:PressedForgotPassword')
- setCurrentForm(Forms.ForgotPassword)
- }
-
- return (
-
- {currentForm === Forms.Login ? (
-
-
-
- ) : undefined}
- {currentForm === Forms.ChooseAccount ? (
-
-
-
- ) : undefined}
- {currentForm === Forms.ForgotPassword ? (
-
-
-
- ) : undefined}
- {currentForm === Forms.SetNewPassword ? (
-
-
-
- ) : undefined}
- {currentForm === Forms.PasswordUpdated ? (
-
-
-
- ) : undefined}
-
- )
-}
diff --git a/src/view/com/auth/login/LoginForm.tsx b/src/view/com/auth/login/LoginForm.tsx
deleted file mode 100644
index 92f4955756..0000000000
--- a/src/view/com/auth/login/LoginForm.tsx
+++ /dev/null
@@ -1,301 +0,0 @@
-import React, {useState, useRef} from 'react'
-import {
- ActivityIndicator,
- Keyboard,
- TextInput,
- TouchableOpacity,
- View,
-} from 'react-native'
-import {
- FontAwesomeIcon,
- FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
-import {ComAtprotoServerDescribeServer} from '@atproto/api'
-import {useAnalytics} from 'lib/analytics/analytics'
-import {Text} from '../../util/text/Text'
-import {s} from 'lib/styles'
-import {createFullHandle} from 'lib/strings/handles'
-import {toNiceDomain} from 'lib/strings/url-helpers'
-import {isNetworkError} from 'lib/strings/errors'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useTheme} from 'lib/ThemeContext'
-import {useSessionApi} from '#/state/session'
-import {cleanError} from 'lib/strings/errors'
-import {logger} from '#/logger'
-import {Trans, msg} from '@lingui/macro'
-import {styles} from './styles'
-import {useLingui} from '@lingui/react'
-import {useDialogControl} from '#/components/Dialog'
-
-import {ServerInputDialog} from '../server-input'
-
-type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
-
-export const LoginForm = ({
- error,
- serviceUrl,
- serviceDescription,
- initialHandle,
- setError,
- setServiceUrl,
- onPressRetryConnect,
- onPressBack,
- onPressForgotPassword,
-}: {
- error: string
- serviceUrl: string
- serviceDescription: ServiceDescription | undefined
- initialHandle: string
- setError: (v: string) => void
- setServiceUrl: (v: string) => void
- onPressRetryConnect: () => void
- onPressBack: () => void
- onPressForgotPassword: () => void
-}) => {
- const {track} = useAnalytics()
- const pal = usePalette('default')
- const theme = useTheme()
- const [isProcessing, setIsProcessing] = useState(false)
- const [identifier, setIdentifier] = useState(initialHandle)
- const [password, setPassword] = useState('')
- const passwordInputRef = useRef(null)
- const {_} = useLingui()
- const {login} = useSessionApi()
- const serverInputControl = useDialogControl()
-
- const onPressSelectService = () => {
- serverInputControl.open()
- Keyboard.dismiss()
- track('Signin:PressedSelectService')
- }
-
- const onPressNext = async () => {
- Keyboard.dismiss()
- setError('')
- setIsProcessing(true)
-
- try {
- // try to guess the handle if the user just gave their own username
- let fullIdent = identifier
- if (
- !identifier.includes('@') && // not an email
- !identifier.includes('.') && // not a domain
- serviceDescription &&
- serviceDescription.availableUserDomains.length > 0
- ) {
- let matched = false
- for (const domain of serviceDescription.availableUserDomains) {
- if (fullIdent.endsWith(domain)) {
- matched = true
- }
- }
- if (!matched) {
- fullIdent = createFullHandle(
- identifier,
- serviceDescription.availableUserDomains[0],
- )
- }
- }
-
- // TODO remove double login
- await login(
- {
- service: serviceUrl,
- identifier: fullIdent,
- password,
- },
- 'LoginForm',
- )
- } catch (e: any) {
- const errMsg = e.toString()
- setIsProcessing(false)
- if (errMsg.includes('Authentication Required')) {
- logger.debug('Failed to login due to invalid credentials', {
- error: errMsg,
- })
- setError(_(msg`Invalid username or password`))
- } else if (isNetworkError(e)) {
- logger.warn('Failed to login due to network error', {error: errMsg})
- setError(
- _(
- msg`Unable to contact your service. Please check your Internet connection.`,
- ),
- )
- } else {
- logger.warn('Failed to login', {error: errMsg})
- setError(cleanError(errMsg))
- }
- }
- }
-
- const isReady = !!serviceDescription && !!identifier && !!password
- return (
-
-
-
-
- Sign into
-
-
-
-
-
-
- {toNiceDomain(serviceUrl)}
-
-
-
-
-
-
-
-
- Account
-
-
-
-
- {
- 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={_(
- msg`Input the username or email address you used at signup`,
- )}
- />
-
-
-
-
-
-
- Forgot
-
-
-
-
- {error ? (
-
-
-
-
-
- {error}
-
-
- ) : undefined}
-
-
-
- Back
-
-
-
- {!serviceDescription && error ? (
-
-
- Retry
-
-
- ) : !serviceDescription ? (
- <>
-
-
- Connecting...
-
- >
- ) : isProcessing ? (
-
- ) : isReady ? (
-
-
- Next
-
-
- ) : undefined}
-
-
- )
-}
diff --git a/src/view/com/auth/login/PasswordUpdatedForm.tsx b/src/view/com/auth/login/PasswordUpdatedForm.tsx
deleted file mode 100644
index 71f750b141..0000000000
--- a/src/view/com/auth/login/PasswordUpdatedForm.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-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 (
- <>
-
-
- Password updated!
-
-
- You can now sign in with your new password.
-
-
-
-
-
- Okay
-
-
-
-
- >
- )
-}
diff --git a/src/view/com/auth/login/SetNewPasswordForm.tsx b/src/view/com/auth/login/SetNewPasswordForm.tsx
deleted file mode 100644
index 6d1584c86c..0000000000
--- a/src/view/com/auth/login/SetNewPasswordForm.tsx
+++ /dev/null
@@ -1,211 +0,0 @@
-import React, {useState, useEffect} from 'react'
-import {
- ActivityIndicator,
- TextInput,
- TouchableOpacity,
- View,
-} from 'react-native'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {BskyAgent} from '@atproto/api'
-import {useAnalytics} from 'lib/analytics/analytics'
-import {Text} from '../../util/text/Text'
-import {s} from 'lib/styles'
-import {isNetworkError} from 'lib/strings/errors'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useTheme} from 'lib/ThemeContext'
-import {cleanError} from 'lib/strings/errors'
-import {checkAndFormatResetCode} from 'lib/strings/password'
-import {logger} from '#/logger'
-import {styles} from './styles'
-import {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-export const SetNewPasswordForm = ({
- error,
- serviceUrl,
- setError,
- onPressBack,
- onPasswordSet,
-}: {
- error: string
- serviceUrl: string
- setError: (v: string) => void
- onPressBack: () => void
- onPasswordSet: () => void
-}) => {
- const pal = usePalette('default')
- const theme = useTheme()
- const {screen} = useAnalytics()
- const {_} = useLingui()
-
- useEffect(() => {
- screen('Signin:SetNewPasswordForm')
- }, [screen])
-
- const [isProcessing, setIsProcessing] = useState(false)
- const [resetCode, setResetCode] = useState('')
- const [password, setPassword] = useState('')
-
- const onPressNext = async () => {
- // Check that the code is correct. We do this again just incase the user enters the code after their pw and we
- // don't get to call onBlur first
- const formattedCode = checkAndFormatResetCode(resetCode)
- // TODO Better password strength check
- if (!formattedCode || !password) {
- setError(
- _(
- msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`,
- ),
- )
- return
- }
-
- setError('')
- setIsProcessing(true)
-
- try {
- const agent = new BskyAgent({service: serviceUrl})
- await agent.com.atproto.server.resetPassword({
- token: formattedCode,
- 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))
- }
- }
- }
-
- const onBlur = () => {
- const formattedCode = checkAndFormatResetCode(resetCode)
- if (!formattedCode) {
- setError(
- _(
- msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`,
- ),
- )
- return
- }
- setResetCode(formattedCode)
- }
-
- return (
- <>
-
-
- Set new password
-
-
-
- You will receive an email with a "reset code." Enter that code here,
- then enter your new password.
-
-
-
-
-
- setError('')}
- onBlur={onBlur}
- editable={!isProcessing}
- accessible={true}
- accessibilityLabel={_(msg`Reset code`)}
- accessibilityHint={_(
- msg`Input code sent to your email for password reset`,
- )}
- />
-
-
-
-
-
-
- {error ? (
-
-
-
-
-
- {error}
-
-
- ) : undefined}
-
-
-
- Back
-
-
-
- {isProcessing ? (
-
- ) : !resetCode || !password ? (
-
- Next
-
- ) : (
-
-
- Next
-
-
- )}
- {isProcessing ? (
-
- Updating...
-
- ) : undefined}
-
-
- >
- )
-}
diff --git a/src/view/com/auth/login/styles.ts b/src/view/com/auth/login/styles.ts
deleted file mode 100644
index 9dccc2803b..0000000000
--- a/src/view/com/auth/login/styles.ts
+++ /dev/null
@@ -1,118 +0,0 @@
-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,
- },
-})